diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts index 0b10df28..ff72d431 100644 --- a/client/src/api/invoicePool.ts +++ b/client/src/api/invoicePool.ts @@ -46,21 +46,35 @@ export type InvoicePoolListResponse = { export type InvoicePoolDetail = { id: string; status: InvoicePoolStatus; + archivedReason: string | null; + archivedAt: string | null; ownerEmployeeId: string; uploadedByEmployeeId: string; + originalName: string | null; + mimeType: string | null; + fileSize: number | null; + fileSha256: string | null; + remarks: string | null; ocrType: string; ocrProvider: string; ocrAttempts: number; + ocrLastRequestedAt: string | null; ocrLastError: string | null; ocrCompletedAt: string | null; invoiceCode: string | null; invoiceNumber: string | null; invoiceDate: string | null; + sellerName: string | null; + buyerName: string | null; amount: string | null; taxAmount: string | null; totalAmount: string | null; linkedReimbursementId: string | null; linkedItemId: string | null; + isDuplicateOverride: boolean; + overrideRefFormNumber: string | null; + overrideReason: string | null; + overriddenBy: string | null; createdAt: string | null; updatedAt: string | null; }; @@ -131,3 +145,51 @@ export async function fetchInvoiceSignedUrl(invoiceId: string) { }, ); } + +export type InvoicePatchPayload = Partial<{ + status: "available"; + remarks: string | null; + invoiceCode: string | null; + invoiceNumber: string | null; + invoiceDate: string | null; + sellerName: string | null; + buyerName: string | null; + amount: string | null; + taxAmount: string | null; + totalAmount: string | null; +}>; + +export async function patchInvoice(invoiceId: string, payload: InvoicePatchPayload) { + return apiRequestData(`/api/invoice-pool/${invoiceId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function archiveInvoice(invoiceId: string) { + return apiRequestData(`/api/invoice-pool/${invoiceId}/archive`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function unbindInvoice(invoiceId: string) { + return apiRequestData(`/api/invoice-pool/${invoiceId}/unbind`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function retryInvoiceOcr(invoiceId: string) { + return apiRequestData(`/api/invoice-pool/${invoiceId}/retry-ocr`, { + method: "POST", + body: JSON.stringify({}), + }); +} + +export async function overrideInvoiceDuplicate(invoiceId: string, payload: { refFormNumber: string; reason: string }) { + return apiRequestData(`/api/invoice-pool/${invoiceId}/override-duplicate`, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index b35d0085..6089d193 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -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 (
@@ -367,14 +496,214 @@ export function InvoicePoolPage() {

详情轮询

- {detailQuery.data ? ( + {detail ? ( <> -
票据ID:{detailQuery.data.id}
-
状态:{detailQuery.data.status}
-
OCR错误:{detailQuery.data.ocrLastError || "-"}
+
票据ID:{detail.id}
+
状态:{detail.status}
+
OCR错误:{detail.ocrLastError || "-"}
- 发票号:{detailQuery.data.invoiceCode || "-"}-{detailQuery.data.invoiceNumber || "-"} + 发票号:{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
+
开票日期:{detail.invoiceDate || "-"}
+
总金额:{detail.totalAmount || "-"}
+
备注:{detail.remarks || "-"}
+
关联报销:{detail.linkedReimbursementId || "-"}
+
+ 判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "} + {detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""} +
+ +
+ {canEditDetail ? ( + + ) : null} + + {canEditDetail && detail.status === "ocr_failed" ? ( + + ) : null} + + {canEditDetail && canUploadInvoice && detail.status === "bound" ? ( + + ) : null} + + {canEditDetail && detail.status !== "bound" && detail.status !== "archived" ? ( + + ) : null} + + {canOverrideDuplicate && + !detail.isDuplicateOverride && + ["available", "ocr_failed"].includes(detail.status) ? ( + + ) : null} +
+ + {editOpen ? ( +
+

人工修正(仅必要字段)

+ + {detail.status === "ocr_failed" ? ( + + ) : null} + +
+
+ + setEditInvoiceCode(event.target.value)} + value={editInvoiceCode} + /> +
+
+ + setEditInvoiceNumber(event.target.value)} + value={editInvoiceNumber} + /> +
+
+ + setEditInvoiceDate(event.target.value)} + type="date" + value={editInvoiceDate} + /> +
+
+ + setEditTotalAmount(event.target.value)} + value={editTotalAmount} + /> +
+
+ +