A3 收口报销发票 OCR 主链路

这个提交包含在:
2026-05-10 10:37:23 +08:00
父节点 9d6abc7d63
当前提交 e79d252b7d
共修改 7 个文件,包含 111 行新增和 53 行删除
+5 -5
查看文件
@@ -177,7 +177,7 @@ export function InvoicePoolPage() {
if (previousStatus === "pending_ocr" && currentStatus && currentStatus !== "pending_ocr") {
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
if (currentStatus === "available") {
toast.success("票据识别已完成,可继续绑定或验真");
toast.success("票据识别已完成,可继续添加到业务单据或财务复核");
} else if (currentStatus === "ocr_failed") {
toast.error(`票据识别未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`);
}
@@ -385,9 +385,9 @@ export function InvoicePoolPage() {
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
]);
setError("");
toast.success("验真已触发");
toast.success("发票核验已提交");
},
onError: (err) => handleError(err, "触发验真失败,请稍后重试"),
onError: (err) => handleError(err, "提交发票核验失败,请稍后重试"),
});
const retryVerificationMutation = useMutation({
@@ -398,9 +398,9 @@ export function InvoicePoolPage() {
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
]);
setError("");
toast.success("验真重试已触发");
toast.success("发票核验已重新提交");
},
onError: (err) => handleError(err, "重试验真失败,请稍后重试"),
onError: (err) => handleError(err, "重新提交发票核验失败,请稍后重试"),
});
const overrideDuplicateMutation = useMutation({
@@ -449,7 +449,7 @@ export function MobileReimbursementDetailPage() {
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
const message = items
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.availabilityMessage ?? item.message}`)
.map((item) => `${item.originalName ?? "未命名发票"}:${item.availabilityMessage ?? item.message}`)
.join(";");
setInvoiceActionError(message);
setInvoiceActionMessage("");
@@ -940,7 +940,7 @@ export function MobileReimbursementDetailPage() {
<div className="shrink-0 text-right">
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
<span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{INVOICE_STATUS_LABEL[invoice.status] ?? invoice.status}
{INVOICE_STATUS_LABEL[invoice.status] ?? "未知状态"}
</span>
</div>
</div>
+36 -27
查看文件
@@ -102,7 +102,7 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
}
function reimbursementStatusLabel(status: ReimbursementStatus) {
return REIMBURSEMENT_STATUS_LABEL[status] ?? status;
return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态";
}
function invoiceStatusLabel(status: string) {
@@ -118,21 +118,30 @@ function resolveErpSyncStatus(
| null,
) {
if (!exportState) {
return "未同步";
return "未导出";
}
if (exportState.lastOutboxStatus === "pending") {
return "已入队";
return "待导出";
}
if (exportState.lastOutboxStatus === "processing") {
return "处理中";
return "导出中";
}
if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") {
return "已失败";
return "导出失败";
}
if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) {
return "已成功";
return "已导出";
}
return "未同步";
return "未导出";
}
function budgetControlModeLabel(controlMode: string | null | undefined) {
const labels: Record<string, string> = {
block: "超额阻断",
warn: "超额提醒",
manual_review: "人工复核",
};
return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理";
}
export function ReimbursementDetailPage() {
@@ -533,7 +542,7 @@ export function ReimbursementDetailPage() {
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
const message = items
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`)
.map((item) => `${item.originalName ?? "未命名发票"}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`)
.join(";");
setInvoiceActionError(message);
toast.error("部分发票需要处理");
@@ -1356,10 +1365,10 @@ export function ReimbursementDetailPage() {
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%)
</div>
<div>控制模式:{budgetHint.controlMode}</div>
<div>控制方式:{budgetControlModeLabel(budgetHint.controlMode)}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断(block 模式)。
当前金额将超出预算上限,提交时会被阻断。
</p>
) : budgetHint.exceedsThreshold ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
@@ -1376,8 +1385,8 @@ export function ReimbursementDetailPage() {
{canManageIntegration ? (
<SectionPanel
title="ERP 同步状态"
description="付款后同步到 ERP。"
title="财务导出状态"
description="付款后可生成财务导出记录。"
actions={
<div className="flex gap-2">
<Button
@@ -1389,18 +1398,18 @@ export function ReimbursementDetailPage() {
预览凭证
</Button>
<Button type="button" disabled={!isPaidReimbursement || erpExportMutation.isPending} onClick={() => erpExportMutation.mutate()}>
{erpExportMutation.isPending ? "导出中..." : "导出到 ERP"}
{erpExportMutation.isPending ? "导出中..." : "生成导出"}
</Button>
</div>
}
>
{!isPaidReimbursement ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
未满足导出条件,仅已付款报销可导出到 ERP。
未满足导出条件,仅已付款报销可生成财务导出记录。
</p>
) : null}
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载 ERP 凭证预览...</p> : null}
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载财务凭证预览...</p> : null}
{erpPreviewQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(erpPreviewQuery.error)
@@ -1409,25 +1418,25 @@ export function ReimbursementDetailPage() {
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
: "加载 ERP 凭证预览失败"}
: "加载财务凭证预览失败"}
</p>
) : null}
<div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4" data-testid="reimbursement-erp-export">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">同步状态</div>
<div className="text-xs text-zinc-500">导出状态</div>
<div className="mt-1 font-medium text-zinc-900">{erpSyncStatus}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">最后同步</div>
<div className="text-xs text-zinc-500">最近导出</div>
<div className="mt-1 font-medium text-zinc-900">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">同步队列</div>
<div className="text-xs text-zinc-500">导出任务</div>
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<div className="text-xs text-zinc-500">外部 ID</div>
<div className="text-xs text-zinc-500">外部凭证号</div>
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.externalId ?? "-"}</div>
</div>
</div>
@@ -1656,8 +1665,8 @@ export function ReimbursementDetailPage() {
<Dialog open={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>ERP 凭证预览</DialogTitle>
<DialogDescription>查看凭证内容和最近同步状态。</DialogDescription>
<DialogTitle>财务凭证预览</DialogTitle>
<DialogDescription>查看凭证内容和最近导出状态。</DialogDescription>
</DialogHeader>
{erpPreviewQuery.data ? (
@@ -1674,16 +1683,16 @@ export function ReimbursementDetailPage() {
</div>
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<h4 className="font-medium text-zinc-900">导出状态</h4>
<div>同步状态:{erpSyncStatus}</div>
<div>外部 ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>队列 ID:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
<div>导出状态:{erpSyncStatus}</div>
<div>外部凭证号:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
<div>任务编号:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
<div>任务状态:{erpSyncStatus}</div>
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">暂无可预览的 ERP 凭证数据。</p>
<p className="text-sm text-zinc-500">暂无可预览的财务凭证数据。</p>
)}
</DialogContent>
</Dialog>
@@ -38,7 +38,12 @@ const TRAVEL_STATUS_LABEL: Record<string, string> = {
function statusLabel(status: string | null | undefined, labels: Record<string, string>) {
if (!status) return "未知状态";
return labels[status] ?? status;
return labels[status] ?? "未知状态";
}
function invoiceOptionLabel(item: InvoicePoolListResponse["items"][number]) {
const invoiceNo = [item.invoiceCode, item.invoiceNumber].filter(Boolean).join("-");
return invoiceNo || item.originalName || "待识别票据";
}
export type InvoiceBindCardProps = {
@@ -107,7 +112,7 @@ export function InvoiceBindCard({
<option value="">选择票据</option>
{(listQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{`${item.invoiceCode ? `${item.invoiceCode}-${item.invoiceNumber}` : item.id.substring(0, 8)} (${statusLabel(item.status, INVOICE_STATUS_LABEL)})`}
{`${invoiceOptionLabel(item)} (${statusLabel(item.status, INVOICE_STATUS_LABEL)})`}
</option>
))}
</select>
@@ -54,7 +54,49 @@ function invoiceStatusLabel(status: string) {
ocr_failed: "识别失败",
duplicate_suspected: "疑似重复",
};
return labels[status] ?? status;
return labels[status] ?? "未知状态";
}
function verificationStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
pending: "核验中",
processing: "核验中",
passed: "核验通过",
success: "核验通过",
succeeded: "核验通过",
failed: "核验未通过",
error: "核验异常",
};
return status ? labels[status] ?? "待财务复核" : "待财务复核";
}
function verificationStatusTone(status: string | null | undefined) {
if (status === "failed" || status === "error") return "text-red-600";
if (status === "passed" || status === "success" || status === "succeeded") return "text-emerald-600";
if (status === "pending" || status === "processing") return "text-amber-600";
return "text-zinc-600";
}
function verificationProviderLabel(provider: string | null | undefined) {
const labels: Record<string, string> = {
mock: "本地核验",
tax: "税务接口",
tencent: "第三方接口",
aliyun: "第三方接口",
manual: "人工复核",
};
return provider ? labels[provider] ?? "系统核验" : "系统核验";
}
function verificationMessage(message: string | null | undefined) {
if (!message) return null;
const normalized = message.toLowerCase();
if (normalized.includes("invoice verified")) return "核验通过";
if (normalized.includes("invoice metadata mismatch")) return "票面信息与核验结果不一致";
if (normalized.includes("mock provider transport error")) return "核验服务暂不可用";
if (normalized.includes("tax provider is not configured")) return "当前环境未配置自动核验服务";
if (normalized.includes("verification_disabled")) return "当前未开启自动核验";
return message;
}
export function InvoiceDetailPanel({
@@ -130,16 +172,18 @@ export function InvoiceDetailPanel({
<div><span className="text-zinc-500">文件名:</span>{detail.originalName || "-"}</div>
<div className="col-span-2 flex flex-col gap-1">
<div>
<span className="text-zinc-500">验真状态:</span>
{detail.verification ? `${detail.verification.status} (${detail.verification.provider})` : "未触发"}
<span className="text-zinc-500">财务核验:</span>
{detail.verification
? `${verificationStatusLabel(detail.verification.status)} · ${verificationProviderLabel(detail.verification.provider)}`
: "待财务复核"}
</div>
{detail.verification?.resultMessage ? (
<div className="text-zinc-900 bg-zinc-50 px-2 py-1.5 rounded-md mt-1 border border-zinc-100">{detail.verification.resultMessage}</div>
<div className="text-zinc-900 bg-zinc-50 px-2 py-1.5 rounded-md mt-1 border border-zinc-100">{verificationMessage(detail.verification.resultMessage)}</div>
) : null}
{detail.verification?.errorMessage ? (
<div className="text-red-700 bg-red-50 px-2 py-1.5 rounded-md mt-1 border border-red-100 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[16px]">error</span>
{detail.verification.errorMessage}
{verificationMessage(detail.verification.errorMessage)}
</div>
) : null}
</div>
@@ -172,7 +216,7 @@ export function InvoiceDetailPanel({
disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"}
onClick={() => triggerVerificationMutation.mutate(detail.id)}
>
{triggerVerificationMutation.isPending ? "触发中..." : "触发验真"}
{triggerVerificationMutation.isPending ? "提交中..." : "发起辅助核验"}
</Button>
) : null}
@@ -186,7 +230,7 @@ export function InvoiceDetailPanel({
disabled={retryVerificationMutation.isPending}
onClick={() => retryVerificationMutation.mutate(detail.id)}
>
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"}
{retryVerificationMutation.isPending ? "重试中..." : "重新核验"}
</Button>
) : null}
@@ -266,29 +310,29 @@ export function InvoiceDetailPanel({
{canVerifyInvoice ? (
<div className="mt-4 rounded-lg bg-zinc-50 border border-zinc-200 p-4" data-testid="invoice-verification-records">
<p className="mb-3 text-sm font-semibold text-zinc-900 border-b border-zinc-200/60 pb-2">验真记录</p>
<p className="mb-3 text-sm font-semibold text-zinc-900 border-b border-zinc-200/60 pb-2">辅助核验记录</p>
{(verificationQuery.data?.items ?? []).length ? (
<div className="space-y-3 text-xs">
{(verificationQuery.data?.items ?? []).map((record, index) => (
<div key={record.id} className="rounded-md border border-zinc-200 bg-white px-3 py-2 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 mb-1">
<span className="font-medium text-zinc-700">记录 {index + 1}</span>
<span className={`font-semibold ${record.status === "failed" || record.status === "error" ? "text-red-600" : "text-emerald-600"}`}>
{record.status}
<span className={`font-semibold ${verificationStatusTone(record.status)}`}>
{verificationStatusLabel(record.status)}
</span>
</div>
<div className="text-zinc-600 break-words">
服务商: <strong className="font-medium text-zinc-800">{record.provider}</strong> |
来源: <strong className="font-medium text-zinc-800">{verificationProviderLabel(record.provider)}</strong> |
重试次数: <strong className="font-medium text-zinc-800">{record.attempts}</strong> |
验真时间: <strong className="font-medium text-zinc-800">{record.requestedAt || "-"}</strong>
核验时间: <strong className="font-medium text-zinc-800">{record.requestedAt || "-"}</strong>
</div>
{record.resultMessage ? <div className="mt-1 text-zinc-800 py-1 px-2 bg-zinc-50 rounded italic">{record.resultMessage}</div> : null}
{record.errorMessage ? <div className="mt-1 text-red-600 font-medium py-1 px-2 border border-red-100 bg-red-50 rounded">{record.errorMessage}</div> : null}
{record.resultMessage ? <div className="mt-1 text-zinc-800 py-1 px-2 bg-zinc-50 rounded italic">{verificationMessage(record.resultMessage)}</div> : null}
{record.errorMessage ? <div className="mt-1 text-red-600 font-medium py-1 px-2 border border-red-100 bg-red-50 rounded">{verificationMessage(record.errorMessage)}</div> : null}
</div>
))}
</div>
) : (
<p className="text-xs text-zinc-500">暂无验真记录</p>
<p className="text-xs text-zinc-500">暂无辅助核验记录</p>
)}
</div>
) : null}
+1 -1
查看文件
@@ -27,7 +27,7 @@ test("E2E 轻交互 OCR:报销详情上传后自动添加并提交", async ({
await login(page, EMPLOYEE);
const reimbursementId = await createBlankReimbursement(page);
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}$`));
await reimbursementDetail.assertLoaded();
await page.getByTestId(TEST_IDS.reimbursement.invoiceAdd).click();