import type { ExpenseStandardCheck } from "../api/feeControl"; import type { ReimbursementDetail } from "../api/reimbursements"; import type { ReimbursementBudgetCheckSnapshot } from "../api/workflow"; import { shouldShowOfficeElectronicsHint } from "./reimbursementPolicyHints"; type DetailItem = ReimbursementDetail["items"][number]; type DetailInvoice = ReimbursementDetail["invoices"][number]; type DetailAttachment = ReimbursementDetail["attachments"][number]; export type ReimbursementAttachmentEvidence = Pick< DetailAttachment, "id" | "originalName" | "mimeType" | "fileSize" | "createdAt" >; export type ReimbursementInvoiceEvidence = Pick< DetailInvoice, "id" | "invoiceCode" | "invoiceNumber" | "invoiceDate" | "sellerName" | "documentCategory" | "documentSubtype" | "totalAmount" | "originalName" | "mimeType" | "linkedItemId" | "amountEvidenceStatus" >; export type ReimbursementItemEvidence = Pick< DetailItem, "id" | "expenseDate" | "amount" | "description" | "assetPriorApprovalBasis" | "expenseType" > & { invoices: ReimbursementInvoiceEvidence[]; standardViolations: ExpenseStandardCheck[]; showOfficeElectronicsHint: boolean; amountExceeded: boolean; }; export type ReimbursementApprovalEvidenceProjection = { items: ReimbursementItemEvidence[]; unlinkedInvoices: ReimbursementInvoiceEvidence[]; attachments: ReimbursementAttachmentEvidence[]; currentCheck: { invoiceCount: number; unlinkedInvoiceCount: number; missingInvoiceItemCount: number; amountUnavailableItemCount: number; manualReviewItemCount: number; amountExceededItemCount: number; }; }; export type ReimbursementSubmissionSnapshot = { standardViolations: ExpenseStandardCheck[]; budgetCheck: ReimbursementBudgetCheckSnapshot | null; budgetCheckStatus: "available" | "not_applicable" | "missing"; parseStatus: "ready" | "invalid"; }; function toInvoiceEvidence(invoice: DetailInvoice): ReimbursementInvoiceEvidence { return { id: invoice.id, invoiceCode: invoice.invoiceCode, invoiceNumber: invoice.invoiceNumber, invoiceDate: invoice.invoiceDate, sellerName: invoice.sellerName, documentCategory: invoice.documentCategory, documentSubtype: invoice.documentSubtype, totalAmount: invoice.totalAmount, originalName: invoice.originalName, mimeType: invoice.mimeType, linkedItemId: invoice.linkedItemId, amountEvidenceStatus: invoice.amountEvidenceStatus, }; } export function buildReimbursementApprovalEvidence( detail: Pick, snapshot: ReimbursementSubmissionSnapshot, ): ReimbursementApprovalEvidenceProjection { const invoicesByItem = new Map(); const unlinkedInvoices: ReimbursementInvoiceEvidence[] = []; for (const invoice of detail.invoices) { const evidence = toInvoiceEvidence(invoice); if (!invoice.linkedItemId) { unlinkedInvoices.push(evidence); continue; } const itemInvoices = invoicesByItem.get(invoice.linkedItemId) ?? []; itemInvoices.push(evidence); invoicesByItem.set(invoice.linkedItemId, itemInvoices); } const violationsByItem = new Map(); for (const violation of snapshot.standardViolations) { const itemViolations = violationsByItem.get(violation.itemId) ?? []; itemViolations.push(violation); violationsByItem.set(violation.itemId, itemViolations); } const items = detail.items.map((item) => { const invoices = invoicesByItem.get(item.id) ?? []; const allAmountsVerified = invoices.length > 0 && invoices.every((invoice) => invoice.amountEvidenceStatus === "verified"); const invoiceTotal = allAmountsVerified ? invoices.reduce((sum, invoice) => sum + Number(invoice.totalAmount ?? 0), 0) : 0; return { id: item.id, expenseDate: item.expenseDate, amount: item.amount, description: item.description, assetPriorApprovalBasis: item.assetPriorApprovalBasis, expenseType: item.expenseType, invoices, standardViolations: violationsByItem.get(item.id) ?? [], showOfficeElectronicsHint: shouldShowOfficeElectronicsHint(item.expenseType, item.amount), amountExceeded: allAmountsVerified && Number.isFinite(invoiceTotal) && Number(item.amount) > invoiceTotal, }; }); return { items, unlinkedInvoices, attachments: detail.attachments.map((attachment) => ({ id: attachment.id, originalName: attachment.originalName, mimeType: attachment.mimeType, fileSize: attachment.fileSize, createdAt: attachment.createdAt, })), currentCheck: { invoiceCount: detail.invoices.length, unlinkedInvoiceCount: unlinkedInvoices.length, missingInvoiceItemCount: items.filter((item) => item.expenseType?.requiresInvoice && item.invoices.length === 0).length, amountUnavailableItemCount: items.filter((item) => item.invoices.some((invoice) => invoice.amountEvidenceStatus === "ocr_failed")).length, manualReviewItemCount: items.filter((item) => item.invoices.some((invoice) => invoice.amountEvidenceStatus === "manual_review")).length, amountExceededItemCount: items.filter((item) => item.amountExceeded).length, }, }; } export function buildReimbursementReturnReference(input: { item?: ReimbursementItemEvidence; invoice?: ReimbursementInvoiceEvidence; }) { if (input.invoice) { const invoiceName = input.invoice.invoiceNumber || input.invoice.originalName || "未命名发票"; return `【发票:${invoiceName}】`; } if (input.item) { return `【明细:${input.item.expenseType?.name ?? "未分类费用"} · ${input.item.expenseDate ?? "日期未填"} · ¥${input.item.amount}】`; } return ""; }