修复报销票据门禁提示与 OCR 降级(08-30-b2-submit-error-ocr-fallback)

这个提交包含在:
2026-08-31 20:20:20 +08:00
父节点 8b19538584
当前提交 f57e2236f2
共修改 21 个文件,包含 1829 行新增和 49 行删除
@@ -29,6 +29,7 @@ import {
submitReimbursement,
updateReimbursementItem,
uploadReimbursementAttachments,
linkReimbursementInvoiceItem,
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
@@ -81,6 +82,15 @@ import { cn } from "../lib/utils";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
import {
focusFirstInvoiceGuardIssue,
invoiceGuardInvoiceMessages,
invoiceGuardIssueCount,
invoiceGuardItemMessages,
parseReimbursementInvoiceGuardIssues,
resolveInvoiceGuardIssuesAfterLink,
type ReimbursementInvoiceGuardIssues,
} from "../lib/reimbursementInvoiceGuard";
type ItemFormState = {
mode: "create" | "edit";
@@ -167,6 +177,9 @@ export function MobileReimbursementDetailPage() {
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
const [itemError, setItemError] = useState("");
const [actionError, setActionError] = useState("");
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
const [invoiceItemSelections, setInvoiceItemSelections] = useState<Record<string, string>>({});
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
@@ -369,6 +382,7 @@ export function MobileReimbursementDetailPage() {
await invalidateDetail();
setItemForm(null);
setItemError("");
setInvoiceGuardIssues(null);
toast.success("明细已保存");
},
onError: (error) => {
@@ -408,15 +422,23 @@ export function MobileReimbursementDetailPage() {
const submitMutation = useMutation({
mutationFn: () => submitReimbursement(reimbursementId),
onSuccess: async () => {
onSuccess: async (data) => {
await invalidateDetail();
setSubmitConfirmPrecheck(null);
setActionError("");
toast.success("已提交审批");
setInvoiceGuardIssues(null);
toast.success(
data.invoiceAmountManualReviewRequired
? "已提交,票面金额将由财务核对"
: "已提交审批",
);
},
onError: (error) => {
const message = resolveErrorMessage(error);
const issues = parseReimbursementInvoiceGuardIssues(error);
setActionError(message);
setInvoiceGuardIssues(issues);
if (issues) focusFirstInvoiceGuardIssue();
toast.error(message);
},
});
@@ -464,6 +486,7 @@ export function MobileReimbursementDetailPage() {
setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
);
if (succeededIds.length) setInvoiceGuardIssues(null);
if (!failed.length) {
setInvoiceActionError("");
@@ -531,6 +554,7 @@ export function MobileReimbursementDetailPage() {
onSuccess: async () => {
await invalidateDetail();
setInvoiceActionError("");
setInvoiceGuardIssues(null);
toast.success("发票已移除");
},
onError: (error) => {
@@ -540,6 +564,35 @@ export function MobileReimbursementDetailPage() {
},
});
const linkInvoiceItemMutation = useMutation({
mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) =>
linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId),
onSuccess: async (_result, variables) => {
await invalidateDetail();
setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink(
previous,
variables.invoiceId,
variables.linkedItemId,
));
setInvoiceItemSelections((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
return next;
});
setInvoiceItemLinkErrors((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
return next;
});
toast.success("发票已关联到报销明细");
},
onError: (error, variables) => {
const message = resolveErrorMessage(error, "关联明细失败,请刷新后重试");
setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message }));
toast.error(message);
},
});
const deleteAvailableInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => archiveInvoice(invoiceId),
onSuccess: async (_result, invoiceId) => {
@@ -805,6 +858,7 @@ export function MobileReimbursementDetailPage() {
try {
setActionError("");
setInvoiceGuardIssues(null);
const precheck = await precheckReimbursement(reimbursementId);
const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") {
@@ -1023,6 +1077,20 @@ export function MobileReimbursementDetailPage() {
</div>
</section>
{invoiceGuardIssues ? (
<section
className="mobile-card border-red-300 bg-red-50"
data-testid="mobile-reimbursement-invoice-guard-summary"
role="alert"
aria-live="assertive"
>
<h2 className="text-base font-semibold text-red-800">票据检查未通过</h2>
<p className="mt-2 text-sm leading-6 text-red-700">
共 {invoiceGuardIssueCount(invoiceGuardIssues)} 项需要处理,请按下方明细和发票提示修改。
</p>
</section>
) : null}
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -1114,7 +1182,15 @@ export function MobileReimbursementDetailPage() {
<div className="mt-3 space-y-2">
{detail.items.length ? (
detail.items.map((item) => (
<article key={item.id} className="mobile-list-card">
<article
key={item.id}
className={cn(
"mobile-list-card",
invoiceGuardItemMessages(invoiceGuardIssues, item.id).length && "border-red-300 bg-red-50/60",
)}
data-reimbursement-guard-issue={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? "true" : undefined}
tabIndex={invoiceGuardItemMessages(invoiceGuardIssues, item.id).length ? -1 : undefined}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div>
@@ -1126,6 +1202,15 @@ export function MobileReimbursementDetailPage() {
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备。</p>
) : null}
{invoiceGuardItemMessages(invoiceGuardIssues, item.id).map((message) => (
<p
key={message}
className="mt-2 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{canMaintainReimbursement ? (
<div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
@@ -1172,6 +1257,12 @@ export function MobileReimbursementDetailPage() {
</div>
</div>
{detail.invoiceAmountManualReviewRequired ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800">
本单包含未执行 OCR 的发票,系统未自动比较相关明细金额;财务审批时需查看发票原件核对。
</p>
) : null}
{detail.invoices.length || (detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
@@ -1273,8 +1364,21 @@ export function MobileReimbursementDetailPage() {
const invoiceHint = invoice.status === "bound" && !canMaintainReimbursement
? "已关联"
: resolveInvoiceNextStepText(invoice);
const guardMessages = invoiceGuardInvoiceMessages(
invoiceGuardIssues,
invoice.id,
invoice.linkedItemId,
);
return (
<article key={invoice.id} className="mobile-list-card">
<article
key={invoice.id}
className={cn(
"mobile-list-card",
guardMessages.length && "border-red-300 bg-red-50/60",
)}
data-reimbursement-guard-issue={guardMessages.length ? "true" : undefined}
tabIndex={guardMessages.length ? -1 : undefined}
>
<button
type="button"
className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
@@ -1298,6 +1402,64 @@ export function MobileReimbursementDetailPage() {
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
</span>
</button>
{guardMessages.map((message) => (
<p
key={message}
className="mt-3 rounded-md border border-red-200 bg-white px-3 py-2 text-sm leading-5 text-red-700"
role="alert"
>
{message}
</p>
))}
{invoice.amountEvidenceStatus === "manual_review" ? (
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-5 text-amber-800">
票面金额未自动识别,需由财务查看原件核对。
</p>
) : null}
{invoice.amountEvidenceStatus === "ocr_failed" ? (
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm leading-5 text-red-700">
OCR 已处理但未取得票面金额,提交前需要重试识别或联系财务。
</p>
) : null}
{canMaintainReimbursement && !invoice.linkedItemId ? (
<div className="mt-3 space-y-2">
<label className="block text-sm font-medium text-zinc-700" htmlFor={`mobile-invoice-item-link-${invoice.id}`}>
选择报销明细
</label>
<select
id={`mobile-invoice-item-link-${invoice.id}`}
className="select-std min-h-11 w-full"
value={invoiceItemSelections[invoice.id] ?? ""}
onChange={(event) => setInvoiceItemSelections((previous) => ({
...previous,
[invoice.id]: event.target.value,
}))}
>
<option value="">请选择明细</option>
{detail.items.map((item) => (
<option key={item.id} value={item.id}>
{item.expenseType?.name ?? "费用"} · {item.description ?? formatMoney(item.amount)}
</option>
))}
</select>
<Button
className="min-h-11 w-full"
type="button"
disabled={!invoiceItemSelections[invoice.id] || linkInvoiceItemMutation.isPending}
onClick={() => linkInvoiceItemMutation.mutate({
invoiceId: invoice.id,
linkedItemId: invoiceItemSelections[invoice.id],
})}
>
{linkInvoiceItemMutation.isPending ? "关联中..." : "确认关联"}
</Button>
{invoiceItemLinkErrors[invoice.id] ? (
<p className="text-sm leading-5 text-red-700" role="alert">
{invoiceItemLinkErrors[invoice.id]}
</p>
) : null}
</div>
) : null}
{canUnbindInvoices ? (
<Button
className="mt-3 w-full"
@@ -1362,7 +1524,11 @@ export function MobileReimbursementDetailPage() {
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
{actionError ? (
<div className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div
className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
role="alert"
aria-live="assertive"
>
{actionError}
</div>
) : null}