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

这个提交包含在:
2026-08-29 23:47:22 +08:00
父节点 9e2aea5f70
当前提交 6e999d5bf9
共修改 34 个文件,包含 1458 行新增和 92 行删除
@@ -80,6 +80,7 @@ import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { cn } from "../lib/utils";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
type ItemFormState = {
mode: "create" | "edit";
@@ -88,6 +89,7 @@ type ItemFormState = {
expenseDate: string;
amount: string;
description: string;
assetPriorApprovalBasis: string;
};
type SelectedInvoiceMap = Record<string, string>;
@@ -177,11 +179,13 @@ export function MobileReimbursementDetailPage() {
const [invoiceSearch, setInvoiceSearch] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
const detailQuery = useQuery({
@@ -327,6 +331,7 @@ export function MobileReimbursementDetailPage() {
expenseDate: detail?.reimbursementDate ?? todayDate(),
amount: "",
description: "",
assetPriorApprovalBasis: "",
});
setItemError("");
};
@@ -339,6 +344,7 @@ export function MobileReimbursementDetailPage() {
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
amount: item.amount,
description: item.description ?? "",
assetPriorApprovalBasis: item.assetPriorApprovalBasis ?? "",
});
setItemError("");
};
@@ -354,6 +360,7 @@ export function MobileReimbursementDetailPage() {
expenseDate: form.expenseDate,
amount: form.amount,
description: form.description.trim() || undefined,
assetPriorApprovalBasis: form.assetPriorApprovalBasis.trim() || null,
};
if (form.mode === "edit" && form.itemId) {
@@ -458,6 +465,9 @@ export function MobileReimbursementDetailPage() {
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 (!failed.length) {
setInvoiceActionError("");
@@ -487,9 +497,14 @@ export function MobileReimbursementDetailPage() {
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 }>) => {
@@ -498,6 +513,10 @@ export function MobileReimbursementDetailPage() {
.join(";");
setInvoiceActionError(message);
setInvoiceActionMessage("");
const unavailableIds = new Set(items.map((item) => item.id));
setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !unavailableIds.has(invoiceId))),
);
toast.error("部分发票需要处理");
}, []);
@@ -577,7 +596,10 @@ export function MobileReimbursementDetailPage() {
});
} else if (uploadResult.status === "available") {
try {
await bindInvoice(uploadResult.invoicePoolId, { reimbursementId });
await bindInvoice(uploadResult.invoicePoolId, {
reimbursementId,
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
});
boundCount += 1;
} catch (error) {
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
@@ -603,6 +625,12 @@ export function MobileReimbursementDetailPage() {
await bindableInvoicesQuery.refetch();
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
if (detail?.invoiceGuardEnabled && invoiceUploadLinkedItemId) {
setPendingOcrLinkedItemIds((previous) => ({
...previous,
...Object.fromEntries(result.pendingIds.map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
}));
}
setSelectedInvoiceFiles(result.failures.map((item) => item.file));
if (!result.failures.length) {
if (fileInputRef.current) {
@@ -771,7 +799,8 @@ export function MobileReimbursementDetailPage() {
toast.error(message);
return;
}
if (!detail.invoices.length) {
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
if (requiresAnyInvoice && !detail.invoices.length) {
const message = "请先添加至少 1 张发票";
setActionError(message);
toast.error(message);
@@ -809,6 +838,7 @@ export function MobileReimbursementDetailPage() {
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFiles([]);
setInvoiceUploadLinkedItemId("");
setInvoiceActionError("");
setInvoiceActionMessage("");
setConfirmDeleteInvoiceId("");
@@ -1029,6 +1059,11 @@ export function MobileReimbursementDetailPage() {
</option>
))}
</select>
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description ? (
<span className="text-xs text-zinc-500">
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description}
</span>
) : null}
</label>
<label className="block space-y-1 text-sm text-zinc-700">
<span>日期</span>
@@ -1058,11 +1093,29 @@ export function MobileReimbursementDetailPage() {
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
/>
</label>
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" ? (
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
<span>事前批准依据</span>
<Textarea
disabled={itemSaveMutation.isPending}
placeholder="填写批准人、批准时间和简短依据"
rows={2}
value={itemForm.assetPriorApprovalBasis}
onChange={(event) => setItemForm((current) => current ? { ...current, assetPriorApprovalBasis: event.target.value } : current)}
/>
</label>
) : null}
{shouldShowOfficeElectronicsHint(
expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.code,
itemForm.amount,
) ? (
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
) : null}
</div>
<div className="mobile-two-action-grid">
<Button
type="button"
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || (expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" && !itemForm.assetPriorApprovalBasis.trim()) || itemSaveMutation.isPending}
onClick={() => itemSaveMutation.mutate(itemForm)}
>
{itemSaveMutation.isPending ? "保存中..." : "保存"}
@@ -1086,6 +1139,12 @@ export function MobileReimbursementDetailPage() {
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div>
</div>
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
) : null}
{item.assetPriorApprovalBasis ? (
<p className="mt-2 text-xs text-zinc-500">事前批准依据:{item.assetPriorApprovalBasis}</p>
) : null}
{canMaintainReimbursement ? (
<div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
@@ -1443,11 +1502,27 @@ export function MobileReimbursementDetailPage() {
</div>
) : null}
{detail.invoiceGuardEnabled ? (
<select
aria-label="新上传发票关联明细"
className="select-std 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}
<Button
className="w-full"
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
type="button"
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || uploadInvoiceMutation.isPending}
onClick={() => uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
>
{uploadInvoiceMutation.isPending
@@ -1540,9 +1615,24 @@ export function MobileReimbursementDetailPage() {
) : null}
{isAvailable && checked ? (
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
添加后自动生成或匹配报销明细
{detail.invoiceGuardEnabled ? "请选择该发票对应的费用明细" : "添加后自动生成或匹配报销明细"}
</div>
) : null}
{isAvailable && checked && detail.invoiceGuardEnabled ? (
<select
aria-label="关联费用明细"
className="select-std mt-3 w-full"
value={selectedInvoices[invoice.id] ?? ""}
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [invoice.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}
<Button
className="mt-3 w-full"
size="sm"
@@ -1561,12 +1651,12 @@ export function MobileReimbursementDetailPage() {
<Button
className="w-full"
type="button"
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
disabled={!selectedInvoiceEntries.length || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
onClick={() =>
bindInvoicesMutation.mutate(
selectedInvoiceEntries.map(([invoiceId]) => ({
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
invoiceId,
linkedItemId: undefined,
linkedItemId: linkedItemId || undefined,
})),
)
}