实现 B-2a 报销审批内容工作台(reimbursement-approval-content-workbench)
这个提交包含在:
@@ -0,0 +1,147 @@
|
||||
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 "";
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户