P25L-1~P25L-5 实现预算控制与AP最小可用运营

合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
2026-03-29 09:21:17 +08:00
父节点 e8d16c47f2
当前提交 6dee2daf3e
共修改 19 个文件,包含 1394 行新增和 482 行删除
+8 -7
查看文件
@@ -3,15 +3,16 @@ import type { RiskFlag } from "./feeControl";
export type ApOperationsSummary = {
summary: {
openPaymentBatchCount: number;
failedPaymentBatchItemCount: number;
openRiskFlagCount: number;
reconciliationAnomalyCount: number;
pendingPaymentCount: number;
partialMatchCount: number;
exceptionCount: number;
budgetOverspendCount: number;
};
openPaymentBatches: Array<{ id: string; batchNo: string; entityType: string; name: string; status: string; createdAt: string | null }>;
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 }>;
openRiskFlags: RiskFlag[];
reconciliationAnomalies: 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 }>;
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 }>;
};
export async function fetchApOperations() {
+90
查看文件
@@ -0,0 +1,90 @@
import { apiRequestData } from "./http";
export type BudgetControlRuleEntityType = "all" | "reimbursement" | "purchase_request" | "travel_request";
export type BudgetControlMode = "warn" | "block" | "manual_review";
export type BudgetControlRule = {
id: string;
name: string;
entityType: BudgetControlRuleEntityType;
departmentId: string | null;
legalEntityId: string | null;
costCenterId: string | null;
expenseTypeId: string | null;
controlMode: BudgetControlMode;
priority: number;
enabled: boolean;
notes: string | null;
archivedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type BudgetControlRuleListResponse = {
items: BudgetControlRule[];
};
function toQueryString(query: Record<string, string | boolean | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null || value === "") {
continue;
}
params.set(key, typeof value === "boolean" ? String(value) : value);
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}
export async function fetchBudgetControlRules(query: {
entityType?: BudgetControlRuleEntityType;
enabled?: boolean;
} = {}) {
return apiRequestData<BudgetControlRuleListResponse>(
`/api/budget-control-rules${toQueryString(query as Record<string, string | boolean | undefined>)}`,
);
}
export async function createBudgetControlRule(payload: {
name: string;
entityType: BudgetControlRuleEntityType;
departmentId?: string | null;
legalEntityId?: string | null;
expenseTypeId?: string | null;
controlMode: BudgetControlMode;
priority?: number;
enabled?: boolean;
notes?: string | null;
}) {
return apiRequestData<{ item: BudgetControlRule }>("/api/budget-control-rules", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function patchBudgetControlRule(
ruleId: string,
payload: Partial<{
name: string;
entityType: BudgetControlRuleEntityType;
departmentId: string | null;
legalEntityId: string | null;
expenseTypeId: string | null;
controlMode: BudgetControlMode;
priority: number;
enabled: boolean;
notes: string | null;
}>,
) {
return apiRequestData<{ item: BudgetControlRule }>(`/api/budget-control-rules/${ruleId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function archiveBudgetControlRule(ruleId: string) {
return apiRequestData<{ item: BudgetControlRule }>(`/api/budget-control-rules/${ruleId}/archive`, {
method: "POST",
body: JSON.stringify({}),
});
}
+20
查看文件
@@ -132,6 +132,26 @@ export function mapApiErrorMessage(
return "当前状态不允许执行付款。";
}
if (details?.review === "maker_checker_conflict") {
return "复核人与批次创建人不能是同一人。";
}
if (details?.review === "second_reviewer_conflict") {
return "二次复核人不能与首个复核人相同。";
}
if (details?.review === "high_risk_second_review_required") {
return "当前为高风险付款批次,完成二次复核后才能执行。";
}
if (details?.review === "batch_review_required") {
return "当前批次尚未完成复核,不能执行。";
}
if (details?.review === "batch_not_reviewable") {
return "当前批次状态不允许继续复核。";
}
if (details?.transfer === "chain_limit_exceeded") {
return "当前任务转审次数已达上限。";
}
+12
查看文件
@@ -1,3 +1,5 @@
import { apiRequestData } from "./http";
export type RiskFlag = {
id: string;
entityType: string;
@@ -32,3 +34,13 @@ export type PrecheckResponse = {
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
nextActions: string[];
};
export async function precheckEntity(payload: {
entityType: "reimbursement" | "purchase_request" | "travel_request";
entityId: string;
}) {
return apiRequestData<PrecheckResponse>("/api/fee-control/precheck", {
method: "POST",
body: JSON.stringify(payload),
});
}
+13
查看文件
@@ -32,12 +32,25 @@ export type PaymentBatch = {
defaultPaymentDate: string | null;
defaultPaymentChannel: PaymentChannel | null;
voucherPrefix: string | null;
totalAmount: string;
highRisk: boolean;
reviewRequirement: {
thresholdAmount: string;
requiredReviewCount: number;
completedReviewCount: number;
nextStep: "first_review" | "second_review" | "ready_for_execute";
makerCheckerRequired: boolean;
reviewerPermission: string;
};
reviewComment: string | null;
secondaryReviewComment: string | null;
executionSummaryJson: Record<string, unknown> | null;
createdAt: string | null;
createdBy: string | null;
reviewedAt: string | null;
reviewedBy: string | null;
secondaryReviewedAt: string | null;
secondaryReviewedBy: string | null;
executedAt: string | null;
executedBy: string | null;
cancelledAt: string | null;
+17 -13
查看文件
@@ -20,48 +20,52 @@ export function ApOperationsPage() {
const data = summaryQuery.data;
return (
<div className="space-y-6">
<PageHeader title="AP 运营" subtitle="待支付、失败批次、风险项与差旅对账异常。" />
<PageHeader title="AP 运营" subtitle="财务视角查看待付款、部分匹配、异常和预算超支四类最小运营摘要。" />
<div className="grid gap-4 md:grid-cols-4">
<div className="card-std p-5"><div className="text-sm text-zinc-500">开放批次</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.openPaymentBatchCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">失败批次项</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.failedPaymentBatchItemCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">开放风险</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.openRiskFlagCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">对账异常</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.reconciliationAnomalyCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">待付款</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.pendingPaymentCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">部分匹配</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.partialMatchCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">异常</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.exceptionCount}</div></div>
<div className="card-std p-5"><div className="text-sm text-zinc-500">预算超支</div><div className="mt-2 text-2xl font-bold text-zinc-900">{data.summary.budgetOverspendCount}</div></div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">开放批次</h3>
<h3 className="text-base font-semibold text-zinc-900">待付款批次</h3>
<div className="space-y-2 text-sm">
{data.openPaymentBatches.length ? data.openPaymentBatches.map((item) => (
{data.pendingPaymentBatches.length ? data.pendingPaymentBatches.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3">
<div className="font-medium text-zinc-900">{item.batchNo}</div>
<div className="text-zinc-600">{item.name}</div>
<div className="text-xs text-zinc-500">{item.entityType} · {item.status}</div>
<div className="text-xs text-zinc-500">{item.entityType} · {item.status} · {item.totalAmount}{item.highRisk ? " · 高风险" : ""}</div>
</div>
)) : <div className="text-zinc-500">暂无开放批次</div>}
)) : <div className="text-zinc-500">暂无待付款批次</div>}
</div>
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">失败批次项</h3>
<h3 className="text-base font-semibold text-zinc-900">异常付款项</h3>
<div className="space-y-2 text-sm">
{data.failedPaymentBatchItems.length ? data.failedPaymentBatchItems.map((item) => (
<div key={item.id} className="rounded-lg border border-red-200 bg-red-50 px-3 py-3">
<div className="font-medium text-zinc-900">{item.entityCode}</div>
<div className="text-zinc-600">{item.failureMessage ?? item.failureCode ?? "执行失败"}</div>
<div className="text-xs text-zinc-500">{item.amount} {item.currencyCode}</div>
</div>
)) : <div className="text-zinc-500">暂无失败项</div>}
</div>
</div>
</div>
<RiskFlagsCard items={data.openRiskFlags} title="开放风险项" />
<div className="grid gap-6 xl:grid-cols-2">
<RiskFlagsCard items={data.exceptionRiskFlags} title="异常风险项" />
<RiskFlagsCard items={data.budgetOverspendRisks} title="预算超支风险" />
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">差旅对账异常</h3>
<h3 className="text-base font-semibold text-zinc-900">部分匹配与对账异常</h3>
<div className="space-y-2 text-sm">
{data.reconciliationAnomalies.length ? data.reconciliationAnomalies.map((item) => (
{data.partialMatchItems.length ? data.partialMatchItems.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3">
<div className="font-medium text-zinc-900">{item.anomalyCode ?? item.matchStatus}</div>
<div className="text-zinc-600">订单 {item.orderAmount ?? "-"} / 交易 {item.transactionAmount ?? "-"} {item.currency}</div>
文件差异内容过多而无法显示 加载差异
+135 -24
查看文件
@@ -13,6 +13,8 @@ import {
} from "../api/paymentBatches";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -32,6 +34,9 @@ function resolveError(error: unknown) {
export function PaymentBatchesPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
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("");
@@ -50,6 +55,23 @@ export function PaymentBatchesPage() {
const items = listQuery.data?.items ?? [];
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
}, [listQuery.data?.items, selectedBatchId]);
const canEditSelectedBatch = canManageBatch && selectedBatch?.status === "draft";
const canReviewSelectedBatch =
canReviewBatch &&
!!selectedBatch &&
(
(selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review") ||
(selectedBatch.status === "reviewed" && selectedBatch.reviewRequirement.nextStep === "second_review")
);
const canExecuteSelectedBatch =
canManageBatch &&
!!selectedBatch &&
["reviewed", "failed"].includes(selectedBatch.status) &&
selectedBatch.reviewRequirement.nextStep === "ready_for_execute";
const canCancelSelectedBatch =
canManageBatch &&
!!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status);
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
@@ -85,10 +107,10 @@ export function PaymentBatchesPage() {
const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id));
return reviewPaymentBatch(batchId, { resolveRiskFlagIds });
},
onSuccess: async () => {
onSuccess: async ({ item }) => {
setError("");
await refresh();
toast.success("付款批次已复核");
toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核");
},
onError: (err) => {
const message = resolveError(err);
@@ -146,36 +168,67 @@ export function PaymentBatchesPage() {
return (
<div className="space-y-6">
<PageHeader title="支付批次" subtitle="财务批量付款、复核与执行。" />
<PageHeader title="支付批次" subtitle="财务批量付款、复核与执行;高风险批次达到 5 万元后需完成二次复核。" />
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
{!canManageBatch ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
</div>
) : null}
<div className="space-y-2">
<Label>实体类型</Label>
<select className="select-std" value={entityType} onChange={(event) => setEntityType(event.target.value as PaymentBatchEntityType)}>
<select
className="select-std"
value={entityType}
disabled={!canManageBatch}
onChange={(event) => setEntityType(event.target.value as PaymentBatchEntityType)}
>
<option value="reimbursement">报销付款</option>
<option value="travel_order">差旅订单付款</option>
</select>
</div>
<div className="space-y-2">
<Label>批次名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:2026Q2-财务付款批次-01" />
<Input
value={name}
disabled={!canManageBatch}
onChange={(event) => setName(event.target.value)}
placeholder="例如:2026Q2-财务付款批次-01"
/>
</div>
<div className="space-y-2">
<Label>实体 ID</Label>
<textarea className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" value={entityIds} onChange={(event) => setEntityIds(event.target.value)} placeholder="用逗号或换行分隔 reimbursement / travel_order ID" />
<textarea
className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-50"
value={entityIds}
disabled={!canManageBatch}
onChange={(event) => setEntityIds(event.target.value)}
placeholder="用逗号或换行分隔 reimbursement / travel_order ID"
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>默认付款日期</Label>
<Input type="date" value={defaultPaymentDate} onChange={(event) => setDefaultPaymentDate(event.target.value)} />
<Input
type="date"
value={defaultPaymentDate}
disabled={!canManageBatch}
onChange={(event) => setDefaultPaymentDate(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label>默认渠道</Label>
<select className="select-std" value={defaultPaymentChannel} onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "") }>
<select
className="select-std"
value={defaultPaymentChannel}
disabled={!canManageBatch}
onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "") }
>
<option value="">不设置</option>
{PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
@@ -183,9 +236,14 @@ export function PaymentBatchesPage() {
</div>
<div className="space-y-2">
<Label>凭证前缀</Label>
<Input value={voucherPrefix} onChange={(event) => setVoucherPrefix(event.target.value)} placeholder="例如 PBX" />
<Input
value={voucherPrefix}
disabled={!canManageBatch}
onChange={(event) => setVoucherPrefix(event.target.value)}
placeholder="例如 PBX"
/>
</div>
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
<Button type="button" disabled={createMutation.isPending || !canManageBatch} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建批次"}
</Button>
</div>
@@ -206,6 +264,11 @@ export function PaymentBatchesPage() {
<div className="text-xs uppercase text-zinc-500">{item.status}</div>
</div>
<div className="mt-1 text-sm text-zinc-600">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">
金额 {item.totalAmount}
{item.highRisk ? " · 高风险" : ""}
{` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`}
</div>
</button>
))}
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无支付批次</div> : null}
@@ -218,25 +281,58 @@ export function PaymentBatchesPage() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
<div className="text-lg font-semibold text-zinc-900">{selectedBatch.name}</div>
<div className="flex flex-wrap items-center gap-2">
<div className="text-lg font-semibold text-zinc-900">{selectedBatch.name}</div>
{selectedBatch.highRisk ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">高风险</span>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate(selectedBatch.id)}>
复核
</Button>
<Button type="button" disabled={executeMutation.isPending} onClick={() => executeMutation.mutate(selectedBatch.id)}>
执行
</Button>
<Button type="button" variant="secondary" disabled={cancelMutation.isPending} onClick={() => cancelMutation.mutate(selectedBatch.id)}>
取消
</Button>
{canReviewBatch ? (
<Button
type="button"
variant="secondary"
disabled={reviewMutation.isPending || !canReviewSelectedBatch}
onClick={() => reviewMutation.mutate(selectedBatch.id)}
>
{selectedBatch.reviewRequirement.nextStep === "second_review" ? "二次复核" : "复核"}
</Button>
) : null}
{canManageBatch ? (
<Button
type="button"
disabled={executeMutation.isPending || !canExecuteSelectedBatch}
onClick={() => executeMutation.mutate(selectedBatch.id)}
>
执行
</Button>
) : null}
{canManageBatch ? (
<Button
type="button"
variant="secondary"
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
onClick={() => cancelMutation.mutate(selectedBatch.id)}
>
取消
</Button>
) : null}
</div>
</div>
<div className="grid gap-3 md:grid-cols-4 text-sm">
<div className="grid gap-3 text-sm md:grid-cols-3 xl:grid-cols-6">
<div>状态:{selectedBatch.status}</div>
<div>实体:{selectedBatch.entityType}</div>
<div>总金额:{selectedBatch.totalAmount}</div>
<div>默认日期:{selectedBatch.defaultPaymentDate ?? "-"}</div>
<div>默认渠道:{selectedBatch.defaultPaymentChannel ?? "-"}</div>
<div>复核进度:{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
当前要求:{selectedBatch.reviewRequirement.requiredReviewCount === 2 ? `批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},需两轮复核` : "单轮复核后可执行"}。
下一步:{selectedBatch.reviewRequirement.nextStep === "first_review" ? "等待首轮复核" : selectedBatch.reviewRequirement.nextStep === "second_review" ? "等待二次复核" : "可执行"}。
{selectedBatch.reviewedBy ? ` 首轮复核人:${selectedBatch.reviewedBy}` : ""}
{selectedBatch.secondaryReviewedBy ? ` 二次复核人:${selectedBatch.secondaryReviewedBy}` : ""}
</div>
</div>
@@ -253,9 +349,24 @@ export function PaymentBatchesPage() {
<div className="text-sm text-zinc-600">{item.amount} {item.currencyCode} · {item.status}</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-4">
<Input defaultValue={item.paymentVoucherNoOverride ?? ""} placeholder="凭证号" onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })} />
<Input type="date" defaultValue={item.paymentDateOverride ?? ""} onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} />
<select className="select-std" defaultValue={item.paymentChannelOverride ?? ""} onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}>
<Input
defaultValue={item.paymentVoucherNoOverride ?? ""}
placeholder="凭证号"
disabled={!canEditSelectedBatch || item.status !== "pending"}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentVoucherNoOverride: event.target.value || null })}
/>
<Input
type="date"
defaultValue={item.paymentDateOverride ?? ""}
disabled={!canEditSelectedBatch || item.status !== "pending"}
onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })}
/>
<select
className="select-std"
defaultValue={item.paymentChannelOverride ?? ""}
disabled={!canEditSelectedBatch || item.status !== "pending"}
onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}
>
<option value="">默认渠道</option>
{PAYMENT_CHANNEL_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
@@ -0,0 +1,7 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
P25L-0,P25-lite合同建档与范围冻结,创建并校验本轮预算控制与AP最小可用运营执行合同 CSV 冻结范围仅含预算规则中心 统一预检 API 付款批次 maker-checker 强化和最小 AP 运营看板,Issue CSV 校验通过 且明确不做银企直连 Dashboard 告警 AP 重工作台与额外实体类型扩展,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-29_08-07-14-p25-lite-budget-ap.csv,manual,DONE,DONE,DONE,issues/2026-03-29_08-07-14-p25-lite-budget-ap.csv,[],已执行 CSV 校验脚本并通过(见 Test_Method)
P25L-1,预算控制规则中心接入预算页,在现有 budgetControlRules 路由基础上提供预算页内规则中心入口 支持按法人实体 部门 费用类型配置 warn block manual_review 并维护规则启停与优先级,预算页内可查看 新增 更新 归档规则 expenseType 仅在 reimbursement 规则下可选 非 budget.manage 用户不显示规则维护入口,业务流程:进入预算管理页 切换到规则中心 新建法体规则 部门规则和报销费用类型规则 保存后刷新仍存在 再归档一条规则确认列表状态正确;命令:cd client && npm run typecheck && npm run build && cd ../server && node --check src/routes/budgetControlRules.js,manual,DONE,DONE,TODO,server/src/routes/budgetControlRules.js|client/src/pages/BudgetsPage.tsx|client/src/api/budgetControlRules.ts|client/src/api/expenseTypes.ts,"[""P25L-0""]","QA 阻塞:当前环境缺 node/npm,无法执行 Test_Method 中的 npm run typecheck / npm run build / node --check;仅完成静态核对(预算页 tabs + 规则中心 CRUD UI 接入)"
P25L-2,统一预检API与旧路由兼容包装,抽出统一预检入口并新增公共 HTTP API 保留报销 采购 差旅现有 precheck 路由作为 wrapper 确保响应结构和权限边界一致,新增总入口可按 entityType 返回与旧接口一致的 precheck 结果 三个旧路由结果不漂移 且不会打穿原有 expense.submit procurement.submit travel.submit 权限,业务流程:分别准备一张报销单 一张采购单 一张差旅单 调用旧 precheck 与统一 precheck 对比 decision 命中规则和 nextActions 一致;命令:cd server && node --check src/services/feeControl.js && node --check src/routes/reimbursements.js && node --check src/routes/purchaseRequests.js && node --check src/routes/travelRequests.js,manual,DONE,DONE,TODO,server/src/services/feeControl.js|server/src/routes/reimbursements.js|server/src/routes/purchaseRequests.js|server/src/routes/travelRequests.js|client/src/api/feeControl.ts|client/src/lib/precheck.ts,"[""P25L-1""]","QA 阻塞:当前环境缺 node/npm,无法执行 node --check 与端到端业务流程;reviewer 已确认统一预检按 entityType 复用 submit 权限边界,旧 /:id/precheck wrapper 结果已统一委托 resolveEntityPrecheck,无权限打穿(但注意未来若收紧实体可见性需补归属校验)"
P25L-3,付款批次maker-checker高风险强化,在现有 paymentBatches 基础上增加批次总金额计算 高风险标识和 5 万元阈值复核要求 保持 create review execute 主状态流不重做,批次返回 totalAmount highRisk reviewRequirement 高风险批次需完成规定复核后才能执行 maker_checker_conflict 与 open risk flag resolve 约束继续生效,业务流程:创建低风险批次并完成复核执行 再创建总金额大于等于 50000 的高风险批次 验证高风险标识和执行前校验;命令:cd server && npm run prisma:validate && node --check src/routes/paymentBatches.js && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,TODO,server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/paymentBatches.js|client/src/api/paymentBatches.ts|client/src/pages/PaymentBatchesPage.tsx,"[""P25L-2""]","reviewer 已审查:二次复核状态流与前端按钮条件对齐,maker-checker/二审人不同约束完整;迁移为 additive 新增列 secondary_review_*(payment_batches)。回滚路径:1) 回滚代码到不引用 secondary_review_*(保留列也可安全运行);2) 若必须清理 schema,再执行 SQL:ALTER TABLE payment_batches DROP COLUMN secondary_review_comment; DROP COLUMN secondary_reviewed_at; DROP COLUMN secondary_reviewed_by。QA 阻塞:当前环境缺 node/npm,无法跑 prisma:validate、node --check、前端构建与业务流程验证"
P25L-4,最小AP运营看板改造成财务视角,扩展 ap-operations 汇总与列表 交付待付款 部分匹配 异常和预算超支四类财务视角摘要与明细,AP 运营页展示 pendingPaymentCount partialMatchCount exceptionCount budgetOverspendCount 并能查看对应列表 预算超支单独可见 不混在通用风险列表里,业务流程:进入 AP 运营页 检查四张摘要卡与开放批次 失败付款项 对账异常和预算超支列表显示正确;命令:cd server && node --check src/routes/apOperations.js && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,TODO,server/src/routes/apOperations.js|client/src/api/apOperations.ts|client/src/pages/ApOperationsPage.tsx,"[""P25L-3""]","QA 阻塞:当前环境缺 node/npm,无法 node --check 与前端构建;静态核对已完成(summary 字段与列表拆分预算超支/普通异常)。注意:exceptionCount 口径为 failedPaymentItemCount + nonBudgetRiskFlagCount,非去重实体数,可能偏大(不阻塞)"
P25L-5,Reviewer与QA回归收口,按 reviewer→qa 顺序完成审批规则 付款状态流和预算/AP 视图的独立审查与回归 并回填 CSV 状态,reviewer 对权限 状态流 回滚路径和行为漂移无阻塞意见 QA 跑通预算规则 统一预检 批次复核执行和 AP 看板四条主线且状态全部回填 DONE,Review:独立审查 paymentBatches feeControl apOperations 相关 diff;QA:执行各 Issue Test_Method,并补跑 cd server && npm run prisma:validate && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,TODO,issues/2026-03-29_08-07-14-p25-lite-budget-ap.csv|server/src/routes/paymentBatches.js|server/src/services/feeControl.js|server/src/routes/apOperations.js|client/src/pages/BudgetsPage.tsx|client/src/pages/PaymentBatchesPage.tsx|client/src/pages/ApOperationsPage.tsx,"[""P25L-1"",""P25L-2"",""P25L-3"",""P25L-4""]","reviewer 结论:1) 发现并修复报销提交预算阻断语义可能覆盖规则中心的问题(现在仅在 budget_precheck_v1 关闭时保留 legacy block 409;开启时以 authoritative precheck 的规则 controlMode 为准);2) 统一预检不打穿 submit 权限但缺少实体可见性约束(与旧路由一致,记录为后续风险);3) payment batch 列表存在 N+1 风险项查询(不阻塞)。QA 阻塞:当前环境缺 node/npm,无法执行 npm run prisma:validate、./scripts/regression-all.sh、npm run typecheck、npm run build,因此 Regression_Status 不置 DONE"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 P25L-0 P25-lite合同建档与范围冻结 创建并校验本轮预算控制与AP最小可用运营执行合同 CSV 冻结范围仅含预算规则中心 统一预检 API 付款批次 maker-checker 强化和最小 AP 运营看板 Issue CSV 校验通过 且明确不做银企直连 Dashboard 告警 AP 重工作台与额外实体类型扩展 python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-29_08-07-14-p25-lite-budget-ap.csv manual DONE DONE DONE issues/2026-03-29_08-07-14-p25-lite-budget-ap.csv [] 已执行 CSV 校验脚本并通过(见 Test_Method)
3 P25L-1 预算控制规则中心接入预算页 在现有 budgetControlRules 路由基础上提供预算页内规则中心入口 支持按法人实体 部门 费用类型配置 warn block manual_review 并维护规则启停与优先级 预算页内可查看 新增 更新 归档规则 expenseType 仅在 reimbursement 规则下可选 非 budget.manage 用户不显示规则维护入口 业务流程:进入预算管理页 切换到规则中心 新建法体规则 部门规则和报销费用类型规则 保存后刷新仍存在 再归档一条规则确认列表状态正确;命令:cd client && npm run typecheck && npm run build && cd ../server && node --check src/routes/budgetControlRules.js manual DONE DONE TODO server/src/routes/budgetControlRules.js|client/src/pages/BudgetsPage.tsx|client/src/api/budgetControlRules.ts|client/src/api/expenseTypes.ts ["P25L-0"] QA 阻塞:当前环境缺 node/npm,无法执行 Test_Method 中的 npm run typecheck / npm run build / node --check;仅完成静态核对(预算页 tabs + 规则中心 CRUD UI 接入)
4 P25L-2 统一预检API与旧路由兼容包装 抽出统一预检入口并新增公共 HTTP API 保留报销 采购 差旅现有 precheck 路由作为 wrapper 确保响应结构和权限边界一致 新增总入口可按 entityType 返回与旧接口一致的 precheck 结果 三个旧路由结果不漂移 且不会打穿原有 expense.submit procurement.submit travel.submit 权限 业务流程:分别准备一张报销单 一张采购单 一张差旅单 调用旧 precheck 与统一 precheck 对比 decision 命中规则和 nextActions 一致;命令:cd server && node --check src/services/feeControl.js && node --check src/routes/reimbursements.js && node --check src/routes/purchaseRequests.js && node --check src/routes/travelRequests.js manual DONE DONE TODO server/src/services/feeControl.js|server/src/routes/reimbursements.js|server/src/routes/purchaseRequests.js|server/src/routes/travelRequests.js|client/src/api/feeControl.ts|client/src/lib/precheck.ts ["P25L-1"] QA 阻塞:当前环境缺 node/npm,无法执行 node --check 与端到端业务流程;reviewer 已确认统一预检按 entityType 复用 submit 权限边界,旧 /:id/precheck wrapper 结果已统一委托 resolveEntityPrecheck,无权限打穿(但注意未来若收紧实体可见性需补归属校验)
5 P25L-3 付款批次maker-checker高风险强化 在现有 paymentBatches 基础上增加批次总金额计算 高风险标识和 5 万元阈值复核要求 保持 create review execute 主状态流不重做 批次返回 totalAmount highRisk reviewRequirement 高风险批次需完成规定复核后才能执行 maker_checker_conflict 与 open risk flag resolve 约束继续生效 业务流程:创建低风险批次并完成复核执行 再创建总金额大于等于 50000 的高风险批次 验证高风险标识和执行前校验;命令:cd server && npm run prisma:validate && node --check src/routes/paymentBatches.js && cd ../client && npm run typecheck && npm run build manual DONE DONE TODO server/prisma/schema.prisma|server/prisma/migrations|server/src/routes/paymentBatches.js|client/src/api/paymentBatches.ts|client/src/pages/PaymentBatchesPage.tsx ["P25L-2"] reviewer 已审查:二次复核状态流与前端按钮条件对齐,maker-checker/二审人不同约束完整;迁移为 additive 新增列 secondary_review_*(payment_batches)。回滚路径:1) 回滚代码到不引用 secondary_review_*(保留列也可安全运行);2) 若必须清理 schema,再执行 SQL:ALTER TABLE payment_batches DROP COLUMN secondary_review_comment; DROP COLUMN secondary_reviewed_at; DROP COLUMN secondary_reviewed_by。QA 阻塞:当前环境缺 node/npm,无法跑 prisma:validate、node --check、前端构建与业务流程验证
6 P25L-4 最小AP运营看板改造成财务视角 扩展 ap-operations 汇总与列表 交付待付款 部分匹配 异常和预算超支四类财务视角摘要与明细 AP 运营页展示 pendingPaymentCount partialMatchCount exceptionCount budgetOverspendCount 并能查看对应列表 预算超支单独可见 不混在通用风险列表里 业务流程:进入 AP 运营页 检查四张摘要卡与开放批次 失败付款项 对账异常和预算超支列表显示正确;命令:cd server && node --check src/routes/apOperations.js && cd ../client && npm run typecheck && npm run build manual DONE DONE TODO server/src/routes/apOperations.js|client/src/api/apOperations.ts|client/src/pages/ApOperationsPage.tsx ["P25L-3"] QA 阻塞:当前环境缺 node/npm,无法 node --check 与前端构建;静态核对已完成(summary 字段与列表拆分预算超支/普通异常)。注意:exceptionCount 口径为 failedPaymentItemCount + nonBudgetRiskFlagCount,非去重实体数,可能偏大(不阻塞)
7 P25L-5 Reviewer与QA回归收口 按 reviewer→qa 顺序完成审批规则 付款状态流和预算/AP 视图的独立审查与回归 并回填 CSV 状态 reviewer 对权限 状态流 回滚路径和行为漂移无阻塞意见 QA 跑通预算规则 统一预检 批次复核执行和 AP 看板四条主线且状态全部回填 DONE Review:独立审查 paymentBatches feeControl apOperations 相关 diff;QA:执行各 Issue Test_Method,并补跑 cd server && npm run prisma:validate && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build manual DONE DONE TODO issues/2026-03-29_08-07-14-p25-lite-budget-ap.csv|server/src/routes/paymentBatches.js|server/src/services/feeControl.js|server/src/routes/apOperations.js|client/src/pages/BudgetsPage.tsx|client/src/pages/PaymentBatchesPage.tsx|client/src/pages/ApOperationsPage.tsx ["P25L-1","P25L-2","P25L-3","P25L-4"] reviewer 结论:1) 发现并修复报销提交预算阻断语义可能覆盖规则中心的问题(现在仅在 budget_precheck_v1 关闭时保留 legacy block 409;开启时以 authoritative precheck 的规则 controlMode 为准);2) 统一预检不打穿 submit 权限但缺少实体可见性约束(与旧路由一致,记录为后续风险);3) payment batch 列表存在 N+1 风险项查询(不阻塞)。QA 阻塞:当前环境缺 node/npm,无法执行 npm run prisma:validate、./scripts/regression-all.sh、npm run typecheck、npm run build,因此 Regression_Status 不置 DONE
@@ -0,0 +1,4 @@
ALTER TABLE "payment_batches"
ADD COLUMN "secondary_review_comment" TEXT,
ADD COLUMN "secondary_reviewed_at" TIMESTAMP(3),
ADD COLUMN "secondary_reviewed_by" UUID;
+3
查看文件
@@ -1558,12 +1558,15 @@ model PaymentBatch {
default_payment_channel String? @db.VarChar(20)
voucher_prefix String? @db.VarChar(32)
review_comment String?
secondary_review_comment String?
execution_summary_json Json?
created_at DateTime @default(now())
created_by String? @db.Uuid
reviewed_at DateTime?
reviewed_by String? @db.Uuid
secondary_reviewed_at DateTime?
secondary_reviewed_by String? @db.Uuid
executed_at DateTime?
executed_by String? @db.Uuid
cancelled_at DateTime?
+2
查看文件
@@ -40,6 +40,7 @@ const exportsRoutes = require("./routes/exports");
const dashboardRoutes = require("./routes/dashboard");
const budgetsRoutes = require("./routes/budgets");
const budgetControlRulesRoutes = require("./routes/budgetControlRules");
const feeControlRoutes = require("./routes/feeControl");
const riskFlagsRoutes = require("./routes/riskFlags");
const paymentBatchesRoutes = require("./routes/paymentBatches");
const apOperationsRoutes = require("./routes/apOperations");
@@ -324,6 +325,7 @@ async function buildApp() {
await app.register(dashboardRoutes, { prefix: "/api" });
await app.register(budgetsRoutes, { prefix: "/api" });
await app.register(budgetControlRulesRoutes, { prefix: "/api" });
await app.register(feeControlRoutes, { prefix: "/api" });
await app.register(riskFlagsRoutes, { prefix: "/api" });
await app.register(paymentBatchesRoutes, { prefix: "/api" });
await app.register(apOperationsRoutes, { prefix: "/api" });
+95 -33
查看文件
@@ -1,7 +1,7 @@
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl");
const { BUDGET_RISK_CODES, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
function ensureReadPermission(fastify, request) {
if (
@@ -30,12 +30,46 @@ async function ensureFeatureEnabled(fastify) {
}
function mapBatchSummary(item) {
const totalAmount = toMoneyString((item.items ?? []).reduce((sum, entry) => sum + toMoneyNumber(entry.amount), 0));
return {
id: item.id,
batchNo: item.batch_no,
entityType: item.entity_type,
name: item.name,
status: item.status,
totalAmount,
highRisk: toMoneyNumber(totalAmount) >= 50000,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
function mapFailedPaymentItem(item) {
return {
id: item.id,
paymentBatchId: item.payment_batch_id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
function mapReconciliationAnomaly(item) {
return {
id: item.id,
travelReconciliationBatchId: item.travel_reconciliation_batch_id,
travelOrderId: item.travel_order_id ?? null,
travelRequestId: item.travel_request_id ?? null,
matchStatus: item.match_status,
resolutionStatus: item.resolution_status,
anomalyCode: item.anomaly_code ?? null,
orderAmount: item.order_amount != null ? toMoneyString(item.order_amount) : null,
transactionAmount: item.transaction_amount != null ? toMoneyString(item.transaction_amount) : null,
currency: item.currency,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
@@ -45,22 +79,62 @@ async function apOperationsRoutes(fastify) {
await ensureFeatureEnabled(fastify);
ensureReadPermission(fastify, request);
const [batches, failedItems, openRiskFlags, anomalies] = await Promise.all([
const [
batches,
pendingPaymentCount,
failedItems,
failedPaymentItemCount,
openRiskFlags,
nonBudgetRiskFlagCount,
budgetOverspendCount,
anomalies,
partialMatchCount,
] = await Promise.all([
fastify.prisma.paymentBatch.findMany({
where: { status: { in: ["draft", "reviewed", "failed"] } },
include: {
items: {
select: {
amount: true,
},
},
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 20,
}),
fastify.prisma.paymentBatchItem.count({
where: {
payment_batch: {
status: { in: ["draft", "reviewed", "failed"] },
},
status: { in: ["pending", "failed"] },
},
}),
fastify.prisma.paymentBatchItem.findMany({
where: { status: "failed" },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
}),
fastify.prisma.paymentBatchItem.count({
where: { status: "failed" },
}),
fastify.prisma.entityRiskFlag.findMany({
where: { status: "open" },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
}),
fastify.prisma.entityRiskFlag.count({
where: {
status: "open",
risk_code: { notIn: BUDGET_RISK_CODES },
},
}),
fastify.prisma.entityRiskFlag.count({
where: {
status: "open",
risk_code: { in: BUDGET_RISK_CODES },
},
}),
fastify.prisma.travelReconciliationItem.findMany({
where: {
resolution_status: "pending",
@@ -82,42 +156,30 @@ async function apOperationsRoutes(fastify) {
created_at: true,
},
}),
fastify.prisma.travelReconciliationItem.count({
where: {
resolution_status: "pending",
match_status: { not: "matched" },
},
}),
]);
const riskFlags = openRiskFlags.map(buildPersistedRiskFlag);
const budgetOverspendRisks = riskFlags.filter((item) => BUDGET_RISK_CODES.includes(item.riskCode));
const exceptionRiskFlags = riskFlags.filter((item) => !BUDGET_RISK_CODES.includes(item.riskCode));
return sendData(reply, request, {
summary: {
openPaymentBatchCount: batches.length,
failedPaymentBatchItemCount: failedItems.length,
openRiskFlagCount: openRiskFlags.length,
reconciliationAnomalyCount: anomalies.length,
pendingPaymentCount,
partialMatchCount,
exceptionCount: failedPaymentItemCount + nonBudgetRiskFlagCount,
budgetOverspendCount,
},
openPaymentBatches: batches.map(mapBatchSummary),
failedPaymentBatchItems: failedItems.map((item) => ({
id: item.id,
paymentBatchId: item.payment_batch_id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
})),
openRiskFlags: openRiskFlags.map(buildPersistedRiskFlag),
reconciliationAnomalies: anomalies.map((item) => ({
id: item.id,
travelReconciliationBatchId: item.travel_reconciliation_batch_id,
travelOrderId: item.travel_order_id ?? null,
travelRequestId: item.travel_request_id ?? null,
matchStatus: item.match_status,
resolutionStatus: item.resolution_status,
anomalyCode: item.anomaly_code ?? null,
orderAmount: item.order_amount != null ? toMoneyString(item.order_amount) : null,
transactionAmount: item.transaction_amount != null ? toMoneyString(item.transaction_amount) : null,
currency: item.currency,
createdAt: item.created_at?.toISOString?.() ?? null,
})),
pendingPaymentBatches: batches.map(mapBatchSummary),
failedPaymentBatchItems: failedItems.map(mapFailedPaymentItem),
exceptionRiskFlags,
budgetOverspendRisks,
partialMatchItems: anomalies.map(mapReconciliationAnomaly),
});
});
}
+53
查看文件
@@ -0,0 +1,53 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const {
getPrecheckPermissionCode,
resolveEntityPrecheck,
} = require("../services/feeControl");
const PRECHECK_BODY_SCHEMA = z.object({
entityType: z.enum(["reimbursement", "purchase_request", "travel_request"]),
entityId: z.string().uuid(),
}).strict();
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseBody(body) {
const parsed = PRECHECK_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
async function feeControlRoutes(fastify) {
fastify.post("/fee-control/precheck", { preHandler: fastify.authenticate }, async (request, reply) => {
const body = parseBody(request.body);
const permissionCode = getPrecheckPermissionCode(body.entityType);
if (!permissionCode || !fastify.hasPermission(request.employee, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
entityType: body.entityType,
entityId: body.entityId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
});
}
module.exports = feeControlRoutes;
+75 -13
查看文件
@@ -5,7 +5,7 @@ const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString } = require("../utils/date");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
@@ -36,6 +36,8 @@ const REVIEW_SCHEMA = z.object({
resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(),
}).strict();
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
@@ -112,6 +114,32 @@ function buildBatchNo() {
return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}`;
}
function summarizeBatchRisk(batch) {
const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0);
const totalAmount = toMoneyString(totalAmountValue);
const highRisk = totalAmountValue >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT;
const completedReviewCount = batch.secondary_reviewed_at ? 2 : batch.reviewed_at ? 1 : 0;
const requiredReviewCount = highRisk ? 2 : 1;
const nextStep = completedReviewCount >= requiredReviewCount
? "ready_for_execute"
: completedReviewCount === 0
? "first_review"
: "second_review";
return {
totalAmount,
highRisk,
reviewRequirement: {
thresholdAmount: toMoneyString(HIGH_RISK_BATCH_THRESHOLD_AMOUNT),
requiredReviewCount,
completedReviewCount,
nextStep,
makerCheckerRequired: true,
reviewerPermission: "risk.review.manage",
},
};
}
async function loadBatchWithItems(tx, batchId) {
const batch = await tx.paymentBatch.findUnique({
where: { id: batchId },
@@ -204,6 +232,7 @@ function ensureBatchEntityStatus(entityType, snapshot) {
}
function mapBatch(batch, openRiskFlags = []) {
const batchRisk = summarizeBatchRisk(batch);
const riskMap = new Map();
for (const item of openRiskFlags) {
const entityId = item.entityId;
@@ -222,12 +251,18 @@ function mapBatch(batch, openRiskFlags = []) {
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null,
defaultPaymentChannel: batch.default_payment_channel ?? null,
voucherPrefix: batch.voucher_prefix ?? null,
totalAmount: batchRisk.totalAmount,
highRisk: batchRisk.highRisk,
reviewRequirement: batchRisk.reviewRequirement,
reviewComment: batch.review_comment ?? null,
secondaryReviewComment: batch.secondary_review_comment ?? null,
executionSummaryJson: batch.execution_summary_json ?? null,
createdAt: batch.created_at?.toISOString?.() ?? null,
createdBy: batch.created_by ?? null,
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
reviewedBy: batch.reviewed_by ?? null,
secondaryReviewedAt: batch.secondary_reviewed_at?.toISOString?.() ?? null,
secondaryReviewedBy: batch.secondary_reviewed_by ?? null,
executedAt: batch.executed_at?.toISOString?.() ?? null,
executedBy: batch.executed_by ?? null,
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
@@ -488,6 +523,8 @@ async function paymentBatchesRoutes(fastify) {
extra: {
entityType: body.entityType,
entityCount: snapshots.length,
totalAmount: toMoneyString(snapshots.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0)),
highRisk: snapshots.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0) >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT,
},
}),
});
@@ -558,14 +595,21 @@ async function paymentBatchesRoutes(fastify) {
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await loadBatchWithItems(tx, batchId);
if (batch.status !== "draft") {
throw buildValidationError({ status: "only_draft_reviewable" }, 409);
const batchRisk = summarizeBatchRisk(batch);
const isFirstReview = batch.status === "draft" && !batch.reviewed_at;
const isSecondReview = batchRisk.highRisk && batch.status === "reviewed" && !batch.secondary_reviewed_at;
if (!isFirstReview && !isSecondReview) {
throw buildValidationError({ review: "batch_not_reviewable" }, 409);
}
if (batch.items.some((item) => item.status !== "pending")) {
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
}
if (batch.created_by && batch.created_by === request.employee.id) {
throw buildValidationError({ review: "maker_checker_conflict" }, 409);
}
if (batch.items.some((item) => item.status !== "pending")) {
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
if (isSecondReview && batch.reviewed_by && batch.reviewed_by === request.employee.id) {
throw buildValidationError({ review: "second_reviewer_conflict" }, 409);
}
const openRiskFlags = await tx.entityRiskFlag.findMany({
@@ -592,26 +636,36 @@ async function paymentBatchesRoutes(fastify) {
});
}
const reviewedAt = new Date();
const reviewed = await tx.paymentBatch.update({
where: { id: batchId },
data: {
status: "reviewed",
review_comment: normalizeNullableString(body.comment),
reviewed_at: new Date(),
reviewed_by: request.employee.id,
updated_by: request.employee.id,
},
data: isFirstReview
? {
status: "reviewed",
review_comment: normalizeNullableString(body.comment),
reviewed_at: reviewedAt,
reviewed_by: request.employee.id,
updated_by: request.employee.id,
}
: {
secondary_review_comment: normalizeNullableString(body.comment),
secondary_reviewed_at: reviewedAt,
secondary_reviewed_by: request.employee.id,
updated_by: request.employee.id,
},
include: { items: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_REVIEWED",
action: isFirstReview ? "PAYMENT_BATCH_REVIEWED" : "PAYMENT_BATCH_SECOND_REVIEWED",
targetType: "payment_batch",
targetId: batchId,
extra: {
highRisk: batchRisk.highRisk,
resolvedRiskFlagCount: openRiskFlags.length,
reviewStage: isFirstReview ? "first_review" : "second_review",
},
}),
});
@@ -629,9 +683,15 @@ async function paymentBatchesRoutes(fastify) {
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await loadBatchWithItems(tx, batchId);
const batchRisk = summarizeBatchRisk(batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_reviewed_or_failed_executable" }, 409);
}
if (batchRisk.reviewRequirement.completedReviewCount < batchRisk.reviewRequirement.requiredReviewCount) {
throw buildValidationError({
review: batchRisk.highRisk ? "high_risk_second_review_required" : "batch_review_required",
}, 409);
}
let successCount = 0;
let failedCount = 0;
@@ -709,6 +769,8 @@ async function paymentBatchesRoutes(fastify) {
targetType: "payment_batch",
targetId: batchId,
extra: {
highRisk: batchRisk.highRisk,
totalAmount: batchRisk.totalAmount,
successCount,
failedCount,
},
+10 -46
查看文件
@@ -15,8 +15,8 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildPurchaseRequestPrecheck,
createRiskFlagsFromPrecheck,
resolveEntityPrecheck,
} = require("../services/feeControl");
const {
PROCUREMENT_ERP_TARGETS,
@@ -343,20 +343,6 @@ function normalizeBudgetControlMode(config) {
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
@@ -1464,29 +1450,13 @@ async function purchaseRequestRoutes(fastify) {
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: { id: true, department_id: true },
});
if (!purchaseRequest) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("purchase_request", purchaseRequestId));
}
const precheck = await buildPurchaseRequestPrecheck(fastify.prisma, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
},
@@ -1517,20 +1487,14 @@ async function purchaseRequestRoutes(fastify) {
});
}
const budgetPrecheckGate = await evaluateFeatureFlag({
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: unlockedPurchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
logger: fastify.log,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildPurchaseRequestPrecheck(fastify.prisma, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("purchase_request", purchaseRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
+26 -50
查看文件
@@ -21,8 +21,8 @@ const { ensureCutoverAllowed } = require("../services/cutover");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const {
buildPersistedRiskFlag,
buildReimbursementPrecheck,
createRiskFlagsFromPrecheck,
resolveEntityPrecheck,
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const {
@@ -435,20 +435,6 @@ function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
};
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
@@ -1511,30 +1497,13 @@ async function reimbursementRoutes(fastify) {
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, department_id: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("reimbursement", reimbursementId));
}
const precheck = await buildReimbursementPrecheck(fastify.prisma, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
entityType: "reimbursement",
entityId: reimbursementId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
},
@@ -1610,20 +1579,14 @@ async function reimbursementRoutes(fastify) {
});
}
const budgetPrecheckGate = await evaluateFeatureFlag({
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
logger: fastify.log,
entityType: "reimbursement",
entityId: reimbursementId,
actorDepartmentId: request.employee.department_id ?? null,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildReimbursementPrecheck(tx, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("reimbursement", reimbursementId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
@@ -1641,7 +1604,20 @@ async function reimbursementRoutes(fastify) {
const currentAmount = Number(reimbursement.total_amount ?? 0);
const nextUsed = usedAmount + currentAmount;
const usagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
const controlMode = normalizeBudgetControlMode(fastify.config);
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const fallbackControlMode = normalizeBudgetControlMode(fastify.config);
const controlMode = budgetPrecheckGate.enabled
? authoritativePrecheck.matchedBudgetControlRule?.controlMode
?? authoritativePrecheck.budgetHint?.controlMode
?? fallbackControlMode
: fallbackControlMode;
const shouldBlockByLegacyMode = !budgetPrecheckGate.enabled && controlMode === "block" && nextUsed > allocatedAmount;
budgetCheck = {
budgetId: budgetPool.id,
@@ -1670,7 +1646,7 @@ async function reimbursementRoutes(fastify) {
});
}
if (controlMode === "block" && nextUsed > allocatedAmount) {
if (shouldBlockByLegacyMode) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
@@ -1683,7 +1659,7 @@ async function reimbursementRoutes(fastify) {
data: {
budget_pool_id: budgetPool.id,
reimbursement_id: reimbursementId,
kind: controlMode === "block" && nextUsed > allocatedAmount ? "blocked" : "submit",
kind: shouldBlockByLegacyMode ? "blocked" : "submit",
amount: reimbursement.total_amount,
note: `submit:${reimbursement.form_number ?? reimbursementId}`,
created_by: request.employee.id,
+10 -46
查看文件
@@ -20,8 +20,8 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildTravelRequestPrecheck,
createRiskFlagsFromPrecheck,
resolveEntityPrecheck,
} = require("../services/feeControl");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
@@ -300,20 +300,6 @@ function ensureDateRangeValid(startDate, endDate) {
}
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
@@ -922,29 +908,13 @@ async function createTravelRequestsRoutes(fastify) {
},
async (request, reply) => {
const travelRequestId = parseTravelRequestId(request.params?.id);
const travelRequest = await fastify.prisma.travelRequest.findUnique({
where: { id: travelRequestId },
select: { id: true, department_id: true },
});
if (!travelRequest) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("travel_request", travelRequestId));
}
const precheck = await buildTravelRequestPrecheck(fastify.prisma, {
travelRequestId,
config: fastify.config,
logger: fastify.log,
entityType: "travel_request",
entityId: travelRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
},
@@ -978,20 +948,14 @@ async function createTravelRequestsRoutes(fastify) {
throw buildValidationError({ segments: "required" }, 409);
}
const budgetPrecheckGate = await evaluateFeatureFlag({
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
logger: fastify.log,
entityType: "travel_request",
entityId: travelRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildTravelRequestPrecheck(tx, {
travelRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("travel_request", travelRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
+134
查看文件
@@ -1,5 +1,6 @@
const { AppError } = require("../utils/AppError");
const { resolveApplicableBudgetPoolByContext } = require("./budgetPool");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
const {
buildWorkflowStepsFromApprovalRule,
parseWorkflowSteps,
@@ -7,6 +8,12 @@ const {
} = require("../utils/workflow");
const PRECHECK_DECISIONS = Object.freeze(["pass", "warn", "manual_review", "block"]);
const PRECHECK_PERMISSION_CODES = Object.freeze({
reimbursement: "expense.submit",
purchase_request: "procurement.submit",
travel_request: "travel.submit",
});
const BUDGET_RISK_CODES = Object.freeze(["budget_exceeded", "budget_threshold_exceeded"]);
const RISK_LEVELS = Object.freeze({
low: "low",
medium: "medium",
@@ -21,6 +28,15 @@ function buildNotFoundError() {
});
}
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function toMoneyNumber(value) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
@@ -47,6 +63,20 @@ function buildBasePrecheck(entityType, entityId) {
};
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildIssue(code, title, detail) {
return {
code,
@@ -726,16 +756,120 @@ async function buildTravelRequestPrecheck(tx, { travelRequestId, config, logger
return applyDecisionByIssues(response, budgetIssues, matchedRule, config);
}
function getPrecheckPermissionCode(entityType) {
return PRECHECK_PERMISSION_CODES[entityType] ?? null;
}
async function loadPrecheckEntityContext(tx, { entityType, entityId }) {
if (entityType === "reimbursement") {
const item = await tx.reimbursementForm.findUnique({
where: { id: entityId },
select: { id: true, department_id: true },
});
if (!item) {
throw buildNotFoundError();
}
return { id: item.id, departmentId: item.department_id ?? null };
}
if (entityType === "purchase_request") {
const item = await tx.purchaseRequest.findUnique({
where: { id: entityId },
select: { id: true, department_id: true },
});
if (!item) {
throw buildNotFoundError();
}
return { id: item.id, departmentId: item.department_id ?? null };
}
if (entityType === "travel_request") {
const item = await tx.travelRequest.findUnique({
where: { id: entityId },
select: { id: true, department_id: true },
});
if (!item) {
throw buildNotFoundError();
}
return { id: item.id, departmentId: item.department_id ?? null };
}
throw buildValidationError({ entityType: "unsupported" });
}
async function buildEntityPrecheck(tx, { entityType, entityId, config, logger }) {
if (entityType === "reimbursement") {
return buildReimbursementPrecheck(tx, {
reimbursementId: entityId,
config,
logger,
});
}
if (entityType === "purchase_request") {
return buildPurchaseRequestPrecheck(tx, {
purchaseRequestId: entityId,
config,
logger,
});
}
if (entityType === "travel_request") {
return buildTravelRequestPrecheck(tx, {
travelRequestId: entityId,
config,
logger,
});
}
throw buildValidationError({ entityType: "unsupported" });
}
async function resolveEntityPrecheck({
prisma,
config,
logger,
entityType,
entityId,
actorDepartmentId = null,
}) {
const entity = await loadPrecheckEntityContext(prisma, { entityType, entityId });
const gate = await evaluateFeatureFlag({
prisma,
config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: entity.departmentId ?? actorDepartmentId ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return buildDisabledPrecheck(entityType, entityId);
}
return buildEntityPrecheck(prisma, {
entityType,
entityId,
config,
logger,
});
}
module.exports = {
BUDGET_RISK_CODES,
PRECHECK_DECISIONS,
buildDisabledPrecheck,
RISK_LEVELS,
getPrecheckPermissionCode,
toMoneyNumber,
toMoneyString,
listRiskFlags,
ensureRiskFlag,
buildPersistedRiskFlag,
createRiskFlagsFromPrecheck,
buildEntityPrecheck,
buildReimbursementPrecheck,
buildPurchaseRequestPrecheck,
buildTravelRequestPrecheck,
loadPrecheckEntityContext,
resolveEntityPrecheck,
};