R52 A1-A6:完成 AP 运营深度操作

- AP 概览页 Tab 工作台化(待付款/异常付款/风险标记/对账异常)
- AP 导出端点支持 CSV/XLSX 与筛选
- 风险标记 resolve/dismiss 支持说明审计留痕
- 支付批次深链、高风险复核提示、对账确认权限收口
- OpenClaw 浏览器级回归全部通过
这个提交包含在:
2026-04-07 09:37:17 +08:00
父节点 02c01dadb9
当前提交 64d9ded024
共修改 9 个文件,包含 2325 行新增和 63 行删除
+62 -4
查看文件
@@ -1,6 +1,48 @@
import { apiRequestData } from "./http";
import { apiRequestBlob, apiRequestData } from "./http";
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 ApPendingPaymentBatchSummary = {
id: string;
batchNo: string;
entityType: string;
name: string;
status: string;
totalAmount: string;
highRisk: boolean;
createdAt: string | null;
};
export type ApFailedPaymentBatchItemSummary = {
id: string;
paymentBatchId: string;
entityId: string;
entityCode: string;
entityTitle: string;
amount: string;
currencyCode: string;
failureCode: string | null;
failureMessage: string | null;
createdAt: string | null;
};
export type ApPartialMatchItemSummary = {
id: string;
travelReconciliationBatchId: string;
travelOrderId: string | null;
travelRequestId: string | null;
matchStatus: string;
resolutionStatus: string;
anomalyCode: string | null;
orderAmount: string | null;
transactionAmount: string | null;
currency: string;
createdAt: string | null;
};
export type ApOperationsSummary = {
summary: {
pendingPaymentCount: number;
@@ -8,13 +50,29 @@ export type ApOperationsSummary = {
exceptionCount: number;
budgetOverspendCount: number;
};
pendingPaymentBatches: Array<{ id: string; batchNo: string; entityType: string; name: string; status: string; totalAmount: string; highRisk: boolean; createdAt: string | null }>;
failedPaymentBatchItems: Array<{ id: string; paymentBatchId: string; entityId: string; entityCode: string; entityTitle: string; amount: string; currencyCode: string; failureCode: string | null; failureMessage: string | null; createdAt: string | null }>;
pendingPaymentBatches: ApPendingPaymentBatchSummary[];
failedPaymentBatchItems: ApFailedPaymentBatchItemSummary[];
exceptionRiskFlags: RiskFlag[];
budgetOverspendRisks: RiskFlag[];
partialMatchItems: Array<{ id: string; travelReconciliationBatchId: string; travelOrderId: string | null; travelRequestId: string | null; matchStatus: string; resolutionStatus: string; anomalyCode: string | null; orderAmount: string | null; transactionAmount: string | null; currency: string; createdAt: string | null }>;
partialMatchItems: ApPartialMatchItemSummary[];
};
export async function fetchApOperations() {
return apiRequestData<ApOperationsSummary>("/api/ap-operations");
}
export async function exportApOperations(params: {
format: ApOperationsExportFormat;
pendingEntityType?: ApOperationsPendingEntityType;
pendingCreatedAtFrom?: string;
pendingCreatedAtTo?: string;
riskBucket?: ApOperationsRiskBucket;
}) {
return apiRequestBlob("/api/exports/ap-operations", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(params),
});
}
+42
查看文件
@@ -35,6 +35,25 @@ export type PrecheckResponse = {
nextActions: string[];
};
export type RiskFlagListResponse = {
items: RiskFlag[];
};
function buildRiskFlagsSearch(query: {
entityType?: string;
entityId?: string;
status?: "open" | "resolved" | "dismissed";
source?: string;
}) {
const params = new URLSearchParams();
if (query.entityType) params.set("entityType", query.entityType);
if (query.entityId) params.set("entityId", query.entityId);
if (query.status) params.set("status", query.status);
if (query.source) params.set("source", query.source);
const search = params.toString();
return search ? `?${search}` : "";
}
export async function precheckEntity(payload: {
entityType: "reimbursement" | "purchase_request" | "travel_request";
entityId: string;
@@ -44,3 +63,26 @@ export async function precheckEntity(payload: {
body: JSON.stringify(payload),
});
}
export async function fetchRiskFlags(query: {
entityType?: string;
entityId?: string;
status?: "open" | "resolved" | "dismissed";
source?: string;
} = {}) {
return apiRequestData<RiskFlagListResponse>(`/api/risk-flags${buildRiskFlagsSearch(query)}`);
}
export async function resolveRiskFlag(riskFlagId: string, payload: { note?: string | null } = {}) {
return apiRequestData<{ item: RiskFlag }>(`/api/risk-flags/${riskFlagId}/resolve`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function dismissRiskFlag(riskFlagId: string, payload: { note?: string | null } = {}) {
return apiRequestData<{ item: RiskFlag }>(`/api/risk-flags/${riskFlagId}/dismiss`, {
method: "POST",
body: JSON.stringify(payload),
});
}
文件差异内容过多而无法显示 加载差异
+25 -4
查看文件
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import {
cancelPaymentBatch,
createPaymentBatch,
@@ -35,9 +36,9 @@ function resolveError(error: unknown) {
export function PaymentBatchesPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const canManageBatch = hasPermission(permissions, "payment.batch.manage");
const canReviewBatch = hasPermission(permissions, "risk.review.manage");
const [selectedBatchId, setSelectedBatchId] = useState("");
const [entityType, setEntityType] = useState<PaymentBatchEntityType>("reimbursement");
const [name, setName] = useState("");
const [entityIds, setEntityIds] = useState("");
@@ -51,10 +52,24 @@ export function PaymentBatchesPage() {
queryFn: () => fetchPaymentBatches(),
});
const selectedBatchId = searchParams.get("id") ?? "";
const selectedBatch = useMemo<PaymentBatch | null>(() => {
const items = listQuery.data?.items ?? [];
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
}, [listQuery.data?.items, selectedBatchId]);
useEffect(() => {
if (!selectedBatch) {
return;
}
if (selectedBatch.id === selectedBatchId) {
return;
}
const next = new URLSearchParams(searchParams);
next.set("id", selectedBatch.id);
setSearchParams(next, { replace: true });
}, [searchParams, selectedBatch, selectedBatchId, setSearchParams]);
const canEditSelectedBatch = canManageBatch && selectedBatch?.status === "draft";
const canReviewSelectedBatch =
canReviewBatch &&
@@ -90,7 +105,9 @@ export function PaymentBatchesPage() {
}),
onSuccess: async ({ item }) => {
setError("");
setSelectedBatchId(item.id);
const next = new URLSearchParams(searchParams);
next.set("id", item.id);
setSearchParams(next, { replace: true });
await refresh();
toast.success("付款批次已创建");
},
@@ -257,7 +274,11 @@ export function PaymentBatchesPage() {
key={item.id}
type="button"
className={selectedBatch?.id === item.id ? "w-full rounded-lg border border-primary bg-primary/5 px-3 py-3 text-left" : "w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"}
onClick={() => setSelectedBatchId(item.id)}
onClick={() => {
const next = new URLSearchParams(searchParams);
next.set("id", item.id);
setSearchParams(next, { replace: true });
}}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.batchNo}</div>