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, };