const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const invoicePoolRoutes = require("../../../src/routes/invoicePool"); function buildQueryRawHandler(state) { return vi.fn(async (strings) => { const sql = Array.isArray(strings) ? strings.join("?") : String(strings); if (sql.includes('FROM "invoice_pool"')) { return state.invoice ? [{ id: state.invoice.id, status: state.invoice.status, linked_reimbursement_id: state.invoice.linked_reimbursement_id, travel_order_id: state.invoice.travel_order_id, }] : []; } if (sql.includes('FROM "travel_orders"')) { return state.travelOrder ? [{ id: state.travelOrder.id, status: state.travelOrder.status }] : []; } if (sql.includes('FROM "reimbursement_forms"')) { return state.reimbursement ? [state.reimbursement] : []; } return []; }); } function buildPrisma({ invoice, travelOrder, reimbursement = null, reimbursementItems = [{ id: "item-1", amount: "500.00" }], linkedItem = { id: "item-1" }, expenseType = { id: "expense-type-1" }, }) { const state = { invoice: { owner_employee_id: "actor-1", uploaded_by_employee_id: "actor-1", file_sha256: "sha256-1", invoice_code: null, invoice_number: null, invoice_date: new Date("2026-05-08T00:00:00.000Z"), seller_name: "测试商户", original_name: "invoice.pdf", archived_reason: null, is_duplicate_override: false, ...invoice, }, travelOrder: travelOrder ? { ...travelOrder } : null, reimbursement, }; const prisma = { invoicePool: { findUnique: vi.fn().mockImplementation(async () => state.invoice), findMany: vi.fn().mockResolvedValue([]), update: vi.fn().mockImplementation(async ({ data, select }) => { state.invoice = { ...state.invoice, ...data }; if (!select) { return state.invoice; } return { ...state.invoice, verification_records: [], }; }), }, reimbursementItem: { findFirst: vi.fn().mockResolvedValue(linkedItem), findMany: vi.fn().mockResolvedValue(reimbursementItems), create: vi.fn().mockResolvedValue({ id: "item-auto-1" }), aggregate: vi.fn().mockResolvedValue({ _sum: { amount: state.invoice.total_amount ?? "0.00" } }), }, reimbursementForm: { update: vi.fn().mockResolvedValue({ id: reimbursement?.id ?? "reim-1" }), }, expenseType: { findFirst: vi.fn().mockResolvedValue(expenseType), }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }), }, $queryRaw: buildQueryRawHandler(state), $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), }; return { prisma, state }; } async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] }) { const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { storageDir: "/tmp", appOrigin: "http://localhost:5173", maxUploadSizeBytes: 10 * 1024 * 1024, invoiceArchiveUnusedDays: 30, }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: "actor-1", permissionCodes }; }); app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode)); app.decorate("requirePermission", (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: "服务器内部错误", details: {} }, }); }); await app.register(invoicePoolRoutes, { prefix: "/api" }); return app; } describe("invoicePool travel-order bind routes", () => { it("adds invoice to reimbursement and auto-creates an item when no detail matches", async () => { const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const { prisma, state } = buildPrisma({ invoice: { id: "11111111-1111-4111-8111-111111111111", status: "available", linked_reimbursement_id: null, linked_item_id: null, travel_order_id: null, total_amount: "1683.00", }, travelOrder: null, reimbursement: { id: reimbursementId, owner_id: "actor-1", submitter_id: "actor-1", status: "draft", reimbursement_date: new Date("2026-05-08T00:00:00.000Z"), }, reimbursementItems: [], }); const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind", headers: { authorization: "Bearer test" }, payload: { reimbursementId }, }); expect(response.statusCode).toBe(200); expect(response.json().data.linkedReimbursementId).toBe(reimbursementId); expect(response.json().data.linkedItemId).toBe("item-auto-1"); expect(state.invoice.linked_item_id).toBe("item-auto-1"); expect(prisma.reimbursementItem.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ reimbursement_id: reimbursementId, expense_type_id: "expense-type-1", amount: "1683.00", }), }), ); expect(prisma.reimbursementForm.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: reimbursementId }, data: expect.objectContaining({ total_amount: "1683.00", updated_by: "actor-1", }), }), ); await app.close(); }); it("rejects reimbursement binding when linked item does not belong to the form", async () => { const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const { prisma } = buildPrisma({ invoice: { id: "11111111-1111-4111-8111-111111111111", status: "available", linked_reimbursement_id: null, linked_item_id: null, travel_order_id: null, total_amount: "1683.00", }, travelOrder: null, reimbursement: { id: reimbursementId, owner_id: "actor-1", submitter_id: "actor-1", status: "draft", reimbursement_date: new Date("2026-05-08T00:00:00.000Z"), }, linkedItem: null, }); const app = await buildApp({ prisma, permissionCodes: ["invoice.upload"] }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind", headers: { authorization: "Bearer test" }, payload: { reimbursementId, linkedItemId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", }, }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ linkedItemId: "item_not_in_reimbursement" }); await app.close(); }); it("binds invoice to confirmed travel order", async () => { const { prisma, state } = buildPrisma({ invoice: { id: "11111111-1111-4111-8111-111111111111", status: "available", linked_reimbursement_id: null, linked_item_id: null, travel_order_id: null, total_amount: "500.00", verification_records: [], }, travelOrder: { id: "22222222-2222-4222-8222-222222222222", status: "confirmed" }, }); const app = await buildApp({ prisma }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind", headers: { authorization: "Bearer test" }, payload: { travelOrderId: "22222222-2222-4222-8222-222222222222" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.linkedTravelOrderId).toBe("22222222-2222-4222-8222-222222222222"); expect(state.invoice.travel_order_id).toBe("22222222-2222-4222-8222-222222222222"); await app.close(); }); it("rejects binding when invoice is already linked elsewhere", async () => { const { prisma } = buildPrisma({ invoice: { id: "11111111-1111-4111-8111-111111111111", status: "bound", linked_reimbursement_id: "reim-1", linked_item_id: null, travel_order_id: null, total_amount: "500.00", verification_records: [], }, travelOrder: { id: "22222222-2222-4222-8222-222222222222", status: "confirmed" }, }); const app = await buildApp({ prisma }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind", headers: { authorization: "Bearer test" }, payload: { travelOrderId: "22222222-2222-4222-8222-222222222222" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.code).toBe("VALID_005"); await app.close(); }); it("unbinds invoice from travel order with payment manage permission", async () => { const { prisma, state } = buildPrisma({ invoice: { id: "11111111-1111-4111-8111-111111111111", status: "bound", linked_reimbursement_id: null, linked_item_id: null, travel_order_id: "22222222-2222-4222-8222-222222222222", total_amount: "500.00", verification_records: [], }, travelOrder: { id: "22222222-2222-4222-8222-222222222222", status: "paid" }, }); const app = await buildApp({ prisma }); const response = await app.inject({ method: "POST", url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/unbind", headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.linkedTravelOrderId).toBe(null); expect(state.invoice.status).toBe("available"); await app.close(); }); });