P25L-1~P25L-5 实现预算控制与AP最小可用运营
合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
@@ -3,15 +3,16 @@ import type { RiskFlag } from "./feeControl";
|
|||||||
|
|
||||||
export type ApOperationsSummary = {
|
export type ApOperationsSummary = {
|
||||||
summary: {
|
summary: {
|
||||||
openPaymentBatchCount: number;
|
pendingPaymentCount: number;
|
||||||
failedPaymentBatchItemCount: number;
|
partialMatchCount: number;
|
||||||
openRiskFlagCount: number;
|
exceptionCount: number;
|
||||||
reconciliationAnomalyCount: 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 }>;
|
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[];
|
exceptionRiskFlags: 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 }>;
|
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() {
|
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 "当前状态不允许执行付款。";
|
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") {
|
if (details?.transfer === "chain_limit_exceeded") {
|
||||||
return "当前任务转审次数已达上限。";
|
return "当前任务转审次数已达上限。";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
export type RiskFlag = {
|
export type RiskFlag = {
|
||||||
id: string;
|
id: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@@ -32,3 +34,13 @@ export type PrecheckResponse = {
|
|||||||
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
|
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
|
||||||
nextActions: string[];
|
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;
|
defaultPaymentDate: string | null;
|
||||||
defaultPaymentChannel: PaymentChannel | null;
|
defaultPaymentChannel: PaymentChannel | null;
|
||||||
voucherPrefix: string | 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;
|
reviewComment: string | null;
|
||||||
|
secondaryReviewComment: string | null;
|
||||||
executionSummaryJson: Record<string, unknown> | null;
|
executionSummaryJson: Record<string, unknown> | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
createdBy: string | null;
|
createdBy: string | null;
|
||||||
reviewedAt: string | null;
|
reviewedAt: string | null;
|
||||||
reviewedBy: string | null;
|
reviewedBy: string | null;
|
||||||
|
secondaryReviewedAt: string | null;
|
||||||
|
secondaryReviewedBy: string | null;
|
||||||
executedAt: string | null;
|
executedAt: string | null;
|
||||||
executedBy: string | null;
|
executedBy: string | null;
|
||||||
cancelledAt: string | null;
|
cancelledAt: string | null;
|
||||||
|
|||||||
@@ -20,48 +20,52 @@ export function ApOperationsPage() {
|
|||||||
const data = summaryQuery.data;
|
const data = summaryQuery.data;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title="AP 运营" subtitle="待支付、失败批次、风险项与差旅对账异常。" />
|
<PageHeader title="AP 运营" subtitle="财务视角查看待付款、部分匹配、异常和预算超支四类最小运营摘要。" />
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<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.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.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.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.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.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.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.budgetOverspendCount}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
<div className="card-std space-y-3 p-6">
|
<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">
|
<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 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="font-medium text-zinc-900">{item.batchNo}</div>
|
||||||
<div className="text-zinc-600">{item.name}</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>
|
||||||
)) : <div className="text-zinc-500">暂无开放批次</div>}
|
)) : <div className="text-zinc-500">暂无待付款批次</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-6">
|
<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">
|
<div className="space-y-2 text-sm">
|
||||||
{data.failedPaymentBatchItems.length ? data.failedPaymentBatchItems.map((item) => (
|
{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 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="font-medium text-zinc-900">{item.entityCode}</div>
|
||||||
<div className="text-zinc-600">{item.failureMessage ?? item.failureCode ?? "执行失败"}</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>
|
||||||
)) : <div className="text-zinc-500">暂无失败项</div>}
|
)) : <div className="text-zinc-500">暂无失败项</div>}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<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 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="font-medium text-zinc-900">{item.anomalyCode ?? item.matchStatus}</div>
|
||||||
<div className="text-zinc-600">订单 {item.orderAmount ?? "-"} / 交易 {item.transactionAmount ?? "-"} {item.currency}</div>
|
<div className="text-zinc-600">订单 {item.orderAmount ?? "-"} / 交易 {item.transactionAmount ?? "-"} {item.currency}</div>
|
||||||
|
|||||||
+431
-1
@@ -2,14 +2,25 @@ import { useMemo, useState } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { archiveBudget, createBudget, fetchBudgets, type BudgetListQuery, type BudgetPool, type BudgetScopeType } from "../api/budgets";
|
import { archiveBudget, createBudget, fetchBudgets, type BudgetListQuery, type BudgetPool, type BudgetScopeType } from "../api/budgets";
|
||||||
|
import {
|
||||||
|
archiveBudgetControlRule,
|
||||||
|
createBudgetControlRule,
|
||||||
|
fetchBudgetControlRules,
|
||||||
|
patchBudgetControlRule,
|
||||||
|
type BudgetControlMode,
|
||||||
|
type BudgetControlRule,
|
||||||
|
type BudgetControlRuleEntityType,
|
||||||
|
} from "../api/budgetControlRules";
|
||||||
import { fetchDepartments } from "../api/departments";
|
import { fetchDepartments } from "../api/departments";
|
||||||
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
|
import { fetchCostCenters, fetchLegalEntities } from "../api/masterData";
|
||||||
|
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
@@ -19,6 +30,32 @@ import { Pagination } from "../components/common/Pagination";
|
|||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
type FormScopeType = Exclude<BudgetScopeType, "invalid">;
|
type FormScopeType = Exclude<BudgetScopeType, "invalid">;
|
||||||
|
type BudgetPageTab = "budget-pools" | "control-rules";
|
||||||
|
type RuleFormState = {
|
||||||
|
name: string;
|
||||||
|
entityType: BudgetControlRuleEntityType;
|
||||||
|
legalEntityId: string;
|
||||||
|
departmentId: string;
|
||||||
|
expenseTypeId: string;
|
||||||
|
controlMode: BudgetControlMode;
|
||||||
|
priority: string;
|
||||||
|
enabled: "true" | "false";
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyRuleForm(): RuleFormState {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
entityType: "all",
|
||||||
|
legalEntityId: "",
|
||||||
|
departmentId: "",
|
||||||
|
expenseTypeId: "",
|
||||||
|
controlMode: "warn",
|
||||||
|
priority: "100",
|
||||||
|
enabled: "true",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveError(error: unknown) {
|
function resolveError(error: unknown) {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
@@ -48,6 +85,19 @@ function describeScope(item: BudgetPool) {
|
|||||||
return "异常作用域";
|
return "异常作用域";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function entityTypeLabel(value: BudgetControlRuleEntityType) {
|
||||||
|
if (value === "all") return "全部单据";
|
||||||
|
if (value === "reimbursement") return "报销";
|
||||||
|
if (value === "purchase_request") return "采购";
|
||||||
|
return "差旅";
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlModeLabel(value: BudgetControlMode) {
|
||||||
|
if (value === "warn") return "预警";
|
||||||
|
if (value === "block") return "阻断";
|
||||||
|
return "人工复核";
|
||||||
|
}
|
||||||
|
|
||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
@@ -55,6 +105,7 @@ export function BudgetsPage() {
|
|||||||
const canViewBudget = hasPermission(permissions, "budget.view") || hasPermission(permissions, "budget.manage");
|
const canViewBudget = hasPermission(permissions, "budget.view") || hasPermission(permissions, "budget.manage");
|
||||||
const canManageBudget = hasPermission(permissions, "budget.manage");
|
const canManageBudget = hasPermission(permissions, "budget.manage");
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<BudgetPageTab>("budget-pools");
|
||||||
const [status, setStatus] = useState<"active" | "archived" | "">("active");
|
const [status, setStatus] = useState<"active" | "archived" | "">("active");
|
||||||
const [departmentId, setDepartmentId] = useState("");
|
const [departmentId, setDepartmentId] = useState("");
|
||||||
const [legalEntityId, setLegalEntityId] = useState("");
|
const [legalEntityId, setLegalEntityId] = useState("");
|
||||||
@@ -73,6 +124,11 @@ export function BudgetsPage() {
|
|||||||
const [formCostCenterId, setFormCostCenterId] = useState("");
|
const [formCostCenterId, setFormCostCenterId] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [confirmArchiveId, setConfirmArchiveId] = useState<{ id: string; code: string } | null>(null);
|
const [confirmArchiveId, setConfirmArchiveId] = useState<{ id: string; code: string } | null>(null);
|
||||||
|
const [ruleEntityTypeFilter, setRuleEntityTypeFilter] = useState<BudgetControlRuleEntityType | "">("");
|
||||||
|
const [ruleEnabledFilter, setRuleEnabledFilter] = useState<"" | "true" | "false">("true");
|
||||||
|
const [ruleForm, setRuleForm] = useState<RuleFormState>(emptyRuleForm);
|
||||||
|
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
||||||
|
const [confirmArchiveRuleId, setConfirmArchiveRuleId] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
const queryInput = useMemo<BudgetListQuery>(
|
const queryInput = useMemo<BudgetListQuery>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -110,6 +166,22 @@ export function BudgetsPage() {
|
|||||||
queryFn: () => fetchBudgets(queryInput),
|
queryFn: () => fetchBudgets(queryInput),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expenseTypesQuery = useQuery({
|
||||||
|
enabled: canManageBudget,
|
||||||
|
queryKey: ["expense-types", "budget-rules"],
|
||||||
|
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rulesQuery = useQuery({
|
||||||
|
enabled: canManageBudget,
|
||||||
|
queryKey: ["budget-control-rules", ruleEntityTypeFilter, ruleEnabledFilter],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchBudgetControlRules({
|
||||||
|
entityType: ruleEntityTypeFilter || undefined,
|
||||||
|
enabled: ruleEnabledFilter === "" ? undefined : ruleEnabledFilter === "true",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const filterCostCenters = useMemo(() => {
|
const filterCostCenters = useMemo(() => {
|
||||||
if (!legalEntityId) {
|
if (!legalEntityId) {
|
||||||
return costCentersQuery.data?.items ?? [];
|
return costCentersQuery.data?.items ?? [];
|
||||||
@@ -124,6 +196,19 @@ export function BudgetsPage() {
|
|||||||
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === formLegalEntityId);
|
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === formLegalEntityId);
|
||||||
}, [costCentersQuery.data?.items, formLegalEntityId]);
|
}, [costCentersQuery.data?.items, formLegalEntityId]);
|
||||||
|
|
||||||
|
const departmentMap = useMemo(
|
||||||
|
() => new Map((departmentsQuery.data?.items ?? []).map((item) => [item.id, item.name])),
|
||||||
|
[departmentsQuery.data?.items],
|
||||||
|
);
|
||||||
|
const legalEntityMap = useMemo(
|
||||||
|
() => new Map((legalEntitiesQuery.data?.items ?? []).map((item) => [item.id, item.name])),
|
||||||
|
[legalEntitiesQuery.data?.items],
|
||||||
|
);
|
||||||
|
const expenseTypeMap = useMemo(
|
||||||
|
() => new Map((expenseTypesQuery.data?.items ?? []).map((item) => [item.id, item.name])),
|
||||||
|
[expenseTypesQuery.data?.items],
|
||||||
|
);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
return createBudget({
|
return createBudget({
|
||||||
@@ -172,6 +257,53 @@ export function BudgetsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveRuleMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload = {
|
||||||
|
name: ruleForm.name.trim(),
|
||||||
|
entityType: ruleForm.entityType,
|
||||||
|
legalEntityId: ruleForm.legalEntityId || null,
|
||||||
|
departmentId: ruleForm.departmentId || null,
|
||||||
|
expenseTypeId: ruleForm.entityType === "reimbursement" ? ruleForm.expenseTypeId || null : null,
|
||||||
|
controlMode: ruleForm.controlMode,
|
||||||
|
priority: Number(ruleForm.priority || 100),
|
||||||
|
enabled: ruleForm.enabled === "true",
|
||||||
|
notes: ruleForm.notes.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingRuleId) {
|
||||||
|
return patchBudgetControlRule(editingRuleId, payload);
|
||||||
|
}
|
||||||
|
return createBudgetControlRule(payload);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["budget-control-rules"] });
|
||||||
|
setRuleForm(emptyRuleForm());
|
||||||
|
setEditingRuleId(null);
|
||||||
|
setError("");
|
||||||
|
toast.success(editingRuleId ? "控制规则已更新" : "控制规则已创建");
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
setError(resolveError(mutationError));
|
||||||
|
toast.error(resolveError(mutationError));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveRuleMutation = useMutation({
|
||||||
|
mutationFn: async (ruleId: string) => archiveBudgetControlRule(ruleId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["budget-control-rules"] });
|
||||||
|
setEditingRuleId(null);
|
||||||
|
setRuleForm(emptyRuleForm());
|
||||||
|
setError("");
|
||||||
|
toast.success("控制规则已归档");
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
setError(resolveError(mutationError));
|
||||||
|
toast.error(resolveError(mutationError));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!canViewBudget) {
|
if (!canViewBudget) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -191,12 +323,61 @@ export function BudgetsPage() {
|
|||||||
!allocatedAmount.trim() ||
|
!allocatedAmount.trim() ||
|
||||||
(formScopeType === "department_legacy" && !formDepartmentId) ||
|
(formScopeType === "department_legacy" && !formDepartmentId) ||
|
||||||
(formScopeType === "cost_center_scoped" && (!formLegalEntityId || !formCostCenterId));
|
(formScopeType === "cost_center_scoped" && (!formLegalEntityId || !formCostCenterId));
|
||||||
|
const ruleSaveDisabled =
|
||||||
|
saveRuleMutation.isPending ||
|
||||||
|
!ruleForm.name.trim() ||
|
||||||
|
!ruleForm.priority.trim();
|
||||||
|
|
||||||
|
const rules = rulesQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
function resetRuleEditor() {
|
||||||
|
setEditingRuleId(null);
|
||||||
|
setRuleForm(emptyRuleForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeRuleScope(rule: BudgetControlRule) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (rule.legalEntityId) {
|
||||||
|
parts.push(`法体:${legalEntityMap.get(rule.legalEntityId) ?? rule.legalEntityId}`);
|
||||||
|
}
|
||||||
|
if (rule.departmentId) {
|
||||||
|
parts.push(`部门:${departmentMap.get(rule.departmentId) ?? rule.departmentId}`);
|
||||||
|
}
|
||||||
|
if (rule.expenseTypeId) {
|
||||||
|
parts.push(`费用类型:${expenseTypeMap.get(rule.expenseTypeId) ?? rule.expenseTypeId}`);
|
||||||
|
}
|
||||||
|
if (rule.costCenterId) {
|
||||||
|
parts.push("成本中心:遗留规则");
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(" / ") : "全局";
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditRule(rule: BudgetControlRule) {
|
||||||
|
if (rule.costCenterId) {
|
||||||
|
toast.error("遗留成本中心规则仅支持归档,不在本轮编辑范围内。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingRuleId(rule.id);
|
||||||
|
setRuleForm({
|
||||||
|
name: rule.name,
|
||||||
|
entityType: rule.entityType,
|
||||||
|
legalEntityId: rule.legalEntityId ?? "",
|
||||||
|
departmentId: rule.departmentId ?? "",
|
||||||
|
expenseTypeId: rule.expenseTypeId ?? "",
|
||||||
|
controlMode: rule.controlMode,
|
||||||
|
priority: String(rule.priority),
|
||||||
|
enabled: rule.enabled ? "true" : "false",
|
||||||
|
notes: rule.notes ?? "",
|
||||||
|
});
|
||||||
|
setTab("control-rules");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="预算管理"
|
title="预算管理"
|
||||||
subtitle="支持全局预算、部门预算与法体/成本中心预算的统一维护。"
|
subtitle="支持预算池维护,并在同一页面下维护预算控制规则中心。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -205,6 +386,13 @@ export function BudgetsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Tabs value={tab} onValueChange={(value) => setTab(value as BudgetPageTab)}>
|
||||||
|
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||||
|
<TabsTrigger value="budget-pools">预算池</TabsTrigger>
|
||||||
|
{canManageBudget ? <TabsTrigger value="control-rules">控制规则</TabsTrigger> : null}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="budget-pools" className="space-y-6">
|
||||||
<div className="card-std p-6 space-y-3">
|
<div className="card-std p-6 space-y-3">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">筛选条件</h3>
|
<h3 className="text-base font-semibold text-zinc-900">筛选条件</h3>
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
@@ -483,7 +671,232 @@ export function BudgetsPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{canManageBudget ? (
|
||||||
|
<TabsContent value="control-rules" className="space-y-6">
|
||||||
|
<div className="card-std p-6 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">规则筛选</h3>
|
||||||
|
<div className="text-sm text-zinc-500">费用类型仅对报销规则生效;遗留成本中心规则本轮只归档不编辑。</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<select className="select-std" value={ruleEntityTypeFilter} onChange={(event) => setRuleEntityTypeFilter(event.target.value as BudgetControlRuleEntityType | "")}>
|
||||||
|
<option value="">全部业务域</option>
|
||||||
|
<option value="all">全部单据</option>
|
||||||
|
<option value="reimbursement">报销</option>
|
||||||
|
<option value="purchase_request">采购</option>
|
||||||
|
<option value="travel_request">差旅</option>
|
||||||
|
</select>
|
||||||
|
<select className="select-std" value={ruleEnabledFilter} onChange={(event) => setRuleEnabledFilter(event.target.value as "" | "true" | "false")}>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="true">仅启用</option>
|
||||||
|
<option value="false">仅停用</option>
|
||||||
|
</select>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["budget-control-rules"] })}>
|
||||||
|
刷新规则
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setRuleEntityTypeFilter("");
|
||||||
|
setRuleEnabledFilter("true");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std p-6 space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">{editingRuleId ? "编辑控制规则" : "新建控制规则"}</h3>
|
||||||
|
{editingRuleId ? (
|
||||||
|
<Button type="button" variant="ghost" onClick={resetRuleEditor}>
|
||||||
|
取消编辑
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<input
|
||||||
|
className="input-std"
|
||||||
|
placeholder="规则名称"
|
||||||
|
value={ruleForm.name}
|
||||||
|
onChange={(event) => setRuleForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={ruleForm.entityType}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextEntityType = event.target.value as BudgetControlRuleEntityType;
|
||||||
|
setRuleForm((current) => ({
|
||||||
|
...current,
|
||||||
|
entityType: nextEntityType,
|
||||||
|
expenseTypeId: nextEntityType === "reimbursement" ? current.expenseTypeId : "",
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">全部单据</option>
|
||||||
|
<option value="reimbursement">报销</option>
|
||||||
|
<option value="purchase_request">采购</option>
|
||||||
|
<option value="travel_request">差旅</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={ruleForm.controlMode}
|
||||||
|
onChange={(event) => setRuleForm((current) => ({ ...current, controlMode: event.target.value as BudgetControlMode }))}
|
||||||
|
>
|
||||||
|
<option value="warn">预警</option>
|
||||||
|
<option value="block">阻断</option>
|
||||||
|
<option value="manual_review">人工复核</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={ruleForm.legalEntityId}
|
||||||
|
onChange={(event) => setRuleForm((current) => ({ ...current, legalEntityId: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">不限法体</option>
|
||||||
|
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={ruleForm.departmentId}
|
||||||
|
onChange={(event) => setRuleForm((current) => ({ ...current, departmentId: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">不限部门</option>
|
||||||
|
{(departmentsQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={ruleForm.expenseTypeId}
|
||||||
|
disabled={ruleForm.entityType !== "reimbursement"}
|
||||||
|
onChange={(event) => setRuleForm((current) => ({ ...current, expenseTypeId: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">{ruleForm.entityType === "reimbursement" ? "不限费用类型" : "仅报销规则可选"}</option>
|
||||||
|
{(expenseTypesQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="input-std"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={9999}
|
||||||
|
placeholder="优先级"
|
||||||
|
value={ruleForm.priority}
|
||||||
|
onChange={(event) => setRuleForm((current) => ({ ...current, priority: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
value={ruleForm.enabled}
|
||||||
|
onChange={(event) => setRuleForm((current) => ({ ...current, enabled: event.target.value as "true" | "false" }))}
|
||||||
|
>
|
||||||
|
<option value="true">启用</option>
|
||||||
|
<option value="false">停用</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input-std"
|
||||||
|
placeholder="备注(可选)"
|
||||||
|
value={ruleForm.notes}
|
||||||
|
onChange={(event) => setRuleForm((current) => ({ ...current, notes: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" disabled={ruleSaveDisabled} onClick={() => saveRuleMutation.mutate()}>
|
||||||
|
{saveRuleMutation.isPending ? "保存中..." : editingRuleId ? "保存规则" : "创建规则"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={resetRuleEditor}>
|
||||||
|
重置表单
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std p-6 space-y-3">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">控制规则列表</h3>
|
||||||
|
|
||||||
|
{rulesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
|
||||||
|
|
||||||
|
{!rulesQuery.isLoading ? (
|
||||||
|
<div className="rounded-lg border border-zinc-200">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>规则名称</TableHead>
|
||||||
|
<TableHead>业务域</TableHead>
|
||||||
|
<TableHead>作用域</TableHead>
|
||||||
|
<TableHead>控制策略</TableHead>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead className="w-40">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rules.length ? (
|
||||||
|
rules.map((rule) => (
|
||||||
|
<TableRow key={rule.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{rule.name}</div>
|
||||||
|
{rule.notes ? <div className="text-xs text-zinc-500">{rule.notes}</div> : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{entityTypeLabel(rule.entityType)}</TableCell>
|
||||||
|
<TableCell>{describeRuleScope(rule)}</TableCell>
|
||||||
|
<TableCell>{controlModeLabel(rule.controlMode)}</TableCell>
|
||||||
|
<TableCell>{rule.priority}</TableCell>
|
||||||
|
<TableCell>{rule.enabled ? "启用" : "停用"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={Boolean(rule.costCenterId)}
|
||||||
|
onClick={() => startEditRule(rule)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={archiveRuleMutation.isPending}
|
||||||
|
onClick={() => setConfirmArchiveRuleId({ id: rule.id, name: rule.name })}
|
||||||
|
>
|
||||||
|
归档
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="py-3 text-zinc-500" colSpan={7}>
|
||||||
|
暂无控制规则
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
) : null}
|
||||||
|
</Tabs>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(confirmArchiveId)}
|
open={Boolean(confirmArchiveId)}
|
||||||
title="确认归档"
|
title="确认归档"
|
||||||
@@ -500,6 +913,23 @@ export function BudgetsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(confirmArchiveRuleId)}
|
||||||
|
title="确认归档规则"
|
||||||
|
description={`确认归档控制规则 ${confirmArchiveRuleId?.name ?? ""} ?`}
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (confirmArchiveRuleId) {
|
||||||
|
archiveRuleMutation.mutate(confirmArchiveRuleId.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setConfirmArchiveRuleId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
} from "../api/paymentBatches";
|
} from "../api/paymentBatches";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
@@ -32,6 +34,9 @@ function resolveError(error: unknown) {
|
|||||||
|
|
||||||
export function PaymentBatchesPage() {
|
export function PaymentBatchesPage() {
|
||||||
const queryClient = useQueryClient();
|
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 [selectedBatchId, setSelectedBatchId] = useState("");
|
||||||
const [entityType, setEntityType] = useState<PaymentBatchEntityType>("reimbursement");
|
const [entityType, setEntityType] = useState<PaymentBatchEntityType>("reimbursement");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -50,6 +55,23 @@ export function PaymentBatchesPage() {
|
|||||||
const items = listQuery.data?.items ?? [];
|
const items = listQuery.data?.items ?? [];
|
||||||
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
|
return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null;
|
||||||
}, [listQuery.data?.items, selectedBatchId]);
|
}, [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 () => {
|
const refresh = async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
|
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));
|
const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id));
|
||||||
return reviewPaymentBatch(batchId, { resolveRiskFlagIds });
|
return reviewPaymentBatch(batchId, { resolveRiskFlagIds });
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async ({ item }) => {
|
||||||
setError("");
|
setError("");
|
||||||
await refresh();
|
await refresh();
|
||||||
toast.success("付款批次已复核");
|
toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const message = resolveError(err);
|
const message = resolveError(err);
|
||||||
@@ -146,36 +168,67 @@ export function PaymentBatchesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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}
|
{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="grid gap-6 xl:grid-cols-[360px,1fr]">
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="card-std space-y-4 p-6">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
|
<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">
|
<div className="space-y-2">
|
||||||
<Label>实体类型</Label>
|
<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="reimbursement">报销付款</option>
|
||||||
<option value="travel_order">差旅订单付款</option>
|
<option value="travel_order">差旅订单付款</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>批次名称</Label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>实体 ID</Label>
|
<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>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>默认付款日期</Label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>默认渠道</Label>
|
<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>
|
<option value="">不设置</option>
|
||||||
{PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
{PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -183,9 +236,14 @@ export function PaymentBatchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>凭证前缀</Label>
|
<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>
|
</div>
|
||||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
|
<Button type="button" disabled={createMutation.isPending || !canManageBatch} onClick={() => createMutation.mutate()}>
|
||||||
{createMutation.isPending ? "创建中..." : "创建批次"}
|
{createMutation.isPending ? "创建中..." : "创建批次"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,6 +264,11 @@ export function PaymentBatchesPage() {
|
|||||||
<div className="text-xs uppercase text-zinc-500">{item.status}</div>
|
<div className="text-xs uppercase text-zinc-500">{item.status}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm text-zinc-600">{item.name}</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>
|
</button>
|
||||||
))}
|
))}
|
||||||
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无支付批次</div> : null}
|
{!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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
|
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="text-lg font-semibold text-zinc-900">{selectedBatch.name}</div>
|
<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>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" variant="secondary" disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate(selectedBatch.id)}>
|
{canReviewBatch ? (
|
||||||
复核
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={reviewMutation.isPending || !canReviewSelectedBatch}
|
||||||
|
onClick={() => reviewMutation.mutate(selectedBatch.id)}
|
||||||
|
>
|
||||||
|
{selectedBatch.reviewRequirement.nextStep === "second_review" ? "二次复核" : "复核"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" disabled={executeMutation.isPending} onClick={() => executeMutation.mutate(selectedBatch.id)}>
|
) : null}
|
||||||
|
{canManageBatch ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={executeMutation.isPending || !canExecuteSelectedBatch}
|
||||||
|
onClick={() => executeMutation.mutate(selectedBatch.id)}
|
||||||
|
>
|
||||||
执行
|
执行
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="secondary" disabled={cancelMutation.isPending} onClick={() => cancelMutation.mutate(selectedBatch.id)}>
|
) : null}
|
||||||
|
{canManageBatch ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
|
||||||
|
onClick={() => cancelMutation.mutate(selectedBatch.id)}
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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.status}</div>
|
||||||
<div>实体:{selectedBatch.entityType}</div>
|
<div>实体:{selectedBatch.entityType}</div>
|
||||||
|
<div>总金额:{selectedBatch.totalAmount}</div>
|
||||||
<div>默认日期:{selectedBatch.defaultPaymentDate ?? "-"}</div>
|
<div>默认日期:{selectedBatch.defaultPaymentDate ?? "-"}</div>
|
||||||
<div>默认渠道:{selectedBatch.defaultPaymentChannel ?? "-"}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,9 +349,24 @@ export function PaymentBatchesPage() {
|
|||||||
<div className="text-sm text-zinc-600">{item.amount} {item.currencyCode} · {item.status}</div>
|
<div className="text-sm text-zinc-600">{item.amount} {item.currencyCode} · {item.status}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-4">
|
<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
|
||||||
<Input type="date" defaultValue={item.paymentDateOverride ?? ""} onBlur={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentDateOverride: event.target.value || null })} />
|
defaultValue={item.paymentVoucherNoOverride ?? ""}
|
||||||
<select className="select-std" defaultValue={item.paymentChannelOverride ?? ""} onChange={(event) => updateItemMutation.mutate({ batchId: selectedBatch.id, itemId: item.id, paymentChannelOverride: (event.target.value as PaymentChannel | "") || null })}>
|
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>
|
<option value="">默认渠道</option>
|
||||||
{PAYMENT_CHANNEL_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
{PAYMENT_CHANNEL_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
</select>
|
</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)
|
default_payment_channel String? @db.VarChar(20)
|
||||||
voucher_prefix String? @db.VarChar(32)
|
voucher_prefix String? @db.VarChar(32)
|
||||||
review_comment String?
|
review_comment String?
|
||||||
|
secondary_review_comment String?
|
||||||
execution_summary_json Json?
|
execution_summary_json Json?
|
||||||
|
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
created_by String? @db.Uuid
|
created_by String? @db.Uuid
|
||||||
reviewed_at DateTime?
|
reviewed_at DateTime?
|
||||||
reviewed_by String? @db.Uuid
|
reviewed_by String? @db.Uuid
|
||||||
|
secondary_reviewed_at DateTime?
|
||||||
|
secondary_reviewed_by String? @db.Uuid
|
||||||
executed_at DateTime?
|
executed_at DateTime?
|
||||||
executed_by String? @db.Uuid
|
executed_by String? @db.Uuid
|
||||||
cancelled_at DateTime?
|
cancelled_at DateTime?
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const exportsRoutes = require("./routes/exports");
|
|||||||
const dashboardRoutes = require("./routes/dashboard");
|
const dashboardRoutes = require("./routes/dashboard");
|
||||||
const budgetsRoutes = require("./routes/budgets");
|
const budgetsRoutes = require("./routes/budgets");
|
||||||
const budgetControlRulesRoutes = require("./routes/budgetControlRules");
|
const budgetControlRulesRoutes = require("./routes/budgetControlRules");
|
||||||
|
const feeControlRoutes = require("./routes/feeControl");
|
||||||
const riskFlagsRoutes = require("./routes/riskFlags");
|
const riskFlagsRoutes = require("./routes/riskFlags");
|
||||||
const paymentBatchesRoutes = require("./routes/paymentBatches");
|
const paymentBatchesRoutes = require("./routes/paymentBatches");
|
||||||
const apOperationsRoutes = require("./routes/apOperations");
|
const apOperationsRoutes = require("./routes/apOperations");
|
||||||
@@ -324,6 +325,7 @@ async function buildApp() {
|
|||||||
await app.register(dashboardRoutes, { prefix: "/api" });
|
await app.register(dashboardRoutes, { prefix: "/api" });
|
||||||
await app.register(budgetsRoutes, { prefix: "/api" });
|
await app.register(budgetsRoutes, { prefix: "/api" });
|
||||||
await app.register(budgetControlRulesRoutes, { prefix: "/api" });
|
await app.register(budgetControlRulesRoutes, { prefix: "/api" });
|
||||||
|
await app.register(feeControlRoutes, { prefix: "/api" });
|
||||||
await app.register(riskFlagsRoutes, { prefix: "/api" });
|
await app.register(riskFlagsRoutes, { prefix: "/api" });
|
||||||
await app.register(paymentBatchesRoutes, { prefix: "/api" });
|
await app.register(paymentBatchesRoutes, { prefix: "/api" });
|
||||||
await app.register(apOperationsRoutes, { prefix: "/api" });
|
await app.register(apOperationsRoutes, { prefix: "/api" });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
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) {
|
function ensureReadPermission(fastify, request) {
|
||||||
if (
|
if (
|
||||||
@@ -30,12 +30,46 @@ async function ensureFeatureEnabled(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapBatchSummary(item) {
|
function mapBatchSummary(item) {
|
||||||
|
const totalAmount = toMoneyString((item.items ?? []).reduce((sum, entry) => sum + toMoneyNumber(entry.amount), 0));
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
batchNo: item.batch_no,
|
batchNo: item.batch_no,
|
||||||
entityType: item.entity_type,
|
entityType: item.entity_type,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
status: item.status,
|
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,
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -45,22 +79,62 @@ async function apOperationsRoutes(fastify) {
|
|||||||
await ensureFeatureEnabled(fastify);
|
await ensureFeatureEnabled(fastify);
|
||||||
ensureReadPermission(fastify, request);
|
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({
|
fastify.prisma.paymentBatch.findMany({
|
||||||
where: { status: { in: ["draft", "reviewed", "failed"] } },
|
where: { status: { in: ["draft", "reviewed", "failed"] } },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
select: {
|
||||||
|
amount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
take: 20,
|
take: 20,
|
||||||
}),
|
}),
|
||||||
|
fastify.prisma.paymentBatchItem.count({
|
||||||
|
where: {
|
||||||
|
payment_batch: {
|
||||||
|
status: { in: ["draft", "reviewed", "failed"] },
|
||||||
|
},
|
||||||
|
status: { in: ["pending", "failed"] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
fastify.prisma.paymentBatchItem.findMany({
|
fastify.prisma.paymentBatchItem.findMany({
|
||||||
where: { status: "failed" },
|
where: { status: "failed" },
|
||||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
take: 50,
|
take: 50,
|
||||||
}),
|
}),
|
||||||
|
fastify.prisma.paymentBatchItem.count({
|
||||||
|
where: { status: "failed" },
|
||||||
|
}),
|
||||||
fastify.prisma.entityRiskFlag.findMany({
|
fastify.prisma.entityRiskFlag.findMany({
|
||||||
where: { status: "open" },
|
where: { status: "open" },
|
||||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
take: 50,
|
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({
|
fastify.prisma.travelReconciliationItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
resolution_status: "pending",
|
resolution_status: "pending",
|
||||||
@@ -82,42 +156,30 @@ async function apOperationsRoutes(fastify) {
|
|||||||
created_at: true,
|
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, {
|
return sendData(reply, request, {
|
||||||
summary: {
|
summary: {
|
||||||
openPaymentBatchCount: batches.length,
|
pendingPaymentCount,
|
||||||
failedPaymentBatchItemCount: failedItems.length,
|
partialMatchCount,
|
||||||
openRiskFlagCount: openRiskFlags.length,
|
exceptionCount: failedPaymentItemCount + nonBudgetRiskFlagCount,
|
||||||
reconciliationAnomalyCount: anomalies.length,
|
budgetOverspendCount,
|
||||||
},
|
},
|
||||||
openPaymentBatches: batches.map(mapBatchSummary),
|
pendingPaymentBatches: batches.map(mapBatchSummary),
|
||||||
failedPaymentBatchItems: failedItems.map((item) => ({
|
failedPaymentBatchItems: failedItems.map(mapFailedPaymentItem),
|
||||||
id: item.id,
|
exceptionRiskFlags,
|
||||||
paymentBatchId: item.payment_batch_id,
|
budgetOverspendRisks,
|
||||||
entityId: item.entity_id,
|
partialMatchItems: anomalies.map(mapReconciliationAnomaly),
|
||||||
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,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { buildAuditLogData } = require("../utils/audit");
|
||||||
const { parseDateOnlyString } = require("../utils/date");
|
const { parseDateOnlyString } = require("../utils/date");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
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 UUID_SCHEMA = z.string().uuid();
|
||||||
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
|
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
|
||||||
@@ -36,6 +36,8 @@ const REVIEW_SCHEMA = z.object({
|
|||||||
resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(),
|
resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
|
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
|
||||||
|
|
||||||
function buildValidationError(details, statusCode = 400) {
|
function buildValidationError(details, statusCode = 400) {
|
||||||
return new AppError({
|
return new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
@@ -112,6 +114,32 @@ function buildBatchNo() {
|
|||||||
return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}`;
|
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) {
|
async function loadBatchWithItems(tx, batchId) {
|
||||||
const batch = await tx.paymentBatch.findUnique({
|
const batch = await tx.paymentBatch.findUnique({
|
||||||
where: { id: batchId },
|
where: { id: batchId },
|
||||||
@@ -204,6 +232,7 @@ function ensureBatchEntityStatus(entityType, snapshot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapBatch(batch, openRiskFlags = []) {
|
function mapBatch(batch, openRiskFlags = []) {
|
||||||
|
const batchRisk = summarizeBatchRisk(batch);
|
||||||
const riskMap = new Map();
|
const riskMap = new Map();
|
||||||
for (const item of openRiskFlags) {
|
for (const item of openRiskFlags) {
|
||||||
const entityId = item.entityId;
|
const entityId = item.entityId;
|
||||||
@@ -222,12 +251,18 @@ function mapBatch(batch, openRiskFlags = []) {
|
|||||||
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||||
defaultPaymentChannel: batch.default_payment_channel ?? null,
|
defaultPaymentChannel: batch.default_payment_channel ?? null,
|
||||||
voucherPrefix: batch.voucher_prefix ?? null,
|
voucherPrefix: batch.voucher_prefix ?? null,
|
||||||
|
totalAmount: batchRisk.totalAmount,
|
||||||
|
highRisk: batchRisk.highRisk,
|
||||||
|
reviewRequirement: batchRisk.reviewRequirement,
|
||||||
reviewComment: batch.review_comment ?? null,
|
reviewComment: batch.review_comment ?? null,
|
||||||
|
secondaryReviewComment: batch.secondary_review_comment ?? null,
|
||||||
executionSummaryJson: batch.execution_summary_json ?? null,
|
executionSummaryJson: batch.execution_summary_json ?? null,
|
||||||
createdAt: batch.created_at?.toISOString?.() ?? null,
|
createdAt: batch.created_at?.toISOString?.() ?? null,
|
||||||
createdBy: batch.created_by ?? null,
|
createdBy: batch.created_by ?? null,
|
||||||
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
|
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
|
||||||
reviewedBy: batch.reviewed_by ?? 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,
|
executedAt: batch.executed_at?.toISOString?.() ?? null,
|
||||||
executedBy: batch.executed_by ?? null,
|
executedBy: batch.executed_by ?? null,
|
||||||
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
|
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
|
||||||
@@ -488,6 +523,8 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
extra: {
|
extra: {
|
||||||
entityType: body.entityType,
|
entityType: body.entityType,
|
||||||
entityCount: snapshots.length,
|
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 result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
const batch = await loadBatchWithItems(tx, batchId);
|
const batch = await loadBatchWithItems(tx, batchId);
|
||||||
if (batch.status !== "draft") {
|
const batchRisk = summarizeBatchRisk(batch);
|
||||||
throw buildValidationError({ status: "only_draft_reviewable" }, 409);
|
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) {
|
if (batch.created_by && batch.created_by === request.employee.id) {
|
||||||
throw buildValidationError({ review: "maker_checker_conflict" }, 409);
|
throw buildValidationError({ review: "maker_checker_conflict" }, 409);
|
||||||
}
|
}
|
||||||
if (batch.items.some((item) => item.status !== "pending")) {
|
if (isSecondReview && batch.reviewed_by && batch.reviewed_by === request.employee.id) {
|
||||||
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
|
throw buildValidationError({ review: "second_reviewer_conflict" }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const openRiskFlags = await tx.entityRiskFlag.findMany({
|
const openRiskFlags = await tx.entityRiskFlag.findMany({
|
||||||
@@ -592,14 +636,22 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reviewedAt = new Date();
|
||||||
const reviewed = await tx.paymentBatch.update({
|
const reviewed = await tx.paymentBatch.update({
|
||||||
where: { id: batchId },
|
where: { id: batchId },
|
||||||
data: {
|
data: isFirstReview
|
||||||
|
? {
|
||||||
status: "reviewed",
|
status: "reviewed",
|
||||||
review_comment: normalizeNullableString(body.comment),
|
review_comment: normalizeNullableString(body.comment),
|
||||||
reviewed_at: new Date(),
|
reviewed_at: reviewedAt,
|
||||||
reviewed_by: request.employee.id,
|
reviewed_by: request.employee.id,
|
||||||
updated_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 },
|
include: { items: true },
|
||||||
});
|
});
|
||||||
@@ -607,11 +659,13 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
await tx.auditLog.create({
|
await tx.auditLog.create({
|
||||||
data: buildAuditLogData(request, {
|
data: buildAuditLogData(request, {
|
||||||
actorId: request.employee.id,
|
actorId: request.employee.id,
|
||||||
action: "PAYMENT_BATCH_REVIEWED",
|
action: isFirstReview ? "PAYMENT_BATCH_REVIEWED" : "PAYMENT_BATCH_SECOND_REVIEWED",
|
||||||
targetType: "payment_batch",
|
targetType: "payment_batch",
|
||||||
targetId: batchId,
|
targetId: batchId,
|
||||||
extra: {
|
extra: {
|
||||||
|
highRisk: batchRisk.highRisk,
|
||||||
resolvedRiskFlagCount: openRiskFlags.length,
|
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 result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
const batch = await loadBatchWithItems(tx, batchId);
|
const batch = await loadBatchWithItems(tx, batchId);
|
||||||
|
const batchRisk = summarizeBatchRisk(batch);
|
||||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||||
throw buildValidationError({ status: "only_reviewed_or_failed_executable" }, 409);
|
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 successCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
@@ -709,6 +769,8 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
targetType: "payment_batch",
|
targetType: "payment_batch",
|
||||||
targetId: batchId,
|
targetId: batchId,
|
||||||
extra: {
|
extra: {
|
||||||
|
highRisk: batchRisk.highRisk,
|
||||||
|
totalAmount: batchRisk.totalAmount,
|
||||||
successCount,
|
successCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
|
|||||||
const { ensureCutoverAllowed } = require("../services/cutover");
|
const { ensureCutoverAllowed } = require("../services/cutover");
|
||||||
const {
|
const {
|
||||||
buildPersistedRiskFlag,
|
buildPersistedRiskFlag,
|
||||||
buildPurchaseRequestPrecheck,
|
|
||||||
createRiskFlagsFromPrecheck,
|
createRiskFlagsFromPrecheck,
|
||||||
|
resolveEntityPrecheck,
|
||||||
} = require("../services/feeControl");
|
} = require("../services/feeControl");
|
||||||
const {
|
const {
|
||||||
PROCUREMENT_ERP_TARGETS,
|
PROCUREMENT_ERP_TARGETS,
|
||||||
@@ -343,20 +343,6 @@ function normalizeBudgetControlMode(config) {
|
|||||||
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
|
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) {
|
function buildPrecheckBlockedError(precheck) {
|
||||||
return new AppError({
|
return new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
@@ -1464,29 +1450,13 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
|
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
|
||||||
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
|
const precheck = await resolveEntityPrecheck({
|
||||||
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({
|
|
||||||
prisma: fastify.prisma,
|
prisma: fastify.prisma,
|
||||||
config: fastify.config,
|
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,
|
logger: fastify.log,
|
||||||
|
entityType: "purchase_request",
|
||||||
|
entityId: purchaseRequestId,
|
||||||
|
actorDepartmentId: request.employee.department_id ?? null,
|
||||||
});
|
});
|
||||||
return sendData(reply, request, precheck);
|
return sendData(reply, request, precheck);
|
||||||
},
|
},
|
||||||
@@ -1517,20 +1487,14 @@ async function purchaseRequestRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const budgetPrecheckGate = await evaluateFeatureFlag({
|
const authoritativePrecheck = await resolveEntityPrecheck({
|
||||||
prisma: fastify.prisma,
|
prisma: fastify.prisma,
|
||||||
config: fastify.config,
|
config: fastify.config,
|
||||||
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
|
|
||||||
departmentId: unlockedPurchaseRequest.department_id ?? request.employee.department_id ?? null,
|
|
||||||
strictDepartment: false,
|
|
||||||
});
|
|
||||||
const authoritativePrecheck = budgetPrecheckGate.enabled
|
|
||||||
? await buildPurchaseRequestPrecheck(fastify.prisma, {
|
|
||||||
purchaseRequestId,
|
|
||||||
config: fastify.config,
|
|
||||||
logger: fastify.log,
|
logger: fastify.log,
|
||||||
})
|
entityType: "purchase_request",
|
||||||
: buildDisabledPrecheck("purchase_request", purchaseRequestId);
|
entityId: purchaseRequestId,
|
||||||
|
actorDepartmentId: request.employee.department_id ?? null,
|
||||||
|
});
|
||||||
if (authoritativePrecheck.decision === "block") {
|
if (authoritativePrecheck.decision === "block") {
|
||||||
throw buildPrecheckBlockedError(authoritativePrecheck);
|
throw buildPrecheckBlockedError(authoritativePrecheck);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const { ensureCutoverAllowed } = require("../services/cutover");
|
|||||||
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||||
const {
|
const {
|
||||||
buildPersistedRiskFlag,
|
buildPersistedRiskFlag,
|
||||||
buildReimbursementPrecheck,
|
|
||||||
createRiskFlagsFromPrecheck,
|
createRiskFlagsFromPrecheck,
|
||||||
|
resolveEntityPrecheck,
|
||||||
} = require("../services/feeControl");
|
} = require("../services/feeControl");
|
||||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||||
const {
|
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) {
|
function buildPrecheckBlockedError(precheck) {
|
||||||
return new AppError({
|
return new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
@@ -1511,30 +1497,13 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
const precheck = await resolveEntityPrecheck({
|
||||||
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({
|
|
||||||
prisma: fastify.prisma,
|
prisma: fastify.prisma,
|
||||||
config: fastify.config,
|
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,
|
logger: fastify.log,
|
||||||
|
entityType: "reimbursement",
|
||||||
|
entityId: reimbursementId,
|
||||||
|
actorDepartmentId: request.employee.department_id ?? null,
|
||||||
});
|
});
|
||||||
return sendData(reply, request, precheck);
|
return sendData(reply, request, precheck);
|
||||||
},
|
},
|
||||||
@@ -1610,20 +1579,14 @@ async function reimbursementRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const budgetPrecheckGate = await evaluateFeatureFlag({
|
const authoritativePrecheck = await resolveEntityPrecheck({
|
||||||
prisma: tx,
|
prisma: tx,
|
||||||
config: fastify.config,
|
config: fastify.config,
|
||||||
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
|
|
||||||
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
|
|
||||||
strictDepartment: false,
|
|
||||||
});
|
|
||||||
const authoritativePrecheck = budgetPrecheckGate.enabled
|
|
||||||
? await buildReimbursementPrecheck(tx, {
|
|
||||||
reimbursementId,
|
|
||||||
config: fastify.config,
|
|
||||||
logger: fastify.log,
|
logger: fastify.log,
|
||||||
})
|
entityType: "reimbursement",
|
||||||
: buildDisabledPrecheck("reimbursement", reimbursementId);
|
entityId: reimbursementId,
|
||||||
|
actorDepartmentId: request.employee.department_id ?? null,
|
||||||
|
});
|
||||||
if (authoritativePrecheck.decision === "block") {
|
if (authoritativePrecheck.decision === "block") {
|
||||||
throw buildPrecheckBlockedError(authoritativePrecheck);
|
throw buildPrecheckBlockedError(authoritativePrecheck);
|
||||||
}
|
}
|
||||||
@@ -1641,7 +1604,20 @@ async function reimbursementRoutes(fastify) {
|
|||||||
const currentAmount = Number(reimbursement.total_amount ?? 0);
|
const currentAmount = Number(reimbursement.total_amount ?? 0);
|
||||||
const nextUsed = usedAmount + currentAmount;
|
const nextUsed = usedAmount + currentAmount;
|
||||||
const usagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
|
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 = {
|
budgetCheck = {
|
||||||
budgetId: budgetPool.id,
|
budgetId: budgetPool.id,
|
||||||
@@ -1670,7 +1646,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controlMode === "block" && nextUsed > allocatedAmount) {
|
if (shouldBlockByLegacyMode) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
message: "参数校验失败",
|
message: "参数校验失败",
|
||||||
@@ -1683,7 +1659,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
data: {
|
data: {
|
||||||
budget_pool_id: budgetPool.id,
|
budget_pool_id: budgetPool.id,
|
||||||
reimbursement_id: reimbursementId,
|
reimbursement_id: reimbursementId,
|
||||||
kind: controlMode === "block" && nextUsed > allocatedAmount ? "blocked" : "submit",
|
kind: shouldBlockByLegacyMode ? "blocked" : "submit",
|
||||||
amount: reimbursement.total_amount,
|
amount: reimbursement.total_amount,
|
||||||
note: `submit:${reimbursement.form_number ?? reimbursementId}`,
|
note: `submit:${reimbursement.form_number ?? reimbursementId}`,
|
||||||
created_by: request.employee.id,
|
created_by: request.employee.id,
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
|
|||||||
const { ensureCutoverAllowed } = require("../services/cutover");
|
const { ensureCutoverAllowed } = require("../services/cutover");
|
||||||
const {
|
const {
|
||||||
buildPersistedRiskFlag,
|
buildPersistedRiskFlag,
|
||||||
buildTravelRequestPrecheck,
|
|
||||||
createRiskFlagsFromPrecheck,
|
createRiskFlagsFromPrecheck,
|
||||||
|
resolveEntityPrecheck,
|
||||||
} = require("../services/feeControl");
|
} = require("../services/feeControl");
|
||||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
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) {
|
function buildPrecheckBlockedError(precheck) {
|
||||||
return new AppError({
|
return new AppError({
|
||||||
code: "VALID_001",
|
code: "VALID_001",
|
||||||
@@ -922,29 +908,13 @@ async function createTravelRequestsRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const travelRequestId = parseTravelRequestId(request.params?.id);
|
const travelRequestId = parseTravelRequestId(request.params?.id);
|
||||||
const travelRequest = await fastify.prisma.travelRequest.findUnique({
|
const precheck = await resolveEntityPrecheck({
|
||||||
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({
|
|
||||||
prisma: fastify.prisma,
|
prisma: fastify.prisma,
|
||||||
config: fastify.config,
|
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,
|
logger: fastify.log,
|
||||||
|
entityType: "travel_request",
|
||||||
|
entityId: travelRequestId,
|
||||||
|
actorDepartmentId: request.employee.department_id ?? null,
|
||||||
});
|
});
|
||||||
return sendData(reply, request, precheck);
|
return sendData(reply, request, precheck);
|
||||||
},
|
},
|
||||||
@@ -978,20 +948,14 @@ async function createTravelRequestsRoutes(fastify) {
|
|||||||
throw buildValidationError({ segments: "required" }, 409);
|
throw buildValidationError({ segments: "required" }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const budgetPrecheckGate = await evaluateFeatureFlag({
|
const authoritativePrecheck = await resolveEntityPrecheck({
|
||||||
prisma: tx,
|
prisma: tx,
|
||||||
config: fastify.config,
|
config: fastify.config,
|
||||||
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
|
|
||||||
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
|
|
||||||
strictDepartment: false,
|
|
||||||
});
|
|
||||||
const authoritativePrecheck = budgetPrecheckGate.enabled
|
|
||||||
? await buildTravelRequestPrecheck(tx, {
|
|
||||||
travelRequestId,
|
|
||||||
config: fastify.config,
|
|
||||||
logger: fastify.log,
|
logger: fastify.log,
|
||||||
})
|
entityType: "travel_request",
|
||||||
: buildDisabledPrecheck("travel_request", travelRequestId);
|
entityId: travelRequestId,
|
||||||
|
actorDepartmentId: request.employee.department_id ?? null,
|
||||||
|
});
|
||||||
if (authoritativePrecheck.decision === "block") {
|
if (authoritativePrecheck.decision === "block") {
|
||||||
throw buildPrecheckBlockedError(authoritativePrecheck);
|
throw buildPrecheckBlockedError(authoritativePrecheck);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { AppError } = require("../utils/AppError");
|
const { AppError } = require("../utils/AppError");
|
||||||
const { resolveApplicableBudgetPoolByContext } = require("./budgetPool");
|
const { resolveApplicableBudgetPoolByContext } = require("./budgetPool");
|
||||||
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
|
||||||
const {
|
const {
|
||||||
buildWorkflowStepsFromApprovalRule,
|
buildWorkflowStepsFromApprovalRule,
|
||||||
parseWorkflowSteps,
|
parseWorkflowSteps,
|
||||||
@@ -7,6 +8,12 @@ const {
|
|||||||
} = require("../utils/workflow");
|
} = require("../utils/workflow");
|
||||||
|
|
||||||
const PRECHECK_DECISIONS = Object.freeze(["pass", "warn", "manual_review", "block"]);
|
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({
|
const RISK_LEVELS = Object.freeze({
|
||||||
low: "low",
|
low: "low",
|
||||||
medium: "medium",
|
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) {
|
function toMoneyNumber(value) {
|
||||||
const numeric = Number(value ?? 0);
|
const numeric = Number(value ?? 0);
|
||||||
if (!Number.isFinite(numeric)) {
|
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) {
|
function buildIssue(code, title, detail) {
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
@@ -726,16 +756,120 @@ async function buildTravelRequestPrecheck(tx, { travelRequestId, config, logger
|
|||||||
return applyDecisionByIssues(response, budgetIssues, matchedRule, config);
|
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 = {
|
module.exports = {
|
||||||
|
BUDGET_RISK_CODES,
|
||||||
PRECHECK_DECISIONS,
|
PRECHECK_DECISIONS,
|
||||||
|
buildDisabledPrecheck,
|
||||||
RISK_LEVELS,
|
RISK_LEVELS,
|
||||||
|
getPrecheckPermissionCode,
|
||||||
toMoneyNumber,
|
toMoneyNumber,
|
||||||
toMoneyString,
|
toMoneyString,
|
||||||
listRiskFlags,
|
listRiskFlags,
|
||||||
ensureRiskFlag,
|
ensureRiskFlag,
|
||||||
buildPersistedRiskFlag,
|
buildPersistedRiskFlag,
|
||||||
createRiskFlagsFromPrecheck,
|
createRiskFlagsFromPrecheck,
|
||||||
|
buildEntityPrecheck,
|
||||||
buildReimbursementPrecheck,
|
buildReimbursementPrecheck,
|
||||||
buildPurchaseRequestPrecheck,
|
buildPurchaseRequestPrecheck,
|
||||||
buildTravelRequestPrecheck,
|
buildTravelRequestPrecheck,
|
||||||
|
loadPrecheckEntityContext,
|
||||||
|
resolveEntityPrecheck,
|
||||||
};
|
};
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户