290 行
7.7 KiB
JavaScript
290 行
7.7 KiB
JavaScript
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",
|
|
};
|
|
|
|
const OCR_FIELD_LABELS = {
|
|
invoiceCode: "发票代码",
|
|
invoiceNumber: "发票号码",
|
|
invoiceDate: "开票日期",
|
|
totalAmount: "总金额",
|
|
sellerName: "销售方",
|
|
};
|
|
|
|
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 isInactiveArchivedInvoice(invoice) {
|
|
return Boolean(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 (isInactiveArchivedInvoice(conflict)) {
|
|
return null;
|
|
}
|
|
|
|
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,
|
|
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: {
|
|
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;
|
|
}
|
|
}
|
|
|
|
function parseMissingInvoiceOcrFields(rawError) {
|
|
if (typeof rawError !== "string") {
|
|
return [];
|
|
}
|
|
|
|
const matched = rawError.match(/^missing_required_fields:(.+)$/);
|
|
if (!matched?.[1]) {
|
|
return [];
|
|
}
|
|
|
|
return matched[1]
|
|
.split(",")
|
|
.map((field) => field.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function formatInvoiceOcrFailureMessage(rawError) {
|
|
const missingFields = parseMissingInvoiceOcrFields(rawError);
|
|
if (missingFields.length) {
|
|
const labels = missingFields.map((field) => OCR_FIELD_LABELS[field] ?? field);
|
|
return `票据识别缺少关键字段:${labels.join("、")}。`;
|
|
}
|
|
|
|
if (rawError === "mock_ocr_failed") {
|
|
return "票据识别失败,请重试或人工补齐关键信息。";
|
|
}
|
|
|
|
if (!rawError) {
|
|
return "票据识别失败,请稍后重试。";
|
|
}
|
|
|
|
return "票据识别失败,请重新识别或手动处理。";
|
|
}
|
|
|
|
function resolveInvoiceAvailability(invoice) {
|
|
if (!invoice) {
|
|
return {
|
|
reason: "not_found",
|
|
message: "未找到发票",
|
|
};
|
|
}
|
|
|
|
if (invoice.status === "pending_ocr") {
|
|
return {
|
|
reason: "pending_ocr",
|
|
message: "发票正在识别,完成后会自动刷新",
|
|
};
|
|
}
|
|
|
|
if (invoice.status === "ocr_failed") {
|
|
const rawError = String(invoice.ocr_last_error ?? "");
|
|
if (rawError === "duplicate_invoice_code_number" || rawError.includes("duplicate_invoice")) {
|
|
return {
|
|
reason: "duplicate_invoice",
|
|
message: "已识别出相同发票,不能重复添加",
|
|
};
|
|
}
|
|
|
|
return {
|
|
reason: "ocr_failed",
|
|
message: formatInvoiceOcrFailureMessage(rawError),
|
|
};
|
|
}
|
|
|
|
if (isUsedInvoice(invoice)) {
|
|
return {
|
|
reason: INVOICE_UNAVAILABLE_REASONS.usedByCompany,
|
|
message: mapInvoiceUnavailableReason(INVOICE_UNAVAILABLE_REASONS.usedByCompany),
|
|
};
|
|
}
|
|
|
|
if (invoice.status === "archived") {
|
|
return {
|
|
reason: "archived",
|
|
message: "发票已归档,不可添加",
|
|
};
|
|
}
|
|
|
|
return {
|
|
reason: "available",
|
|
message: "发票可添加",
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
INVOICE_UNAVAILABLE_REASONS,
|
|
buildInvoiceDedupFilters,
|
|
classifyInvoiceConflict,
|
|
findInvoiceDedupConflicts,
|
|
formatInvoiceOcrFailureMessage,
|
|
isUsedInvoice,
|
|
mapInvoiceUnavailableReason,
|
|
parseMissingInvoiceOcrFields,
|
|
resolveInvoiceAvailability,
|
|
resolveInvoiceDedupBlocker,
|
|
resolveUploadDedupDecision,
|
|
};
|