R73-A3 优化轻交互 OCR 前端处理
这个提交包含在:
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatInvoiceOcrFieldLabels } from "../lib/invoiceOcr";
|
||||||
|
|
||||||
function readStringList(value: unknown): string[] {
|
function readStringList(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -189,6 +191,14 @@ export function mapApiErrorMessage(
|
|||||||
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。";
|
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (details?.ocr === "missing_required_fields") {
|
||||||
|
const labels = formatInvoiceOcrFieldLabels(readStringList(details?.missingFields));
|
||||||
|
if (labels.length) {
|
||||||
|
return `票据仍缺少关键字段:${labels.join("、")},补齐后才能标记为可用。`;
|
||||||
|
}
|
||||||
|
return "票据仍缺少关键字段,补齐后才能标记为可用。";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (details?.verification === "disabled") {
|
if (details?.verification === "disabled") {
|
||||||
return "当前环境未开启发票验真。";
|
return "当前环境未开启发票验真。";
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-lg -tranzinc-x-1/2 -tranzinc-y-1/2 rounded-xl border border-zinc-200 bg-white p-6 shadow-lg focus-visible:outline-none",
|
"fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl border border-zinc-200 bg-white p-6 shadow-lg focus-visible:outline-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -73,4 +73,3 @@ export const DialogDescription = React.forwardRef<
|
|||||||
));
|
));
|
||||||
|
|
||||||
DialogDescription.displayName = "DialogDescription";
|
DialogDescription.displayName = "DialogDescription";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const OCR_FIELD_LABELS: Record<string, string> = {
|
||||||
|
invoiceCode: "发票代码",
|
||||||
|
invoiceNumber: "发票号码",
|
||||||
|
invoiceDate: "开票日期",
|
||||||
|
totalAmount: "总金额",
|
||||||
|
sellerName: "销售方",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseMissingInvoiceOcrFields(rawError: string | null | undefined) {
|
||||||
|
if (typeof rawError !== "string") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = rawError.match(/^missing_required_fields:(.+)$/);
|
||||||
|
if (!matched?.[1]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched[1]
|
||||||
|
.split(",")
|
||||||
|
.map((field) => field.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInvoiceOcrFieldLabels(fields: string[]) {
|
||||||
|
return fields.map((field) => OCR_FIELD_LABELS[field] ?? field);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInvoiceOcrErrorMessage(rawError: string | null | undefined) {
|
||||||
|
const missingFields = parseMissingInvoiceOcrFields(rawError);
|
||||||
|
if (missingFields.length) {
|
||||||
|
return `OCR 缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawError === "duplicate_invoice_code_number") {
|
||||||
|
return "OCR 识别到的发票代码/号码与已使用票据重复,请核对后再处理。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawError === "mock_ocr_failed") {
|
||||||
|
return "OCR 识别失败,请重试或人工补齐关键信息。";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawError) {
|
||||||
|
return "OCR 识别失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawError;
|
||||||
|
}
|
||||||
@@ -41,6 +41,13 @@ export const TEST_IDS = {
|
|||||||
erpExport: "reimbursement-erp-export",
|
erpExport: "reimbursement-erp-export",
|
||||||
overview: "reimbursement-overview",
|
overview: "reimbursement-overview",
|
||||||
workflowProgress: "reimbursement-workflow-progress",
|
workflowProgress: "reimbursement-workflow-progress",
|
||||||
|
invoices: "reimbursement-invoices",
|
||||||
|
invoiceAdd: "reimbursement-invoice-add",
|
||||||
|
invoiceDialog: "reimbursement-invoice-dialog",
|
||||||
|
invoiceUploadFile: "reimbursement-invoice-upload-file",
|
||||||
|
invoiceUploadSubmit: "reimbursement-invoice-upload-submit",
|
||||||
|
invoicePendingHint: "reimbursement-invoice-pending-hint",
|
||||||
|
invoiceActionError: "reimbursement-invoice-action-error",
|
||||||
},
|
},
|
||||||
weeklyReport: {
|
weeklyReport: {
|
||||||
save: "weekly-report-save",
|
save: "weekly-report-save",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
@@ -26,6 +26,7 @@ import { useAuth } from "../auth/AuthContext";
|
|||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { downloadBlob } from "../lib/download";
|
import { downloadBlob } from "../lib/download";
|
||||||
|
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
@@ -64,9 +65,14 @@ export function InvoicePoolPage() {
|
|||||||
const [editInvoiceCode, setEditInvoiceCode] = useState("");
|
const [editInvoiceCode, setEditInvoiceCode] = useState("");
|
||||||
const [editInvoiceNumber, setEditInvoiceNumber] = useState("");
|
const [editInvoiceNumber, setEditInvoiceNumber] = useState("");
|
||||||
const [editInvoiceDate, setEditInvoiceDate] = useState("");
|
const [editInvoiceDate, setEditInvoiceDate] = useState("");
|
||||||
|
const [editSellerName, setEditSellerName] = useState("");
|
||||||
|
const [editBuyerName, setEditBuyerName] = useState("");
|
||||||
|
const [editAmount, setEditAmount] = useState("");
|
||||||
|
const [editTaxAmount, setEditTaxAmount] = useState("");
|
||||||
const [editTotalAmount, setEditTotalAmount] = useState("");
|
const [editTotalAmount, setEditTotalAmount] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null);
|
const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null);
|
||||||
|
const previousDetailStatusRef = useRef<InvoicePoolStatus | null>(null);
|
||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
|
|
||||||
@@ -140,9 +146,29 @@ export function InvoicePoolPage() {
|
|||||||
setEditInvoiceCode(detailQuery.data.invoiceCode ?? "");
|
setEditInvoiceCode(detailQuery.data.invoiceCode ?? "");
|
||||||
setEditInvoiceNumber(detailQuery.data.invoiceNumber ?? "");
|
setEditInvoiceNumber(detailQuery.data.invoiceNumber ?? "");
|
||||||
setEditInvoiceDate(detailQuery.data.invoiceDate ?? "");
|
setEditInvoiceDate(detailQuery.data.invoiceDate ?? "");
|
||||||
|
setEditSellerName(detailQuery.data.sellerName ?? "");
|
||||||
|
setEditBuyerName(detailQuery.data.buyerName ?? "");
|
||||||
|
setEditAmount(detailQuery.data.amount ?? "");
|
||||||
|
setEditTaxAmount(detailQuery.data.taxAmount ?? "");
|
||||||
setEditTotalAmount(detailQuery.data.totalAmount ?? "");
|
setEditTotalAmount(detailQuery.data.totalAmount ?? "");
|
||||||
}, [detailQuery.data, editOpen]);
|
}, [detailQuery.data, editOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentStatus = detailQuery.data?.status ?? null;
|
||||||
|
const previousStatus = previousDetailStatusRef.current;
|
||||||
|
|
||||||
|
if (previousStatus === "pending_ocr" && currentStatus && currentStatus !== "pending_ocr") {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
|
if (currentStatus === "available") {
|
||||||
|
toast.success("票据 OCR 已完成,可继续绑定或验真");
|
||||||
|
} else if (currentStatus === "ocr_failed") {
|
||||||
|
toast.error(`票据 OCR 未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousDetailStatusRef.current = currentStatus;
|
||||||
|
}, [detailQuery.data?.ocrLastError, detailQuery.data?.status, queryClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canManageInvoice && ownerScope === "all") {
|
if (!canManageInvoice && ownerScope === "all") {
|
||||||
setOwnerScope("own");
|
setOwnerScope("own");
|
||||||
@@ -188,7 +214,7 @@ export function InvoicePoolPage() {
|
|||||||
setDetailInvoiceId(data.invoicePoolId);
|
setDetailInvoiceId(data.invoicePoolId);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setError("");
|
setError("");
|
||||||
toast.success("上传成功");
|
toast.success(data.status === "pending_ocr" ? "上传成功,OCR 识别中" : "上传成功,可直接处理");
|
||||||
},
|
},
|
||||||
onError: (err) => handleError(err, "上传失败,请稍后重试"),
|
onError: (err) => handleError(err, "上传失败,请稍后重试"),
|
||||||
});
|
});
|
||||||
@@ -246,6 +272,10 @@ export function InvoicePoolPage() {
|
|||||||
invoiceCode: editInvoiceCode.trim() ? editInvoiceCode.trim() : null,
|
invoiceCode: editInvoiceCode.trim() ? editInvoiceCode.trim() : null,
|
||||||
invoiceNumber: editInvoiceNumber.trim() ? editInvoiceNumber.trim() : null,
|
invoiceNumber: editInvoiceNumber.trim() ? editInvoiceNumber.trim() : null,
|
||||||
invoiceDate: editInvoiceDate ? editInvoiceDate : null,
|
invoiceDate: editInvoiceDate ? editInvoiceDate : null,
|
||||||
|
sellerName: editSellerName.trim() ? editSellerName.trim() : null,
|
||||||
|
buyerName: editBuyerName.trim() ? editBuyerName.trim() : null,
|
||||||
|
amount: editAmount.trim() ? editAmount.trim() : null,
|
||||||
|
taxAmount: editTaxAmount.trim() ? editTaxAmount.trim() : null,
|
||||||
totalAmount: editTotalAmount.trim() ? editTotalAmount.trim() : null,
|
totalAmount: editTotalAmount.trim() ? editTotalAmount.trim() : null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -461,6 +491,14 @@ export function InvoicePoolPage() {
|
|||||||
setEditInvoiceNumber={setEditInvoiceNumber}
|
setEditInvoiceNumber={setEditInvoiceNumber}
|
||||||
editInvoiceDate={editInvoiceDate}
|
editInvoiceDate={editInvoiceDate}
|
||||||
setEditInvoiceDate={setEditInvoiceDate}
|
setEditInvoiceDate={setEditInvoiceDate}
|
||||||
|
editSellerName={editSellerName}
|
||||||
|
setEditSellerName={setEditSellerName}
|
||||||
|
editBuyerName={editBuyerName}
|
||||||
|
setEditBuyerName={setEditBuyerName}
|
||||||
|
editAmount={editAmount}
|
||||||
|
setEditAmount={setEditAmount}
|
||||||
|
editTaxAmount={editTaxAmount}
|
||||||
|
setEditTaxAmount={setEditTaxAmount}
|
||||||
editTotalAmount={editTotalAmount}
|
editTotalAmount={editTotalAmount}
|
||||||
setEditTotalAmount={setEditTotalAmount}
|
setEditTotalAmount={setEditTotalAmount}
|
||||||
confirmAction={confirmAction}
|
confirmAction={confirmAction}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
|||||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||||
|
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
|
||||||
import { TEST_IDS } from "../lib/testIds";
|
import { TEST_IDS } from "../lib/testIds";
|
||||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||||
import {
|
import {
|
||||||
@@ -437,6 +438,12 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
if (!failedResults.length) {
|
if (!failedResults.length) {
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
|
setIsInvoiceDialogOpen(false);
|
||||||
|
setInvoiceSearchInput("");
|
||||||
|
setInvoiceSearch("");
|
||||||
|
setSelectedInvoices({});
|
||||||
|
setSelectedInvoiceFile(null);
|
||||||
|
setPendingOcrInvoiceId(null);
|
||||||
toast.success(`已绑定 ${succeededIds.length} 张发票`);
|
toast.success(`已绑定 ${succeededIds.length} 张发票`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -482,13 +489,27 @@ export function ReimbursementDetailPage() {
|
|||||||
if (!ownerEmployeeId) {
|
if (!ownerEmployeeId) {
|
||||||
throw new Error("missing ownerEmployeeId");
|
throw new Error("missing ownerEmployeeId");
|
||||||
}
|
}
|
||||||
return uploadInvoice(file, undefined, ownerEmployeeId);
|
return uploadInvoice(file, "vat", ownerEmployeeId);
|
||||||
},
|
},
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
setInvoiceActionError("");
|
setInvoiceActionError("");
|
||||||
setSelectedInvoiceFile(null);
|
setSelectedInvoiceFile(null);
|
||||||
setInvoiceSearchInput("");
|
setInvoiceSearchInput("");
|
||||||
setInvoiceSearch("");
|
setInvoiceSearch("");
|
||||||
|
|
||||||
|
if (result.status === "available") {
|
||||||
|
const refreshed = await bindableInvoicesQuery.refetch();
|
||||||
|
if (refreshed.data?.items.some((item) => item.id === result.invoicePoolId)) {
|
||||||
|
setSelectedInvoices((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[result.invoicePoolId]: previous[result.invoicePoolId] ?? "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setPendingOcrInvoiceId(null);
|
||||||
|
toast.success("发票上传成功,可直接绑定");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setPendingOcrInvoiceId(result.invoicePoolId);
|
setPendingOcrInvoiceId(result.invoicePoolId);
|
||||||
toast.success("发票上传成功,OCR 识别中");
|
toast.success("发票上传成功,OCR 识别中");
|
||||||
|
|
||||||
@@ -512,7 +533,9 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
if (invoice.status === "ocr_failed") {
|
if (invoice.status === "ocr_failed") {
|
||||||
setPendingOcrInvoiceId(null);
|
setPendingOcrInvoiceId(null);
|
||||||
toast.error("发票 OCR 识别失败,请前往票据中心处理");
|
const message = `发票 OCR 未完成:${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}可前往票据中心继续修正或重试。`;
|
||||||
|
setInvoiceActionError(message);
|
||||||
|
toast.error("发票 OCR 识别失败,请在当前提示或票据中心继续处理");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -522,6 +545,7 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
setPendingOcrInvoiceId(null);
|
setPendingOcrInvoiceId(null);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] });
|
||||||
|
setInvoiceActionError("发票仍在 OCR 处理中,请稍后刷新列表;若长时间未完成,可前往票据中心查看详情。");
|
||||||
toast("发票仍在 OCR 处理中,请稍后手动刷新候选列表");
|
toast("发票仍在 OCR 处理中,请稍后手动刷新候选列表");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -1002,7 +1026,7 @@ export function ReimbursementDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 text-sm" data-testid="reimbursement-invoices">
|
<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="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="space-y-1">
|
<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>
|
||||||
@@ -1010,6 +1034,7 @@ export function ReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{canOpenInvoiceBindDialog ? (
|
{canOpenInvoiceBindDialog ? (
|
||||||
<Button
|
<Button
|
||||||
|
data-testid={TEST_IDS.reimbursement.invoiceAdd}
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => handleInvoiceDialogOpenChange(true)}
|
onClick={() => handleInvoiceDialogOpenChange(true)}
|
||||||
@@ -1022,7 +1047,10 @@ export function ReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{invoiceActionError ? (
|
{invoiceActionError ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<p
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||||
|
data-testid={TEST_IDS.reimbursement.invoiceActionError}
|
||||||
|
>
|
||||||
{invoiceActionError}
|
{invoiceActionError}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1231,7 +1259,10 @@ export function ReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
|
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
|
||||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
<DialogContent
|
||||||
|
className="max-h-[85vh] max-w-3xl overflow-y-auto"
|
||||||
|
data-testid={TEST_IDS.reimbursement.invoiceDialog}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>添加发票</DialogTitle>
|
<DialogTitle>添加发票</DialogTitle>
|
||||||
<DialogDescription>选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。</DialogDescription>
|
<DialogDescription>选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。</DialogDescription>
|
||||||
@@ -1350,11 +1381,13 @@ export function ReimbursementDetailPage() {
|
|||||||
<input
|
<input
|
||||||
id="reimbursement-inline-invoice-upload"
|
id="reimbursement-inline-invoice-upload"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||||
onChange={(event) => setSelectedInvoiceFile(event.target.files?.[0] ?? null)}
|
onChange={(event) => setSelectedInvoiceFile(event.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedInvoiceFile || uploadInvoiceMutation.isPending}
|
disabled={!selectedInvoiceFile || uploadInvoiceMutation.isPending}
|
||||||
onClick={() => selectedInvoiceFile && uploadInvoiceMutation.mutate(selectedInvoiceFile)}
|
onClick={() => selectedInvoiceFile && uploadInvoiceMutation.mutate(selectedInvoiceFile)}
|
||||||
@@ -1364,7 +1397,7 @@ export function ReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pendingOcrInvoiceId ? (
|
{pendingOcrInvoiceId ? (
|
||||||
<p className="mt-3 text-xs text-zinc-500">
|
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||||
发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。
|
发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
|||||||
import type { InvoicePoolDetail, InvoiceVerificationRecord } from "../../api/invoicePool";
|
import type { InvoicePoolDetail, InvoiceVerificationRecord } from "../../api/invoicePool";
|
||||||
import { ConfirmDialog } from "../../components/common/ConfirmDialog";
|
import { ConfirmDialog } from "../../components/common/ConfirmDialog";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { formatInvoiceOcrErrorMessage } from "../../lib/invoiceOcr";
|
||||||
|
|
||||||
export type InvoiceDetailPanelProps = {
|
export type InvoiceDetailPanelProps = {
|
||||||
detail: InvoicePoolDetail | undefined;
|
detail: InvoicePoolDetail | undefined;
|
||||||
@@ -30,6 +31,14 @@ export type InvoiceDetailPanelProps = {
|
|||||||
setEditInvoiceNumber: (value: string) => void;
|
setEditInvoiceNumber: (value: string) => void;
|
||||||
editInvoiceDate: string;
|
editInvoiceDate: string;
|
||||||
setEditInvoiceDate: (value: string) => void;
|
setEditInvoiceDate: (value: string) => void;
|
||||||
|
editSellerName: string;
|
||||||
|
setEditSellerName: (value: string) => void;
|
||||||
|
editBuyerName: string;
|
||||||
|
setEditBuyerName: (value: string) => void;
|
||||||
|
editAmount: string;
|
||||||
|
setEditAmount: (value: string) => void;
|
||||||
|
editTaxAmount: string;
|
||||||
|
setEditTaxAmount: (value: string) => void;
|
||||||
editTotalAmount: string;
|
editTotalAmount: string;
|
||||||
setEditTotalAmount: (value: string) => void;
|
setEditTotalAmount: (value: string) => void;
|
||||||
confirmAction: { type: string; invoiceId: string } | null;
|
confirmAction: { type: string; invoiceId: string } | null;
|
||||||
@@ -62,6 +71,14 @@ export function InvoiceDetailPanel({
|
|||||||
setEditInvoiceNumber,
|
setEditInvoiceNumber,
|
||||||
editInvoiceDate,
|
editInvoiceDate,
|
||||||
setEditInvoiceDate,
|
setEditInvoiceDate,
|
||||||
|
editSellerName,
|
||||||
|
setEditSellerName,
|
||||||
|
editBuyerName,
|
||||||
|
setEditBuyerName,
|
||||||
|
editAmount,
|
||||||
|
setEditAmount,
|
||||||
|
editTaxAmount,
|
||||||
|
setEditTaxAmount,
|
||||||
editTotalAmount,
|
editTotalAmount,
|
||||||
setEditTotalAmount,
|
setEditTotalAmount,
|
||||||
confirmAction,
|
confirmAction,
|
||||||
@@ -80,6 +97,8 @@ export function InvoiceDetailPanel({
|
|||||||
<span className="text-zinc-500">发票号:</span>
|
<span className="text-zinc-500">发票号:</span>
|
||||||
{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
|
{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
|
||||||
</div>
|
</div>
|
||||||
|
<div><span className="text-zinc-500">销售方:</span>{detail.sellerName || "-"}</div>
|
||||||
|
<div><span className="text-zinc-500">购买方:</span>{detail.buyerName || "-"}</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className="text-zinc-500">总金额:</span>
|
<span className="text-zinc-500">总金额:</span>
|
||||||
{detail.totalAmount ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(detail.totalAmount)) : "-"}
|
{detail.totalAmount ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(detail.totalAmount)) : "-"}
|
||||||
@@ -101,7 +120,7 @@ export function InvoiceDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{detail.ocrLastError ? <div className="col-span-2 text-red-600"><span className="text-zinc-500">OCR错误:</span>{detail.ocrLastError}</div> : null}
|
{detail.ocrLastError ? <div className="col-span-2 text-red-600"><span className="text-zinc-500">OCR错误:</span>{formatInvoiceOcrErrorMessage(detail.ocrLastError)}</div> : null}
|
||||||
{detail.remarks ? <div className="col-span-2"><span className="text-zinc-500">备注:</span>{detail.remarks}</div> : null}
|
{detail.remarks ? <div className="col-span-2"><span className="text-zinc-500">备注:</span>{detail.remarks}</div> : null}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<span className="text-zinc-500">判重例外:</span>
|
<span className="text-zinc-500">判重例外:</span>
|
||||||
@@ -109,6 +128,18 @@ export function InvoiceDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detail.status === "pending_ocr" ? (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700" data-testid="invoice-ocr-status-banner">
|
||||||
|
OCR 正在处理中,当前页面会自动轮询;完成后可直接继续绑定、验真或查看详情。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{detail.status === "ocr_failed" ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="invoice-ocr-status-banner">
|
||||||
|
{formatInvoiceOcrErrorMessage(detail.ocrLastError)} 可在下方直接重试 OCR,或补齐关键字段后标记为可用。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 pt-4 border-t border-zinc-100 mt-4">
|
<div className="flex flex-wrap gap-2 pt-4 border-t border-zinc-100 mt-4">
|
||||||
{canVerifyInvoice ? (
|
{canVerifyInvoice ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -294,6 +325,54 @@ export function InvoiceDetailPanel({
|
|||||||
value={editInvoiceDate}
|
value={editInvoiceDate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceSellerName">
|
||||||
|
销售方
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input-std"
|
||||||
|
data-testid="invoice-edit-seller-name"
|
||||||
|
id="invoiceSellerName"
|
||||||
|
onChange={(event) => setEditSellerName(event.target.value)}
|
||||||
|
value={editSellerName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceBuyerName">
|
||||||
|
购买方
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input-std"
|
||||||
|
data-testid="invoice-edit-buyer-name"
|
||||||
|
id="invoiceBuyerName"
|
||||||
|
onChange={(event) => setEditBuyerName(event.target.value)}
|
||||||
|
value={editBuyerName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceAmount">
|
||||||
|
不含税金额
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input-std"
|
||||||
|
data-testid="invoice-edit-amount"
|
||||||
|
id="invoiceAmount"
|
||||||
|
onChange={(event) => setEditAmount(event.target.value)}
|
||||||
|
value={editAmount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceTaxAmount">
|
||||||
|
税额
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input-std"
|
||||||
|
data-testid="invoice-edit-tax-amount"
|
||||||
|
id="invoiceTaxAmount"
|
||||||
|
onChange={(event) => setEditTaxAmount(event.target.value)}
|
||||||
|
value={editTaxAmount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceTotalAmount">
|
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceTotalAmount">
|
||||||
总金额
|
总金额
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { StatusBadge } from "../../components/common/StatusBadge";
|
|||||||
import { TableSkeleton } from "../../components/common/TableSkeleton";
|
import { TableSkeleton } from "../../components/common/TableSkeleton";
|
||||||
import { Pagination } from "../../components/common/Pagination";
|
import { Pagination } from "../../components/common/Pagination";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
|
||||||
|
import { formatInvoiceOcrErrorMessage } from "../../lib/invoiceOcr";
|
||||||
|
|
||||||
export type InvoiceListCardProps = {
|
export type InvoiceListCardProps = {
|
||||||
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
||||||
@@ -102,7 +103,7 @@ export function InvoiceListCard({
|
|||||||
) : item.status === "ocr_failed" ? (
|
) : item.status === "ocr_failed" ? (
|
||||||
<span className="flex items-center gap-1.5 text-red-600">
|
<span className="flex items-center gap-1.5 text-red-600">
|
||||||
<span className="size-1.5 rounded-full bg-red-500" />
|
<span className="size-1.5 rounded-full bg-red-500" />
|
||||||
识别失败 <span className="text-zinc-400 text-xs">({item.ocrLastError || "未知错误"})</span>
|
识别失败 <span className="text-zinc-400 text-xs">({formatInvoiceOcrErrorMessage(item.ocrLastError)})</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-zinc-400 text-xs">解析成功</span>
|
<span className="text-zinc-400 text-xs">解析成功</span>
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户