const { loadReimbursementPaymentHandoffs } = require("../../../src/services/reimbursementPaymentHandoffs"); const gates = { global: true, disabled: [] }; function setup({ permissions = [], roles = [], returned = [], approved = [], reservations = [], batches = [], sources = [] } = {}) { const tx = { $executeRawUnsafe: vi.fn(async () => 0), featureFlag: { findUnique: vi.fn(async () => ({ override_mode: gates.global ? "force_on" : "force_off", rollout_type: gates.disabled.length ? "department_allowlist" : "global", department_ids: gates.disabled.length ? ["free"] : [], })) }, reimbursementForm: { findMany: vi.fn(async ({ where }) => where.OR ? returned : where.status === "approved" ? approved : sources) }, paymentBatchItem: { findMany: vi.fn(async () => reservations) }, paymentBatch: { findMany: vi.fn(async ({ where }) => batches.filter((batch) => where.OR.some((filter) => filter.status === "draft" ? batch.status === "draft" && batch.created_by !== "actor" : filter.status.in.includes(batch.status)))) }, }; const fastify = { config: { paymentBatchEnabled: true }, prisma: { $transaction: vi.fn(async (work) => work(tx)) }, hasPermission: (_, permission) => permissions.includes(permission) }; const employee = { id: "actor", roleCodes: roles }; return { tx, fastify, run: () => loadReimbursementPaymentHandoffs(fastify, employee) }; } function batch(status = "draft", creator = "maker") { return { id: "batch", batch_no: "PB-SYN", status, created_by: creator, default_payment_channel: "bank", payment_order_export_count: 0, items: [{ entity_id: "form", status: "pending" }], _count: { receipt_attachments: 0 } }; } describe("报销付款四类只读交接", () => { beforeEach(() => { gates.global = true; gates.disabled = []; }); it("无权限不读取业务来源,事务始终只读", async () => { const { run, tx, fastify } = setup(); expect(await run()).toEqual({ total: 0, items: [] }); expect(tx.reimbursementForm.findMany).not.toHaveBeenCalled(); expect(tx.paymentBatch.findMany).not.toHaveBeenCalled(); expect(tx.$executeRawUnsafe).toHaveBeenCalledWith("SET TRANSACTION READ ONLY"); expect(fastify.prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 }); }); it("只认最新流程退回/驳回,不把新草稿、已撤回或旧退回历史当待修改", async () => { const returned = ["return", "reject", "withdraw", null].map((action, index) => ({ id: `form-${index}`, form_number: `BX-${index}`, workflow_instances: action ? [{ workflow_history: [{ action }] }] : [] })); const { run, tx } = setup({ permissions: ["expense.submit", "expense.view_own"], returned }); const result = await run(); expect(result.total).toBe(2); expect(result.items.map((item) => item.kind)).toEqual(["reimbursement_edit", "reimbursement_edit"]); expect(tx.reimbursementForm.findMany.mock.calls[0][0].where).toEqual({ deleted_at: null, paid_at: null, status: { in: ["draft", "rejected"] }, OR: [{ owner_id: "actor" }, { submitter_id: "actor" }] }); }); it("待组批排除活动占用和部门停用,但不读取或解密银行资料", async () => { gates.disabled = ["disabled"]; const approved = ["free", "reserved", "disabled"].map((id) => ({ id, form_number: id, department_id: id })); const { run, tx } = setup({ permissions: ["payment.batch.manage"], approved, reservations: [{ entity_id: "reserved" }] }); const result = await run(); expect(result.items.map((item) => item.reference)).toEqual(["free"]); expect(tx.reimbursementForm.findMany.mock.calls[0][0].where).toEqual({ status: "approved", paid_at: null, deleted_at: null }); expect(tx.paymentBatchItem.findMany.mock.calls[0][0].where.payment_batch.status).toEqual({ not: "cancelled" }); expect(JSON.stringify(tx.reimbursementForm.findMany.mock.calls)).not.toMatch(/bank|total_amount|description/); }); it.each([[[], false], [["admin"], false], [["manager"], true], [["ceo"], true]])("复核需要真实业务角色 %j,不用通配冒充", async (roles, expected) => { const { run } = setup({ roles, permissions: ["payment.batch.review"], batches: [batch()], sources: [{ id: "form", department_id: "department" }] }); expect((await run()).total).toBe(expected ? 1 : 0); }); it("多角色制单人也不能收到自审任务", async () => { const { run } = setup({ roles: ["ceo", "finance"], permissions: ["payment.batch.review", "payment.batch.manage"], batches: [batch("draft", "actor")] }); expect((await run()).total).toBe(0); }); it.each(["reviewed", "failed"])("%s 交给财务,不交给单独 CEO;导出不意味着付款", async (status) => { const data = { batches: [{ ...batch(status), payment_order_export_count: 1 }], sources: [{ id: "form", department_id: "department" }] }; const result = await setup({ ...data, permissions: ["payment.batch.manage"] }).run(); expect(result.total).toBe(1); expect(result.items[0].kind).toBe("payment_execute"); expect(result.items[0].nextStep).toContain(status === "failed" ? "付款登记失败" : "已生成银行制单"); expect((await setup({ ...data, permissions: ["payment.batch.review"], roles: ["ceo"] }).run()).total).toBe(0); }); it("批次已取消/完成、来源已删除/缺失、部门停用不继续投影", async () => { const base = { permissions: ["payment.batch.manage"], batches: [batch("executed"), batch("cancelled")] }; expect((await setup(base).run()).total).toBe(0); expect((await setup({ ...base, batches: [batch("reviewed")] }).run()).total).toBe(0); gates.disabled = ["department"]; expect((await setup({ ...base, batches: [batch("reviewed")], sources: [{ id: "form", department_id: "department" }] }).run()).total).toBe(0); }); it("反复加载不重复,通知设施不可用也不丢待办;全局停用不暴露批次", async () => { const { run } = setup({ permissions: ["payment.batch.manage"], approved: [{ id: "free", department_id: "department" }] }); expect(await run()).toEqual(await run()); gates.global = false; expect((await run()).total).toBe(0); }); });