P25L-1~P25L-5 实现预算控制与AP最小可用运营
合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
@@ -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() {
|
||||
|
||||
@@ -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({}),
|
||||
});
|
||||
}
|
||||
@@ -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 "当前任务转审次数已达上限。";
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
+680
-250
文件差异内容过多而无法显示
加载差异
@@ -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"
|
||||
|
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户