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