R111/A7-A10 统一报销双端发票附件体验

这个提交包含在:
2026-05-08 10:05:38 +08:00
父节点 0ab0740d1a
当前提交 ae054ca497
共修改 8 个文件,包含 908 行新增和 168 行删除
+6 -2
查看文件
@@ -237,7 +237,11 @@ export function InvoicePoolPage() {
setUploadProgress({ completed: index, total: files.length, currentFileName: file.name });
try {
const data = await uploadInvoice(file, "vat", uploadOwnerEmployeeId || undefined);
result.successes.push({ file, data });
if (data.uploadAction === "blocked" || !data.invoicePoolId) {
result.failures.push({ file, message: data.unavailableMessage ?? "该发票不可添加" });
} else {
result.successes.push({ file, data });
}
} catch (err) {
result.failures.push({ file, message: toUserError(err, "上传失败,请稍后重试") });
}
@@ -249,7 +253,7 @@ export function InvoicePoolPage() {
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
const lastSuccess = result.successes.at(-1);
if (lastSuccess) {
if (lastSuccess?.data.invoicePoolId) {
setDetailInvoiceId(lastSuccess.data.invoicePoolId);
}
setSelectedFiles(result.failures.map((item) => item.file));
@@ -6,18 +6,22 @@ import { fetchExpenseTypes } from "../api/expenseTypes";
import {
bindInvoice,
fetchBindableInvoices,
fetchInvoiceStatuses,
unbindInvoice,
uploadInvoice,
type BindableInvoiceSummary,
} from "../api/invoicePool";
import {
createReimbursementItem,
deleteReimbursementAttachment,
deleteReimbursement,
deleteReimbursementItem,
downloadReimbursementAttachment,
fetchReimbursementDetail,
precheckReimbursement,
submitReimbursement,
updateReimbursementItem,
uploadReimbursementAttachments,
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
@@ -77,7 +81,7 @@ const INVOICE_STATUS_LABEL: Record<string, string> = {
pending_ocr: "识别中",
ocr_failed: "识别失败",
available: "可用",
bound: "已绑定",
bound: "已添加",
archived: "已归档",
};
@@ -100,6 +104,13 @@ function formatDate(value: string | null | undefined) {
return value || "未填写";
}
function formatDateTime(value: string | null | undefined) {
if (!value) {
return "-";
}
return value.slice(0, 19).replace("T", " ");
}
function formatInvoiceTitle(invoice: {
invoiceCode?: string | null;
invoiceNumber?: string | null;
@@ -128,6 +139,7 @@ export function MobileReimbursementDetailPage() {
const queryClient = useQueryClient();
const { me, permissions } = useAuth();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
const [itemError, setItemError] = useState("");
@@ -141,6 +153,7 @@ export function MobileReimbursementDetailPage() {
const [invoiceSearch, setInvoiceSearch] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
@@ -168,6 +181,7 @@ export function MobileReimbursementDetailPage() {
const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice);
const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense));
const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense));
const canManageAttachments = Boolean(canEdit && (isParticipant || canViewAllExpense));
const selectedInvoiceEntries = Object.entries(selectedInvoices);
const bindableInvoicesQuery = useQuery({
@@ -182,6 +196,13 @@ export function MobileReimbursementDetailPage() {
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
const pendingInvoiceStatusesQuery = useQuery({
enabled: pendingOcrInvoiceIds.length > 0,
queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds],
queryFn: () => fetchInvoiceStatuses(pendingOcrInvoiceIds),
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
useEffect(() => {
const timerId = window.setTimeout(() => {
setInvoiceSearch(invoiceSearchInput.trim());
@@ -205,23 +226,6 @@ export function MobileReimbursementDetailPage() {
]);
};
const resolveDefaultLinkedItemId = (invoice?: BindableInvoiceSummary | null) => {
if (!detail?.items.length) {
return "";
}
if (detail.items.length === 1) {
return detail.items[0].id;
}
const invoiceAmount = Number(invoice?.totalAmount ?? NaN);
if (!Number.isFinite(invoiceAmount)) {
return "";
}
const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount);
return matchedItems.length === 1 ? matchedItems[0].id : "";
};
const startCreateItem = () => {
setItemForm({
mode: "create",
@@ -348,14 +352,14 @@ export function MobileReimbursementDetailPage() {
if (!failed.length) {
setInvoiceActionError("");
setInvoiceActionMessage(`已绑定 ${succeededIds.length} 张发票`);
toast.success(`已绑定 ${succeededIds.length} 张发票`);
setInvoiceActionMessage(`已添加 ${succeededIds.length} 张发票`);
toast.success(`已添加 ${succeededIds.length} 张发票`);
return;
}
const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "绑定失败"}`).join(";");
const message = failed.map((item) => `${item.invoiceId.slice(0, 8)}:${item.message ?? "添加失败"}`).join(";");
setInvoiceActionError(message);
toast.error("部分发票绑定失败");
toast.error("部分发票添加失败");
},
onError: (error) => {
const message = resolveErrorMessage(error);
@@ -369,7 +373,7 @@ export function MobileReimbursementDetailPage() {
onSuccess: async () => {
await invalidateDetail();
setInvoiceActionError("");
toast.success("发票已解绑");
toast.success("发票已移除");
},
onError: (error) => {
const message = resolveErrorMessage(error);
@@ -401,21 +405,24 @@ export function MobileReimbursementDetailPage() {
);
uploadedCount += 1;
if (uploadResult.status === "available") {
const linkedItemId = resolveDefaultLinkedItemId(null);
if (uploadResult.uploadAction === "blocked" || !uploadResult.invoicePoolId) {
failures.push({
file,
message: uploadResult.unavailableMessage ?? "该发票不可添加",
});
} else if (uploadResult.status === "available") {
try {
await bindInvoice(uploadResult.invoicePoolId, {
reimbursementId,
...(linkedItemId ? { linkedItemId } : {}),
});
await bindInvoice(uploadResult.invoicePoolId, { reimbursementId });
boundCount += 1;
} catch (error) {
failures.push({ file, message: `上传成功,绑定失败:${resolveErrorMessage(error)}` });
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
}
} else if (uploadResult.status === "pending_ocr") {
pendingIds.push(uploadResult.invoicePoolId);
} else if (uploadResult.status === "ocr_failed") {
failures.push({ file, message: "OCR 识别失败,请到发票池补录后再绑定" });
failures.push({ file, message: uploadResult.unavailableMessage ?? "发票识别失败,请重新识别或手动处理" });
} else {
failures.push({ file, message: uploadResult.unavailableMessage ?? "该发票不可添加" });
}
} catch (error) {
failures.push({ file, message: resolveErrorMessage(error) });
@@ -438,7 +445,7 @@ export function MobileReimbursementDetailPage() {
const messages: string[] = [];
if (result.boundCount) {
messages.push(`${result.boundCount} 张已绑定`);
messages.push(`${result.boundCount} 张已添加`);
}
if (result.pendingIds.length) {
messages.push(`${result.pendingIds.length} 张识别中`);
@@ -464,6 +471,80 @@ export function MobileReimbursementDetailPage() {
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
});
const uploadAttachmentsMutation = useMutation({
mutationFn: async (files: File[]) => {
if (!files.length) {
throw new Error("请选择附件材料");
}
return uploadReimbursementAttachments(reimbursementId, files);
},
onSuccess: async () => {
await invalidateDetail();
setSelectedAttachmentFiles([]);
if (attachmentInputRef.current) {
attachmentInputRef.current.value = "";
}
toast.success("附件材料已上传");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const deleteAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
onSuccess: async () => {
await invalidateDetail();
toast.success("附件材料已删除");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const openAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
onSuccess: (result) => {
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
useEffect(() => {
const statuses = pendingInvoiceStatusesQuery.data?.items ?? [];
if (!statuses.length || bindInvoicesMutation.isPending) {
return;
}
const availableIds = statuses
.filter((item) => item.status === "available")
.map((item) => item.id);
const finishedIds = new Set(
statuses
.filter((item) => item.status !== "pending_ocr")
.map((item) => item.id),
);
const failedMessages = statuses
.filter((item) => item.status === "ocr_failed" || item.status === "archived")
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.message}`);
if (failedMessages.length) {
setInvoiceActionError(failedMessages.join(";"));
}
if (availableIds.length) {
bindInvoicesMutation.mutate(availableIds.map((invoiceId) => ({ invoiceId })));
}
if (finishedIds.size) {
setPendingOcrInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId)));
}
}, [bindInvoicesMutation, pendingInvoiceStatusesQuery.data?.items]);
const handleSubmit = async () => {
if (!detail) {
return;
@@ -529,21 +610,14 @@ export function MobileReimbursementDetailPage() {
}
return {
...previous,
[invoice.id]: previous[invoice.id] ?? resolveDefaultLinkedItemId(invoice),
[invoice.id]: previous[invoice.id] ?? "",
};
});
};
const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => {
setSelectedInvoices((previous) => ({
...previous,
[invoiceId]: linkedItemId,
}));
};
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) {
return "整单";
return "自动生成或匹配明细";
}
const item = detail?.items.find((candidate) => candidate.id === linkedItemId);
return item ? itemLabel(item) : "明细";
@@ -760,7 +834,7 @@ export function MobileReimbursementDetailPage() {
<div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
</div>
<div className="mt-1 text-xs text-zinc-500">绑定:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
@@ -778,7 +852,7 @@ export function MobileReimbursementDetailPage() {
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
解绑
移除
</Button>
) : null}
</article>
@@ -791,6 +865,98 @@ export function MobileReimbursementDetailPage() {
)}
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-attachments">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">附件材料</h2>
<p className="mt-1 text-xs text-zinc-500">合同、订单、付款截图或情况说明</p>
</div>
{canManageAttachments ? (
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => attachmentInputRef.current?.click()}
>
选择
</Button>
) : null}
</div>
{canManageAttachments ? (
<div className="mt-3 space-y-3">
<input
ref={attachmentInputRef}
className="sr-only"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
/>
{selectedAttachmentFiles.length ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{selectedAttachmentFiles.slice(0, 4).map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name} · {Math.ceil(file.size / 1024)} KB
</div>
))}
{selectedAttachmentFiles.length > 4 ? <div>另有 {selectedAttachmentFiles.length - 4} 个文件</div> : null}
</div>
) : null}
<Button
className="w-full"
type="button"
variant="secondary"
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
>
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
) : null}
{(detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<article key={attachment.id} className="rounded-lg border border-zinc-200 px-3 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
size="sm"
variant="secondary"
type="button"
disabled={openAttachmentMutation.isPending}
onClick={() => openAttachmentMutation.mutate(attachment.id)}
>
查看
</Button>
{canManageAttachments ? (
<Button
size="sm"
variant="secondary"
type="button"
disabled={deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
>
删除
</Button>
) : null}
</div>
</article>
))}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无附件材料
</div>
)}
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
{actionError ? (
@@ -833,7 +999,7 @@ export function MobileReimbursementDetailPage() {
>
<DialogHeader className="text-left">
<DialogTitle>添加发票</DialogTitle>
<DialogDescription>可一次上传多张,识别完成后再绑定。</DialogDescription>
<DialogDescription>可一次上传多张,识别完成后会自动添加。</DialogDescription>
</DialogHeader>
<div className="mt-4 grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1">
@@ -900,7 +1066,7 @@ export function MobileReimbursementDetailPage() {
{pendingOcrInvoiceIds.length ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
{pendingOcrInvoiceIds.length} 张发票正在识别。识别完成后可在“已有发票”中绑定。
{pendingOcrInvoiceIds.length} 张发票正在识别,完成后会自动刷新并添加。
</p>
) : null}
@@ -940,7 +1106,7 @@ export function MobileReimbursementDetailPage() {
) : null}
{!bindableInvoicesQuery.isLoading && !bindableInvoicesQuery.isError && !(bindableInvoicesQuery.data?.items ?? []).length ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无可绑定发票
暂无可添加发票
</div>
) : null}
{(bindableInvoicesQuery.data?.items ?? []).map((invoice) => {
@@ -965,18 +1131,9 @@ export function MobileReimbursementDetailPage() {
</div>
</div>
{checked ? (
<select
className="select-std mt-3"
value={selectedInvoices[invoice.id] ?? ""}
onChange={(event) => updateSelectedInvoiceItem(invoice.id, event.target.value)}
>
<option value="">绑定整单</option>
{detail.items.map((item) => (
<option key={item.id} value={item.id}>
{itemLabel(item)}
</option>
))}
</select>
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
添加后自动生成或匹配报销明细
</div>
) : null}
</label>
);
@@ -989,14 +1146,14 @@ export function MobileReimbursementDetailPage() {
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
onClick={() =>
bindInvoicesMutation.mutate(
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
selectedInvoiceEntries.map(([invoiceId]) => ({
invoiceId,
linkedItemId: linkedItemId || undefined,
linkedItemId: undefined,
})),
)
}
>
{bindInvoicesMutation.isPending ? "绑定中..." : `绑定选中发票 ${selectedInvoiceEntries.length}`}
{bindInvoicesMutation.isPending ? "添加中..." : `添加选中发票 ${selectedInvoiceEntries.length}`}
</Button>
</div>
)}
@@ -1050,7 +1207,7 @@ export function MobileReimbursementDetailPage() {
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title="删除草稿?"
description="删除后无法恢复,已绑定发票会回到可用状态。"
description="删除后无法恢复,已添加发票会回到可用状态。"
variant="destructive"
confirmLabel="删除草稿"
onConfirm={() => deleteReimbursementMutation.mutate()}
+190 -101
查看文件
@@ -9,22 +9,24 @@ import { exportReimbursementJournal, previewReimbursementJournal } from "../api/
import { fetchProjects } from "../api/projects";
import {
createReimbursementItem,
deleteReimbursementAttachment,
deleteReimbursement,
deleteReimbursementItem,
downloadReimbursementAttachment,
fetchReimbursementDetail,
payReimbursement,
precheckReimbursement,
submitReimbursement,
updateReimbursementItem,
uploadReimbursementAttachments,
withdrawReimbursement,
linkReimbursementProject,
type ReimbursementItem,
} from "../api/reimbursements";
import {
bindInvoice,
type BindableInvoiceSummary,
fetchBindableInvoices,
fetchInvoicePoolDetail,
fetchInvoiceStatuses,
unbindInvoice,
uploadInvoice,
} from "../api/invoicePool";
@@ -130,6 +132,7 @@ export function ReimbursementDetailPage() {
const [invoiceSearch, setInvoiceSearch] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
@@ -156,7 +159,8 @@ export function ReimbursementDetailPage() {
queryFn: () => fetchProjects({ mine: true }),
});
const canManageIntegration = hasPermission(permissions, "integration.manage");
const showExternalSyncUi = import.meta.env.VITE_SHOW_ERP_UI === "true";
const canManageIntegration = hasPermission(permissions, "integration.manage") && showExternalSyncUi;
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
@@ -443,7 +447,7 @@ export function ReimbursementDetailPage() {
results.push({
invoiceId: selection.invoiceId,
success: false,
message: buildActionErrorMessage(error, "绑定发票失败"),
message: buildActionErrorMessage(error, "添加发票失败"),
});
}
}
@@ -472,22 +476,22 @@ export function ReimbursementDetailPage() {
setSelectedInvoices({});
setSelectedInvoiceFiles([]);
setPendingOcrInvoiceIds([]);
toast.success(`已绑定 ${succeededIds.length} 张发票`);
toast.success(`已添加 ${succeededIds.length} 张发票`);
return;
}
const summary = failedResults
.map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "绑定失败"}`)
.map((item) => `${item.invoiceId.slice(0, 8)}: ${item.message ?? "添加失败"}`)
.join(";");
setInvoiceActionError(summary);
if (succeededIds.length) {
toast.success(`已绑定 ${succeededIds.length} 张,${failedResults.length} 张失败`);
toast.success(`已添加 ${succeededIds.length} 张,${failedResults.length} 张失败`);
} else {
toast.error("发票绑定失败");
toast.error("发票添加失败");
}
},
onError: (error) => {
const message = buildActionErrorMessage(error, "发票绑定失败");
const message = buildActionErrorMessage(error, "发票添加失败");
setInvoiceActionError(message);
toast.error(message);
},
@@ -502,10 +506,10 @@ export function ReimbursementDetailPage() {
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
]);
setInvoiceActionError("");
toast.success("已解绑发票");
toast.success("发票已移除");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "解绑发票失败");
const message = buildActionErrorMessage(error, "移除发票失败");
setInvoiceActionError(message);
toast.error(message);
},
@@ -526,15 +530,27 @@ export function ReimbursementDetailPage() {
const pendingById = new Map<string, string>();
const availableIds: string[] = [];
const ocrFailures: string[] = [];
const bindFailures: string[] = [];
let boundCount = 0;
setInvoiceUploadProgress({ completed: 0, total: files.length });
for (const [index, file] of files.entries()) {
try {
const result = await uploadInvoice(file, "vat", ownerEmployeeId);
if (result.status === "available") {
if (result.uploadAction === "blocked" || !result.invoicePoolId) {
uploadFailures.push({
file,
message: result.unavailableMessage ?? "该发票不可添加",
});
} else if (result.status === "available") {
availableIds.push(result.invoicePoolId);
} else if (result.status === "pending_ocr") {
pendingById.set(result.invoicePoolId, file.name);
} else {
uploadFailures.push({
file,
message: result.unavailableMessage ?? "该发票不可添加",
});
}
} catch (error) {
uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") });
@@ -547,17 +563,9 @@ export function ReimbursementDetailPage() {
const deadline = Date.now() + 60_000;
while (pendingById.size && Date.now() < deadline) {
await sleep(2_000);
const invoiceResults = await Promise.all(
Array.from(pendingById.keys()).map(async (invoiceId) => {
try {
return await fetchInvoicePoolDetail(invoiceId);
} catch {
return null;
}
}),
);
const invoiceResults = await fetchInvoiceStatuses(Array.from(pendingById.keys()));
for (const invoice of invoiceResults) {
for (const invoice of invoiceResults.items) {
if (!invoice) {
continue;
}
@@ -567,7 +575,11 @@ export function ReimbursementDetailPage() {
pendingById.delete(invoice.id);
} else if (invoice.status === "ocr_failed") {
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
ocrFailures.push(`${fileName}: ${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
ocrFailures.push(`${fileName}: ${invoice.message || formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
pendingById.delete(invoice.id);
} else if (invoice.status === "archived" || invoice.status === "bound") {
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
ocrFailures.push(`${fileName}: ${invoice.message}`);
pendingById.delete(invoice.id);
}
}
@@ -575,10 +587,20 @@ export function ReimbursementDetailPage() {
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
}
for (const invoiceId of availableIds) {
try {
await bindInvoice(invoiceId, { reimbursementId });
boundCount += 1;
} catch (error) {
bindFailures.push(`${invoiceId.slice(0, 8)}: ${buildActionErrorMessage(error, "添加发票失败")}`);
}
}
return {
uploadedCount: files.length - uploadFailures.length,
uploadFailures,
availableIds,
boundCount,
bindFailures,
pendingIds: Array.from(pendingById.keys()),
ocrFailures,
};
@@ -590,28 +612,17 @@ export function ReimbursementDetailPage() {
setPendingOcrInvoiceIds(result.pendingIds);
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
const refreshed = await bindableInvoicesQuery.refetch();
const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id));
const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId));
if (selectableIds.length) {
const invoiceById = new Map((refreshed.data?.items ?? []).map((item) => [item.id, item]));
setSelectedInvoices((previous) => ({
...previous,
...Object.fromEntries(
selectableIds.map((invoiceId) => [
invoiceId,
previous[invoiceId] ?? resolveDefaultLinkedItemId(invoiceById.get(invoiceId)),
]),
),
}));
}
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
await bindableInvoicesQuery.refetch();
const messages = [
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
...result.ocrFailures,
...result.bindFailures,
];
if (result.pendingIds.length) {
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 识别中,可稍后刷新`);
messages.push(`${result.pendingIds.length} 张发票仍在识别中,可稍后刷新`);
}
if (messages.length) {
@@ -622,7 +633,7 @@ export function ReimbursementDetailPage() {
}
setInvoiceActionError("");
toast.success(`发票上传完成:${selectableIds.length || result.uploadedCount} 张可绑定`);
toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`);
},
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
onError: (error) => {
@@ -632,6 +643,46 @@ export function ReimbursementDetailPage() {
},
});
const uploadAttachmentsMutation = useMutation({
mutationFn: async (files: File[]) => {
if (!files.length) {
throw new Error("请选择附件材料");
}
return uploadReimbursementAttachments(reimbursementId, files);
},
onSuccess: async () => {
setSelectedAttachmentFiles([]);
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
toast.success("附件材料已上传");
},
onError: (error) => {
toast.error(buildActionErrorMessage(error, "上传附件失败"));
},
});
const deleteAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
toast.success("附件材料已删除");
},
onError: (error) => {
toast.error(buildActionErrorMessage(error, "删除附件失败"));
},
});
const openAttachmentMutation = useMutation({
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
onSuccess: (result) => {
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (error) => {
toast.error(buildActionErrorMessage(error, "打开附件失败"));
},
});
if (!reimbursementId) {
return (
<div className="card-std p-6">
@@ -684,7 +735,7 @@ export function ReimbursementDetailPage() {
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
const missingInvoiceSubmitMessage = "提交前需先绑定至少 1 张发票,可在“关联票据”区域添加。";
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
const selectedInvoiceEntries = Object.entries(selectedInvoices);
@@ -786,24 +837,6 @@ export function ReimbursementDetailPage() {
}
};
const resolveDefaultLinkedItemId = (invoice: BindableInvoiceSummary | null | undefined) => {
if (!detail.items.length) {
return "";
}
if (detail.items.length === 1) {
return detail.items[0].id;
}
const invoiceAmount = Number(invoice?.totalAmount ?? NaN);
if (!Number.isFinite(invoiceAmount)) {
return "";
}
const matchedItems = detail.items.filter((item) => Number(item.amount) === invoiceAmount);
return matchedItems.length === 1 ? matchedItems[0].id : "";
};
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
setSelectedInvoices((previous) => {
if (!checked) {
@@ -812,37 +845,29 @@ export function ReimbursementDetailPage() {
return next;
}
const invoice = (bindableInvoicesQuery.data?.items ?? []).find((item) => item.id === invoiceId);
return {
...previous,
[invoiceId]: previous[invoiceId] ?? resolveDefaultLinkedItemId(invoice),
[invoiceId]: previous[invoiceId] ?? "",
};
});
};
const updateSelectedInvoiceItem = (invoiceId: string, linkedItemId: string) => {
setSelectedInvoices((previous) => ({
...previous,
[invoiceId]: linkedItemId,
}));
};
const handleBindSelectedInvoices = () => {
if (!selectedInvoiceEntries.length) {
return;
}
bindInvoicesMutation.mutate(
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
selectedInvoiceEntries.map(([invoiceId]) => ({
invoiceId,
linkedItemId: linkedItemId || undefined,
linkedItemId: undefined,
})),
);
};
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
if (!linkedItemId) {
return "整张报销单";
return "自动生成或匹配明细";
}
const linkedItem = detail.items.find((item) => item.id === linkedItemId);
@@ -908,7 +933,7 @@ export function ReimbursementDetailPage() {
<div className="mt-2 font-medium text-zinc-900">{detail.items.length}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">关联票据</div>
<div className="text-zinc-500">发票</div>
<div className="mt-2 font-medium text-zinc-900">{detail.invoices.length}</div>
</div>
</div>
@@ -987,6 +1012,84 @@ export function ReimbursementDetailPage() {
</select>
</div>
</div>
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">附件材料</h3>
<p className="text-xs text-zinc-500">支付截图、合同、订单或情况说明,不参与发票识别。</p>
</div>
{canEditItems ? (
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<label
htmlFor="reimbursement-attachment-upload"
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
{selectedAttachmentFiles.length
? selectedAttachmentFiles.length === 1
? selectedAttachmentFiles[0].name
: `已选择 ${selectedAttachmentFiles.length} 个文件`
: "选择附件"}
</label>
<input
id="reimbursement-attachment-upload"
className="hidden"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
/>
<Button
type="button"
variant="secondary"
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
>
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
) : null}
</div>
{(detail.attachments ?? []).length ? (
<div className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<div key={attachment.id} className="flex flex-col gap-3 rounded-lg border border-zinc-200 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
<div className="text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={openAttachmentMutation.isPending}
onClick={() => openAttachmentMutation.mutate(attachment.id)}
>
查看
</Button>
{canEditItems ? (
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleteAttachmentMutation.isPending}
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
>
删除
</Button>
) : null}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">暂无附件材料</p>
)}
</div>
</SectionPanel>
<SectionPanel title="明细与票据">
@@ -1143,7 +1246,7 @@ export function ReimbursementDetailPage() {
<div className="space-y-4 text-sm" data-testid={TEST_IDS.reimbursement.invoices}>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
<h3 className="text-base font-semibold text-zinc-900">发票</h3>
</div>
{canOpenInvoiceBindDialog ? (
<Button
@@ -1155,7 +1258,7 @@ export function ReimbursementDetailPage() {
添加发票
</Button>
) : canEditItems ? (
<p className="text-xs text-zinc-500">当前账号不可在此页绑定发票</p>
<p className="text-xs text-zinc-500">当前账号不可在此页添加发票</p>
) : null}
</div>
@@ -1187,7 +1290,7 @@ export function ReimbursementDetailPage() {
原始文件:{item.originalName || item.id}
</div>
<div className="text-xs text-zinc-500">
绑定范围:{resolveLinkedItemLabel(item.linkedItemId)}
明细:{resolveLinkedItemLabel(item.linkedItemId)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -1202,7 +1305,7 @@ export function ReimbursementDetailPage() {
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(item.id)}
>
解绑
移除
</Button>
) : null}
</div>
@@ -1211,13 +1314,13 @@ export function ReimbursementDetailPage() {
))}
</div>
) : (
<p className="text-sm text-zinc-500">暂无关联票据</p>
<p className="text-sm text-zinc-500">暂无发票</p>
)}
</div>
</div>
</SectionPanel>
<SectionPanel title="风险与同步">
<SectionPanel title="预算与审核提示">
<div className="space-y-6">
{budgetHint ? (
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-budget-hint">
@@ -1406,10 +1509,10 @@ export function ReimbursementDetailPage() {
<div className="max-h-[360px] space-y-3 overflow-y-auto pr-1">
{bindableInvoicesQuery.isLoading ? (
<p className="text-sm text-zinc-500">正在加载可绑定发票...</p>
<p className="text-sm text-zinc-500">正在加载可添加发票...</p>
) : bindableInvoicesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可绑定发票失败")}
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可添加发票失败")}
</p>
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
bindableInvoicesQuery.data?.items.map((item) => {
@@ -1440,22 +1543,8 @@ export function ReimbursementDetailPage() {
</div>
</div>
<div className="min-w-[220px] md:max-w-[240px]">
<div className="text-xs text-zinc-500">绑定到明细(可选)</div>
<select
className="select-std mt-1"
data-testid={`reimbursement-invoice-linked-item-${item.id}`}
value={selectedInvoices[item.id] ?? ""}
disabled={!isSelected}
onChange={(event) => updateSelectedInvoiceItem(item.id, event.target.value)}
>
<option value="">不绑定具体明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? detailItem.expenseTypeId} · {detailItem.description ?? "未填写说明"}
</option>
))}
</select>
<div className="min-w-[220px] rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700 md:max-w-[240px]">
添加后自动生成或匹配报销明细
</div>
</div>
</label>
@@ -1463,7 +1552,7 @@ export function ReimbursementDetailPage() {
})
) : (
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-500">
当前没有可绑定发票,可直接在下方上传新发票。
当前没有可添加发票,可直接在下方上传新发票。
</div>
)}
</div>
@@ -1475,7 +1564,7 @@ export function ReimbursementDetailPage() {
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
onClick={handleBindSelectedInvoices}
>
{bindInvoicesMutation.isPending ? "绑定中..." : "绑定选中发票"}
{bindInvoicesMutation.isPending ? "添加中..." : "添加选中发票"}
</Button>
</div>
@@ -1530,7 +1619,7 @@ export function ReimbursementDetailPage() {
{pendingOcrInvoiceIds.length ? (
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别,可稍后刷新。
{pendingOcrInvoiceIds.length} 张发票正在识别,可稍后刷新。
</p>
) : null}
</div>
@@ -1640,7 +1729,7 @@ export function ReimbursementDetailPage() {
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title={`确认删除 ${displayCode}?`}
description="删除后无法恢复,已绑定发票会回到可用状态。"
description="删除后无法恢复,已添加发票会回到可用状态。"
variant="destructive"
confirmLabel="删除草稿"
onConfirm={() => { deleteReimbursementMutation.mutate(); }}