const { INVOICE_UNAVAILABLE_REASONS, classifyInvoiceConflict, buildInvoiceDedupFilters, formatInvoiceOcrFailureMessage, resolveInvoiceAvailability, resolveUploadDedupDecision, } = require("../../src/services/invoiceGovernance"); function buildInvoice(overrides = {}) { return { id: overrides.id ?? "invoice-1", owner_employee_id: overrides.owner_employee_id ?? "employee-1", uploaded_by_employee_id: overrides.uploaded_by_employee_id ?? "employee-1", status: overrides.status ?? "available", archived_reason: overrides.archived_reason ?? null, linked_reimbursement_id: overrides.linked_reimbursement_id ?? null, linked_item_id: overrides.linked_item_id ?? null, travel_order_id: overrides.travel_order_id ?? null, file_sha256: overrides.file_sha256 ?? "sha256-a", invoice_code: overrides.invoice_code ?? null, invoice_number: overrides.invoice_number ?? null, ocr_last_error: overrides.ocr_last_error ?? null, }; } function buildTx(conflicts) { return { invoicePool: { findMany: async () => conflicts, }, }; } describe("invoiceGovernance dedup", () => { it("uses invoice number alone only when the caller marks a known no-code subtype", () => { expect( buildInvoiceDedupFilters({ invoiceNumber: "251234", deduplicateByInvoiceNumberOnly: true, }), ).toEqual([{ invoice_code: null, invoice_number: "251234" }]); expect(buildInvoiceDedupFilters({ invoiceNumber: "251234" })).toEqual([]); }); it("ignores manually archived invoices when re-uploading the same file", async () => { const archivedManual = buildInvoice({ status: "archived", archived_reason: "manual", }); expect(classifyInvoiceConflict(archivedManual, "employee-1")).toBeNull(); const decision = await resolveUploadDedupDecision(buildTx([archivedManual]), { fileSha256: "sha256-a", ownerEmployeeId: "employee-1", }); expect(decision).toEqual({ action: "create" }); }); it("blocks invoices that have already completed reimbursement", async () => { const usedComplete = buildInvoice({ status: "archived", archived_reason: "used_complete", linked_reimbursement_id: "reimbursement-1", }); expect(classifyInvoiceConflict(usedComplete, "employee-1")).toBe(INVOICE_UNAVAILABLE_REASONS.usedByCompany); const decision = await resolveUploadDedupDecision(buildTx([usedComplete]), { fileSha256: "sha256-a", ownerEmployeeId: "employee-1", }); expect(decision.action).toBe("blocked"); expect(decision.reason).toBe(INVOICE_UNAVAILABLE_REASONS.usedByCompany); }); it("reuses the owner's active available invoice", async () => { const available = buildInvoice({ status: "available" }); const decision = await resolveUploadDedupDecision(buildTx([available]), { fileSha256: "sha256-a", ownerEmployeeId: "employee-1", }); expect(decision.action).toBe("reuse_existing"); expect(decision.reason).toBe(INVOICE_UNAVAILABLE_REASONS.duplicateAvailable); expect(decision.invoice.id).toBe("invoice-1"); }); it("blocks another owner's active invoice in the same company", async () => { const otherOwner = buildInvoice({ owner_employee_id: "employee-2", status: "available", }); const decision = await resolveUploadDedupDecision(buildTx([otherOwner]), { fileSha256: "sha256-a", ownerEmployeeId: "employee-1", }); expect(decision.action).toBe("blocked"); expect(decision.reason).toBe(INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner); }); it("formats invoice availability without leaking raw OCR error codes", () => { expect( resolveInvoiceAvailability( buildInvoice({ status: "ocr_failed", ocr_last_error: "missing_required_fields:invoiceCode,sellerName", }), ), ).toEqual({ reason: "ocr_failed", message: "票据识别缺少关键字段:发票代码、销售方。", }); expect( resolveInvoiceAvailability( buildInvoice({ status: "ocr_failed", ocr_last_error: "duplicate_invoice_code_number", }), ), ).toEqual({ reason: "duplicate_invoice", message: "已识别出相同发票,不能重复添加", }); expect(formatInvoiceOcrFailureMessage("mock_ocr_failed")).toBe( "票据识别失败,请重试或人工补齐关键信息。", ); expect(formatInvoiceOcrFailureMessage("multiple_invoices_not_supported")).toBe( "一个 PDF 第一页只能包含一张发票,请拆分后重新上传。", ); expect( formatInvoiceOcrFailureMessage( "[pdf_native][ResourceUnavailable.ResourcePackageRunOut] resource package run out", ), ).toBe("票据识别额度或计费状态暂不可用,请联系管理员。"); }); });