148 行
5.7 KiB
TypeScript
148 行
5.7 KiB
TypeScript
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<ReimbursementDetail, "items" | "invoices" | "attachments">,
|
|
snapshot: ReimbursementSubmissionSnapshot,
|
|
): ReimbursementApprovalEvidenceProjection {
|
|
const invoicesByItem = new Map<string, ReimbursementInvoiceEvidence[]>();
|
|
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<string, ExpenseStandardCheck[]>();
|
|
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 "";
|
|
}
|