R111/A3-A6 强化发票查重和状态治理
这个提交包含在:
@@ -0,0 +1,179 @@
|
||||
const USED_COMPLETE_ARCHIVED_REASON = "used_complete";
|
||||
|
||||
const INVOICE_UNAVAILABLE_REASONS = {
|
||||
duplicateAvailable: "duplicate_available_in_company",
|
||||
duplicatePending: "duplicate_pending_in_company",
|
||||
duplicateFailed: "duplicate_failed_in_company",
|
||||
duplicateOtherOwner: "duplicate_other_owner_in_company",
|
||||
usedByCompany: "used_by_company",
|
||||
};
|
||||
|
||||
function buildInvoiceDedupFilters({ fileSha256 = null, invoiceCode = null, invoiceNumber = null }) {
|
||||
const filters = [];
|
||||
if (fileSha256) {
|
||||
filters.push({ file_sha256: fileSha256 });
|
||||
}
|
||||
if (invoiceCode && invoiceNumber) {
|
||||
filters.push({
|
||||
invoice_code: invoiceCode,
|
||||
invoice_number: invoiceNumber,
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
function isUsedInvoice(invoice) {
|
||||
return Boolean(
|
||||
invoice?.status === "bound" ||
|
||||
invoice?.linked_reimbursement_id ||
|
||||
invoice?.travel_order_id ||
|
||||
(invoice?.status === "archived" && invoice?.archived_reason === USED_COMPLETE_ARCHIVED_REASON),
|
||||
);
|
||||
}
|
||||
|
||||
function classifyInvoiceConflict(conflict, ownerEmployeeId) {
|
||||
if (!conflict) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isUsedInvoice(conflict)) {
|
||||
return INVOICE_UNAVAILABLE_REASONS.usedByCompany;
|
||||
}
|
||||
|
||||
if (conflict.owner_employee_id && conflict.owner_employee_id !== ownerEmployeeId) {
|
||||
return INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner;
|
||||
}
|
||||
|
||||
if (conflict.status === "pending_ocr") {
|
||||
return INVOICE_UNAVAILABLE_REASONS.duplicatePending;
|
||||
}
|
||||
|
||||
if (conflict.status === "ocr_failed") {
|
||||
return INVOICE_UNAVAILABLE_REASONS.duplicateFailed;
|
||||
}
|
||||
|
||||
return INVOICE_UNAVAILABLE_REASONS.duplicateAvailable;
|
||||
}
|
||||
|
||||
function rankInvoiceConflict(reason) {
|
||||
switch (reason) {
|
||||
case INVOICE_UNAVAILABLE_REASONS.usedByCompany:
|
||||
return 10;
|
||||
case INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner:
|
||||
return 20;
|
||||
case INVOICE_UNAVAILABLE_REASONS.duplicateAvailable:
|
||||
return 30;
|
||||
case INVOICE_UNAVAILABLE_REASONS.duplicatePending:
|
||||
return 40;
|
||||
case INVOICE_UNAVAILABLE_REASONS.duplicateFailed:
|
||||
return 50;
|
||||
default:
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
|
||||
function pickBlockingInvoiceConflict(conflicts, ownerEmployeeId) {
|
||||
const ranked = conflicts
|
||||
.map((conflict) => ({
|
||||
conflict,
|
||||
reason: classifyInvoiceConflict(conflict, ownerEmployeeId),
|
||||
}))
|
||||
.filter((item) => item.reason)
|
||||
.sort((a, b) => rankInvoiceConflict(a.reason) - rankInvoiceConflict(b.reason));
|
||||
|
||||
return ranked[0] ?? null;
|
||||
}
|
||||
|
||||
async function findInvoiceDedupConflicts(tx, input) {
|
||||
const filters = buildInvoiceDedupFilters(input);
|
||||
if (!filters.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tx.invoicePool.findMany({
|
||||
where: {
|
||||
...(input.excludeInvoiceId ? { id: { not: input.excludeInvoiceId } } : {}),
|
||||
is_duplicate_override: false,
|
||||
OR: filters,
|
||||
status: { in: ["pending_ocr", "ocr_failed", "available", "bound", "archived"] },
|
||||
},
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
owner_employee_id: true,
|
||||
uploaded_by_employee_id: true,
|
||||
status: true,
|
||||
archived_reason: true,
|
||||
linked_reimbursement_id: true,
|
||||
linked_item_id: true,
|
||||
travel_order_id: true,
|
||||
invoice_code: true,
|
||||
invoice_number: true,
|
||||
file_sha256: true,
|
||||
linked_reimbursement: {
|
||||
select: {
|
||||
form_number: true,
|
||||
status: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveInvoiceDedupBlocker(tx, input) {
|
||||
const conflicts = await findInvoiceDedupConflicts(tx, input);
|
||||
return pickBlockingInvoiceConflict(conflicts, input.ownerEmployeeId);
|
||||
}
|
||||
|
||||
async function resolveUploadDedupDecision(tx, { fileSha256, ownerEmployeeId }) {
|
||||
const conflicts = await findInvoiceDedupConflicts(tx, { fileSha256, ownerEmployeeId });
|
||||
const blocker = pickBlockingInvoiceConflict(conflicts, ownerEmployeeId);
|
||||
if (!blocker) {
|
||||
return { action: "create" };
|
||||
}
|
||||
|
||||
const conflict = blocker.conflict;
|
||||
if (!isUsedInvoice(conflict) && conflict.owner_employee_id === ownerEmployeeId) {
|
||||
return {
|
||||
action: "reuse_existing",
|
||||
reason: blocker.reason,
|
||||
invoice: conflict,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "blocked",
|
||||
reason: blocker.reason,
|
||||
invoice: conflict,
|
||||
};
|
||||
}
|
||||
|
||||
function mapInvoiceUnavailableReason(reason) {
|
||||
switch (reason) {
|
||||
case INVOICE_UNAVAILABLE_REASONS.usedByCompany:
|
||||
return "该发票已用于报销,不能重复添加";
|
||||
case INVOICE_UNAVAILABLE_REASONS.duplicateOtherOwner:
|
||||
return "公司内已有相同发票,需管理员处理";
|
||||
case INVOICE_UNAVAILABLE_REASONS.duplicatePending:
|
||||
return "相同发票正在识别,请稍后刷新";
|
||||
case INVOICE_UNAVAILABLE_REASONS.duplicateFailed:
|
||||
return "相同发票已存在但识别失败,请先处理已有记录";
|
||||
case INVOICE_UNAVAILABLE_REASONS.duplicateAvailable:
|
||||
return "公司内已有相同可用发票,不能重复添加";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INVOICE_UNAVAILABLE_REASONS,
|
||||
buildInvoiceDedupFilters,
|
||||
classifyInvoiceConflict,
|
||||
findInvoiceDedupConflicts,
|
||||
isUsedInvoice,
|
||||
mapInvoiceUnavailableReason,
|
||||
resolveInvoiceDedupBlocker,
|
||||
resolveUploadDedupDecision,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户