实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -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) => (
|
||||
|
||||
在新工单中引用
屏蔽一个用户