From 20a783543b942444c467c010bbbc673b26ad200e Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 17 May 2026 00:19:39 +0800 Subject: [PATCH] =?UTF-8?q?P2FLIVE=20=E6=94=B6=E5=8F=A3=E5=85=AC=E7=BD=91?= =?UTF-8?q?=E4=BB=98=E6=AC=BE=E6=89=B9=E6=AC=A1=E7=9F=AD=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manual/p2-fin-live-payment-batch.spec.ts | 522 ++++++++++++++++++ docs/trialrun/人工试运行报告.md | 9 +- docs/文档事实源状态矩阵.md | 9 +- docs/文档索引.md | 8 +- ...42-08-p2-fin-payment-batch-live-enable.csv | 11 + scripts/qa/run-p2-fin-live-window.sh | 197 +++++++ 6 files changed, 746 insertions(+), 10 deletions(-) create mode 100644 client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts create mode 100644 issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv create mode 100755 scripts/qa/run-p2-fin-live-window.sh 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 new file mode 100644 index 00000000..8d5f1bf0 --- /dev/null +++ b/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts @@ -0,0 +1,522 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { apiGetData, apiPostData, type AuthenticatedApiClient } from "../helpers/auth"; +import { createLiveApiClient, type LiveSmokeRole } from "../helpers/liveAuth"; +import { approveWorkflowTaskViaApi, waitForWorkflowTask } from "../helpers/workflow"; + +type FeatureFlagItem = { + flagKey: string; + overrideMode: "inherit" | "force_on" | "force_off"; + rolloutType: "global" | "department_allowlist"; + departmentIds: string[]; + note: string; + version: number; + effectiveEnabled: boolean; + effectiveReason: string; +}; + +type FeatureFlagSnapshot = Pick< + FeatureFlagItem, + "flagKey" | "overrideMode" | "rolloutType" | "departmentIds" | "version" | "effectiveEnabled" | "effectiveReason" +>; + +type ReimbursementDetail = { + id: string; + formNumber: string | null; + status: string; + totalAmount: string | null; +}; + +type InvoiceDetail = { + id: string; + status: string; + linkedReimbursementId: string | null; + archivedAt: string | null; +}; + +type PaymentBatch = { + id: string; + name: string; + status: string; + reviewRequirement: { + completedReviewCount: number; + requiredReviewCount: number; + nextStep: string; + }; +}; + +type FinanceLedgerResponse = { + total: number; + items: Array<{ + id: string; + formNumber: string | null; + status: string; + payment: { + status: string; + paymentVoucherNo: string | null; + paidAt: string | null; + }; + }>; +}; + +type AuditLogListResponse = { + items: Array<{ + action: string; + targetId: string | null; + actorId: string | null; + extra: Record | null; + }>; +}; + +type LiveSummary = { + issueId: string; + mode: string; + status: "RUNNING" | "PASS" | "FAIL"; + baseUrl: string; + marker: string; + startedAt: string; + completedAt?: string; + stages: Record; + error?: string; +}; + +const issueId = "P2FLIVE"; +const baseUrl = process.env.LIVE_SMOKE_BASE_URL?.trim() || "https://oa.hyinda.com"; +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"); + +function nowMarker() { + return `P2FLIVE-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +function today() { + return new Date().toISOString().slice(0, 10); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function sanitizeError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer ") + .replace(/password[^,}\n]*/gi, "password=") + .slice(0, 1000); +} + +function snapshotFlag(item: FeatureFlagItem): FeatureFlagSnapshot { + return { + flagKey: item.flagKey, + overrideMode: item.overrideMode, + rolloutType: item.rolloutType, + departmentIds: item.departmentIds, + version: item.version, + effectiveEnabled: item.effectiveEnabled, + effectiveReason: item.effectiveReason, + }; +} + +async function parseData(response: { ok(): boolean; status(): number; text(): Promise }, label: string) { + const text = await response.text(); + let payload: { data?: T; error?: { code?: string; message?: string; details?: unknown } } = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = {}; + } + + if (!response.ok()) { + const error = payload.error; + throw new Error(`${label} failed: HTTP ${response.status()} ${error?.code ?? ""} ${error?.message ?? ""}`.trim()); + } + + return payload.data as T; +} + +async function createLiveClientWithRetry(role: LiveSmokeRole): Promise { + let lastError: unknown = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + return await createLiveApiClient(role); + } catch (error) { + lastError = error; + if (!/429|请求过于频繁|rate.?limit|too many requests/i.test(sanitizeError(error)) || attempt > 0) { + break; + } + await sleep(Number.parseInt(process.env.P2_FIN_LIVE_LOGIN_RETRY_WAIT_MS ?? "65000", 10)); + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +async function loadFlag(api: APIRequestContext, flagKey: string) { + const data = await apiGetData<{ items: FeatureFlagItem[] }>(api, "/api/system/feature-flags"); + const item = data.items.find((candidate) => candidate.flagKey === flagKey); + if (!item) { + throw new Error(`Missing feature flag: ${flagKey}`); + } + return item; +} + +async function updateFlag( + api: APIRequestContext, + flagKey: string, + overrideMode: FeatureFlagItem["overrideMode"], + note: string, +) { + const current = await loadFlag(api, flagKey); + const response = await api.put(`/api/system/feature-flags/${flagKey}`, { + data: { + overrideMode, + rolloutType: "global", + departmentIds: [], + note, + expectedVersion: current.version, + }, + }); + const data = await parseData<{ item: FeatureFlagItem }>(response, `update ${flagKey}`); + return data.item; +} + +async function forcePaymentBatchOff(api: APIRequestContext, note: string) { + const current = await loadFlag(api, "payment_batch_v1"); + if (current.overrideMode === "force_off" && !current.effectiveEnabled) { + return current; + } + return updateFlag(api, "payment_batch_v1", "force_off", note); +} + +function resolveLivePdfBuffer(marker: string) { + const sample = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim() || process.env.LIVE_OCR_INVOICE_FILE?.trim() || ""; + if (!sample || !existsSync(sample) || path.extname(sample).toLowerCase() !== ".pdf") { + throw new Error("missing_authorized_pdf_sample"); + } + const source = readFileSync(sample); + return Buffer.concat([source, Buffer.from(`\n% ${marker}\n`, "utf8")]); +} + +async function firstEnabledExpenseTypeId(api: APIRequestContext) { + const data = await apiGetData<{ items: Array<{ id: string; enabled?: boolean }> }>(api, "/api/expense-types?enabled=true"); + const item = data.items.find((candidate) => candidate.enabled !== false) ?? data.items[0]; + if (!item?.id) { + throw new Error("missing_enabled_expense_type"); + } + return item.id; +} + +async function createTestReimbursement(api: APIRequestContext, marker: string) { + const reimbursement = await apiPostData(api, "/api/reimbursements", { + description: `P2FINLIVE 公网付款测试 ${marker}`, + reimbursementDate: today(), + }); + const expenseTypeId = await firstEnabledExpenseTypeId(api); + await apiPostData>(api, `/api/reimbursements/${reimbursement.id}/items`, { + expenseTypeId, + expenseDate: today(), + amount: "168.30", + description: `P2FINLIVE 小额测试 ${marker}`, + }); + return apiGetData(api, `/api/reimbursements/${reimbursement.id}`); +} + +async function uploadAndBindPdfInvoice(api: APIRequestContext, reimbursementId: string, marker: string) { + const buffer = resolveLivePdfBuffer(marker); + const response = await api.post("/api/invoice-pool/upload", { + multipart: { + ocr_type: "general", + file: { + name: `p2-fin-live-${marker}.pdf`, + mimeType: "application/pdf", + buffer, + }, + }, + }); + const upload = await parseData<{ + invoicePoolId: string | null; + status: string; + uploadAction?: string; + unavailableReason?: string | null; + }>(response, "upload invoice"); + if (!upload.invoicePoolId || upload.status !== "available") { + throw new Error(`invoice_not_bindable:${upload.status}:${upload.uploadAction ?? "-"}:${upload.unavailableReason ?? "-"}`); + } + + await apiPostData>(api, `/api/invoice-pool/${upload.invoicePoolId}/bind`, { reimbursementId }); + return upload.invoicePoolId; +} + +async function approveReimbursementToApproved(input: { + employeeApi: APIRequestContext; + managerApi: APIRequestContext; + financeApi: APIRequestContext; + reimbursementId: string; +}) { + const submit = await apiPostData<{ currentTaskId?: string }>( + input.employeeApi, + `/api/reimbursements/${input.reimbursementId}/submit`, + {}, + ); + const managerTaskId = + submit.currentTaskId ?? + (await waitForWorkflowTask(input.managerApi, { entityType: "reimbursement", entityId: input.reimbursementId })).id; + await approveWorkflowTaskViaApi(input.managerApi, managerTaskId); + + let financeTaskId = ""; + try { + financeTaskId = (await waitForWorkflowTask(input.financeApi, { + entityType: "reimbursement", + entityId: input.reimbursementId, + attempts: 12, + intervalMs: 1000, + })).id; + } catch { + financeTaskId = ""; + } + + if (financeTaskId) { + await approveWorkflowTaskViaApi(input.financeApi, financeTaskId); + } + + await expect + .poll(async () => { + const detail = await apiGetData(input.financeApi, `/api/reimbursements/${input.reimbursementId}`); + return detail.status; + }, { timeout: 30_000 }) + .toBe("approved"); + + return { managerTaskId, financeTaskId }; +} + +async function createPaymentBatch(api: APIRequestContext, input: { reimbursementId: string; name: string; marker: string }) { + const data = await apiPostData<{ item: PaymentBatch }>(api, "/api/payment-batches", { + entityType: "reimbursement", + name: input.name, + entityIds: [input.reimbursementId], + defaultPaymentDate: today(), + defaultPaymentChannel: "bank", + voucherPrefix: `P2LIVE-${input.marker.slice(-6)}`, + }); + return data.item; +} + +async function expectPostRejected(api: APIRequestContext, pathName: string, data: unknown, label: string) { + const response = await api.post(pathName, { data }); + expect(response.ok(), `${label} should be rejected`).toBe(false); + expect([403, 409, 404]).toContain(response.status()); + return response.status(); +} + +function isWildcardSuperAdmin(client: AuthenticatedApiClient) { + const roleCodes = client.me.roleCodes ?? []; + return roleCodes.includes("admin"); +} + +async function fetchFinanceLedger(api: APIRequestContext, query: string) { + const params = new URLSearchParams({ page: "1", pageSize: "20", q: query }); + return apiGetData(api, `/api/ap-operations/reimbursement-ledger?${params.toString()}`); +} + +async function expectAuditAction( + api: APIRequestContext, + action: string, + predicate: (item: AuditLogListResponse["items"][number]) => boolean, +) { + await expect + .poll(async () => { + const data = await apiGetData( + api, + `/api/audit-logs?action=${encodeURIComponent(action)}&page=1&pageSize=50`, + ); + return data.items.some(predicate); + }, { timeout: 20_000 }) + .toBe(true); +} + +test.describe("P2-FIN-LIVE 公网付款批次短窗口", () => { + test.skip(process.env.E2E_MODE !== "live", "仅在 live 模式下执行"); + test.describe.configure({ mode: "serial" }); + + test("公网付款批次 flag-on 闭环后强制回滚到 flag-off", async ({}, testInfo) => { + test.setTimeout(420_000); + if (process.env.P2_FIN_LIVE_APPROVED !== "1") { + throw new Error("P2_FIN_LIVE_APPROVED=1 is required for public write test"); + } + + mkdirSync(reportDir, { recursive: true }); + const marker = nowMarker(); + const summary: LiveSummary = { + issueId, + mode: "p2-fin-live", + status: "RUNNING", + baseUrl, + marker, + startedAt: new Date().toISOString(), + stages: {}, + }; + + const clients: AuthenticatedApiClient[] = []; + let admin: AuthenticatedApiClient | null = null; + let caughtError: unknown = null; + let rollbackError: unknown = null; + + try { + admin = await createLiveClientWithRetry("admin"); + const employee = await createLiveClientWithRetry("employee"); + const manager = await createLiveClientWithRetry("manager"); + const finance = await createLiveClientWithRetry("finance"); + const financeMgr = await createLiveClientWithRetry("financeMgr"); + clients.push(admin, employee, manager, finance, financeMgr); + + const paymentFlagBefore = await loadFlag(admin.api, "payment_batch_v1"); + const erpFlagBefore = await loadFlag(admin.api, "erp_sync"); + summary.stages.preflight = { + paymentFlagBefore: snapshotFlag(paymentFlagBefore), + erpSyncEffectiveEnabled: erpFlagBefore.effectiveEnabled, + }; + expect(paymentFlagBefore.effectiveEnabled, "payment_batch_v1 should be off before public live window").toBe(false); + expect(erpFlagBefore.effectiveEnabled, "erp_sync must remain off for this test").toBe(false); + + const reimbursement = await createTestReimbursement(employee.api, marker); + const invoiceId = await uploadAndBindPdfInvoice(employee.api, reimbursement.id, marker); + const approval = await approveReimbursementToApproved({ + employeeApi: employee.api, + managerApi: manager.api, + financeApi: finance.api, + reimbursementId: reimbursement.id, + }); + const approved = await apiGetData(finance.api, `/api/reimbursements/${reimbursement.id}`); + summary.stages.reimbursement = { + reimbursementId: reimbursement.id, + formNumber: approved.formNumber, + status: approved.status, + invoiceId, + managerTaskId: approval.managerTaskId, + financeTaskApproved: Boolean(approval.financeTaskId), + }; + + const paymentFlagOn = await updateFlag( + admin.api, + "payment_batch_v1", + "force_on", + `P2FLIVE short window ${marker}`, + ); + summary.stages.flagOn = { paymentFlagOn: snapshotFlag(paymentFlagOn) }; + expect(paymentFlagOn.effectiveEnabled).toBe(true); + const erpFlagAfterPaymentOn = await loadFlag(admin.api, "erp_sync"); + expect(erpFlagAfterPaymentOn.effectiveEnabled, "erp_sync must not be opened by P2FLIVE").toBe(false); + + const batchName = `P2FINLIVE 公网付款批次 ${marker}`; + const adminWildcard = isWildcardSuperAdmin(admin); + const rejectedCreate: Record = { + employee: await expectPostRejected(employee.api, "/api/payment-batches", { + entityType: "reimbursement", + name: `${batchName} employee denied`, + entityIds: [reimbursement.id], + }, "employee create payment batch"), + manager: await expectPostRejected(manager.api, "/api/payment-batches", { + entityType: "reimbursement", + name: `${batchName} manager denied`, + entityIds: [reimbursement.id], + }, "manager create payment batch"), + }; + if (adminWildcard) { + rejectedCreate.admin = "SKIP_WILDCARD_SUPERADMIN"; + } else { + rejectedCreate.admin = await expectPostRejected(admin.api, "/api/payment-batches", { + entityType: "reimbursement", + name: `${batchName} admin denied`, + entityIds: [reimbursement.id], + }, "admin-only create payment batch"); + } + + let batch = await createPaymentBatch(finance.api, { + reimbursementId: reimbursement.id, + name: batchName, + marker, + }); + const adminExecuteBoundary = adminWildcard + ? "SKIP_WILDCARD_SUPERADMIN" + : await expectPostRejected(admin.api, `/api/payment-batches/${batch.id}/execute`, {}, "admin-only execute payment batch"); + + batch = (await apiPostData<{ item: PaymentBatch }>(financeMgr.api, `/api/payment-batches/${batch.id}/review`, { + comment: `P2FLIVE 复核 ${marker}`, + })).item; + expect(batch.reviewRequirement.completedReviewCount).toBeGreaterThanOrEqual(1); + + batch = (await apiPostData<{ item: PaymentBatch }>(finance.api, `/api/payment-batches/${batch.id}/execute`, {})).item; + expect(batch.status).toBe("executed"); + + const paid = await apiGetData(finance.api, `/api/reimbursements/${reimbursement.id}`); + expect(paid.status).toBe("paid"); + const invoice = await apiGetData(employee.api, `/api/invoice-pool/${invoiceId}`); + expect(invoice.status).toBe("archived"); + expect(invoice.linkedReimbursementId).toBe(reimbursement.id); + + const ledger = await fetchFinanceLedger(finance.api, paid.formNumber ?? reimbursement.id); + const ledgerItem = ledger.items.find((item) => item.id === reimbursement.id || item.formNumber === paid.formNumber); + expect(ledgerItem?.payment.status).toBe("paid"); + + const exportResponse = await finance.api.post("/api/exports/ap-operations", { + data: { format: "csv", ledgerSearch: paid.formNumber ?? reimbursement.id }, + }); + expect(exportResponse.status(), "finance AP export should succeed while flag is on").toBe(200); + + await expectAuditAction(admin.api, "FEATURE_FLAG_UPDATED", (item) => + item.extra?.flagKey === "payment_batch_v1" && + (item.extra?.to as Record | undefined)?.overrideMode === "force_on", + ); + await expectAuditAction(admin.api, "PAYMENT_BATCH_CREATED", (item) => item.targetId === batch.id); + await expectAuditAction(admin.api, "PAYMENT_BATCH_REVIEWED", (item) => item.targetId === batch.id); + await expectAuditAction(admin.api, "PAYMENT_BATCH_EXECUTED", (item) => item.targetId === batch.id); + await expectAuditAction(admin.api, "REIMBURSEMENT_PAYMENT_MARKED", (item) => item.targetId === reimbursement.id); + await expectAuditAction(admin.api, "EXPORT_AP_OPERATIONS", (item) => item.actorId === finance.me.id); + + summary.stages.payment = { + batchId: batch.id, + batchStatus: batch.status, + reimbursementStatus: paid.status, + invoiceStatus: invoice.status, + ledgerPaymentStatus: ledgerItem?.payment.status ?? "missing", + rejectedCreate, + adminExecuteBoundary, + exportStatus: exportResponse.status(), + }; + summary.status = "PASS"; + } catch (error) { + caughtError = error; + summary.status = "FAIL"; + summary.error = sanitizeError(error); + } finally { + if (admin) { + try { + const rolledBack = await forcePaymentBatchOff(admin.api, `P2FLIVE rollback ${marker}`); + summary.stages.rollback = { paymentFlagAfterRollback: snapshotFlag(rolledBack) }; + expect(rolledBack.effectiveEnabled).toBe(false); + } catch (error) { + rollbackError = error; + summary.status = "FAIL"; + summary.stages.rollback = { error: sanitizeError(error) }; + } + } + + summary.completedAt = new Date().toISOString(); + writeFileSync(summaryPath, JSON.stringify(summary, null, 2), "utf8"); + await testInfo.attach("p2-fin-live-summary.json", { + path: summaryPath, + contentType: "application/json", + }); + await Promise.all(clients.map((client) => client.dispose().catch(() => undefined))); + } + + if (caughtError) { + throw caughtError; + } + if (rollbackError) { + throw rollbackError; + } + }); +}); diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index 3ea683da..6acb270f 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -24,7 +24,8 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 - 2026-05-16 P2Q 已完成 P2OCRS 后续任务队列规划;P2AF 后图片 OCR 不再作为默认主线等待样票,后续默认按 UX8 真实反馈池、P2-FIN-LIVE 公网启用窗口、P2-WF、P2-BIZ 和持续上线门禁拆独立合同推进。 - 2026-05-16 P2AUTO 已完成自动化优先回归:后端 focused、前端 typecheck/build、高风险确认、隔离核心业务流、隔离付款批次闭环、公网 readiness/PDF live OCR、公网移动矩阵和企业微信 UA/渲染自动化均通过;PDF 样票和 live smoke 凭据可用,P2AF 后 provider-only 默认结论为 `FREEZE_BY_DEFAULT`,不开放图片 OCR,不启用公网 `payment_batch_v1`,未部署。 - 2026-05-16 P2AF 已完成可自动实施收口:图片 OCR 默认 `FREEZE_BY_DEFAULT`,PDF-only OCR 继续维护;新增 QA-only 回归聚合和付款批次公网关闭态只读门禁,公网 `payment_batch_v1` 继续 `flag_force_off`,本轮不做业务实现、不部署。 -- 2026-05-16 P2-FIN-LIVE-DECISION 已完成公网付款批次启用窗口决策:live smoke/PDF OCR readiness 通过,付款批次公网关闭态只读门禁通过;由于尚未明确公网启用窗口、财务/复核参与人、flag 启停人、回滚联系人、暂停/继续决策人和测试报销单来源,结论为 `HOLD_FOR_WINDOW`,公网 `payment_batch_v1` 继续关闭。 +- 2026-05-16 P2-FIN-LIVE-DECISION 已完成公网付款批次启用窗口决策:live smoke/PDF OCR readiness 通过,付款批次公网关闭态只读门禁通过;初始结论为 `HOLD_FOR_WINDOW`,随后用户批准立即短窗口、现有 live smoke 测试账号和新建测试报销,进入 `P2-FIN-LIVE`。 +- 2026-05-17 P2-FIN-LIVE 已按用户批准完成公网付款批次短窗口受控测试:使用现有 live smoke 测试账号新建专用测试报销,临时打开 `payment_batch_v1`,完成付款批次创建、复核、执行、AP 导出和审计核验后强制回到 `flag_force_off`;未部署,不接真实支付,不写 ERP 实时凭证,不开放图片 OCR。 ## 关键证据索引 @@ -56,7 +57,8 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | P2Q | 已收口:P2OCRS 后续队列和启动门槛完成;图片 OCR 默认冻结,所有实现或公网启用均需另建独立合同 | `issues/2026-05-16_10-35-22-p2-post-p2ocrs-followup-queue.csv` | | P2AUTO | 已收口:自动化优先回归和样票 provider 门禁完成;核心隔离业务流通过、付款批次隔离闭环 1 passed、公网 readiness/PDF live OCR 通过、公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed;provider 默认结论改为 `FREEZE_BY_DEFAULT` | `issues/2026-05-16_20-09-49-p2-automated-regression-sweep.csv`、`artifacts/qa-flow/business-flow-20260516-203001/summary.txt`、`artifacts/qa-flow/business-flow-20260516-203856/summary.txt`、`artifacts/qa-flow/live-smoke-readiness-20260516-204157/` | | P2AF | 已收口:可自动实施收口完成;安全回归聚合 PASS,付款批次公网关闭态只读门禁 PASS,UX8 自动矩阵通过且 UX8-1..5 保持真实反馈驱动 | `issues/2026-05-16_22-11-10-p2-automatable-followup.csv`、`artifacts/qa-flow/p2-safe-regression-20260516-222222/summary.json` | -| P2-FIN-LIVE-DECISION | 已收口:公网付款批次启用窗口决策完成,结论 `HOLD_FOR_WINDOW`;readiness 和 flag-off 证据通过,等待明确窗口、参与人、回滚联系人和测试报销单来源 | `issues/2026-05-16_22-49-05-p2-fin-payment-batch-live-decision.csv` | +| P2-FIN-LIVE-DECISION | 已收口:公网付款批次启用窗口决策完成,初始结论 `HOLD_FOR_WINDOW`;readiness 和 flag-off 证据通过,后续由用户批准进入 P2-FIN-LIVE 短窗口 | `issues/2026-05-16_22-49-05-p2-fin-payment-batch-live-decision.csv` | +| P2-FIN-LIVE | 已收口:公网短窗口受控测试 PASS;专用测试报销 `BX-20260517-0022` 付款闭环到 paid,票据 archived,AP 台账 paid,导出和审计核验通过;公网移动矩阵 6 passed,企业微信渲染矩阵 8 passed;最终 `payment_batch_v1=flag_force_off` | `issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv`、`artifacts/qa-flow/p2-fin-live-20260517-001022/summary.json`、`artifacts/qa-flow/p2-fin-live-20260517-001022/live-result.json` | ## 当前门禁 @@ -78,7 +80,8 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | P2Q 后续队列 | 已收口,未部署 | 图片 OCR 不再作为默认主线等待样票;UX8 等待真实反馈;P2-FIN-LIVE 等待公网启用窗口批准;后续 P2-WF/P2-BIZ 均需独立合同 | | P2AUTO 自动化优先回归 | 通过,未部署 | 后端 focused 43 tests passed;`client` typecheck/build 通过;核心隔离业务流 auth/reimbursement/approval/leave/procurement/governance PASS;付款批次隔离 flag-on 1 passed;公网 `/ready`、public bundle、live smoke 3 passed、PDF live OCR 1 passed;公网移动矩阵 6 passed;企业微信渲染矩阵 8 passed;P2AF 后 provider-only 默认结论为 `FREEZE_BY_DEFAULT` | | P2AF 可自动实施收口 | 通过,未部署 | `run-p2-safe-regression.sh` PASS:后端 focused 43 tests passed,`client` typecheck/build 通过,核心隔离业务流 auth/reimbursement/approval/leave/procurement/governance PASS,公网 readiness/PDF live OCR 通过,付款批次公网关闭态 409 `payment_batch_v1_disabled / flag_force_off`,公网移动矩阵 6 passed,企业微信渲染矩阵 8 passed,OCR provider-only 默认 `FREEZE_BY_DEFAULT`;不启用公网 `payment_batch_v1`,不开放图片 OCR,不做业务实现 | -| P2-FIN-LIVE-DECISION | HOLD,未部署 | `check-live-smoke-env --strict` 通过,`check-payment-batch-flag-off --json` 通过,财务/员工/admin 对付款批次公网只读访问均为 409 `payment_batch_v1_disabled / flag_force_off`;未提供启用窗口、财务/复核参与人、flag 启停人、回滚联系人、暂停/继续决策人和测试报销单来源,因此不启动 P2-FIN-LIVE | +| P2-FIN-LIVE-DECISION | 已完成,未部署 | `check-live-smoke-env --strict` 通过,`check-payment-batch-flag-off --json` 通过,财务/员工/admin 对付款批次公网只读访问均为 409 `payment_batch_v1_disabled / flag_force_off`;初始 `HOLD_FOR_WINDOW`,后续用户批准立即短窗口并由 P2-FIN-LIVE 承接 | +| P2-FIN-LIVE 公网短窗口 | 通过,未部署,最终关闭 | `run-p2-fin-live-window.sh` PASS:live-env strict PASS,pre flag-off PASS,公网付款批次 live spec 1 passed,移动矩阵 6 passed,企业微信渲染矩阵 8 passed,final flag-off check PASS;测试中只临时开启 `payment_batch_v1`,未开启 `erp_sync`,未接真实支付,最终恢复 409 `payment_batch_v1_disabled / flag_force_off` | ## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进 diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index 5f207e8d..81e34419 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -45,7 +45,8 @@ | `P2Q` | `issues/2026-05-16_10-35-22-p2-post-p2ocrs-followup-queue.csv` | `EVIDENCE` | P2OCRS 后续任务队列已锁定;图片 OCR 默认冻结,后续实现均需另建独立合同,当前不部署、不改业务代码 | | `P2AUTO` | `issues/2026-05-16_20-09-49-p2-automated-regression-sweep.csv` | `EVIDENCE` | 自动化优先回归已收口;后端/前端/隔离业务流/公网 readiness/PDF live OCR/移动与企业微信自动化均有证据,provider-only 默认决策为 `FREEZE_BY_DEFAULT` | | `P2AF` | `issues/2026-05-16_22-11-10-p2-automatable-followup.csv` | `EVIDENCE` | 可自动实施收口已完成;纠正图片 OCR 默认冻结口径,补 QA-only 安全回归、付款批次公网关闭态只读门禁和 UX8 自动矩阵扫尾;报告 `artifacts/qa-flow/p2-safe-regression-20260516-222222/` | -| `P2-FIN-LIVE-DECISION` | `issues/2026-05-16_22-49-05-p2-fin-payment-batch-live-decision.csv` | `EVIDENCE` | 公网付款批次启用窗口决策已收口;readiness 和 flag-off 只读门禁通过,但缺启用窗口/参与人/回滚联系人/测试数据前置,结论 `HOLD_FOR_WINDOW` | +| `P2-FIN-LIVE-DECISION` | `issues/2026-05-16_22-49-05-p2-fin-payment-batch-live-decision.csv` | `EVIDENCE` | 公网付款批次启用窗口决策已收口;readiness 和 flag-off 只读门禁通过,初始结论 `HOLD_FOR_WINDOW`,随后用户批准进入 `P2-FIN-LIVE` | +| `P2-FIN-LIVE` | `issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv` | `EVIDENCE` | 公网付款批次短窗口受控测试已收口;测试报销、付款批次、AP 导出和审计核验通过,最终 `payment_batch_v1=flag_force_off`,未部署 | 旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。 @@ -53,8 +54,8 @@ | Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION` | `rg` | none | 2026-05-16 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 | -| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION` | `audit-docs` | none | 2026-05-16 | `audit-docs --check-registry` | medium | 判断文档权威性 | +| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE` | `rg` | none | 2026-05-16 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 | +| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE` | `audit-docs` | none | 2026-05-16 | `audit-docs --check-registry` | medium | 判断文档权威性 | | `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 | | `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 | | `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 | @@ -159,4 +160,4 @@ - `ME6-3` 代表性企业微信真机门禁已关闭。 - MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。 -- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口和 QAOPS9 上线硬化连续合同均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同;P2 功能开发必须另建或延续对应连续合同。 +- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口和 QAOPS9 上线硬化连续合同均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同;P2-FIN-LIVE 已完成公网付款批次短窗口受控测试并回到 `payment_batch_v1=flag_force_off`;P2 功能开发必须另建或延续对应连续合同。 diff --git a/docs/文档索引.md b/docs/文档索引.md index 1aa39575..d2f60d9c 100644 --- a/docs/文档索引.md +++ b/docs/文档索引.md @@ -20,7 +20,7 @@ ## 最近事实源链 -R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION。 +R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE。 - R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。 - ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。 @@ -39,7 +39,8 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M - P2Q 是 P2OCRS 后续任务队列合同:只锁定 `UX8`、`P2-FIN-LIVE`、审批通知、轻业务增强和持续上线门禁等启动条件;图片 OCR 不进入默认主线,只有用户明确重启评估才另建 provider 回归或实现合同。 - P2AUTO 是 P2Q 后自动化优先回归合同:后端 focused、前端构建、高风险确认、隔离核心业务流、隔离付款批次闭环、公网 readiness/PDF live OCR、公网移动矩阵和企业微信 UA/渲染自动化均已完成;PDF 样票和 live smoke 凭据可用,图片 OCR 默认冻结。 - P2AF 是 P2AUTO 后可自动实施收口合同:纠正图片 OCR 默认冻结口径,补 QA-only provider 决策语义、安全回归聚合、付款批次公网关闭态只读门禁和 UX8 自动矩阵扫尾;不部署、不启用公网付款批次、不开放图片 OCR。 -- P2-FIN-LIVE-DECISION 是 P2AF 后付款批次公网启用窗口决策合同:公网 readiness 和 flag-off 只读门禁通过,但缺明确启用窗口、财务/复核参与人、flag 启停人、回滚联系人、暂停/继续决策人和测试报销单来源,结论为 `HOLD_FOR_WINDOW`;公网 `payment_batch_v1` 继续关闭。 +- P2-FIN-LIVE-DECISION 是 P2AF 后付款批次公网启用窗口决策合同:公网 readiness 和 flag-off 只读门禁通过,初始结论为 `HOLD_FOR_WINDOW`;随后用户批准立即短窗口、现有 live smoke 测试账号和新建测试报销,已进入并完成 `P2-FIN-LIVE`。 +- P2-FIN-LIVE 已按用户批准完成公网付款批次短窗口受控测试:使用 live smoke 测试账号新建专用测试报销,临时打开 `payment_batch_v1`,完成创建/复核/执行、AP 导出和审计核验后强制回到 `flag_force_off`;不部署、不接真实支付、不写 ERP 实时凭证,不开放图片 OCR。 ## 当前活跃治理流 @@ -65,7 +66,8 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | `P2Q` | `issues/2026-05-16_10-35-22-p2-post-p2ocrs-followup-queue.csv` | 已收口:P2OCRS 后续队列和启动门槛完成;图片 OCR 默认冻结 | UX8、付款批次公网启用、审批通知、轻业务增强和持续上线门禁的后续合同顺序;图片 OCR 仅在用户明确重启评估时另建合同 | 不直接启动 HOLD 项、不新建综合规划文档、不改业务代码、不部署 | | `P2AUTO` | `issues/2026-05-16_20-09-49-p2-automated-regression-sweep.csv` | 已收口:自动化优先回归和样票 provider 门禁完成;P2AF 后 provider 默认结论为 `FREEZE_BY_DEFAULT` | 凭据/样票掩码核对、后端 focused、前端构建、高风险确认、隔离业务流、隔离付款批次、公网 readiness/PDF live OCR、移动/企业微信自动化、OCR provider-only 脚本 | 不部署、不开放图片 OCR、不打开公网 `payment_batch_v1`、不自动选择未登记图片样票 | | `P2AF` | `issues/2026-05-16_22-11-10-p2-automatable-followup.csv` | 已收口:可自动实施收口完成 | 图片 OCR 默认冻结纠偏、QA 脚本语义、安全回归聚合、付款批次公网关闭态只读门禁、UX8 自动矩阵扫尾;报告 `artifacts/qa-flow/p2-safe-regression-20260516-222222/` | 不部署、不开放图片 OCR、不启用公网 `payment_batch_v1`、不做业务实现 | -| `P2-FIN-LIVE-DECISION` | `issues/2026-05-16_22-49-05-p2-fin-payment-batch-live-decision.csv` | 已收口:公网启用窗口决策完成,结论 `HOLD_FOR_WINDOW` | 公网 readiness、付款批次 flag-off 只读门禁、窗口/参与人/回滚联系人/测试数据前置、后续 P2-FIN-LIVE 启动条件 | 不打开公网 `payment_batch_v1`、不写付款批次、不部署、不接真实支付、不写 ERP 实时凭证 | +| `P2-FIN-LIVE-DECISION` | `issues/2026-05-16_22-49-05-p2-fin-payment-batch-live-decision.csv` | 已收口:公网启用窗口决策完成,初始结论 `HOLD_FOR_WINDOW`,后续用户批准并进入 `P2-FIN-LIVE` | 公网 readiness、付款批次 flag-off 只读门禁、窗口/参与人/回滚联系人/测试数据前置、后续 P2-FIN-LIVE 启动条件 | 不在决策合同中打开公网 `payment_batch_v1`、不写付款批次、不部署、不接真实支付、不写 ERP 实时凭证 | +| `P2-FIN-LIVE` | `issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv` | 已收口:公网短窗口付款批次受控测试通过,最终 `payment_batch_v1=flag_force_off` | 公网测试报销、PDF 样票绑定、审批到 approved、付款批次创建/复核/执行、AP 导出审计、权限边界、移动/企业微信自动化 | 不保持公网开启、不部署、不接真实支付、不写 ERP 实时凭证、不开放图片 OCR | 旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。 diff --git a/issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv b/issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv new file mode 100644 index 00000000..de39601d --- /dev/null +++ b/issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv @@ -0,0 +1,11 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"P2FLIVE-0","P2-FIN-LIVE 合同建档","建立公网付款批次受控短窗口测试合同;用户已批准立即短窗口、现有 live smoke 测试账号和新建专用测试报销;默认测试结束后关回 payment_batch_v1。","1) CSV 校验通过;2) 不部署、不改业务 API/schema/UI;3) 不接真实支付、不写 ERP 实时凭证、不开放图片 OCR;4) 公网写入仅限测试报销、付款批次、审计和 AP 导出审计;5) 最终 payment_batch_v1 回到 force_off。","受影响流程:P2-FIN-V1 启用前硬化 -> 公网短窗口付款批次测试 -> 强制 flag 回滚;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv;人工核对本合同范围和用户批准。","python3|manual","DONE","DONE","DONE","issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv","[]","DONE: user approved immediate public short window; executed report=artifacts/qa-flow/p2-fin-live-20260517-001022; no deploy/API/schema/UI change; no real payment/ERP/image OCR; final payment_batch_v1=force_off." +"P2FLIVE-1","公网前置与关闭态复核","执行公网 readiness、PDF live OCR readiness 和 payment_batch_v1 关闭态只读复核,确认进入短窗口前系统可用且付款批次仍未打开。","1) check-live-smoke-env --strict 通过;2) check-payment-batch-flag-off --json 通过;3) 工作区无非本轮未解释改动;4) 不打印 secret、cookie、样票路径或远端 env;5) 不执行付款批次写入。","受影响流程:公网 /ready、live smoke、PDF OCR readiness、付款批次关闭态;验证步骤:set -a && source ~/.hyb-oa/live-smoke.env && set +a;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-smoke-env.mjs --strict;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-payment-batch-flag-off.mjs --json;git status --short。","bash|node|git","DONE","DONE","DONE","scripts/qa/check-live-smoke-env.mjs|scripts/qa/check-payment-batch-flag-off.mjs|external","[""P2FLIVE-0""]","DONE: live-env-strict PASS and pre-flag-off-check PASS before flag-on; workspace dirty only with this P2FLIVE contract/QA files; no secrets or sample paths printed." +"P2FLIVE-2","公网安全 runner 和 live spec","新增 QA-only runner 与 Playwright live spec,只有 P2_FIN_LIVE_APPROVED=1 时才允许公网写入,并在 finally 中执行 flag 回滚。","1) scripts/qa/run-p2-fin-live-window.sh 存在且 bash -n 通过;2) client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts 使用 helpers/liveAuth.ts 的 live 账号;3) 缺 P2_FIN_LIVE_APPROVED=1 直接拒绝;4) 证据落 artifacts/qa-flow/p2-fin-live-*;5) 不打印 secret、cookie、PDF 路径或导出内容。","受影响流程:公网短窗口自动化编排;验证步骤:bash -n scripts/qa/run-p2-fin-live-window.sh;cd client && npx playwright test tests/e2e/manual/p2-fin-live-payment-batch.spec.ts --list;人工核对脚本强制审批变量和 finally 回滚。","bash|playwright|manual","DONE","DONE","DONE","scripts/qa/run-p2-fin-live-window.sh|client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts","[""P2FLIVE-1""]","DONE: added QA-only runner/spec with P2_FIN_LIVE_APPROVED=1 guard and final rollback; bash -n/list/typecheck/build passed; evidence report=artifacts/qa-flow/p2-fin-live-20260517-001022." +"P2FLIVE-3","flag-off 下创建测试报销并审批","在打开付款批次前,用员工账号新建专用测试报销、上传并绑定授权 PDF 样票、提交审批,并由 manager/finance 链路审批到 approved;样票不可绑定则停止。","1) 生成专用测试报销单号或 ID;2) 报销金额为小额测试值;3) 发票为 PDF-only 授权样票且成功绑定;4) 报销状态进入 approved;5) payment_batch_v1 仍处于关闭态。","受影响流程:员工新建报销 -> PDF 发票绑定 -> 提交 -> manager/finance 审批到 approved;验证步骤:P2_FIN_LIVE_APPROVED=1 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-p2-fin-live-window.sh;检查 summary.json 中 reimbursement.status=approved before flag-on。","playwright|node","DONE","DONE","DONE","client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts|artifacts/qa-flow","[""P2FLIVE-2""]","DONE: created dedicated small test reimbursement BX-20260517-0022, bound authorized PDF sample, and approved to approved before flag-on; payment_batch_v1 was force_off before opening." +"P2FLIVE-4","公网短窗口 flag-on","由 admin 读取 payment_batch_v1 当前快照并临时设置 force_on;仅打开 payment_batch_v1,不打开 erp_sync。","1) 原始 feature flag 快照记录到证据摘要但不含 token;2) payment_batch_v1 设置为 force_on 且 effectiveEnabled=true;3) erp_sync 不被本轮改为 force_on;4) 操作写入 FEATURE_FLAG_UPDATED 审计;5) 失败时立即执行回滚。","受影响流程:系统功能开关 -> 公网付款批次入口短窗口开启;验证步骤:live spec 通过 /api/system/feature-flags 读取 version 并 PUT /api/system/feature-flags/payment_batch_v1;随后 GET /api/payment-batches 返回 200。","playwright|node","DONE","DONE","DONE","client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts|server/src/routes/featureFlags.js","[""P2FLIVE-3""]","DONE: payment_batch_v1 moved force_off -> force_on for short window only; erp_sync remained disabled; FEATURE_FLAG_UPDATED audit verified; final rollback force_off." +"P2FLIVE-5","付款批次公网闭环","在短窗口内由 finance 创建付款批次,finance_mgr 复核,finance 执行,并验证报销 paid、票据 archived、AP 台账已付款。","1) 付款批次创建成功;2) finance_mgr 复核成功;3) finance 执行成功;4) 报销状态为 paid;5) 关联发票为 archived 且 AP 台账 payment.status=paid。","受影响流程:approved 报销 -> 付款批次创建 -> 复核 -> 执行 -> 报销 paid -> 发票 archived -> AP 台账已付款;验证步骤:P2_FIN_LIVE_APPROVED=1 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-p2-fin-live-window.sh;检查 summary.json 中 batch/reimbursement/invoice/ledger 终态。","playwright|node","DONE","DONE","DONE","client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts|artifacts/qa-flow","[""P2FLIVE-4""]","DONE: finance created batch c7eefe90-7164-4232-a307-bfbf3b823065; finance_mgr reviewed; finance executed; reimbursement paid, invoice archived, AP ledger paid." +"P2FLIVE-6","权限审计导出核验","核验员工、manager、admin-only 不可越权,审计含 FEATURE_FLAG_UPDATED、PAYMENT_BATCH_*、REIMBURSEMENT_PAYMENT_MARKED 和 EXPORT_AP_OPERATIONS;AP 导出只记录状态和审计,不打印内容。","1) employee/manager 创建或执行付款被拒绝;2) admin-only 账号若可用则创建或执行付款被拒绝,若 LIVE_SMOKE_ADMIN 为 wildcard superadmin 则记录 SKIP_WILDCARD_SUPERADMIN;3) PAYMENT_BATCH_CREATED/REVIEWED/EXECUTED 可查;4) REIMBURSEMENT_PAYMENT_MARKED 可查;5) EXPORT_AP_OPERATIONS 可查;6) 导出内容不输出到日志或报告。","受影响流程:付款批次权限边界、AP 导出权限、审计追溯;验证步骤:live spec 用 API 探测 employee/manager 越权 POST;若 live admin 是 wildcard superadmin 则跳过 admin-only 写负例并记录 SKIP_WILDCARD_SUPERADMIN;查询 /api/audit-logs;finance POST /api/exports/ap-operations 只检查 200/审计,不输出响应正文。","playwright|node","DONE","DONE","DONE","client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts|server/src/routes/paymentBatches.js|server/src/routes/exports.js","[""P2FLIVE-5""]","DONE: employee/manager create boundaries returned 403; LIVE_SMOKE_ADMIN is wildcard superadmin so admin-only negative write boundary recorded SKIP_WILDCARD_SUPERADMIN; PAYMENT_BATCH_*, REIMBURSEMENT_PAYMENT_MARKED and EXPORT_AP_OPERATIONS audits verified; export content not printed." +"P2FLIVE-7","强制回滚与关闭态复核","无论 P2FLIVE-3..6 成功或失败,恢复 payment_batch_v1 到原始关闭态,并复跑公网 flag-off 只读门禁。","1) live spec finally 执行回滚;2) runner 结束后 check-payment-batch-flag-off --json 通过;3) 财务/员工/admin 对 payment-batches/AP 返回 409 flag_force_off 或权限拒绝;4) 回滚失败时 runner 返回非 0 并突出风险;5) 不执行生产恢复或数据库重置。","受影响流程:公网付款批次 feature flag 回滚;验证步骤:P2_FIN_LIVE_APPROVED=1 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-p2-fin-live-window.sh;随后 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-payment-batch-flag-off.mjs --json。","bash|node|playwright","DONE","DONE","DONE","scripts/qa/run-p2-fin-live-window.sh|scripts/qa/check-payment-batch-flag-off.mjs|client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts","[""P2FLIVE-6""]","DONE: spec finally and runner final hook restored payment_batch_v1 to force_off; final check returned 409 payment_batch_v1_disabled / flag_force_off for finance/employee/admin." +"P2FLIVE-8","移动和企业微信自动化复核","付款闭环后跑公网移动矩阵和 WeCom UA 自动化;真机测试机器只确认入口、免登和状态查看,不执行付款动作。","1) 公网移动矩阵通过或有可解释失败;2) WeCom UA 自动化通过或有可解释失败;3) 真机如执行只记录查看确认;4) 不新增移动付款能力;5) 不因真机未执行付款阻塞短窗口自动化结论。","受影响流程:移动首页、移动财务入口、企业微信 UA 渲染和 deeplink 查看;验证步骤:cd client && E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list。","playwright|manual","DONE","DONE","DONE","client/tests/e2e/dg7-mobile-information-dedupe.spec.ts|client/tests/e2e/r124-mobile-entry-matrix.spec.ts|client/tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts|external","[""P2FLIVE-7""]","DONE: public mobile matrix 6 passed and WeCom UA rendering matrix 8 passed in report artifacts/qa-flow/p2-fin-live-20260517-001022; no mobile payment action added." +"P2FLIVE-9","收口回填与提交","回填 P2-FIN-LIVE CSV、文档索引、事实源矩阵和人工试运行报告,执行最终门禁并提交中文 commit。","1) P2FLIVE 各行状态按真实证据回填;2) docs/文档索引.md、docs/文档事实源状态矩阵.md、docs/trialrun/人工试运行报告.md 必要状态更新;3) validate、audit-docs、rg、git diff --check 通过;4) 中文 commit 引用 P2FLIVE;5) 明确未部署和公网最终 flag 状态。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv;node scripts/qa/audit-docs.mjs --check-registry;rg -n 'P2FLIVE|P2-FIN-LIVE|payment_batch_v1|PAYMENT_BATCH|EXPORT_AP_OPERATIONS|flag_force_off' docs issues scripts client/tests;git diff --check;git status --short。","python3|node|rg|git","DONE","DONE","DONE","issues/2026-05-16_23-42-08-p2-fin-payment-batch-live-enable.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md","[""P2FLIVE-7"",""P2FLIVE-8""]","DONE: CSV/docs updated; validate_issues_csv passed; audit-docs errors=0 warnings=1 existing unregistered docs files=24; keyword sweep explainable; git diff --check passed; final public flag-off check PASS." diff --git a/scripts/qa/run-p2-fin-live-window.sh b/scripts/qa/run-p2-fin-live-window.sh new file mode 100755 index 00000000..df19c9ec --- /dev/null +++ b/scripts/qa/run-p2-fin-live-window.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +set -u -o pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +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" +STATUS=0 + +mkdir -p "$REPORT_DIR" +: >"$SUMMARY_TXT" + +load_default_env() { + local env_file="${P2_FIN_LIVE_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}" + if [[ "${P2_FIN_LIVE_SKIP_ENV_FILE:-0}" == "1" || ! -f "$env_file" ]]; then + return 0 + fi + + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a + PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}" + echo "[p2-fin-live] loaded masked env file" | tee -a "$SUMMARY_TXT" +} + +run_step() { + local name="$1" + shift + local log_file="$REPORT_DIR/${name}.log" + + echo ">>> [p2-fin-live] $name" | tee -a "$SUMMARY_TXT" + if "$@" > >(tee "$log_file") 2>&1; then + echo "[p2-fin-live][PASS] $name" | tee -a "$SUMMARY_TXT" + else + local code="$?" + echo "[p2-fin-live][FAIL] $name exit=$code" | tee -a "$SUMMARY_TXT" + STATUS=1 + fi +} + +run_shell_step() { + local name="$1" + local command="$2" + 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(); + +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: "P2FLIVE 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 +} + +write_summary_json() { + local qa_status="PASS" + if [[ "$STATUS" -ne 0 ]]; then + qa_status="FAIL" + fi + + QA_REPORT_DIR="$REPORT_DIR" \ + QA_ISSUE_ID="${QA_ISSUE_ID:-P2FLIVE}" \ + QA_BASE_URL="$PUBLIC_BASE_URL" \ + QA_MODE="p2-fin-live-window" \ + QA_STATUS="$qa_status" \ + QA_SUMMARY_NOTES="P2-FIN-LIVE 公网短窗口:临时启用 payment_batch_v1,完成测试付款闭环后强制回滚到 flag_force_off;不部署、不接真实支付、不开放图片 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" + 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" + STATUS=1 + fi + fi + + write_summary_json + echo "[p2-fin-live] report=$REPORT_DIR" | tee -a "$SUMMARY_TXT" + exit "$STATUS" +} + +trap finish EXIT + +load_default_env + +if [[ "${P2_FIN_LIVE_APPROVED:-0}" != "1" ]]; then + echo "[p2-fin-live][ERROR] P2_FIN_LIVE_APPROVED=1 is required for public write test" | tee -a "$SUMMARY_TXT" + STATUS=2 + exit "$STATUS" +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 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" +else + run_shell_step "public-mobile-matrix" "cd '$CLIENT_DIR' && E2E_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list" + run_shell_step "wecom-rendering-matrix" "cd '$CLIENT_DIR' && E2E_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list" +fi