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" | "note" | "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 ReceiptAttachment = { id: string; originalName: string; mimeType: string; fileSize: number; governanceStatus: string; sensitivityLevel: string; attachmentType: string; downloadPolicy: 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 = process.env.QA_ISSUE_ID?.trim() || "P2FLIVE"; const markerPrefix = process.env.P2_FIN_LIVE_MARKER_PREFIX?.trim() || issueId; 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"); 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"; function nowMarker() { return `${markerPrefix}-${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, note: item.note, 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, 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, departmentIds, note, expectedVersion: current.version, }, }); const data = await parseData<{ item: FeatureFlagItem }>(response, `update ${flagKey}`); return data.item; } async function restoreFlagSnapshot(api: APIRequestContext, snapshot: FeatureFlagSnapshot) { const current = await loadFlag(api, "payment_batch_v1"); 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", snapshot.overrideMode, snapshot.note, snapshot.rolloutType, snapshot.departmentIds, ); } 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: `${markerPrefix} 公网付款测试 ${marker}`, reimbursementDate: today(), }); const expenseTypeId = await firstEnabledExpenseTypeId(api); await apiPostData>(api, `/api/reimbursements/${reimbursement.id}/items`, { expenseTypeId, expenseDate: today(), amount: "168.30", description: `${markerPrefix} 小额测试 ${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: `${markerPrefix.slice(0, 12)}-${input.marker.slice(-6)}`, }); return data.item; } async function uploadPaymentBatchReceipt(api: APIRequestContext, batchId: string, marker: string) { const receiptName = `${markerPrefix.toLowerCase()}-bank-receipt-${marker.slice(-8)}.pdf`; const response = await api.post(`/api/payment-batches/${batchId}/receipt-attachments`, { multipart: { file: { name: receiptName, mimeType: "application/pdf", buffer: Buffer.from(`%PDF-1.4\n${issueId} bank receipt ${marker}\n%%EOF\n`, "utf8"), }, }, }); const data = await parseData<{ items: ReceiptAttachment[] }>(response, "upload payment batch receipt"); const item = data.items[0]; if (!item?.id) { throw new Error("receipt_upload_missing_item"); } return 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("公网付款批次受控验收", () => { test.skip(process.env.E2E_MODE !== "live", "仅在 live 模式下执行"); test.describe.configure({ mode: "serial" }); test("公网付款批次 flag-on 闭环后按 final mode 收口", 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: finalFlagState === "force_on" ? "fin-prod-enable" : "p2-fin-live", status: "RUNNING", baseUrl, marker, startedAt: new Date().toISOString(), stages: { finalFlagState, }, }; const clients: AuthenticatedApiClient[] = []; let admin: AuthenticatedApiClient | null = null; let paymentFlagBefore: FeatureFlagItem | 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); 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, alreadyOnMode, }; if (alreadyOnMode) { expect(paymentFlagBefore.effectiveEnabled, "payment_batch_v1 should already be on").toBe(true); } else { 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 = alreadyOnMode ? paymentFlagBefore : await updateFlag( admin.api, "payment_batch_v1", "force_on", `${issueId} 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 ${issueId}`).toBe(false); const batchName = `${markerPrefix} 公网付款批次 ${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: `${issueId} 复核 ${marker}`, })).item; expect(batch.reviewRequirement.completedReviewCount).toBeGreaterThanOrEqual(1); const missingReceiptExecuteStatus = await expectPostRejected( finance.api, `/api/payment-batches/${batch.id}/execute`, {}, "bank payment execute without receipt", ); expect(missingReceiptExecuteStatus).toBe(409); const receipt = await uploadPaymentBatchReceipt(finance.api, batch.id, marker); const receiptList = await apiGetData<{ items: ReceiptAttachment[] }>( finance.api, `/api/payment-batches/${batch.id}/receipt-attachments`, ); const listedReceipt = receiptList.items.find((item) => item.id === receipt.id); expect(listedReceipt).toBeTruthy(); expect(listedReceipt?.governanceStatus).toBe("approved"); expect(listedReceipt?.sensitivityLevel).toBe("confidential"); expect(listedReceipt?.attachmentType).toBe("receipt"); expect(listedReceipt?.downloadPolicy).toBe("reason_required"); const receiptDownloadWithoutReason = await finance.api.get(`/api/payment-batches/${batch.id}/receipt-attachments/${receipt.id}/file`); expect(receiptDownloadWithoutReason.status(), "bank receipt download without reason should be rejected").toBe(400); const receiptDownloadReason = `${issueId} live payment receipt verification`; const receiptDownload = await finance.api.get( `/api/payment-batches/${batch.id}/receipt-attachments/${receipt.id}/file?reason=${encodeURIComponent(receiptDownloadReason)}`, ); expect(receiptDownload.status(), "bank receipt download should succeed").toBe(200); 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); if (!alreadyOnMode) { 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_RECEIPT_UPLOADED", (item) => item.targetId === batch.id && Array.isArray(item.extra?.attachmentIds), ); await expectAuditAction(admin.api, "PAYMENT_BATCH_RECEIPT_DOWNLOADED", (item) => item.targetId === batch.id && item.extra?.attachmentId === receipt.id && item.extra?.attachmentType === "receipt" && item.extra?.sensitivityLevel === "confidential" && item.extra?.governanceStatus === "approved" && item.extra?.reason === receiptDownloadReason, ); 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, receiptAttachmentId: receipt.id, missingReceiptExecuteStatus, receiptDownloadWithoutReasonStatus: receiptDownloadWithoutReason.status(), receiptDownloadStatus: receiptDownload.status(), receiptGovernance: { governanceStatus: listedReceipt?.governanceStatus, sensitivityLevel: listedReceipt?.sensitivityLevel, attachmentType: listedReceipt?.attachmentType, downloadPolicy: listedReceipt?.downloadPolicy, }, 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 { if (finalFlagState === "force_on" && !caughtError) { const paymentFlag = await loadFlag(admin.api, "payment_batch_v1"); const finalPaymentFlag = paymentFlag.overrideMode === "force_on" && paymentFlag.effectiveEnabled ? paymentFlag : await updateFlag(admin.api, "payment_batch_v1", "force_on", `${issueId} keep force_on ${marker}`); 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(finalErpFlag.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; summary.status = "FAIL"; summary.stages.finalState = { 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; } }); });