修复 R125 发票删除后重传查重

这个提交包含在:
2026-05-08 22:20:34 +08:00
父节点 b78e82edab
当前提交 b75ad226c6
共修改 4 个文件,包含 556 行新增和 2 行删除
+17 -2
查看文件
@@ -31,6 +31,10 @@ function isUsedInvoice(invoice) {
);
}
function isInactiveArchivedInvoice(invoice) {
return Boolean(invoice?.status === "archived" && invoice?.archived_reason !== USED_COMPLETE_ARCHIVED_REASON);
}
function classifyInvoiceConflict(conflict, ownerEmployeeId) {
if (!conflict) {
return null;
@@ -40,6 +44,10 @@ function classifyInvoiceConflict(conflict, ownerEmployeeId) {
return INVOICE_UNAVAILABLE_REASONS.usedByCompany;
}
if (isInactiveArchivedInvoice(conflict)) {
return null;
}
if (conflict.owner_employee_id && conflict.owner_employee_id !== ownerEmployeeId) {
return INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner;
}
@@ -94,8 +102,15 @@ async function findInvoiceDedupConflicts(tx, input) {
where: {
...(input.excludeInvoiceId ? { id: { not: input.excludeInvoiceId } } : {}),
is_duplicate_override: false,
OR: filters,
status: { in: ["pending_ocr", "ocr_failed", "available", "bound", "archived"] },
AND: [
{ OR: filters },
{
OR: [
{ status: { in: ["pending_ocr", "ocr_failed", "available", "bound"] } },
{ status: "archived", archived_reason: USED_COMPLETE_ARCHIVED_REASON },
],
},
],
},
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
@@ -0,0 +1,93 @@
const {
INVOICE_UNAVAILABLE_REASONS,
classifyInvoiceConflict,
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,
};
}
function buildTx(conflicts) {
return {
invoicePool: {
findMany: async () => conflicts,
},
};
}
describe("invoiceGovernance dedup", () => {
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);
});
});