const { PAYMENT_PROCESSING_STAGE_DEFINITIONS, buildPaymentProcessingSemantics, requiresBankReceipt, } = require("../../../src/services/paymentProcessingProgress"); const BASE = { createdAt: new Date("2026-08-03T01:00:00.000Z"), reviewedAt: new Date("2026-08-03T02:00:00.000Z"), executedAt: new Date("2026-08-03T03:00:00.000Z"), cancelledAt: new Date("2026-08-03T04:00:00.000Z"), cancelledReason: "合成取消原因", }; describe("paymentProcessingProgress", () => { it("owns the three payment stage labels", () => { expect(PAYMENT_PROCESSING_STAGE_DEFINITIONS).toEqual({ created: { key: "payment_batch_created", label: "批次创建" }, review: { key: "payment_batch_review", label: "付款复核" }, payment: { key: "payment_and_receipt", label: "付款完成" }, }); }); it.each([ ["draft", "current", "pending"], ["reviewed", "completed", "current"], ["executed", "completed", "completed"], ["failed", "completed", "exception"], ])("maps %s without a second route-level state matrix", (status, reviewState, paymentState) => { const stages = buildPaymentProcessingSemantics({ ...BASE, status }); expect(stages.review.state).toBe(reviewState); expect(stages.payment.state).toBe(paymentState); }); it("terminates a cancelled draft at review", () => { const stages = buildPaymentProcessingSemantics({ ...BASE, status: "cancelled", reviewedAt: null }); expect(stages.review).toMatchObject({ state: "exception", result: "批次已取消", actorSource: "canceller", comment: "合成取消原因", }); expect(stages.payment).toMatchObject({ state: "terminated", result: "已终止", detail: null }); }); it("terminates a reviewed cancellation at payment", () => { const stages = buildPaymentProcessingSemantics({ ...BASE, status: "cancelled" }); expect(stages.review).toMatchObject({ state: "completed", actorSource: "reviewer" }); expect(stages.payment).toMatchObject({ state: "exception", result: "批次已取消", actorSource: "canceller", }); }); it.each([ [false, 0, 0, "等待财务登记付款完成"], [true, 0, 0, "等待财务线下付款并上传回单"], [true, 0, 1, "已生成银行制单"], [true, 1, 1, "银行回单已上传"], ])("uses the receipt contract (%s, %s, %s)", (bankReceiptRequired, receiptCount, exportCount, text) => { const stages = buildPaymentProcessingSemantics({ ...BASE, status: "reviewed", requiresBankReceipt: bankReceiptRequired, receiptCount, paymentOrderExportCount: exportCount, }); expect(stages.payment.detail).toContain(text); }); it("derives receipt requirements from the same effective-channel rule", () => { expect(requiresBankReceipt({ entity_type: "reimbursement", default_payment_channel: "cash", items: [{ status: "pending", payment_channel_override: null }], })).toBe(false); expect(requiresBankReceipt({ entity_type: "reimbursement", default_payment_channel: "cash", items: [{ status: "failed", payment_channel_override: "bank" }], })).toBe(true); expect(requiresBankReceipt({ entity_type: "reimbursement", default_payment_channel: null, items: [{ status: "pending", payment_channel_override: null }], })).toBe(true); expect(requiresBankReceipt({ entity_type: "reimbursement", default_payment_channel: "bank", items: [{ status: "executed", payment_channel_override: null }], })).toBe(false); }); it("keeps route assembly layers free of a second payment status matrix", () => { const reimbursementsSource = fs.readFileSync( path.resolve(__dirname, "../../../src/routes/reimbursements.js"), "utf8", ); const paymentBatchesSource = fs.readFileSync( path.resolve(__dirname, "../../../src/routes/paymentBatches.js"), "utf8", ); const reimbursementAssembler = reimbursementsSource.slice( reimbursementsSource.indexOf("function buildProcessingProgress("), reimbursementsSource.indexOf("function ensureReimbursementParticipant("), ); const paymentBatchAssembler = paymentBatchesSource.slice( paymentBatchesSource.indexOf("function buildPaymentBatchProcessingProgress("), paymentBatchesSource.indexOf("async function mapBatchForResponse("), ); for (const assembler of [reimbursementAssembler, paymentBatchAssembler]) { expect(assembler).toContain("buildPaymentProcessingSemantics("); expect(assembler).not.toMatch(/batch\.status\s*===|\.includes\(batch\.status\)/); expect(assembler).not.toContain('label: "付款复核"'); expect(assembler).not.toContain('label: "付款完成"'); } }); it("keeps workflow action stacking local while the shared panel owns sticky positioning", () => { const templateSource = fs.readFileSync( path.resolve(__dirname, "../../../../client/src/components/patterns/BusinessDetailTemplate.tsx"), "utf8", ); expect(templateSource).toContain("detail-action-panel lg:sticky lg:top-24"); }); }); const fs = require("node:fs"); const path = require("node:path");