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

这个提交包含在:
2026-08-29 23:47:22 +08:00
父节点 9e2aea5f70
当前提交 6e999d5bf9
共修改 34 个文件,包含 1458 行新增和 92 行删除
+1 -1
查看文件
@@ -8,6 +8,7 @@ export type ExpenseType = {
category: string;
sortOrder: number;
accountCodeDefault: string | null;
requiresInvoice: boolean;
isSystemDefault: boolean;
enabled: boolean;
createdAt: string | null;
@@ -28,4 +29,3 @@ export async function fetchExpenseTypes(query: { enabled?: boolean } = {}) {
const qs = params.toString();
return apiRequestData<ExpenseTypeListResponse>(`/api/expense-types${qs ? `?${qs}` : ""}`);
}
+5
查看文件
@@ -17,12 +17,14 @@ export type ReimbursementItem = {
expenseDate: string | null;
amount: string;
description: string | null;
assetPriorApprovalBasis?: string | null;
expenseType: {
id: string;
code: string;
name: string;
category: string;
enabled: boolean;
requiresInvoice?: boolean;
} | null;
};
@@ -222,6 +224,7 @@ export type ReimbursementDetail = {
processingProgress: ReimbursementProcessingProgress;
approvalHistory: ReimbursementApprovalHistoryItem[];
riskFlags: RiskFlag[];
invoiceGuardEnabled?: boolean;
items: ReimbursementItem[];
};
@@ -345,6 +348,7 @@ export type ReimbursementItemPayload = {
expenseDate: string;
amount: string;
description?: string;
assetPriorApprovalBasis?: string | null;
};
export type ReimbursementItemMutationResponse = {
@@ -354,6 +358,7 @@ export type ReimbursementItemMutationResponse = {
expenseDate: string;
amount: string;
description: string | null;
assetPriorApprovalBasis?: string | null;
};
totalAmount: string;
};
@@ -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,
})),
)
}
+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) => (
@@ -6,6 +6,15 @@ import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
export const OFFICE_ELECTRONICS_HINT_THRESHOLD = 2000;
export function shouldShowOfficeElectronicsHint(expenseTypeCode: string | null | undefined, amount: string | number) {
const numericAmount = Number(amount);
return expenseTypeCode === "office_supplies"
&& Number.isFinite(numericAmount)
&& numericAmount >= OFFICE_ELECTRONICS_HINT_THRESHOLD;
}
export type ItemEditorMode = "create" | "edit";
export type ItemEditorInitialValues = {
@@ -13,6 +22,7 @@ export type ItemEditorInitialValues = {
expenseDate: string;
amount: string;
description: string;
assetPriorApprovalBasis: string;
};
export type ItemEditorSaveData = {
@@ -20,6 +30,7 @@ export type ItemEditorSaveData = {
expenseDate: string;
amount: string;
description: string;
assetPriorApprovalBasis: string;
};
export interface ReimbursementItemEditorProps {
@@ -49,6 +60,7 @@ export function ReimbursementItemEditor({
const [expenseDate, setExpenseDate] = useState(initialValues.expenseDate);
const [amount, setAmount] = useState(initialValues.amount);
const [description, setDescription] = useState(initialValues.description);
const [assetPriorApprovalBasis, setAssetPriorApprovalBasis] = useState(initialValues.assetPriorApprovalBasis);
// Reset internal state when initialValues change (e.g. switching between create/edit)
useEffect(() => {
@@ -56,7 +68,14 @@ export function ReimbursementItemEditor({
setExpenseDate(initialValues.expenseDate);
setAmount(initialValues.amount);
setDescription(initialValues.description);
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
setAssetPriorApprovalBasis(initialValues.assetPriorApprovalBasis);
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description, initialValues.assetPriorApprovalBasis]);
const selectedExpenseType = expenseTypes.find((item) => item.id === expenseTypeId) ?? null;
const isAssetExpense = selectedExpenseType?.category === "assets";
const numericAmount = Number(amount || 0);
const shouldWarnOfficeEquipment =
shouldShowOfficeElectronicsHint(selectedExpenseType?.code, numericAmount);
const standardsQuery = useQuery({
enabled: Boolean(expenseTypeId),
@@ -77,14 +96,13 @@ export function ReimbursementItemEditor({
);
}, [departmentId, standardsQuery.data?.items]);
const numericAmount = Number(amount || 0);
const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0);
const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount;
const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00";
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
const handleSave = () => {
onSave({ expenseTypeId, expenseDate, amount, description });
onSave({ expenseTypeId, expenseDate, amount, description, assetPriorApprovalBasis });
};
return (
@@ -121,6 +139,9 @@ export function ReimbursementItemEditor({
</option>
))}
</select>
{selectedExpenseType?.description ? (
<p className="text-xs text-zinc-500">{selectedExpenseType.description}</p>
) : null}
</div>
<div className="space-y-1">
@@ -162,8 +183,30 @@ export function ReimbursementItemEditor({
超出费用标准 ¥{activeStandard?.maxAmount},超出 ¥{exceededAmount}
</p>
) : null}
{shouldWarnOfficeEquipment ? (
<p className="text-xs text-amber-600">
该金额通常应选择办公电子设备并填写事前批准依据。
</p>
) : null}
</div>
{isAssetExpense ? (
<div className="space-y-1 md:col-span-4">
<label className="text-xs text-zinc-600" htmlFor="assetPriorApprovalBasis">
事前批准依据
</label>
<Textarea
data-testid="reimbursement-item-asset-approval-basis"
disabled={isPending}
id="assetPriorApprovalBasis"
onChange={(event) => setAssetPriorApprovalBasis(event.target.value)}
placeholder="填写批准人、批准时间和简短依据"
rows={2}
value={assetPriorApprovalBasis}
/>
</div>
) : null}
<div className="space-y-1 md:col-span-4">
<label className="text-xs text-zinc-600" htmlFor="itemDescription">
说明
@@ -187,7 +230,8 @@ export function ReimbursementItemEditor({
!expenseTypeId ||
!expenseDate ||
!amount.trim() ||
!hasPositiveAmount
!hasPositiveAmount ||
(isAssetExpense && !assetPriorApprovalBasis.trim())
}
onClick={handleSave}
type="button"
@@ -2,6 +2,8 @@ import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
test.use({ serviceWorkers: "block" });
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r111-invoice-reimbursement");
const WECOM_UA =
@@ -63,6 +65,26 @@ function buildExpenseTypes() {
code: "business_client_meal",
name: "业务招待",
category: "expense",
description: null,
requiresInvoice: true,
enabled: true,
},
{
id: "expense-type-office-supplies-r111",
code: "office_supplies",
name: "办公用品",
category: "office",
description: "按本条明细金额判断,多件同类分开记。",
requiresInvoice: true,
enabled: true,
},
{
id: "expense-type-assets-r111",
code: "assets_office_electronics",
name: "办公电子设备",
category: "assets",
description: "2,000 元及以上通常选本类型,不等同于会计固定资产标准。",
requiresInvoice: true,
enabled: true,
},
],
@@ -163,6 +185,10 @@ function buildReimbursementState() {
availableInvoices: [buildAvailableInvoice()],
failedInvoices: [buildFailedInvoice()],
statusPollCount: 0,
bindRequests: [] as Array<{ reimbursementId?: string; linkedItemId?: string }>,
submitRequestCount: 0,
invoiceGuardEnabled: false,
detailItems: null as null | Array<Record<string, unknown>>,
};
}
@@ -209,7 +235,8 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
},
approvalHistory: [],
riskFlags: [],
items: [
invoiceGuardEnabled: state.invoiceGuardEnabled,
items: state.detailItems ?? [
{
id: "item-r111",
expenseTypeId: "expense-type-r111",
@@ -360,13 +387,23 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) {
await fulfillJson(route, {
decision: "pass",
matchedBudgetControlRule: null,
standardChecks: [],
budget: null,
riskFlags: [],
warnings: [],
blockingReasons: [],
nextActions: ["可直接提交"],
});
return;
}
if (pathName === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
state.submitRequestCount += 1;
await fulfillJson(route, { id: reimbursementId, status: "reviewing" });
return;
}
if (pathName === `/api/reimbursements/${reimbursementId}/attachments` && request.method() === "POST") {
state.attachments = [buildAttachment()];
await fulfillJson(route, { items: state.attachments });
@@ -483,6 +520,7 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
}
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/bind`) {
state.bindRequests.push(request.postDataJSON());
state.invoices = [buildInvoice(), buildUnlinkedInvoice()];
await fulfillJson(route, {
id: pendingInvoiceId,
@@ -697,6 +735,91 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true });
});
test("B1/B2 在 PC 和移动端显示设备提示并要求明确关联明细", async ({ page }) => {
const state = buildReimbursementState();
state.invoiceGuardEnabled = true;
state.detailItems = [{
id: "item-r111",
expenseTypeId: "expense-type-office-supplies-r111",
expenseDate: "2026-05-08",
amount: "2000.00",
description: "合成办公配件",
assetPriorApprovalBasis: null,
expenseType: {
id: "expense-type-office-supplies-r111",
code: "office_supplies",
name: "办公用品",
category: "office",
enabled: true,
requiresInvoice: true,
},
}];
await mockApis(page, state);
await login(page);
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByText("该金额通常应选择办公电子设备并填写事前批准依据。")).toBeVisible();
await page.getByTestId("reimbursement-item-add").click();
await page.locator("#expenseTypeId").selectOption("expense-type-assets-r111");
await expect(page.getByTestId("reimbursement-item-asset-approval-basis")).toBeVisible();
await expect(page.getByText("2,000 元及以上通常选本类型,不等同于会计固定资产标准。")).toBeVisible();
await page.getByTestId("reimbursement-invoice-add").click();
const invoiceDialog = page.getByTestId("reimbursement-invoice-dialog");
await invoiceDialog.locator("input[type='checkbox']").first().check();
await expect(invoiceDialog.getByText("请选择该发票对应的费用明细。")).toBeVisible();
const bindButton = invoiceDialog.getByRole("button", { name: "添加选中发票" });
await expect(bindButton).toBeDisabled();
await invoiceDialog.getByLabel("关联费用明细").selectOption("item-r111");
await expect(bindButton).toBeEnabled();
await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
name: "待识别办公发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r111 guarded invoice"),
});
await invoiceDialog.getByLabel("新上传发票关联明细").selectOption("item-r111");
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect.poll(() => state.bindRequests).toContainEqual({
reimbursementId,
linkedItemId: "item-r111",
});
await page.keyboard.press("Escape");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(page.getByText("该金额通常应选择办公电子设备并填写事前批准依据。")).toBeVisible();
await page.getByRole("button", { name: "新增", exact: true }).click();
await page.getByLabel("费用类型").selectOption("expense-type-assets-r111");
await expect(page.getByText("事前批准依据", { exact: true })).toBeVisible();
state.invoiceGuardEnabled = false;
state.invoices = [];
state.detailItems = [{
id: "allowance-item-r111",
expenseTypeId: "expense-type-allowance-r111",
expenseDate: "2026-05-08",
amount: "100.00",
description: "1 天 × 100 元/天",
assetPriorApprovalBasis: null,
expenseType: {
id: "expense-type-allowance-r111",
code: "travel_meal",
name: "伙食补助费",
category: "travel",
enabled: true,
requiresInvoice: false,
},
}];
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByRole("button", { name: "提交报销", exact: true })).toBeVisible();
await expect(page.getByText("提交审批", { exact: true })).toBeVisible();
await expect(page.getByTestId("reimbursement-overview").getByText("添加发票", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "提交报销", exact: true }).click();
await expect.poll(() => state.submitRequestCount).toBe(1);
});
test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => {
const state = buildReimbursementState();
const mobile = await newWecomPage(browser);