文件
hyb-oa/client/src/lib/reimbursementApprovalProjection.ts
T

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