实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)

这个提交包含在:
2026-08-29 23:47:22 +08:00
父节点 9e2aea5f70
当前提交 6e999d5bf9
共修改 34 个文件,包含 1458 行新增和 92 行删除
+82 -12
查看文件
@@ -90,6 +90,7 @@ import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFi
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
shouldShowOfficeElectronicsHint,
type ItemEditorInitialValues,
type ItemEditorSaveData,
} from "./reimbursement-detail/ReimbursementItemEditor";
@@ -122,17 +123,20 @@ export function ReimbursementDetailPage() {
const [invoiceSearch, setInvoiceSearch] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
expenseTypeId: "",
expenseDate: "",
amount: "",
description: "",
assetPriorApprovalBasis: "",
});
const currentDetailSource = buildPcDetailSource(location.pathname, location.search);
const navigationState = location.state as PcDetailNavigationState | null;
@@ -287,6 +291,7 @@ export function ReimbursementDetailPage() {
expenseDate: data.expenseDate,
amount: data.amount,
description: data.description.trim() || undefined,
assetPriorApprovalBasis: data.assetPriorApprovalBasis.trim() || null,
};
if (itemEditor.mode === "create") {
@@ -411,6 +416,9 @@ export function ReimbursementDetailPage() {
setSelectedInvoices((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
);
setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
);
if (!failedResults.length) {
setInvoiceActionError("");
@@ -451,9 +459,14 @@ export function ReimbursementDetailPage() {
if (!invoiceIds.length) {
return;
}
bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId })));
bindInvoicesMutation.mutate(
invoiceIds.map((invoiceId) => ({
invoiceId,
linkedItemId: pendingOcrLinkedItemIds[invoiceId] || undefined,
})),
);
},
[bindInvoicesMutation],
[bindInvoicesMutation, pendingOcrLinkedItemIds],
);
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
@@ -461,6 +474,10 @@ export function ReimbursementDetailPage() {
.map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
.join(";");
setInvoiceActionError(message);
const unavailableIds = new Set(items.map((item) => item.id));
setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !unavailableIds.has(invoiceId))),
);
toast.error("部分发票需要处理");
}, []);
@@ -537,10 +554,19 @@ export function ReimbursementDetailPage() {
setPendingOcrInvoiceIds((previous) =>
Array.from(new Set([...previous, ...Array.from(pendingById.keys())])),
);
if (detail.invoiceGuardEnabled && invoiceUploadLinkedItemId) {
setPendingOcrLinkedItemIds((previous) => ({
...previous,
...Object.fromEntries(Array.from(pendingById.keys()).map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
}));
}
for (const invoiceId of availableIds) {
try {
await bindInvoice(invoiceId, { reimbursementId });
await bindInvoice(invoiceId, {
reimbursementId,
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
});
boundCount += 1;
} catch (error) {
bindFailures.push(`新上传发票:${buildActionErrorMessage(error, "添加发票失败")}`);
@@ -758,7 +784,8 @@ export function ReimbursementDetailPage() {
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
const submitInvoiceNotice = canMaintainReimbursement && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
const submitInvoiceNotice = canMaintainReimbursement && requiresAnyInvoice && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
const selectedInvoiceEntries = Object.entries(selectedInvoices);
function handleInvoicePdfSelection(files: FileList | null) {
@@ -787,6 +814,7 @@ export function ReimbursementDetailPage() {
expenseTypeId: defaultExpenseTypeId,
amount: item?.amount ?? "",
description: item?.description ?? "",
assetPriorApprovalBasis: item?.assetPriorApprovalBasis ?? "",
};
};
@@ -830,7 +858,7 @@ export function ReimbursementDetailPage() {
toast.error(message);
return;
}
if (detail.invoices.length < 1) {
if (requiresAnyInvoice && detail.invoices.length < 1) {
setActionError(missingInvoiceSubmitMessage);
toast.error(missingInvoiceSubmitMessage);
return;
@@ -864,6 +892,7 @@ export function ReimbursementDetailPage() {
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFiles([]);
setInvoiceUploadLinkedItemId("");
setInvoiceActionError("");
setPendingOcrInvoiceIds([]);
}
@@ -890,9 +919,9 @@ export function ReimbursementDetailPage() {
}
bindInvoicesMutation.mutate(
selectedInvoiceEntries.map(([invoiceId]) => ({
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
invoiceId,
linkedItemId: undefined,
linkedItemId: linkedItemId || undefined,
})),
);
};
@@ -928,7 +957,7 @@ export function ReimbursementDetailPage() {
if (!itemCount) {
return "补报销明细";
}
if (!invoiceCount) {
if (requiresAnyInvoice && !invoiceCount) {
return "添加发票";
}
if (hasBlockingPrecheck) {
@@ -962,7 +991,7 @@ export function ReimbursementDetailPage() {
</Button>
);
}
if (!invoiceCount) {
if (requiresAnyInvoice && !invoiceCount) {
return (
<Button
data-testid="reimbursement-primary-action"
@@ -1248,6 +1277,16 @@ export function ReimbursementDetailPage() {
</TableCell>
<TableCell>
<div>{item.description ?? "-"}</div>
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
<div className="mt-1 text-xs text-amber-600">
该金额通常应选择办公电子设备并填写事前批准依据。
</div>
) : null}
{item.assetPriorApprovalBasis ? (
<div className="mt-1 text-xs text-zinc-500">
事前批准依据:{item.assetPriorApprovalBasis}
</div>
) : null}
{precheckStandardChecks
.filter((check) => check.itemId === item.id && check.exceeded)
.map((check) => (
@@ -1617,9 +1656,24 @@ export function ReimbursementDetailPage() {
</div>
<div className="min-w-[220px] rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700 md:max-w-[240px]">
{invoiceHint} 添加后自动生成或匹配报销明细。
{detail.invoiceGuardEnabled ? "请选择该发票对应的费用明细。" : `${invoiceHint} 添加后自动生成或匹配报销明细。`}
</div>
</div>
{isSelected && detail.invoiceGuardEnabled ? (
<select
aria-label="关联费用明细"
className="select-std mt-3 w-full"
value={selectedInvoices[item.id] ?? ""}
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [item.id]: event.target.value }))}
>
<option value="">请选择费用明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
</option>
))}
</select>
) : null}
</label>
);
})
@@ -1634,7 +1688,7 @@ export function ReimbursementDetailPage() {
<div className="text-sm text-zinc-600">已选 {selectedInvoiceEntries.length} 张发票</div>
<Button
type="button"
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
disabled={!selectedInvoiceEntries.length || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
onClick={handleBindSelectedInvoices}
>
{bindInvoicesMutation.isPending ? "添加中..." : "添加选中发票"}
@@ -1673,7 +1727,7 @@ export function ReimbursementDetailPage() {
<Button
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
type="button"
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || uploadInvoiceMutation.isPending}
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
>
{uploadInvoiceMutation.isPending
@@ -1682,6 +1736,22 @@ export function ReimbursementDetailPage() {
</Button>
</div>
{detail.invoiceGuardEnabled ? (
<select
aria-label="新上传发票关联明细"
className="select-std mt-3 w-full"
value={invoiceUploadLinkedItemId}
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
>
<option value="">请选择新上传发票对应的费用明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
</option>
))}
</select>
) : null}
{selectedInvoiceFiles.length ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-500">
{selectedInvoiceFiles.slice(0, 5).map((file) => (