feat(client): 完成 F34 票据中心增强\n\n- 补齐人工修正/手工归档/解绑/重试 OCR/判重例外等入口\n- 补充 data-testid 并与后端错误码映射保持一致\n- 更新 P5 蓝图 v3 中 F34 状态为 DONE

这个提交包含在:
2026-02-09 18:55:46 +08:00
父节点 00f23678ea
当前提交 c9ac18e2e2
共修改 3 个文件,包含 397 行新增和 6 行删除
+334 -5
查看文件
@@ -4,10 +4,15 @@ import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import {
archiveInvoice,
bindInvoice,
fetchInvoicePoolDetail,
fetchInvoicePoolList,
fetchInvoiceSignedUrl,
overrideInvoiceDuplicate,
patchInvoice,
retryInvoiceOcr,
unbindInvoice,
uploadInvoice,
type InvoicePoolStatus,
} from "../api/invoicePool";
@@ -25,6 +30,8 @@ export function InvoicePoolPage() {
const queryClient = useQueryClient();
const { permissions, me } = useAuth();
const canManageInvoice = hasPermission(permissions, "invoice.manage");
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration;
@@ -36,6 +43,13 @@ export function InvoicePoolPage() {
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
const [detailInvoiceId, setDetailInvoiceId] = useState("");
const [editOpen, setEditOpen] = useState(false);
const [editSetAvailable, setEditSetAvailable] = useState(false);
const [editRemarks, setEditRemarks] = useState("");
const [editInvoiceCode, setEditInvoiceCode] = useState("");
const [editInvoiceNumber, setEditInvoiceNumber] = useState("");
const [editInvoiceDate, setEditInvoiceDate] = useState("");
const [editTotalAmount, setEditTotalAmount] = useState("");
const [error, setError] = useState("");
const listQuery = useQuery({
@@ -73,6 +87,19 @@ export function InvoicePoolPage() {
},
});
useEffect(() => {
if (!detailQuery.data || editOpen) {
return;
}
setEditSetAvailable(false);
setEditRemarks(detailQuery.data.remarks ?? "");
setEditInvoiceCode(detailQuery.data.invoiceCode ?? "");
setEditInvoiceNumber(detailQuery.data.invoiceNumber ?? "");
setEditInvoiceDate(detailQuery.data.invoiceDate ?? "");
setEditTotalAmount(detailQuery.data.totalAmount ?? "");
}, [detailQuery.data, editOpen]);
const uploadMutation = useMutation({
mutationFn: async () => {
if (!selectedFile) {
@@ -145,7 +172,109 @@ export function InvoicePoolPage() {
}
}, [detailInvoiceId, listQuery.data?.items]);
const patchMutation = useMutation({
mutationFn: async () => {
if (!detailInvoiceId) {
throw new Error("请选择一条票据");
}
return patchInvoice(detailInvoiceId, {
...(editSetAvailable ? { status: "available" } : {}),
remarks: editRemarks.trim() ? editRemarks.trim() : null,
invoiceCode: editInvoiceCode.trim() ? editInvoiceCode.trim() : null,
invoiceNumber: editInvoiceNumber.trim() ? editInvoiceNumber.trim() : null,
invoiceDate: editInvoiceDate ? editInvoiceDate : null,
totalAmount: editTotalAmount.trim() ? editTotalAmount.trim() : null,
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setEditOpen(false);
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else if (err instanceof Error) {
setError(err.message);
} else {
setError("保存失败,请稍后重试");
}
},
});
const archiveMutation = useMutation({
mutationFn: async (invoiceId: string) => archiveInvoice(invoiceId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("归档失败,请稍后重试");
}
},
});
const unbindMutation = useMutation({
mutationFn: async (invoiceId: string) => unbindInvoice(invoiceId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("解绑失败,请稍后重试");
}
},
});
const retryOcrMutation = useMutation({
mutationFn: async (invoiceId: string) => retryInvoiceOcr(invoiceId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("OCR 重试失败,请稍后再试");
}
},
});
const overrideDuplicateMutation = useMutation({
mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) =>
overrideInvoiceDuplicate(params.invoiceId, {
refFormNumber: params.refFormNumber,
reason: params.reason,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("判重例外失败,请稍后重试");
}
},
});
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
const detail = detailQuery.data;
const canEditDetail = Boolean(
detail &&
(canManageInvoice ||
detail.ownerEmployeeId === me?.id ||
detail.uploadedByEmployeeId === me?.id),
);
return (
<div className="space-y-6">
@@ -367,14 +496,214 @@ export function InvoicePoolPage() {
<Card className="space-y-2 p-6 text-sm" data-testid="invoice-detail-panel">
<h3 className="text-base font-semibold text-slate-900">详情轮询</h3>
{detailQuery.data ? (
{detail ? (
<>
<div>票据ID:{detailQuery.data.id}</div>
<div>状态:{detailQuery.data.status}</div>
<div>OCR错误:{detailQuery.data.ocrLastError || "-"}</div>
<div>票据ID:{detail.id}</div>
<div>状态:{detail.status}</div>
<div>OCR错误:{detail.ocrLastError || "-"}</div>
<div>
发票号:{detailQuery.data.invoiceCode || "-"}-{detailQuery.data.invoiceNumber || "-"}
发票号:{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
</div>
<div>开票日期:{detail.invoiceDate || "-"}</div>
<div>总金额:{detail.totalAmount || "-"}</div>
<div>备注:{detail.remarks || "-"}</div>
<div>关联报销:{detail.linkedReimbursementId || "-"}</div>
<div>
判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "}
{detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
</div>
<div className="flex flex-wrap gap-2 pt-2">
{canEditDetail ? (
<Button
data-testid="invoice-edit-open"
size="sm"
variant="secondary"
type="button"
disabled={patchMutation.isPending}
onClick={() => setEditOpen((prev) => !prev)}
>
{editOpen ? "关闭编辑" : "人工修正"}
</Button>
) : null}
{canEditDetail && detail.status === "ocr_failed" ? (
<Button
data-testid="invoice-retry-ocr"
size="sm"
variant="secondary"
type="button"
disabled={retryOcrMutation.isPending}
onClick={() => {
if (!window.confirm("确认重试 OCR?将重新进入 pending_ocr 并消耗一次尝试次数。")) return;
retryOcrMutation.mutate(detail.id);
}}
>
重试 OCR
</Button>
) : null}
{canEditDetail && canUploadInvoice && detail.status === "bound" ? (
<Button
data-testid="invoice-unbind"
size="sm"
variant="secondary"
type="button"
disabled={unbindMutation.isPending}
onClick={() => {
if (!window.confirm("确认解绑该票据?仅草稿/驳回报销允许解绑。")) return;
unbindMutation.mutate(detail.id);
}}
>
解绑
</Button>
) : null}
{canEditDetail && detail.status !== "bound" && detail.status !== "archived" ? (
<Button
data-testid="invoice-archive"
size="sm"
variant="secondary"
type="button"
disabled={archiveMutation.isPending}
onClick={() => {
if (!window.confirm("确认归档该票据?归档后将不再用于绑定与判重。")) return;
archiveMutation.mutate(detail.id);
}}
>
手工归档
</Button>
) : null}
{canOverrideDuplicate &&
!detail.isDuplicateOverride &&
["available", "ocr_failed"].includes(detail.status) ? (
<Button
data-testid="invoice-override-duplicate"
size="sm"
variant="secondary"
type="button"
disabled={overrideDuplicateMutation.isPending}
onClick={() => {
if (!window.confirm("确认执行判重例外?该操作将写入审计并影响判重口径。")) return;
const refFormNumber = window.prompt("请输入参考单号(必填)");
if (!refFormNumber?.trim()) return;
const reason = window.prompt("请输入例外原因(必填)");
if (!reason?.trim()) return;
overrideDuplicateMutation.mutate({
invoiceId: detail.id,
refFormNumber: refFormNumber.trim(),
reason: reason.trim(),
});
}}
>
判重例外
</Button>
) : null}
</div>
{editOpen ? (
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 p-4">
<p className="text-sm font-medium text-slate-800">人工修正(仅必要字段)</p>
{detail.status === "ocr_failed" ? (
<label className="flex items-center gap-2 text-sm">
<input
data-testid="invoice-edit-set-available"
checked={editSetAvailable}
onChange={(event) => setEditSetAvailable(event.target.checked)}
type="checkbox"
/>
<span>将状态标记为 available(人工确认可用)</span>
</label>
) : null}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="invoiceCode">
发票代码
</label>
<input
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-code"
id="invoiceCode"
onChange={(event) => setEditInvoiceCode(event.target.value)}
value={editInvoiceCode}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="invoiceNumber">
发票号码
</label>
<input
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-number"
id="invoiceNumber"
onChange={(event) => setEditInvoiceNumber(event.target.value)}
value={editInvoiceNumber}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="invoiceDate">
开票日期
</label>
<input
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-date"
id="invoiceDate"
onChange={(event) => setEditInvoiceDate(event.target.value)}
type="date"
value={editInvoiceDate}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="invoiceTotalAmount">
总金额
</label>
<input
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-total-amount"
id="invoiceTotalAmount"
onChange={(event) => setEditTotalAmount(event.target.value)}
value={editTotalAmount}
/>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs text-slate-600" htmlFor="invoiceRemarks">
备注
</label>
<textarea
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="invoice-edit-remarks"
id="invoiceRemarks"
onChange={(event) => setEditRemarks(event.target.value)}
rows={2}
value={editRemarks}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
data-testid="invoice-edit-save"
disabled={patchMutation.isPending}
onClick={() => patchMutation.mutate()}
type="button"
>
{patchMutation.isPending ? "保存中..." : "保存修正"}
</Button>
<Button
data-testid="invoice-edit-cancel"
variant="secondary"
disabled={patchMutation.isPending}
onClick={() => setEditOpen(false)}
type="button"
>
取消
</Button>
</div>
</div>
) : null}
</>
) : (
<p className="text-slate-500">请选择一条票据查看详情</p>