diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 9a419373..9272591a 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -1404,3 +1404,79 @@ DEPLOY_SERVER= \ ```json {"preserve_original":["FIELD_ENCRYPTION_KEY"]} ``` + +## Scenario: Formal Feature-Flag Baseline And Reversible QA Window + +### 1. Scope / Trigger + +- Trigger: a feature is formally enabled after an earlier short-window rollout, or a QA runner temporarily changes an existing feature flag. +- Purpose: prevent a new database, stale `.env` or QA cleanup path from silently returning a formal capability to its old disabled state. + +### 2. Signatures + +- Hard gate: `PAYMENT_BATCH_ENABLED=true` in `deploy/trialrun/.env.example` and the active production env. +- Database baseline: `payment_batch_v1` is first-created as `force_on/global`; `seed:config` still uses create plus `P2002` preservation and never updates an existing row. +- Deployment assertion: `smoke-trialrun-runtime.js` requires both the env value and effective `FEATURE_FLAG_KEYS.paymentBatch` result. +- QA snapshot helper: `node scripts/qa/payment-batch-flag-snapshot.mjs capture|restore --snapshot-file `. +- Mobile entry: `/mobile/payment-batches`; PC remains `/payment-batches`. + +### 3. Contracts + +- Formal enablement is a three-layer contract: env hard gate, database feature flag and runtime smoke. Changing only one layer is incomplete. +- A short QA window captures `overrideMode`, `rolloutType`, `departmentIds` and `note` before its first flag write and restores those fields in `finally`; it must work when the caller starts either enabled or disabled. +- A formal-enable runner keeps `force_on` only after every acceptance step passes. Any failed step restores its caller snapshot, not a hard-coded `force_off`. +- Snapshot files use mode `0600`, contain configuration only and remain under the ignored QA report directory. Credentials and access tokens never enter the snapshot or output. +- Mobile links must target the mobile route. Enterprise-messenger routing maps a PC-only payment path back to the mobile workbench, so a PC path is not a valid mobile deep link even when the underlying page is responsive. +- Mobile and PC payment routes share the same permission guard; adding reachability must not broaden `payment.batch.manage|payment.batch.review|risk.review.manage`. + +### 4. Validation & Error Matrix + +- Env false or absent -> runtime smoke fails before deployment is accepted. +- Database flag absent/disabled -> runtime smoke fails with the feature-flag reason; do not hide the failure by changing navigation only. +- Seed meets an existing flag -> `P2002` is preserved with zero update/upsert calls. +- QA capture fails -> stop before the temporary window; do not run with an unknown rollback target. +- QA action or later matrix fails -> restore the caller snapshot and keep the run failed. +- Snapshot restore version conflicts -> fail visibly; never fall back to `force_off`. +- Mobile finance entry points at `/payment-batches` -> small-screen routing returns to `/mobile`; treat this as an unreachable entry and use `/mobile/payment-batches`. +- User lacks payment permission -> both routes remain forbidden even if the feature is enabled. + +### 5. Good/Base/Bad Cases + +- Good: env true + seed first-create force-on + effective runtime smoke pass; a temporary off/on test restores an original allowlist and note byte-for-byte. +- Base: a database already contains an operator-managed flag; deployment seed preserves it and runtime smoke reports whether it matches the formal baseline. +- Bad: change only the DB flag while env remains false, or make the mobile card visible but link it to a path that the mobile shell redirects home. +- Bad: every QA cleanup writes `force_off`, erasing a formal-on state that existed before the test. + +### 6. Tests Required + +- Seed unit test asserts first-create `force_on/global` and proves `P2002` performs no update/upsert. +- Snapshot helper tests cover an original `force_off` allowlist and an original `force_on` global configuration, including note restoration. +- Deployment contract test asserts the env baseline, runtime-smoke flag assertion and absence of fixed-off cleanup helpers. +- Mobile Playwright covers zero batches/zero exceptions with the feature enabled, a visible “进入付款” link, successful `/mobile/payment-batches` navigation and no finance card for an unrelated role. +- Run client typecheck/build, Prisma validate, shell/Node syntax, task/governance/process/sensitive gates and `git diff --check`. +- Public rollout additionally verifies active env, effective flag reason, finance navigation and eligible candidate count without creating, reviewing or executing a real batch. + +### 7. Wrong vs Correct + +#### Wrong + +```bash +# Cleanup assumes disabled was always the caller's state. +PUT payment_batch_v1 '{"overrideMode":"force_off"}' +``` + +```tsx +进入付款 +``` + +#### Correct + +```bash +node scripts/qa/payment-batch-flag-snapshot.mjs capture --snapshot-file "$REPORT_DIR/flag-before.json" +# temporary test writes +node scripts/qa/payment-batch-flag-snapshot.mjs restore --snapshot-file "$REPORT_DIR/flag-before.json" +``` + +```tsx +进入付款 +``` diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md index dbf0ca0d..253409c6 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/design.md @@ -54,3 +54,25 @@ - 文案改动可直接回滚;共享映射回滚不会影响服务端事实。 - 付款通知若出现收件范围错误,关闭对应通知类型/代码路径即可,不撤销已经创建或复核的付款批次。 - 本轮不合并财务复核与组批;累计 5–10 张真实单后的用户访谈是重新立项条件,不是当前发布门禁。 + +## 7. 付款入口与正式常开防复发 + +### 7.1 已确认根因 + +- 当前生产库 `payment_batch_v1` 为版本 1,创建时间等于更新时间,`updated_by` 为空,且没有任何 `FEATURE_FLAG_UPDATED` 审计。 +- 这证明当前库是在初始化时由 seed 首次创建默认 `force_off`,不是 seed 覆盖已有值,也不是 QA runner 或控制台在当前库执行了关闭。 +- 当前运行环境同时为 `PAYMENT_BATCH_ENABLED=false`,因此数据库即使改成 `force_on` 也会被 `env_hard_off` 拦截。 +- 当前唯一 active 财务人员具备 `payment.batch.manage`,但付款入口和已批准候选均因功能门控被隐藏;权限不是根因。 + +### 7.2 稳定基线 + +- 试运行正式基线把 `PAYMENT_BATCH_ENABLED` 设为 true,并把 seed 中 `payment_batch_v1` 的首次创建默认值设为全局 `force_on`;seed 继续只创建缺失记录,不更新已有记录。 +- 部署/runtime smoke 明确断言付款批次有效开启,防止换库、换 `.env` 或恢复后再次静默关闭。 +- QA 短窗口保存进入前完整快照并在 finally 恢复该快照;只有明确的正式启用流程在成功时保留 `force_on`,失败才恢复调用前状态。 + +### 7.3 入口规则 + +- PC 继续使用现有“付款”导航。 +- 移动财务工作台在付款能力开启时固定显示财务卡片,不再要求先存在批次或异常;卡片进入受同一付款权限保护的 `/mobile/payment-batches`,复用现有响应式付款页,避免企微小屏把 PC 路径重定向回首页。 +- 入口只对真实 finance/finance_mgr 且具备付款权限的人员显示;不向普通员工或仅审批人开放创建能力。 +- 本轮不自动打开创建面板、不自动选择报销、不代替真实财务动作。 diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md index f8b8130e..774c32fe 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/implement.md @@ -39,11 +39,34 @@ - [x] 运行客户端 typecheck/build、相关 PC/移动 Playwright 和动作标签单测。 - [x] 运行 Prisma validate、task/governance/process/sensitive/diff 门禁。 - [x] 因涉及 workflow/付款职责和通知收件范围,实施后执行只读 reviewer 检查,不允许 reviewer 改代码。 -- [ ] 部署与公网检查另行批准;未部署前不通知用户可以测试,企微实际到达必须真机确认。 +- [x] 已获批部署并完成公网检查;企微实际到达仍必须由新业务事件和真机确认,不能用历史 WMSG1 或自动化替代。 只读审查结论:首次检查发现站内通知中心缺少 `payment_batch` 目标链接,已补 PC/移动统一直达付款页并增加回归;复查未发现阻断项。通知记录与付款动作同事务,IM 投递发生在提交后且失败不回滚;复核人解析排除制单人,付款责任人解析排除复核人;重复创建/复核由既有锁和状态校验拒绝,不重复通知。生产仍无启用且匹配的企微渠道,属于已知部署后配置门,不得表述为企微通知已恢复。 +生产发布记录(2026-08-01,脱敏): + +- 已将已发布提交 `aa6b9bc` 按既有安装固化入口部署;环境合同、迁移状态、`/ready`、外部依赖 smoke、运行时 smoke、公网构建标识和前端资源扫描通过。 +- 历史 WMSG1 已证明当前企微应用具备应用消息发送能力;本轮没有另发渠道测试消息。 +- 生产仅启用 1 条 `wecom_app_message` 渠道,限定 workflow/reimbursement 域和 7 类审批、报销、付款通知;非相关业务类型不匹配。 +- `EMAIL_ENABLED=false` 已在新容器生效;没有创建邮件通知渠道。 +- 渠道创建写入 1 条审计;没有扫描、补发或重放任何历史通知。 +- 当前仅证明“新事件可匹配企微渠道”;仍待王芝玲财务复核/创建付款批次等真实新动作产生 delivery,并由收件人真机确认消息到达与 Deeplink 回流。 + ## 6. 运行观察 - [x] 运行约定为每周集中建立一次付款批次,不建设排程功能。 - [ ] 王芝玲累计处理 5–10 张真实报销后触发一次访谈,决定财务复核与加入批次是否值得另立合并任务(未来触发条件,当前尚未发生)。 + +## 7. 付款入口与常开恢复 + +- [x] 先查生产 R54 变更历史、当前 flag 版本、创建/更新时间、updated_by 和环境硬开关,不先恢复。 +- [x] 确认当前库没有 `FEATURE_FLAG_UPDATED` 审计;根因是当前库首次初始化与 `.env` 均采用关闭默认值,不是 QA runner 或人工关闭。 +- [x] 将正式试运行环境默认硬开关和 seed 首次创建默认值改为付款常开,seed 仍不得覆盖已有记录。 +- [x] 修复 QA 窗口 runner:结束时恢复调用前快照,不无条件写死 `force_off`;正式启用 runner 成功保持 `force_on`、失败恢复原值。 +- [x] 移动财务工作台在付款能力启用但尚无批次时仍显示“付款”入口;PC 入口保持不变;新增移动付款路由避免小屏自动回首页。 +- [x] 补测试覆盖新环境初始化、runner 原值 on/off、移动端零批次入口、普通员工不可见和已批准候选可读。 +- [ ] 通过只读审查后提交、部署;受控设置生产环境硬开关 true 和数据库全局 `force_on`,写审计并验证候选计数,不创建真实批次。 + +本地验证(2026-08-01):seed/部署合同/Portal 聚焦单测 19 passed;flag 快照 helper 的原值 `force_off department_allowlist` 与 `force_on global` 两类恢复测试 2 passed;移动信息架构 6 passed;客户端 typecheck/build、Prisma validate、脚本语法、Trellis/task/process/sensitive/diff 门禁通过。 + +只读审查(2026-08-01):未发现阻断项。PC 与移动付款路由使用同一权限集合,普通员工不会因新增移动路由获得付款能力;移动财务卡片同时受真实财务角色/付款权限和后端 `financeSummary` 门控;env、DB flag 与 runtime smoke 三层缺一即失败;短窗口在任何临时写入前保存完整快照,成功/失败分支均不会把调用前 `force_on` 或 allowlist 静默改成固定关闭。审查未修改代码。 diff --git a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md index d1f72357..e41726db 100644 --- a/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md +++ b/.trellis/tasks/08-01-reimbursement-notification-progress-clarity/prd.md @@ -20,6 +20,8 @@ - 保留付款 maker-checker:付款批次创建人与复核人不得相同,当前只需一次非制单复核,不增加更多复核层级。 - 通知以“新的责任人需要行动”为准:提交后通知主管、主管通过后通知财务、创建付款批次后通知非制单复核人、付款复核后通知财务/批次创建人继续付款、付款完成后通知申请人。 - 每周集中建立一次付款批次作为当前运行约定,不在本任务建设定时排程、自动组批或强制付款日。 +- 修复财务首次使用时的入口死锁:付款能力正式常开时,即使尚无付款批次,财务人员也必须能在 PC 和移动工作台看到“付款”入口并进入创建页。 +- 恢复生产 `payment_batch_v1` 正式常开基线,并防止新环境初始化或 QA 窗口收尾把正式常开状态静默改回关闭。 ## Requirements @@ -29,6 +31,9 @@ - 普通员工页面不突出“付款批次”内部对象名;有付款批次权限的人员仍可通过现有权限门控入口查看批次。 - 共享动作标签是唯一事实源;PC、移动和打印页不得继续维护互相漂移的局部映射。 - 不删除主管审批、财务复核或付款复核,不放宽自审,不把付款完成伪装成审批通过。 +- 付款开关恢复前先查询 R54 变更历史;若当前库无变更审计,必须区分“seed 首次创建默认值”和“seed 覆盖已有值”,不得把不存在的 runner/人工操作写成根因。 +- QA 短窗口结束时必须恢复调用前快照;正式启用流程成功后保持常开,失败才回滚,不得无条件写死 `force_off`。 +- 正式环境和新初始化的试运行环境必须同时满足环境硬开关与数据库开关常开;任何一层关闭都应由部署/运行门禁发现。 ## Acceptance Criteria @@ -39,6 +44,9 @@ - [ ] 创建付款批次后通知实际可复核人员;复核通过后通知实际付款责任人;付款完成继续通知申请人,且同一事件不会重复创建/投递。 - [ ] 付款复核仍强制非制单人且仅一轮;既有付款批次、审批快照和历史记录不迁移、不改写。 - [ ] PC/移动相关组件测试、通知路由/服务测试、付款 maker-checker 回归、前端 typecheck/build 与 Trellis 门禁通过。 +- [ ] 财务人员在尚无付款批次但存在已批准待付款报销时,PC 与移动端均可看到“付款”入口,并能看到该报销作为新建批次候选。 +- [ ] 正式初始化基线为付款批次常开;QA 窗口测试覆盖原值为 on/off 两种情况并恢复原值,不再硬编码关闭。 +- [ ] 生产恢复后只验证入口和候选可见,不代替财务创建批次、负责人复核或执行付款。 ## Out of Scope diff --git a/client/src/lib/notificationLinks.ts b/client/src/lib/notificationLinks.ts index 17c57e15..34108fd7 100644 --- a/client/src/lib/notificationLinks.ts +++ b/client/src/lib/notificationLinks.ts @@ -65,7 +65,7 @@ export function resolveMobileNotificationTargetLink(item: NotificationTarget) { } if (item.targetType === "payment_batch") { - return `/payment-batches?id=${encodeSegment(item.targetId)}`; + return `/mobile/payment-batches?id=${encodeSegment(item.targetId)}`; } if (item.targetType === "leave_request") { diff --git a/client/src/lib/runtimeContext.ts b/client/src/lib/runtimeContext.ts index d8dd98b0..c73bd853 100644 --- a/client/src/lib/runtimeContext.ts +++ b/client/src/lib/runtimeContext.ts @@ -118,7 +118,6 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") { normalizedPath.startsWith("/projects") || normalizedPath.startsWith("/contracts-registry") || normalizedPath.startsWith("/travel-requests") || - normalizedPath.startsWith("/payment-batches") || normalizedPath.startsWith("/ap-operations") || normalizedPath.startsWith("/weekly-reports") || normalizedPath.startsWith("/todos") || @@ -130,6 +129,10 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") { return "/mobile"; } + if (normalizedPath === "/payment-batches") { + return `/mobile/payment-batches${search}`; + } + if (normalizedPath === "/purchase-requests") { const purchaseRequestId = searchParams.get("highlight") ?? searchParams.get("id") ?? searchParams.get("requestId"); if (purchaseRequestId) { diff --git a/client/src/pages/MobilePortalPage.tsx b/client/src/pages/MobilePortalPage.tsx index 37098db9..c3b83a76 100644 --- a/client/src/pages/MobilePortalPage.tsx +++ b/client/src/pages/MobilePortalPage.tsx @@ -184,7 +184,10 @@ export function MobilePortalPage() { const paymentBatchEnabled = Boolean(financeSummary?.paymentBatchEnabled); const financePendingCount = financeSummary?.pendingBatchCount ?? 0; const financeExceptionCount = (financeSummary?.failedItemCount ?? 0) + (financeSummary?.riskCount ?? 0); - const shouldShowFinanceCard = canUseFinance && !portalHomeQuery.isLoading && !portalHomeQuery.isError && (financePendingCount > 0 || financeExceptionCount > 0); + const shouldShowFinanceCard = canUseFinance && + !portalHomeQuery.isLoading && + !portalHomeQuery.isError && + (paymentBatchEnabled || financePendingCount > 0 || financeExceptionCount > 0); const financeCardBadge = paymentBatchEnabled ? "复核与付款" : financePendingCount > 0 ? "待付款" : "异常与复核"; async function openHomeNotification(item: NotificationItem) { @@ -326,9 +329,14 @@ export function MobilePortalPage() {
处理
-
电脑端
+
OA
+ {paymentBatchEnabled ? ( + + ) : null} ) : null} diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 121300c1..d3db15a8 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -352,6 +352,15 @@ const routeElements = ( )} /> + + {withRouteFallback()} + + } + /> + url.pathname.startsWith("/api/"), async (route) => { const request = route.request(); const url = new URL(request.url()); @@ -544,7 +557,7 @@ async function mockApis(page: Page, role: RolePreset, notifications: MockNotific isAdminOnly: role === "admin", }, financeSummary: isFinance - ? { + ? financeSummaryOverride ?? { pendingBatchCount: 2, failedItemCount: 1, riskCount: 3, @@ -889,7 +902,7 @@ test.describe("MIF7 移动信息架构整改", () => { const target = { targetType: "payment_batch", targetId: "batch/dg7" }; expect(resolveNotificationTargetLink(target)).toBe("/payment-batches?id=batch%2Fdg7"); - expect(resolveMobileNotificationTargetLink(target)).toBe("/payment-batches?id=batch%2Fdg7"); + expect(resolveMobileNotificationTargetLink(target)).toBe("/mobile/payment-batches?id=batch%2Fdg7"); }); test.skip(process.env.E2E_MODE === "live", "MIF7 本地专项使用 mock 数据验证信息架构。"); @@ -1161,6 +1174,27 @@ test.describe("MIF7 移动信息架构整改", () => { await financeMobile.context.close(); } + const firstUseFinanceMobile = await newMobilePage(browser, 390); + try { + await mockApis(firstUseFinanceMobile.page, "finance", buildNotifications(), { + pendingBatchCount: 0, + failedItemCount: 0, + riskCount: 0, + paymentBatchEnabled: true, + href: "/payment-batches", + }); + await loginForMobile(firstUseFinanceMobile.page, "finance"); + await firstUseFinanceMobile.page.goto("/mobile"); + const financeSummary = firstUseFinanceMobile.page.getByTestId("mobile-finance-summary"); + await expect(financeSummary).toBeVisible(); + await expect(financeSummary).toContainText("待付款"); + await expect(financeSummary.getByRole("link", { name: "进入付款" })).toHaveAttribute("href", "/mobile/payment-batches"); + await financeSummary.getByRole("link", { name: "进入付款" }).click(); + await expect(firstUseFinanceMobile.page).toHaveURL(/\/mobile\/payment-batches$/); + } finally { + await firstUseFinanceMobile.context.close(); + } + const adminMobile = await newMobilePage(browser, 390); try { await mockApis(adminMobile.page, "admin", buildNotifications()); diff --git a/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts b/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts index 783cbb90..19e3d7fe 100644 --- a/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts +++ b/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts @@ -19,7 +19,7 @@ type FeatureFlagItem = { type FeatureFlagSnapshot = Pick< FeatureFlagItem, - "flagKey" | "overrideMode" | "rolloutType" | "departmentIds" | "version" | "effectiveEnabled" | "effectiveReason" + "flagKey" | "overrideMode" | "rolloutType" | "departmentIds" | "note" | "version" | "effectiveEnabled" | "effectiveReason" >; type ReimbursementDetail = { @@ -99,7 +99,7 @@ const baseUrl = process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.co const reportDir = process.env.P2_FIN_LIVE_REPORT_DIR?.trim() || path.resolve(process.cwd(), "..", "artifacts/qa-flow/p2-fin-live-local"); const summaryPath = path.join(reportDir, "live-result.json"); -const finalFlagState = process.env.P2_FIN_LIVE_FINAL_STATE?.trim() === "force_on" ? "force_on" : "force_off"; +const finalFlagState = process.env.P2_FIN_LIVE_FINAL_STATE?.trim() === "force_on" ? "force_on" : "restore"; const alreadyOnMode = process.env.P2_FIN_LIVE_ALREADY_ON === "1" || process.env.LAUNCH_AUTO_PAYMENT_BATCH_ALREADY_ON === "1"; @@ -130,6 +130,7 @@ function snapshotFlag(item: FeatureFlagItem): FeatureFlagSnapshot { overrideMode: item.overrideMode, rolloutType: item.rolloutType, departmentIds: item.departmentIds, + note: item.note, version: item.version, effectiveEnabled: item.effectiveEnabled, effectiveReason: item.effectiveReason, @@ -183,13 +184,15 @@ async function updateFlag( flagKey: string, overrideMode: FeatureFlagItem["overrideMode"], note: string, + rolloutType: FeatureFlagItem["rolloutType"] = "global", + departmentIds: string[] = [], ) { const current = await loadFlag(api, flagKey); const response = await api.put(`/api/system/feature-flags/${flagKey}`, { data: { overrideMode, - rolloutType: "global", - departmentIds: [], + rolloutType, + departmentIds, note, expectedVersion: current.version, }, @@ -198,12 +201,24 @@ async function updateFlag( return data.item; } -async function forcePaymentBatchOff(api: APIRequestContext, note: string) { +async function restoreFlagSnapshot(api: APIRequestContext, snapshot: FeatureFlagSnapshot) { const current = await loadFlag(api, "payment_batch_v1"); - if (current.overrideMode === "force_off" && !current.effectiveEnabled) { + const configurationMatches = + current.overrideMode === snapshot.overrideMode && + current.rolloutType === snapshot.rolloutType && + JSON.stringify(current.departmentIds) === JSON.stringify(snapshot.departmentIds) && + current.note === snapshot.note; + if (configurationMatches) { return current; } - return updateFlag(api, "payment_batch_v1", "force_off", note); + return updateFlag( + api, + "payment_batch_v1", + snapshot.overrideMode, + snapshot.note, + snapshot.rolloutType, + snapshot.departmentIds, + ); } function resolveLivePdfBuffer(marker: string) { @@ -397,6 +412,7 @@ test.describe("公网付款批次受控验收", () => { const clients: AuthenticatedApiClient[] = []; let admin: AuthenticatedApiClient | null = null; + let paymentFlagBefore: FeatureFlagItem | null = null; let caughtError: unknown = null; let rollbackError: unknown = null; @@ -408,7 +424,7 @@ test.describe("公网付款批次受控验收", () => { const financeMgr = await createLiveClientWithRetry("financeMgr"); clients.push(admin, employee, manager, finance, financeMgr); - const paymentFlagBefore = await loadFlag(admin.api, "payment_batch_v1"); + paymentFlagBefore = await loadFlag(admin.api, "payment_batch_v1"); const erpFlagBefore = await loadFlag(admin.api, "erp_sync"); summary.stages.preflight = { paymentFlagBefore: snapshotFlag(paymentFlagBefore), @@ -587,19 +603,7 @@ test.describe("公网付款批次受控验收", () => { } finally { if (admin) { try { - if (alreadyOnMode) { - const finalPaymentFlag = await loadFlag(admin.api, "payment_batch_v1"); - const finalErpFlag = await loadFlag(admin.api, "erp_sync"); - summary.stages.finalState = { - paymentFlag: snapshotFlag(finalPaymentFlag), - erpSyncEffectiveEnabled: finalErpFlag.effectiveEnabled, - alreadyOnMode, - }; - expect(finalPaymentFlag.effectiveEnabled).toBe(true); - expect(finalPaymentFlag.overrideMode).toBe("force_on"); - expect(finalFlagState).toBe("force_on"); - expect(finalErpFlag.effectiveEnabled).toBe(false); - } else if (finalFlagState === "force_on" && !caughtError) { + if (finalFlagState === "force_on" && !caughtError) { const paymentFlag = await loadFlag(admin.api, "payment_batch_v1"); const finalPaymentFlag = paymentFlag.overrideMode === "force_on" && paymentFlag.effectiveEnabled @@ -609,14 +613,18 @@ test.describe("公网付款批次受控验收", () => { summary.stages.finalState = { paymentFlag: snapshotFlag(finalPaymentFlag), erpSyncEffectiveEnabled: finalErpFlag.effectiveEnabled, + alreadyOnMode, }; expect(finalPaymentFlag.effectiveEnabled).toBe(true); expect(finalPaymentFlag.overrideMode).toBe("force_on"); expect(finalErpFlag.effectiveEnabled).toBe(false); - } else { - const rolledBack = await forcePaymentBatchOff(admin.api, `${issueId} rollback ${marker}`); - summary.stages.rollback = { paymentFlagAfterRollback: snapshotFlag(rolledBack) }; - expect(rolledBack.effectiveEnabled).toBe(false); + } else if (paymentFlagBefore) { + const restored = await restoreFlagSnapshot(admin.api, snapshotFlag(paymentFlagBefore)); + summary.stages.rollback = { paymentFlagAfterRestore: snapshotFlag(restored) }; + expect(restored.overrideMode).toBe(paymentFlagBefore.overrideMode); + expect(restored.rolloutType).toBe(paymentFlagBefore.rolloutType); + expect(restored.departmentIds).toEqual(paymentFlagBefore.departmentIds); + expect(restored.note).toBe(paymentFlagBefore.note); } } catch (error) { rollbackError = error; diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index 9a83a239..8058cdf1 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -148,7 +148,7 @@ INVOICE_ARCHIVE_BATCH_SIZE=200 # ===== 预算 / 费控 ===== BUDGET_CONTROL_MODE=warn BUDGET_PRECHECK_ENABLED=false -PAYMENT_BATCH_ENABLED=false +PAYMENT_BATCH_ENABLED=true # ===== 周边集成 / ERP ===== ERP_SYNC_ENABLED=false diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 0da75565..f6cd4705 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -548,8 +548,8 @@ docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml - 入口策略:账号密码主入口,站内通知主通道,桌面优先 ### 7.2 推荐环境开关 -- 建议打开:`PORTAL_HOME_ENABLED=true`、`GLOBAL_SEARCH_ENABLED=true`、`ANNOUNCEMENTS_ENABLED=true`、`PRESENCE_BOARD_ENABLED=true`、`WEEKLY_REPORT_ENABLED=true`、`EMPLOYEE_LIFECYCLE_ENABLED=true`、`TODO_BOARD_ENABLED=true` -- 建议保留备用:`PAYMENT_BATCH_ENABLED=false`、`CUTOVER_CONSOLE_ENABLED=false` +- 建议打开:`PORTAL_HOME_ENABLED=true`、`GLOBAL_SEARCH_ENABLED=true`、`ANNOUNCEMENTS_ENABLED=true`、`PRESENCE_BOARD_ENABLED=true`、`WEEKLY_REPORT_ENABLED=true`、`EMPLOYEE_LIFECYCLE_ENABLED=true`、`TODO_BOARD_ENABLED=true`、`PAYMENT_BATCH_ENABLED=true` +- 建议保留备用:`CUTOVER_CONSOLE_ENABLED=false` - OCR 回归环境建议:`OCR_PROVIDER=mock` ### 7.2A 本地 Playwright 辅助复核门禁 diff --git a/scripts/qa/payment-batch-flag-snapshot.mjs b/scripts/qa/payment-batch-flag-snapshot.mjs new file mode 100644 index 00000000..36831b84 --- /dev/null +++ b/scripts/qa/payment-batch-flag-snapshot.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +import { chmod, readFile, writeFile } from "node:fs/promises"; + +const DEFAULT_BASE_URL = "https://oa.hyinda.com"; +const FLAG_KEY = "payment_batch_v1"; + +function parseArgs(argv) { + const [action, ...rest] = argv; + const options = { + action, + baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL, + snapshotFile: "", + }; + + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]; + if (arg === "--snapshot-file") { + options.snapshotFile = rest[index + 1] ?? ""; + index += 1; + } else if (arg.startsWith("--snapshot-file=")) { + options.snapshotFile = arg.slice("--snapshot-file=".length); + } else if (arg.startsWith("--base-url=")) { + options.baseUrl = arg.slice("--base-url=".length); + } + } + + if (!new Set(["capture", "restore"]).has(options.action)) { + throw new Error("action must be capture or restore"); + } + if (!options.snapshotFile.trim()) { + throw new Error("--snapshot-file is required"); + } + options.baseUrl = options.baseUrl.trim().replace(/\/$/, ""); + options.snapshotFile = options.snapshotFile.trim(); + return options; +} + +function getAdminCredential() { + const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim(); + const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim(); + if (!identifier || !password) { + throw new Error("missing LIVE_SMOKE_ADMIN_* credentials"); + } + return { identifier, password }; +} + +async function fetchJson(url, init = {}) { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(20_000), + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { ok: response.ok, status: response.status, json }; +} + +function errorCode(json) { + return typeof json?.error?.code === "string" ? json.error.code : ""; +} + +async function login(baseUrl, credential) { + const result = await fetchJson(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-fingerprint": "payment-batch-flag-window-snapshot", + }, + body: JSON.stringify(credential), + }); + const accessToken = result.json?.data?.accessToken; + if (!result.ok || typeof accessToken !== "string" || !accessToken) { + throw new Error(`admin login failed: HTTP ${result.status} ${errorCode(result.json)}`.trim()); + } + return accessToken; +} + +async function loadFlag(baseUrl, accessToken) { + const result = await fetchJson(`${baseUrl}/api/system/feature-flags`, { + headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" }, + }); + if (!result.ok) { + throw new Error(`feature flag list failed: HTTP ${result.status} ${errorCode(result.json)}`.trim()); + } + const flag = result.json?.data?.items?.find((item) => item.flagKey === FLAG_KEY); + if (!flag) { + throw new Error(`missing feature flag: ${FLAG_KEY}`); + } + return flag; +} + +function toSnapshot(flag) { + return { + flagKey: FLAG_KEY, + overrideMode: flag.overrideMode, + rolloutType: flag.rolloutType, + departmentIds: Array.isArray(flag.departmentIds) ? [...flag.departmentIds] : [], + note: typeof flag.note === "string" ? flag.note : "", + effectiveEnabled: flag.effectiveEnabled === true, + effectiveReason: typeof flag.effectiveReason === "string" ? flag.effectiveReason : "unknown", + }; +} + +function validateSnapshot(value) { + const overrideModes = new Set(["inherit", "force_on", "force_off"]); + const rolloutTypes = new Set(["global", "department_allowlist"]); + if ( + value?.flagKey !== FLAG_KEY || + !overrideModes.has(value.overrideMode) || + !rolloutTypes.has(value.rolloutType) || + !Array.isArray(value.departmentIds) || + !value.departmentIds.every((item) => typeof item === "string") || + typeof value.note !== "string" || + typeof value.effectiveEnabled !== "boolean" || + typeof value.effectiveReason !== "string" + ) { + throw new Error("invalid payment batch flag snapshot"); + } + return value; +} + +function sameConfiguration(flag, snapshot) { + return flag.overrideMode === snapshot.overrideMode && + flag.rolloutType === snapshot.rolloutType && + JSON.stringify(flag.departmentIds ?? []) === JSON.stringify(snapshot.departmentIds) && + (flag.note ?? "") === snapshot.note; +} + +async function updateFlag(baseUrl, accessToken, current, snapshot) { + const result = await fetchJson(`${baseUrl}/api/system/feature-flags/${FLAG_KEY}`, { + method: "PUT", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + accept: "application/json", + }, + body: JSON.stringify({ + overrideMode: snapshot.overrideMode, + rolloutType: snapshot.rolloutType, + departmentIds: snapshot.departmentIds, + note: snapshot.note, + expectedVersion: current.version, + }), + }); + if (!result.ok) { + throw new Error(`feature flag restore failed: HTTP ${result.status} ${errorCode(result.json)}`.trim()); + } +} + +async function main() { + if (process.env.P2_FIN_LIVE_APPROVED !== "1") { + throw new Error("P2_FIN_LIVE_APPROVED=1 is required"); + } + + const options = parseArgs(process.argv.slice(2)); + const credential = getAdminCredential(); + const accessToken = await login(options.baseUrl, credential); + + if (options.action === "capture") { + const current = await loadFlag(options.baseUrl, accessToken); + await writeFile(options.snapshotFile, `${JSON.stringify(toSnapshot(current), null, 2)}\n`, { mode: 0o600 }); + await chmod(options.snapshotFile, 0o600); + console.log(`[payment-flag-snapshot] captured override=${current.overrideMode} effective=${current.effectiveEnabled}`); + return; + } + + const snapshot = validateSnapshot(JSON.parse(await readFile(options.snapshotFile, "utf8"))); + const current = await loadFlag(options.baseUrl, accessToken); + if (!sameConfiguration(current, snapshot)) { + await updateFlag(options.baseUrl, accessToken, current, snapshot); + } + const restored = await loadFlag(options.baseUrl, accessToken); + if (!sameConfiguration(restored, snapshot)) { + throw new Error("payment batch flag does not match caller snapshot after restore"); + } + console.log(`[payment-flag-snapshot] restored override=${restored.overrideMode} effective=${restored.effectiveEnabled}`); +} + +main().catch((error) => { + console.error(`[payment-flag-snapshot][ERROR] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/qa/run-fin-prod-enable-window.sh b/scripts/qa/run-fin-prod-enable-window.sh index e7c900a9..56c241ae 100644 --- a/scripts/qa/run-fin-prod-enable-window.sh +++ b/scripts/qa/run-fin-prod-enable-window.sh @@ -6,6 +6,7 @@ CLIENT_DIR="$ROOT_DIR/client" PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" REPORT_DIR="${FIN_PROD_ENABLE_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/fin-prod-enable-$(date +%Y%m%d-%H%M%S)}" SUMMARY_TXT="$REPORT_DIR/summary.txt" +FLAG_SNAPSHOT_FILE="$REPORT_DIR/payment-batch-flag-before.json" STATUS=0 mkdir -p "$REPORT_DIR" @@ -124,101 +125,18 @@ if (expected === "force_off" && (!paymentOff || !erpOff)) { NODE } -force_payment_batch_flag_off() { - node <<'NODE' -const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").replace(/\/$/, ""); -const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim(); -const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim(); -const issueId = process.env.QA_ISSUE_ID?.trim() || "FINPROD"; - -if (!identifier || !password) { - console.error("[fin-prod-enable][rollback] missing admin credentials"); - process.exit(1); -} - -async function fetchJson(url, init = {}) { - const response = await fetch(url, { - ...init, - signal: AbortSignal.timeout(20_000), - }); - const text = await response.text(); - let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch { - json = null; - } - return { response, json }; -} - -function errorSummary(json, status) { - const error = json?.error ?? {}; - return `${status} ${error.code ?? ""} ${error.message ?? ""}`.trim(); -} - -const login = await fetchJson(`${baseUrl}/api/auth/login`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-device-fingerprint": "fin-prod-enable-rollback", - }, - body: JSON.stringify({ identifier, password }), -}); -if (!login.response.ok || !login.json?.data?.accessToken) { - console.error(`[fin-prod-enable][rollback] login failed: ${errorSummary(login.json, login.response.status)}`); - process.exit(1); -} - -const token = login.json.data.accessToken; -const list = await fetchJson(`${baseUrl}/api/system/feature-flags`, { - headers: { authorization: `Bearer ${token}`, accept: "application/json" }, -}); -if (!list.response.ok) { - console.error(`[fin-prod-enable][rollback] list flags failed: ${errorSummary(list.json, list.response.status)}`); - process.exit(1); -} - -const flag = list.json?.data?.items?.find((item) => item.flagKey === "payment_batch_v1"); -if (!flag) { - console.error("[fin-prod-enable][rollback] missing payment_batch_v1"); - process.exit(1); -} - -if (flag.overrideMode === "force_off" && flag.effectiveEnabled === false) { - console.log("[fin-prod-enable][rollback] payment_batch_v1 already force_off"); - process.exit(0); -} - -const updated = await fetchJson(`${baseUrl}/api/system/feature-flags/payment_batch_v1`, { - method: "PUT", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json", - accept: "application/json", - }, - body: JSON.stringify({ - overrideMode: "force_off", - rolloutType: "global", - departmentIds: [], - note: `${issueId} failed final rollback`, - expectedVersion: flag.version, - }), -}); -if (!updated.response.ok) { - console.error(`[fin-prod-enable][rollback] update failed: ${errorSummary(updated.json, updated.response.status)}`); - process.exit(1); -} -const item = updated.json?.data?.item; -console.log(`[fin-prod-enable][rollback] payment_batch_v1 override=${item?.overrideMode ?? "unknown"} effective=${item?.effectiveEnabled ?? "unknown"}`); -NODE +restore_payment_batch_flag_snapshot() { + P2_FIN_LIVE_APPROVED=1 LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \ + node "$ROOT_DIR/scripts/qa/payment-batch-flag-snapshot.mjs" restore \ + --snapshot-file "$FLAG_SNAPSHOT_FILE" } write_summary_json() { local qa_status="PASS" - local notes="FINPROD 正式启用:公网付款批次验收通过后保持 payment_batch_v1=force_on;失败时回滚 force_off;不接真实支付、不接 ERP、不开放图片 OCR。" + local notes="FINPROD 正式启用:公网付款批次验收通过后保持 payment_batch_v1=force_on;失败时恢复调用前完整快照;不接真实支付、不接 ERP、不开放图片 OCR。" if [[ "$STATUS" -ne 0 ]]; then qa_status="FAIL" - notes="FINPROD 正式启用失败或门禁失败:已尝试将 payment_batch_v1 回滚到 force_off;不接真实支付、不接 ERP、不开放图片 OCR。" + notes="FINPROD 正式启用失败或门禁失败:已尝试恢复 payment_batch_v1 调用前完整快照;不接真实支付、不接 ERP、不开放图片 OCR。" fi QA_REPORT_DIR="$REPORT_DIR" \ @@ -243,21 +161,13 @@ finish() { fi fi - if [[ "$STATUS" -ne 0 ]]; then - echo ">>> [fin-prod-enable] failure-force-flag-off" | tee -a "$SUMMARY_TXT" - if force_payment_batch_flag_off > >(tee "$REPORT_DIR/failure-force-flag-off.log") 2>&1; then - echo "[fin-prod-enable][PASS] failure-force-flag-off" | tee -a "$SUMMARY_TXT" + if [[ "$STATUS" -ne 0 && -f "$FLAG_SNAPSHOT_FILE" ]]; then + echo ">>> [fin-prod-enable] restore-caller-flag-snapshot" | tee -a "$SUMMARY_TXT" + if restore_payment_batch_flag_snapshot > >(tee "$REPORT_DIR/restore-caller-flag-snapshot.log") 2>&1; then + echo "[fin-prod-enable][PASS] restore-caller-flag-snapshot" | tee -a "$SUMMARY_TXT" else local code="$?" - echo "[fin-prod-enable][FAIL] failure-force-flag-off exit=$code" | tee -a "$SUMMARY_TXT" - fi - - echo ">>> [fin-prod-enable] final-flag-off-check" | tee -a "$SUMMARY_TXT" - if LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" fetch_feature_flags_node "force_off" > >(tee "$REPORT_DIR/final-flag-off-check.log") 2>&1; then - echo "[fin-prod-enable][PASS] final-flag-off-check" | tee -a "$SUMMARY_TXT" - else - local code="$?" - echo "[fin-prod-enable][FAIL] final-flag-off-check exit=$code" | tee -a "$SUMMARY_TXT" + echo "[fin-prod-enable][FAIL] restore-caller-flag-snapshot exit=$code" | tee -a "$SUMMARY_TXT" fi fi fi @@ -283,6 +193,9 @@ run_shell_step "live-env-strict" "LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ run_shell_step "pre-flag-off-check" "LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ROOT_DIR/scripts/qa/check-payment-batch-flag-off.mjs' --json" [[ "$STATUS" -eq 0 ]] || exit "$STATUS" +run_shell_step "capture-caller-flag-snapshot" "P2_FIN_LIVE_APPROVED=1 LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ROOT_DIR/scripts/qa/payment-batch-flag-snapshot.mjs' capture --snapshot-file '$FLAG_SNAPSHOT_FILE'" +[[ "$STATUS" -eq 0 ]] || exit "$STATUS" + run_shell_step "fin-prod-enable-spec" "cd '$CLIENT_DIR' && PLAYWRIGHT_MANUAL=1 E2E_MODE=live P2_FIN_LIVE_APPROVED=1 P2_FIN_LIVE_FINAL_STATE=force_on QA_ISSUE_ID='${QA_ISSUE_ID:-FINPROD}' P2_FIN_LIVE_MARKER_PREFIX='${P2_FIN_LIVE_MARKER_PREFIX:-${QA_ISSUE_ID:-FINPROD}}' P2_FIN_LIVE_REPORT_DIR='$REPORT_DIR' LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/manual/p2-fin-live-payment-batch.spec.ts --workers=1 --reporter=list" if [[ "${FIN_PROD_ENABLE_SKIP_MOBILE:-0}" == "1" ]]; then diff --git a/scripts/qa/run-p2-fin-live-window.sh b/scripts/qa/run-p2-fin-live-window.sh index a667defc..14b7c196 100755 --- a/scripts/qa/run-p2-fin-live-window.sh +++ b/scripts/qa/run-p2-fin-live-window.sh @@ -6,7 +6,9 @@ CLIENT_DIR="$ROOT_DIR/client" PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" REPORT_DIR="${P2_FIN_LIVE_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/p2-fin-live-$(date +%Y%m%d-%H%M%S)}" SUMMARY_TXT="$REPORT_DIR/summary.txt" +FLAG_SNAPSHOT_FILE="$REPORT_DIR/payment-batch-flag-before.json" STATUS=0 +ALREADY_ON_MODE=0 mkdir -p "$REPORT_DIR" : >"$SUMMARY_TXT" @@ -46,93 +48,10 @@ run_shell_step() { run_step "$name" bash -lc "$command" } -force_payment_batch_flag_off() { - node <<'NODE' -const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").replace(/\/$/, ""); -const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim(); -const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim(); -const issueId = process.env.QA_ISSUE_ID?.trim() || "P2FLIVE"; - -if (!identifier || !password) { - console.error("[p2-fin-live][rollback] missing admin credentials"); - process.exit(1); -} - -async function fetchJson(url, init = {}) { - const response = await fetch(url, { - ...init, - signal: AbortSignal.timeout(20_000), - }); - const text = await response.text(); - let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch { - json = null; - } - return { response, json }; -} - -function errorSummary(json, status) { - const error = json?.error ?? {}; - return `${status} ${error.code ?? ""} ${error.message ?? ""}`.trim(); -} - -const login = await fetchJson(`${baseUrl}/api/auth/login`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-device-fingerprint": "p2-fin-live-runner-rollback", - }, - body: JSON.stringify({ identifier, password }), -}); -if (!login.response.ok || !login.json?.data?.accessToken) { - console.error(`[p2-fin-live][rollback] login failed: ${errorSummary(login.json, login.response.status)}`); - process.exit(1); -} - -const token = login.json.data.accessToken; -const list = await fetchJson(`${baseUrl}/api/system/feature-flags`, { - headers: { authorization: `Bearer ${token}`, accept: "application/json" }, -}); -if (!list.response.ok) { - console.error(`[p2-fin-live][rollback] list flags failed: ${errorSummary(list.json, list.response.status)}`); - process.exit(1); -} - -const flag = list.json?.data?.items?.find((item) => item.flagKey === "payment_batch_v1"); -if (!flag) { - console.error("[p2-fin-live][rollback] missing payment_batch_v1"); - process.exit(1); -} - -if (flag.overrideMode === "force_off" && flag.effectiveEnabled === false) { - console.log("[p2-fin-live][rollback] payment_batch_v1 already force_off"); - process.exit(0); -} - -const updated = await fetchJson(`${baseUrl}/api/system/feature-flags/payment_batch_v1`, { - method: "PUT", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json", - accept: "application/json", - }, - body: JSON.stringify({ - overrideMode: "force_off", - rolloutType: "global", - departmentIds: [], - note: `${issueId} runner final rollback`, - expectedVersion: flag.version, - }), -}); -if (!updated.response.ok) { - console.error(`[p2-fin-live][rollback] update failed: ${errorSummary(updated.json, updated.response.status)}`); - process.exit(1); -} -const item = updated.json?.data?.item; -console.log(`[p2-fin-live][rollback] payment_batch_v1 override=${item?.overrideMode ?? "unknown"} effective=${item?.effectiveEnabled ?? "unknown"}`); -NODE +restore_payment_batch_flag_snapshot() { + P2_FIN_LIVE_APPROVED=1 LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \ + node "$ROOT_DIR/scripts/qa/payment-batch-flag-snapshot.mjs" restore \ + --snapshot-file "$FLAG_SNAPSHOT_FILE" } write_summary_json() { @@ -146,27 +65,18 @@ write_summary_json() { QA_BASE_URL="$PUBLIC_BASE_URL" \ QA_MODE="p2-fin-live-window" \ QA_STATUS="$qa_status" \ - QA_SUMMARY_NOTES="${QA_ISSUE_ID:-P2FLIVE} 公网短窗口:临时启用 payment_batch_v1,完成测试付款闭环后强制回滚到 flag_force_off;不部署、不接真实支付、不开放图片 OCR。" \ + QA_SUMMARY_NOTES="${QA_ISSUE_ID:-P2FLIVE} 公网短窗口:临时启用 payment_batch_v1,结束后恢复调用前完整快照;不部署、不接真实支付、不开放图片 OCR。" \ node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true } finish() { - if [[ "${P2_FIN_LIVE_APPROVED:-0}" == "1" ]]; then - echo ">>> [p2-fin-live] final-force-flag-off" | tee -a "$SUMMARY_TXT" - if force_payment_batch_flag_off > >(tee "$REPORT_DIR/final-force-flag-off.log") 2>&1; then - echo "[p2-fin-live][PASS] final-force-flag-off" | tee -a "$SUMMARY_TXT" + if [[ "${P2_FIN_LIVE_APPROVED:-0}" == "1" && -f "$FLAG_SNAPSHOT_FILE" ]]; then + echo ">>> [p2-fin-live] restore-caller-flag-snapshot" | tee -a "$SUMMARY_TXT" + if restore_payment_batch_flag_snapshot > >(tee "$REPORT_DIR/restore-caller-flag-snapshot.log") 2>&1; then + echo "[p2-fin-live][PASS] restore-caller-flag-snapshot" | tee -a "$SUMMARY_TXT" else local code="$?" - echo "[p2-fin-live][FAIL] final-force-flag-off exit=$code" | tee -a "$SUMMARY_TXT" - STATUS=1 - fi - - echo ">>> [p2-fin-live] final-flag-off-check" | tee -a "$SUMMARY_TXT" - if LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" node "$ROOT_DIR/scripts/qa/check-payment-batch-flag-off.mjs" --json > >(tee "$REPORT_DIR/final-flag-off-check.log") 2>&1; then - echo "[p2-fin-live][PASS] final-flag-off-check" | tee -a "$SUMMARY_TXT" - else - local code="$?" - echo "[p2-fin-live][FAIL] final-flag-off-check exit=$code" | tee -a "$SUMMARY_TXT" + echo "[p2-fin-live][FAIL] restore-caller-flag-snapshot exit=$code" | tee -a "$SUMMARY_TXT" STATUS=1 fi fi @@ -187,8 +97,13 @@ if [[ "${P2_FIN_LIVE_APPROVED:-0}" != "1" ]]; then fi run_shell_step "live-env-strict" "LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs' --strict" -run_shell_step "pre-flag-off-check" "LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ROOT_DIR/scripts/qa/check-payment-batch-flag-off.mjs' --json" -run_shell_step "p2-fin-live-spec" "cd '$CLIENT_DIR' && PLAYWRIGHT_MANUAL=1 E2E_MODE=live P2_FIN_LIVE_APPROVED=1 QA_ISSUE_ID='${QA_ISSUE_ID:-P2FLIVE}' P2_FIN_LIVE_MARKER_PREFIX='${P2_FIN_LIVE_MARKER_PREFIX:-${QA_ISSUE_ID:-P2FLIVE}}' P2_FIN_LIVE_REPORT_DIR='$REPORT_DIR' LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/manual/p2-fin-live-payment-batch.spec.ts --workers=1 --reporter=list" +[[ "$STATUS" -eq 0 ]] || exit "$STATUS" + +run_shell_step "capture-caller-flag-snapshot" "P2_FIN_LIVE_APPROVED=1 LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ROOT_DIR/scripts/qa/payment-batch-flag-snapshot.mjs' capture --snapshot-file '$FLAG_SNAPSHOT_FILE'" +[[ "$STATUS" -eq 0 ]] || exit "$STATUS" + +ALREADY_ON_MODE="$(node -e 'const snapshot = require(process.argv[1]); process.stdout.write(snapshot.effectiveEnabled === true ? "1" : "0")' "$FLAG_SNAPSHOT_FILE")" +run_shell_step "p2-fin-live-spec" "cd '$CLIENT_DIR' && PLAYWRIGHT_MANUAL=1 E2E_MODE=live P2_FIN_LIVE_APPROVED=1 P2_FIN_LIVE_FINAL_STATE=restore P2_FIN_LIVE_ALREADY_ON='$ALREADY_ON_MODE' QA_ISSUE_ID='${QA_ISSUE_ID:-P2FLIVE}' P2_FIN_LIVE_MARKER_PREFIX='${P2_FIN_LIVE_MARKER_PREFIX:-${QA_ISSUE_ID:-P2FLIVE}}' P2_FIN_LIVE_REPORT_DIR='$REPORT_DIR' LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/manual/p2-fin-live-payment-batch.spec.ts --workers=1 --reporter=list" if [[ "${P2_FIN_LIVE_SKIP_MOBILE:-0}" == "1" ]]; then echo "[p2-fin-live][SKIP] mobile-wecom P2_FIN_LIVE_SKIP_MOBILE=1" | tee -a "$SUMMARY_TXT" diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index d25a26e8..422cde9e 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -283,10 +283,10 @@ const DEFAULT_FEATURE_FLAGS = [ }, { flag_key: "payment_batch_v1", - override_mode: "force_off", + override_mode: "force_on", rollout_type: "global", department_ids: [], - note: "bootstrap default: force_off", + note: "bootstrap default: force_on", }, { flag_key: "portal_home_v1", @@ -1093,7 +1093,9 @@ if (require.main === module) { module.exports = { DEFAULT_APPROVAL_RULES, + DEFAULT_FEATURE_FLAGS, ENABLED_EXPENSE_TYPES, seedApprovalRules, seedExpenseTypes, + seedFeatureFlags, }; diff --git a/server/src/scripts/smoke-trialrun-runtime.js b/server/src/scripts/smoke-trialrun-runtime.js index 3fe17364..a8ee3ac6 100644 --- a/server/src/scripts/smoke-trialrun-runtime.js +++ b/server/src/scripts/smoke-trialrun-runtime.js @@ -21,6 +21,7 @@ const REQUIRED_ENV_VALUES = { PROJECT_LINKING_ENABLED: 'true', PROJECT_COMMUNICATION_LOG_ENABLED: 'true', PROJECT_CLOSURE_CHECKLIST_ENABLED: 'true', + PAYMENT_BATCH_ENABLED: 'true', }; const REQUIRED_FLAGS = [ @@ -35,6 +36,7 @@ const REQUIRED_FLAGS = [ FEATURE_FLAG_KEYS.projectLinking, FEATURE_FLAG_KEYS.projectCommunicationLog, FEATURE_FLAG_KEYS.projectClosureChecklist, + FEATURE_FLAG_KEYS.paymentBatch, ]; const REQUIRED_MAIN_NAV_KEYS = [ diff --git a/server/tests/unit/scripts/deployment-safety-contract.test.js b/server/tests/unit/scripts/deployment-safety-contract.test.js index 5af74348..84f7d511 100644 --- a/server/tests/unit/scripts/deployment-safety-contract.test.js +++ b/server/tests/unit/scripts/deployment-safety-contract.test.js @@ -104,4 +104,23 @@ describe("deployment safety contract", () => { expect(external).not.toContain("from=${config.emailFrom}"); expect(runtime).not.toContain("${adminEmployee.employee_no}"); }); + + it("keeps the formal payment batch baseline enabled and restores QA caller state", async () => { + const envExample = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/.env.example"), "utf8"); + const runtime = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/smoke-trialrun-runtime.js"), "utf8"); + const shortWindow = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "scripts/qa/run-p2-fin-live-window.sh"), "utf8"); + const enableWindow = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "scripts/qa/run-fin-prod-enable-window.sh"), "utf8"); + const liveSpec = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts"), "utf8"); + + expect(envExample).toMatch(/^PAYMENT_BATCH_ENABLED=true$/m); + expect(runtime).toContain("PAYMENT_BATCH_ENABLED: 'true'"); + expect(runtime).toContain("FEATURE_FLAG_KEYS.paymentBatch"); + expect(shortWindow).toContain("restore-caller-flag-snapshot"); + expect(shortWindow).toContain("P2_FIN_LIVE_ALREADY_ON='$ALREADY_ON_MODE'"); + expect(enableWindow).toContain("restore-caller-flag-snapshot"); + expect(liveSpec).toContain("restoreFlagSnapshot"); + expect(liveSpec).not.toContain("forcePaymentBatchOff"); + expect(shortWindow).not.toContain("force_payment_batch_flag_off"); + expect(enableWindow).not.toContain("force_payment_batch_flag_off"); + }); }); diff --git a/server/tests/unit/scripts/payment-batch-flag-snapshot.test.js b/server/tests/unit/scripts/payment-batch-flag-snapshot.test.js new file mode 100644 index 00000000..59557f1f --- /dev/null +++ b/server/tests/unit/scripts/payment-batch-flag-snapshot.test.js @@ -0,0 +1,148 @@ +const { execFile } = require("node:child_process"); +const { mkdtemp, readFile, rm } = require("node:fs/promises"); +const http = require("node:http"); +const os = require("node:os"); +const path = require("node:path"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); +const ROOT_DIR = path.resolve(__dirname, "../../../.."); +const SNAPSHOT_SCRIPT = path.join(ROOT_DIR, "scripts/qa/payment-batch-flag-snapshot.mjs"); + +async function readJsonBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); +} + +async function withFlagApi(initialFlag, callback) { + let flag = { ...initialFlag, departmentIds: [...initialFlag.departmentIds] }; + const server = http.createServer(async (request, response) => { + response.setHeader("content-type", "application/json"); + if (request.method === "POST" && request.url === "/api/auth/login") { + response.end(JSON.stringify({ data: { accessToken: "synthetic-access-token" } })); + return; + } + if (request.method === "GET" && request.url === "/api/system/feature-flags") { + response.end(JSON.stringify({ data: { items: [flag] } })); + return; + } + if (request.method === "PUT" && request.url === "/api/system/feature-flags/payment_batch_v1") { + const body = await readJsonBody(request); + if (body.expectedVersion !== flag.version) { + response.statusCode = 409; + response.end(JSON.stringify({ error: { code: "CONFLICT" } })); + return; + } + flag = { + ...flag, + overrideMode: body.overrideMode, + rolloutType: body.rolloutType, + departmentIds: [...body.departmentIds], + note: body.note, + version: flag.version + 1, + effectiveEnabled: body.overrideMode === "force_on", + effectiveReason: `mock_${body.overrideMode}`, + }; + response.end(JSON.stringify({ data: { item: flag } })); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: { code: "NOT_FOUND" } })); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const baseUrl = `http://127.0.0.1:${address.port}`; + try { + await callback({ + baseUrl, + getFlag: () => flag, + setFlag: (next) => { + flag = { ...next, departmentIds: [...next.departmentIds] }; + }, + }); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +} + +function runSnapshotScript(action, baseUrl, snapshotFile) { + return execFileAsync(process.execPath, [SNAPSHOT_SCRIPT, action, "--snapshot-file", snapshotFile], { + env: { + ...process.env, + P2_FIN_LIVE_APPROVED: "1", + LIVE_SMOKE_BASE_URL: baseUrl, + LIVE_SMOKE_ADMIN_IDENTIFIER: "synthetic-admin", + LIVE_SMOKE_ADMIN_PASSWORD: "synthetic-password", + }, + }); +} + +describe.each([ + { + label: "force_off allowlist", + initial: { + flagKey: "payment_batch_v1", + overrideMode: "force_off", + rolloutType: "department_allowlist", + departmentIds: ["00000000-0000-4000-8000-000000000001"], + note: "original off note", + version: 3, + effectiveEnabled: false, + effectiveReason: "flag_force_off", + }, + }, + { + label: "force_on global", + initial: { + flagKey: "payment_batch_v1", + overrideMode: "force_on", + rolloutType: "global", + departmentIds: [], + note: "original on note", + version: 8, + effectiveEnabled: true, + effectiveReason: "flag_force_on_global", + }, + }, +])("payment batch flag snapshot: $label", ({ initial }) => { + it("restores the caller configuration instead of forcing a fixed mode", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-payment-flag-")); + const snapshotFile = path.join(tempDir, "snapshot.json"); + try { + await withFlagApi(initial, async ({ baseUrl, getFlag, setFlag }) => { + await runSnapshotScript("capture", baseUrl, snapshotFile); + const saved = JSON.parse(await readFile(snapshotFile, "utf8")); + expect(saved).toMatchObject({ + overrideMode: initial.overrideMode, + rolloutType: initial.rolloutType, + departmentIds: initial.departmentIds, + note: initial.note, + effectiveEnabled: initial.effectiveEnabled, + }); + + setFlag({ + ...getFlag(), + overrideMode: initial.overrideMode === "force_on" ? "force_off" : "force_on", + rolloutType: "global", + departmentIds: [], + note: "temporary window state", + version: getFlag().version + 1, + effectiveEnabled: initial.overrideMode !== "force_on", + effectiveReason: "temporary", + }); + + await runSnapshotScript("restore", baseUrl, snapshotFile); + expect(getFlag()).toMatchObject({ + overrideMode: initial.overrideMode, + rolloutType: initial.rolloutType, + departmentIds: initial.departmentIds, + note: initial.note, + }); + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/server/tests/unit/scripts/seed-config.defaults.test.js b/server/tests/unit/scripts/seed-config.defaults.test.js index 8c6b2073..e4c4c550 100644 --- a/server/tests/unit/scripts/seed-config.defaults.test.js +++ b/server/tests/unit/scripts/seed-config.defaults.test.js @@ -1,8 +1,10 @@ const { DEFAULT_APPROVAL_RULES, + DEFAULT_FEATURE_FLAGS, ENABLED_EXPENSE_TYPES, seedApprovalRules, seedExpenseTypes, + seedFeatureFlags, } = require("../../../src/scripts/seed-config"); describe("default travel expense types", () => { @@ -108,3 +110,35 @@ describe("small-team default reimbursement approval rules", () => { }); }); }); + +describe("trialrun payment batch baseline", () => { + it("creates payment_batch_v1 as globally enabled", () => { + expect(DEFAULT_FEATURE_FLAGS).toContainEqual({ + flag_key: "payment_batch_v1", + override_mode: "force_on", + rollout_type: "global", + department_ids: [], + note: "bootstrap default: force_on", + }); + }); + + it("preserves an existing payment flag instead of overwriting it", async () => { + const create = vi.fn(async ({ data }) => { + if (data.flag_key === "payment_batch_v1") { + throw Object.assign(new Error("unique conflict"), { code: "P2002" }); + } + return data; + }); + const prisma = { featureFlag: { create } }; + + await expect(seedFeatureFlags(prisma)).resolves.toBeUndefined(); + expect(create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + flag_key: "payment_batch_v1", + override_mode: "force_on", + }), + }); + expect(prisma.featureFlag.update).toBeUndefined(); + expect(prisma.featureFlag.upsert).toBeUndefined(); + }); +});