From 6dee2daf3e3cca1c83d246f8c6406c2349d6a538 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 29 Mar 2026 09:21:17 +0800 Subject: [PATCH] =?UTF-8?q?P25L-1~P25L-5=20=E5=AE=9E=E7=8E=B0=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E6=8E=A7=E5=88=B6=E4=B8=8EAP=E6=9C=80=E5=B0=8F?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E8=BF=90=E8=90=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。 --- client/src/api/apOperations.ts | 15 +- client/src/api/budgetControlRules.ts | 90 ++ client/src/api/errorMessages.ts | 20 + client/src/api/feeControl.ts | 12 + client/src/api/paymentBatches.ts | 13 + client/src/pages/ApOperationsPage.tsx | 30 +- client/src/pages/BudgetsPage.tsx | 930 +++++++++++++----- client/src/pages/PaymentBatchesPage.tsx | 159 ++- ...2026-03-29_08-07-14-p25-lite-budget-ap.csv | 7 + .../migration.sql | 4 + server/prisma/schema.prisma | 3 + server/src/app.js | 2 + server/src/routes/apOperations.js | 128 ++- server/src/routes/feeControl.js | 53 + server/src/routes/paymentBatches.js | 88 +- server/src/routes/purchaseRequests.js | 56 +- server/src/routes/reimbursements.js | 76 +- server/src/routes/travelRequests.js | 56 +- server/src/services/feeControl.js | 134 +++ 19 files changed, 1394 insertions(+), 482 deletions(-) create mode 100644 client/src/api/budgetControlRules.ts create mode 100644 issues/2026-03-29_08-07-14-p25-lite-budget-ap.csv create mode 100644 server/prisma/migrations/20260329083000_p25l_payment_batch_secondary_review/migration.sql create mode 100644 server/src/routes/feeControl.js diff --git a/client/src/api/apOperations.ts b/client/src/api/apOperations.ts index 7e9b9e55..556a0bc7 100644 --- a/client/src/api/apOperations.ts +++ b/client/src/api/apOperations.ts @@ -3,15 +3,16 @@ import type { RiskFlag } from "./feeControl"; export type ApOperationsSummary = { summary: { - openPaymentBatchCount: number; - failedPaymentBatchItemCount: number; - openRiskFlagCount: number; - reconciliationAnomalyCount: number; + pendingPaymentCount: number; + partialMatchCount: number; + exceptionCount: number; + budgetOverspendCount: number; }; - openPaymentBatches: Array<{ id: string; batchNo: string; entityType: string; name: string; status: string; createdAt: string | null }>; + pendingPaymentBatches: Array<{ id: string; batchNo: string; entityType: string; name: string; status: string; totalAmount: string; highRisk: boolean; createdAt: string | null }>; failedPaymentBatchItems: Array<{ id: string; paymentBatchId: string; entityId: string; entityCode: string; entityTitle: string; amount: string; currencyCode: string; failureCode: string | null; failureMessage: string | null; createdAt: string | null }>; - openRiskFlags: RiskFlag[]; - reconciliationAnomalies: Array<{ id: string; travelReconciliationBatchId: string; travelOrderId: string | null; travelRequestId: string | null; matchStatus: string; resolutionStatus: string; anomalyCode: string | null; orderAmount: string | null; transactionAmount: string | null; currency: string; createdAt: string | null }>; + exceptionRiskFlags: RiskFlag[]; + budgetOverspendRisks: RiskFlag[]; + partialMatchItems: Array<{ id: string; travelReconciliationBatchId: string; travelOrderId: string | null; travelRequestId: string | null; matchStatus: string; resolutionStatus: string; anomalyCode: string | null; orderAmount: string | null; transactionAmount: string | null; currency: string; createdAt: string | null }>; }; export async function fetchApOperations() { diff --git a/client/src/api/budgetControlRules.ts b/client/src/api/budgetControlRules.ts new file mode 100644 index 00000000..6768aeb1 --- /dev/null +++ b/client/src/api/budgetControlRules.ts @@ -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) { + 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( + `/api/budget-control-rules${toQueryString(query as Record)}`, + ); +} + +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({}), + }); +} diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index e9682fae..b3d21e28 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -132,6 +132,26 @@ export function mapApiErrorMessage( return "当前状态不允许执行付款。"; } + if (details?.review === "maker_checker_conflict") { + return "复核人与批次创建人不能是同一人。"; + } + + if (details?.review === "second_reviewer_conflict") { + return "二次复核人不能与首个复核人相同。"; + } + + if (details?.review === "high_risk_second_review_required") { + return "当前为高风险付款批次,完成二次复核后才能执行。"; + } + + if (details?.review === "batch_review_required") { + return "当前批次尚未完成复核,不能执行。"; + } + + if (details?.review === "batch_not_reviewable") { + return "当前批次状态不允许继续复核。"; + } + if (details?.transfer === "chain_limit_exceeded") { return "当前任务转审次数已达上限。"; } diff --git a/client/src/api/feeControl.ts b/client/src/api/feeControl.ts index 4cc8379d..4b09aa8d 100644 --- a/client/src/api/feeControl.ts +++ b/client/src/api/feeControl.ts @@ -1,3 +1,5 @@ +import { apiRequestData } from "./http"; + export type RiskFlag = { id: string; entityType: string; @@ -32,3 +34,13 @@ export type PrecheckResponse = { riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>; nextActions: string[]; }; + +export async function precheckEntity(payload: { + entityType: "reimbursement" | "purchase_request" | "travel_request"; + entityId: string; +}) { + return apiRequestData("/api/fee-control/precheck", { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/api/paymentBatches.ts b/client/src/api/paymentBatches.ts index d452f0e6..d136fb9e 100644 --- a/client/src/api/paymentBatches.ts +++ b/client/src/api/paymentBatches.ts @@ -32,12 +32,25 @@ export type PaymentBatch = { defaultPaymentDate: string | null; defaultPaymentChannel: PaymentChannel | null; voucherPrefix: string | null; + totalAmount: string; + highRisk: boolean; + reviewRequirement: { + thresholdAmount: string; + requiredReviewCount: number; + completedReviewCount: number; + nextStep: "first_review" | "second_review" | "ready_for_execute"; + makerCheckerRequired: boolean; + reviewerPermission: string; + }; reviewComment: string | null; + secondaryReviewComment: string | null; executionSummaryJson: Record | null; createdAt: string | null; createdBy: string | null; reviewedAt: string | null; reviewedBy: string | null; + secondaryReviewedAt: string | null; + secondaryReviewedBy: string | null; executedAt: string | null; executedBy: string | null; cancelledAt: string | null; diff --git a/client/src/pages/ApOperationsPage.tsx b/client/src/pages/ApOperationsPage.tsx index 2869932d..256dbee3 100644 --- a/client/src/pages/ApOperationsPage.tsx +++ b/client/src/pages/ApOperationsPage.tsx @@ -20,48 +20,52 @@ export function ApOperationsPage() { const data = summaryQuery.data; return (
- +
-
开放批次
{data.summary.openPaymentBatchCount}
-
失败批次项
{data.summary.failedPaymentBatchItemCount}
-
开放风险
{data.summary.openRiskFlagCount}
-
对账异常
{data.summary.reconciliationAnomalyCount}
+
待付款
{data.summary.pendingPaymentCount}
+
部分匹配
{data.summary.partialMatchCount}
+
异常
{data.summary.exceptionCount}
+
预算超支
{data.summary.budgetOverspendCount}
-

开放批次

+

待付款批次

- {data.openPaymentBatches.length ? data.openPaymentBatches.map((item) => ( + {data.pendingPaymentBatches.length ? data.pendingPaymentBatches.map((item) => (
{item.batchNo}
{item.name}
-
{item.entityType} · {item.status}
+
{item.entityType} · {item.status} · {item.totalAmount}{item.highRisk ? " · 高风险" : ""}
- )) :
暂无开放批次
} + )) :
暂无待付款批次
}
-

失败批次项

+

异常付款项

{data.failedPaymentBatchItems.length ? data.failedPaymentBatchItems.map((item) => (
{item.entityCode}
{item.failureMessage ?? item.failureCode ?? "执行失败"}
+
{item.amount} {item.currencyCode}
)) :
暂无失败项
}
- +
+ + +
-

差旅对账异常

+

部分匹配与对账异常

- {data.reconciliationAnomalies.length ? data.reconciliationAnomalies.map((item) => ( + {data.partialMatchItems.length ? data.partialMatchItems.map((item) => (
{item.anomalyCode ?? item.matchStatus}
订单 {item.orderAmount ?? "-"} / 交易 {item.transactionAmount ?? "-"} {item.currency}
diff --git a/client/src/pages/BudgetsPage.tsx b/client/src/pages/BudgetsPage.tsx index c42e46b1..bdd72db4 100644 --- a/client/src/pages/BudgetsPage.tsx +++ b/client/src/pages/BudgetsPage.tsx @@ -2,14 +2,25 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { mapApiErrorMessage } from "../api/errorMessages"; 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 { fetchCostCenters, fetchLegalEntities } from "../api/masterData"; +import { fetchExpenseTypes } from "../api/expenseTypes"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; 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 { TableSkeleton } from "../components/common/TableSkeleton"; import { StatusBadge } from "../components/common/StatusBadge"; @@ -19,6 +30,32 @@ import { Pagination } from "../components/common/Pagination"; const PAGE_SIZE = 20; type FormScopeType = Exclude; +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) { if (isApiError(error)) { @@ -48,6 +85,19 @@ function describeScope(item: BudgetPool) { 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() { const queryClient = useQueryClient(); const { permissions } = useAuth(); @@ -55,6 +105,7 @@ export function BudgetsPage() { const canViewBudget = hasPermission(permissions, "budget.view") || hasPermission(permissions, "budget.manage"); const canManageBudget = hasPermission(permissions, "budget.manage"); + const [tab, setTab] = useState("budget-pools"); const [status, setStatus] = useState<"active" | "archived" | "">("active"); const [departmentId, setDepartmentId] = useState(""); const [legalEntityId, setLegalEntityId] = useState(""); @@ -73,6 +124,11 @@ export function BudgetsPage() { const [formCostCenterId, setFormCostCenterId] = useState(""); const [error, setError] = useState(""); const [confirmArchiveId, setConfirmArchiveId] = useState<{ id: string; code: string } | null>(null); + const [ruleEntityTypeFilter, setRuleEntityTypeFilter] = useState(""); + const [ruleEnabledFilter, setRuleEnabledFilter] = useState<"" | "true" | "false">("true"); + const [ruleForm, setRuleForm] = useState(emptyRuleForm); + const [editingRuleId, setEditingRuleId] = useState(null); + const [confirmArchiveRuleId, setConfirmArchiveRuleId] = useState<{ id: string; name: string } | null>(null); const queryInput = useMemo( () => ({ @@ -110,6 +166,22 @@ export function BudgetsPage() { 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(() => { if (!legalEntityId) { return costCentersQuery.data?.items ?? []; @@ -124,6 +196,19 @@ export function BudgetsPage() { return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === 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({ mutationFn: async () => { 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) { return (
@@ -191,12 +323,61 @@ export function BudgetsPage() { !allocatedAmount.trim() || (formScopeType === "department_legacy" && !formDepartmentId) || (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 (
{error ? ( @@ -205,285 +386,517 @@ export function BudgetsPage() {

) : null} -
-

筛选条件

-
- + setTab(value as BudgetPageTab)}> + + 预算池 + {canManageBudget ? 控制规则 : null} + - - - - - -
- -
- - -
-
- - {canManageBudget ? ( -
-

新建预算池

-
- setCode(event.target.value)} - /> - setName(event.target.value)} - /> - - - {formScopeType === "department_legacy" ? ( + +
+

筛选条件

+
+ + - ) : null} - {formScopeType === "cost_center_scoped" ? ( - <> + + + +
+ +
+ + +
+
+ + {canManageBudget ? ( +
+

新建预算池

+
+ setCode(event.target.value)} + /> + setName(event.target.value)} + /> + + {formScopeType === "department_legacy" ? ( + + ) : null} + + {formScopeType === "cost_center_scoped" ? ( + <> + + + + ) : null} + + setPeriodStart(event.target.value)} + /> + setPeriodEnd(event.target.value)} + /> + setAllocatedAmount(event.target.value)} + /> + setWarningThresholdPercent(event.target.value)} + /> +
+ +
+ ) : null} + +
+

预算池列表

+ + {budgetsQuery.isLoading ? : null} + + {!budgetsQuery.isLoading ? ( + <> +
+ + + + 编码 + 名称 + 作用域 + 状态 + 期间 + 额度 + 预警阈值 + 操作 + + + + {(budgetsQuery.data?.items ?? []).length ? ( + (budgetsQuery.data?.items ?? []).map((item) => ( + + {item.code} + {item.name} + +
{describeScope(item)}
+
+ + + {(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")} + + + {item.usedAmount} / {item.allocatedAmount} +
剩余 {item.remainingAmount}
+
+ {item.warningThresholdPercent}% + + {canManageBudget && item.status !== "archived" ? ( + + ) : ( + - + )} + +
+ )) + ) : ( + + + 暂无预算池 + + + )} +
+
+
+ + + + ) : null} +
+
+ + {canManageBudget ? ( + +
+
+

规则筛选

+
费用类型仅对报销规则生效;遗留成本中心规则本轮只归档不编辑。
+
+
+ + +
+ + +
+
+
+ +
+
+

{editingRuleId ? "编辑控制规则" : "新建控制规则"}

+ {editingRuleId ? ( + + ) : null} +
+
+ setRuleForm((current) => ({ ...current, name: event.target.value }))} + /> + + + + + - - ) : null} - setPeriodStart(event.target.value)} - /> - setPeriodEnd(event.target.value)} - /> - setAllocatedAmount(event.target.value)} - /> - setWarningThresholdPercent(event.target.value)} - /> -
- -
- ) : null} - -
-

预算池列表

- - {budgetsQuery.isLoading ? : null} - - {!budgetsQuery.isLoading ? ( - <> -
- - - - 编码 - 名称 - 作用域 - 状态 - 期间 - 额度 - 预警阈值 - 操作 - - - - {(budgetsQuery.data?.items ?? []).length ? ( - (budgetsQuery.data?.items ?? []).map((item) => ( - - {item.code} - {item.name} - -
{describeScope(item)}
-
- - - {(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")} - - - {item.usedAmount} / {item.allocatedAmount} -
剩余 {item.remainingAmount}
-
- {item.warningThresholdPercent}% - - {canManageBudget && item.status !== "archived" ? ( - - ) : ( - - - )} - -
- )) - ) : ( - - - 暂无预算池 - - - )} -
-
+ setRuleForm((current) => ({ ...current, priority: event.target.value }))} + /> + + setRuleForm((current) => ({ ...current, notes: event.target.value }))} + /> +
+
+ + +
- - - ) : null} -
+
+

控制规则列表

+ {rulesQuery.isLoading ? : null} + + {!rulesQuery.isLoading ? ( +
+ + + + 规则名称 + 业务域 + 作用域 + 控制策略 + 优先级 + 状态 + 操作 + + + + {rules.length ? ( + rules.map((rule) => ( + + +
{rule.name}
+ {rule.notes ?
{rule.notes}
: null} +
+ {entityTypeLabel(rule.entityType)} + {describeRuleScope(rule)} + {controlModeLabel(rule.controlMode)} + {rule.priority} + {rule.enabled ? "启用" : "停用"} + +
+ + +
+
+
+ )) + ) : ( + + + 暂无控制规则 + + + )} +
+
+
+ ) : null} +
+ + ) : null} + + + { + if (confirmArchiveRuleId) { + archiveRuleMutation.mutate(confirmArchiveRuleId.id); + } + }} + onOpenChange={(open) => { + if (!open) { + setConfirmArchiveRuleId(null); + } + }} + />
); } diff --git a/client/src/pages/PaymentBatchesPage.tsx b/client/src/pages/PaymentBatchesPage.tsx index e7e81f6a..da9a46e0 100644 --- a/client/src/pages/PaymentBatchesPage.tsx +++ b/client/src/pages/PaymentBatchesPage.tsx @@ -13,6 +13,8 @@ import { } from "../api/paymentBatches"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; +import { useAuth } from "../auth/AuthContext"; +import { hasPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; @@ -32,6 +34,9 @@ function resolveError(error: unknown) { export function PaymentBatchesPage() { const queryClient = useQueryClient(); + const { permissions } = useAuth(); + const canManageBatch = hasPermission(permissions, "payment.batch.manage"); + const canReviewBatch = hasPermission(permissions, "risk.review.manage"); const [selectedBatchId, setSelectedBatchId] = useState(""); const [entityType, setEntityType] = useState("reimbursement"); const [name, setName] = useState(""); @@ -50,6 +55,23 @@ export function PaymentBatchesPage() { const items = listQuery.data?.items ?? []; return items.find((item) => item.id === selectedBatchId) ?? items[0] ?? null; }, [listQuery.data?.items, selectedBatchId]); + const canEditSelectedBatch = canManageBatch && selectedBatch?.status === "draft"; + const canReviewSelectedBatch = + canReviewBatch && + !!selectedBatch && + ( + (selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review") || + (selectedBatch.status === "reviewed" && selectedBatch.reviewRequirement.nextStep === "second_review") + ); + const canExecuteSelectedBatch = + canManageBatch && + !!selectedBatch && + ["reviewed", "failed"].includes(selectedBatch.status) && + selectedBatch.reviewRequirement.nextStep === "ready_for_execute"; + const canCancelSelectedBatch = + canManageBatch && + !!selectedBatch && + !["executed", "cancelled"].includes(selectedBatch.status); const refresh = async () => { await queryClient.invalidateQueries({ queryKey: ["payment-batches"] }); @@ -85,10 +107,10 @@ export function PaymentBatchesPage() { const resolveRiskFlagIds = (batch?.items ?? []).flatMap((item) => item.openRiskFlags.map((flag) => flag.id)); return reviewPaymentBatch(batchId, { resolveRiskFlagIds }); }, - onSuccess: async () => { + onSuccess: async ({ item }) => { setError(""); await refresh(); - toast.success("付款批次已复核"); + toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次已完成首轮复核"); }, onError: (err) => { const message = resolveError(err); @@ -146,36 +168,67 @@ export function PaymentBatchesPage() { return (
- + {error ?
{error}
: null}

新建批次

+ {!canManageBatch ? ( +
+ 当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。 +
+ ) : null}
- setEntityType(event.target.value as PaymentBatchEntityType)} + >
- setName(event.target.value)} placeholder="例如:2026Q2-财务付款批次-01" /> + setName(event.target.value)} + placeholder="例如:2026Q2-财务付款批次-01" + />
-