diff --git a/client/src/api/apOperations.ts b/client/src/api/apOperations.ts index 1bc2c6a3..4277e65e 100644 --- a/client/src/api/apOperations.ts +++ b/client/src/api/apOperations.ts @@ -4,6 +4,9 @@ import type { RiskFlag } from "./feeControl"; export type ApOperationsExportFormat = "csv" | "xlsx"; export type ApOperationsRiskBucket = "all" | "exception" | "budget_overspend"; export type ApOperationsPendingEntityType = "reimbursement" | "travel_order"; +export type FinanceLedgerStatus = "draft" | "reviewing" | "approved" | "paid" | "rejected" | "cancelled"; +export type FinanceLedgerPaymentStatus = "unpaid" | "paid"; +export type FinanceLedgerInvoiceStatus = "none" | "pending_ocr" | "ocr_failed" | "available" | "bound" | "archived"; export type ApPendingPaymentBatchSummary = { id: string; @@ -61,12 +64,87 @@ export async function fetchApOperations() { return apiRequestData("/api/ap-operations"); } +export type FinanceLedgerItem = { + id: string; + formNumber: string | null; + reimbursementDate: string | null; + status: FinanceLedgerStatus; + totalAmount: string; + description: string | null; + owner: { id: string; employeeNo: string; name: string } | null; + submitter: { id: string; employeeNo: string; name: string } | null; + department: { id: string; name: string } | null; + payment: { + status: FinanceLedgerPaymentStatus; + paymentVoucherNo: string | null; + paymentDate: string | null; + paymentChannel: string | null; + paidAt: string | null; + paidBy: { id: string; employeeNo: string; name: string } | null; + }; + invoice: { + status: FinanceLedgerInvoiceStatus | "mixed"; + counts: { + total: number; + pendingOcr: number; + ocrFailed: number; + available: number; + bound: number; + archived: number; + usedComplete: number; + }; + }; + attachmentCount: number; + createdAt: string | null; + updatedAt: string | null; +}; + +export type FinanceLedgerQuery = { + page?: number; + pageSize?: number; + status?: FinanceLedgerStatus; + paymentStatus?: FinanceLedgerPaymentStatus; + invoiceStatus?: FinanceLedgerInvoiceStatus; + dateFrom?: string; + dateTo?: string; + q?: string; +}; + +export type FinanceLedgerResponse = { + page: number; + pageSize: number; + total: number; + items: FinanceLedgerItem[]; +}; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") continue; + params.set(key, String(value)); + } + const suffix = params.toString(); + return suffix ? `?${suffix}` : ""; +} + +export async function fetchFinanceLedger(query: FinanceLedgerQuery = {}) { + return apiRequestData( + `/api/ap-operations/reimbursement-ledger${toQueryString(query as Record)}`, + ); +} + export async function exportApOperations(params: { format: ApOperationsExportFormat; pendingEntityType?: ApOperationsPendingEntityType; pendingCreatedAtFrom?: string; pendingCreatedAtTo?: string; riskBucket?: ApOperationsRiskBucket; + ledgerStatus?: FinanceLedgerStatus; + ledgerPaymentStatus?: FinanceLedgerPaymentStatus; + ledgerInvoiceStatus?: FinanceLedgerInvoiceStatus; + ledgerDateFrom?: string; + ledgerDateTo?: string; + ledgerSearch?: string; }) { return apiRequestBlob("/api/exports/ap-operations", { method: "POST", diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index af2c05f5..9944cd64 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -136,6 +136,17 @@ export type ReimbursementDetail = { reimbursementDate: string | null; totalAmount: string | null; description: string | null; + payment: { + paymentVoucherNo: string | null; + paymentDate: string | null; + paymentChannel: string | null; + paidAt: string | null; + paidBy: { + id: string; + employeeNo: string; + name: string; + } | null; + }; projectId: string | null; linkedTravelRequestId: string | null; linkedTravelRequest: LinkedTravelRequestSummary | null; @@ -249,6 +260,7 @@ export type ReimbursementPayPayload = { paymentVoucherNo?: string; paymentDate?: string; paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other"; + paymentNote?: string; }; export type ReimbursementCreatePayload = { diff --git a/client/src/pages/ApOperationsPage.tsx b/client/src/pages/ApOperationsPage.tsx index d68e05f5..821573fc 100644 --- a/client/src/pages/ApOperationsPage.tsx +++ b/client/src/pages/ApOperationsPage.tsx @@ -6,9 +6,13 @@ import { toast } from "sonner"; import { exportApOperations, fetchApOperations, + fetchFinanceLedger, type ApOperationsPendingEntityType, type ApOperationsRiskBucket, type ApPartialMatchItemSummary, + type FinanceLedgerInvoiceStatus, + type FinanceLedgerPaymentStatus, + type FinanceLedgerStatus, } from "../api/apOperations"; import { mapApiErrorMessage } from "../api/errorMessages"; import { @@ -59,9 +63,12 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; import { downloadBlob } from "../lib/download"; -type OperationsTab = "pending" | "failed" | "risk" | "reconciliation"; +type OperationsTab = "ledger" | "pending" | "failed" | "risk" | "reconciliation"; type RiskActionType = "resolve" | "dismiss"; type PendingEntityFilter = ApOperationsPendingEntityType | "all"; +type FinanceLedgerStatusFilter = FinanceLedgerStatus | "all"; +type FinanceLedgerPaymentFilter = FinanceLedgerPaymentStatus | "all"; +type FinanceLedgerInvoiceFilter = FinanceLedgerInvoiceStatus | "all"; type FailedPaymentItemRow = PaymentBatchItem & { batchId: string; @@ -101,10 +108,10 @@ function formatDateTime(value: string | null) { } function statusBadgeClass(status: string) { - if (status === "open" || status === "pending" || status === "draft") { + if (status === "open" || status === "pending" || status === "draft" || status === "reviewing" || status === "unpaid") { return "border-amber-200 bg-amber-50 text-amber-700"; } - if (status === "resolved" || status === "confirmed" || status === "reviewed") { + if (status === "resolved" || status === "confirmed" || status === "reviewed" || status === "approved" || status === "paid") { return "border-emerald-200 bg-emerald-50 text-emerald-700"; } if (status === "dismissed" || status === "cancelled") { @@ -133,6 +140,33 @@ function batchStatusLabel(value: string) { return value || "-"; } +function reimbursementStatusLabel(value: string) { + if (value === "draft") return "草稿"; + if (value === "reviewing") return "审批中"; + if (value === "approved") return "已通过"; + if (value === "paid") return "已支付"; + if (value === "rejected") return "已驳回"; + if (value === "cancelled") return "已取消"; + return value || "-"; +} + +function paymentStatusLabel(value: string) { + if (value === "paid") return "已付款"; + if (value === "unpaid") return "未付款"; + return value || "-"; +} + +function invoiceStatusLabel(value: string) { + if (value === "none") return "无发票"; + if (value === "pending_ocr") return "识别中"; + if (value === "ocr_failed") return "识别失败"; + if (value === "available") return "可添加"; + if (value === "bound") return "已绑定"; + if (value === "archived") return "已归档"; + if (value === "mixed") return "多状态"; + return value || "-"; +} + function riskBucketLabel(value: ApOperationsRiskBucket) { if (value === "budget_overspend") return "预算超支"; if (value === "exception") return "异常风险"; @@ -232,11 +266,18 @@ export function ApOperationsPage() { const canReviewRisk = hasPermission(permissions, "risk.review.manage"); const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage"); - const [activeTab, setActiveTab] = useState("pending"); + const [activeTab, setActiveTab] = useState("ledger"); const [pendingEntityType, setPendingEntityType] = useState("all"); const [pendingCreatedAtFrom, setPendingCreatedAtFrom] = useState(""); const [pendingCreatedAtTo, setPendingCreatedAtTo] = useState(""); const [riskBucket, setRiskBucket] = useState("all"); + const [ledgerStatus, setLedgerStatus] = useState("all"); + const [ledgerPaymentStatus, setLedgerPaymentStatus] = useState("all"); + const [ledgerInvoiceStatus, setLedgerInvoiceStatus] = useState("all"); + const [ledgerDateFrom, setLedgerDateFrom] = useState(""); + const [ledgerDateTo, setLedgerDateTo] = useState(""); + const [ledgerSearch, setLedgerSearch] = useState(""); + const [ledgerPage, setLedgerPage] = useState(1); const [reviewTarget, setReviewTarget] = useState(null); const [reviewComment, setReviewComment] = useState(""); @@ -271,6 +312,30 @@ export function ApOperationsPage() { queryFn: () => fetchRiskFlags({ status: "open" }), }); + const financeLedgerQuery = useQuery({ + queryKey: [ + "finance-ledger", + ledgerStatus, + ledgerPaymentStatus, + ledgerInvoiceStatus, + ledgerDateFrom, + ledgerDateTo, + ledgerSearch, + ledgerPage, + ], + queryFn: () => + fetchFinanceLedger({ + page: ledgerPage, + pageSize: 20, + status: ledgerStatus === "all" ? undefined : ledgerStatus, + paymentStatus: ledgerPaymentStatus === "all" ? undefined : ledgerPaymentStatus, + invoiceStatus: ledgerInvoiceStatus === "all" ? undefined : ledgerInvoiceStatus, + dateFrom: ledgerDateFrom || undefined, + dateTo: ledgerDateTo || undefined, + q: ledgerSearch.trim() || undefined, + }), + }); + const failedDetailQuery = useQuery({ enabled: Boolean(failedDetailTarget?.batchId), queryKey: ["payment-batches", "detail", failedDetailTarget?.batchId], @@ -362,6 +427,7 @@ export function ApOperationsPage() { const refreshWorkbench = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["ap-operations"] }), + queryClient.invalidateQueries({ queryKey: ["finance-ledger"] }), queryClient.invalidateQueries({ queryKey: ["payment-batches"] }), queryClient.invalidateQueries({ queryKey: ["risk-flags"] }), failedDetailTarget?.batchId @@ -477,6 +543,12 @@ export function ApOperationsPage() { pendingCreatedAtFrom: pendingCreatedAtFrom || undefined, pendingCreatedAtTo: pendingCreatedAtTo || undefined, riskBucket, + ledgerStatus: ledgerStatus === "all" ? undefined : ledgerStatus, + ledgerPaymentStatus: ledgerPaymentStatus === "all" ? undefined : ledgerPaymentStatus, + ledgerInvoiceStatus: ledgerInvoiceStatus === "all" ? undefined : ledgerInvoiceStatus, + ledgerDateFrom: ledgerDateFrom || undefined, + ledgerDateTo: ledgerDateTo || undefined, + ledgerSearch: ledgerSearch.trim() || undefined, }), onSuccess: (response, format) => { downloadBlob(response.blob, response.filename ?? `ap-operations.${format}`); @@ -532,7 +604,7 @@ export function ApOperationsPage() { } }; - if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading) { + if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading && financeLedgerQuery.isLoading) { return
加载财务工作台...
; } @@ -547,7 +619,7 @@ export function ApOperationsPage() { type="button" variant="secondary" onClick={() => refreshWorkbench()} - disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching} + disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching || financeLedgerQuery.isFetching} > 刷新 @@ -579,8 +651,12 @@ export function ApOperationsPage() { {riskFlagsQuery.isError ? ( ) : null} + {financeLedgerQuery.isError ? ( + + ) : null} -
+
+ setActiveTab(value as OperationsTab)}> + 财务台账 {financeLedgerQuery.data?.total ?? 0} 待付款 {pendingBatches.length} 异常付款 {failedItems.length} 风险标记 {filteredRiskFlags.length} 对账异常 {partialMatchItems.length} + +
+
+
审批状态
+ +
+
+
付款状态
+ +
+
+
发票状态
+ +
+
+
报销日期从
+ { + setLedgerDateFrom(event.target.value); + setLedgerPage(1); + }} + /> +
+
+
报销日期至
+ { + setLedgerDateTo(event.target.value); + setLedgerPage(1); + }} + /> +
+
+
搜索
+ { + setLedgerSearch(event.target.value); + setLedgerPage(1); + }} + /> +
+
+
+ +
+ +
+ + + + 报销单 + 申请人 / 部门 + 金额 + 审批状态 + 付款状态 + 发票 / 附件 + 更新时间 + 操作 + + + + {financeLedgerQuery.data?.items.length ? ( + financeLedgerQuery.data.items.map((item) => ( + + +
{item.formNumber ?? item.id}
+
{item.description || "无说明"}
+
+ +
{item.owner?.name ?? item.submitter?.name ?? "-"}
+
{item.department?.name ?? "-"}
+
+ {item.totalAmount} + + + {reimbursementStatusLabel(item.status)} + + + +
+ + {paymentStatusLabel(item.payment.status)} + +
+ {item.payment.paymentDate ?? "-"} · {item.payment.paidBy?.name ?? "未记录操作人"} +
+
+ 凭证:{item.payment.paymentVoucherNo ?? "-"} +
+
+
+ +
{invoiceStatusLabel(item.invoice.status)} · {item.invoice.counts.total} 张
+
附件 {item.attachmentCount} 个
+
+ {formatDateTime(item.updatedAt)} + + + +
+ )) + ) : ( + + + 当前筛选下暂无财务台账记录。 + + + )} +
+
+
+
+
+ 共 {financeLedgerQuery.data?.total ?? 0} 条,第 {financeLedgerQuery.data?.page ?? ledgerPage} 页 +
+
+ + +
+
+
+
diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 7923537b..59ae9c54 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -263,6 +263,7 @@ export function ReimbursementDetailPage() { paymentVoucherNo: params.payload.paymentVoucherNo || undefined, paymentDate: params.payload.paymentDate || undefined, paymentChannel: params.payload.paymentChannel, + paymentNote: params.payload.paymentNote || undefined, }); }, onSuccess: async (_data, params) => { @@ -751,6 +752,13 @@ export function ReimbursementDetailPage() { } const detail = detailQuery.data; + const payment = detail.payment ?? { + paymentVoucherNo: null, + paymentDate: null, + paymentChannel: null, + paidAt: null, + paidBy: null, + }; const displayCode = resolveReimbursementDisplayCode(detail); const isPaidReimbursement = detail.status === "paid"; const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null); @@ -984,6 +992,44 @@ export function ReimbursementDetailPage() {
说明:{detail.description || "-"}
+
+
+
+

付款状态

+

付款备注写入审计日志,凭证、日期和操作人用于财务台账核对。

+
+ + {detail.status === "paid" ? "已付款" : "未付款"} + +
+
+
+
付款日期
+
{payment.paymentDate ?? "-"}
+
+
+
付款凭证号
+
{payment.paymentVoucherNo ?? "-"}
+
+
+
付款渠道
+
{payment.paymentChannel ?? "-"}
+
+
+
操作人 / 时间
+
+ {payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)} +
+
+
+
+ {detail.linkedTravelRequest ? (
diff --git a/client/src/pages/reimbursement-detail/ReimbursementActions.tsx b/client/src/pages/reimbursement-detail/ReimbursementActions.tsx index 958e89e2..233c6681 100644 --- a/client/src/pages/reimbursement-detail/ReimbursementActions.tsx +++ b/client/src/pages/reimbursement-detail/ReimbursementActions.tsx @@ -10,6 +10,7 @@ export type PayPayload = { paymentVoucherNo: string; paymentDate: string; paymentChannel: PaymentChannel; + paymentNote: string; }; export interface ReimbursementActionsProps { @@ -41,6 +42,7 @@ export function ReimbursementActions({ const [paymentVoucherNo, setPaymentVoucherNo] = useState(""); const [paymentDate, setPaymentDate] = useState("2026-02-08"); const [paymentChannel, setPaymentChannel] = useState("bank"); + const [paymentNote, setPaymentNote] = useState(""); return (
@@ -122,6 +124,13 @@ export function ReimbursementActions({ +