From 845558ab172288b4856c72b0b48876574ba60786 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 8 Apr 2026 12:47:40 +0800 Subject: [PATCH] =?UTF-8?q?R56=20A1-A6=EF=BC=9A=E8=B4=B9=E7=94=A8=E6=A0=87?= =?UTF-8?q?=E5=87=86=E7=AE=A1=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/expenseStandards.ts | 90 +++ client/src/api/feeControl.ts | 17 + client/src/api/reimbursements.ts | 3 +- client/src/api/workflow.ts | 4 + client/src/pages/ExpenseStandardsPage.tsx | 521 +++++++++++++++ client/src/pages/ReimbursementDetailPage.tsx | 123 +++- client/src/pages/SystemSettingsPage.tsx | 1 + client/src/pages/WorkflowTaskDetailPage.tsx | 25 + .../ReimbursementActions.tsx | 7 +- .../ReimbursementItemEditor.tsx | 43 +- client/src/router/index.tsx | 10 + .../src/router/lazy/ExpenseStandardsPage.ts | 1 + client/src/router/lazy/index.ts | 1 + issues/2026-04-08_r56-expense-standards.csv | 7 + .../migration.sql | 34 + server/prisma/schema.prisma | 24 + server/src/app.js | 2 + server/src/routes/expenseStandards.js | 617 ++++++++++++++++++ server/src/routes/reimbursements.js | 27 +- server/src/routes/workflow.js | 6 + server/src/services/feeControl.js | 180 ++++- .../unit/routes/expenseStandards.test.js | 457 +++++++++++++ .../reimbursements.expense-standards.test.js | 363 +++++++++++ .../unit/routes/workflow.task-detail.test.js | 134 ++++ .../services/expenseStandards.check.test.js | 306 +++++++++ 25 files changed, 2993 insertions(+), 10 deletions(-) create mode 100644 client/src/api/expenseStandards.ts create mode 100644 client/src/pages/ExpenseStandardsPage.tsx create mode 100644 client/src/router/lazy/ExpenseStandardsPage.ts create mode 100644 issues/2026-04-08_r56-expense-standards.csv create mode 100644 server/prisma/migrations/20260408090000_r56_expense_standards/migration.sql create mode 100644 server/src/routes/expenseStandards.js create mode 100644 server/tests/unit/routes/expenseStandards.test.js create mode 100644 server/tests/unit/routes/reimbursements.expense-standards.test.js create mode 100644 server/tests/unit/routes/workflow.task-detail.test.js create mode 100644 server/tests/unit/services/expenseStandards.check.test.js diff --git a/client/src/api/expenseStandards.ts b/client/src/api/expenseStandards.ts new file mode 100644 index 00000000..8a4917e5 --- /dev/null +++ b/client/src/api/expenseStandards.ts @@ -0,0 +1,90 @@ +import { apiRequestData } from "./http"; + +export type ExpenseStandardPerUnit = "item" | "day" | "trip"; +export type ExpenseStandardControlMode = "warn" | "block"; +export type ExpenseStandardScopeType = "global" | "department"; + +export type ExpenseStandard = { + id: string; + expenseTypeId: string; + expenseTypeName: string | null; + name: string; + maxAmount: string; + perUnit: ExpenseStandardPerUnit; + controlMode: ExpenseStandardControlMode; + scopeType: ExpenseStandardScopeType; + scopeKey: string | null; + scopeLabel: string; + enabled: boolean; + note: string | null; + createdAt: string | null; + updatedAt: string | null; +}; + +export type ExpenseStandardListResponse = { + expenseTypeId: string | null; + scopeType: ExpenseStandardScopeType | null; + enabled: boolean | null; + items: ExpenseStandard[]; +}; + +export type ExpenseStandardListQuery = { + expenseTypeId?: string; + scopeType?: ExpenseStandardScopeType; + enabled?: boolean; +}; + +export type CreateExpenseStandardPayload = { + expenseTypeId: string; + name: string; + maxAmount: string; + perUnit?: ExpenseStandardPerUnit; + controlMode?: ExpenseStandardControlMode; + scopeType?: ExpenseStandardScopeType; + scopeKey?: string | null; + enabled?: boolean; + note?: string | null; +}; + +export type UpdateExpenseStandardPayload = Partial; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, String(value)); + } + + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + +export async function fetchExpenseStandards(query: ExpenseStandardListQuery = {}) { + return apiRequestData( + `/api/expense-standards${toQueryString(query as Record)}`, + ); +} + +export async function createExpenseStandard(payload: CreateExpenseStandardPayload) { + return apiRequestData("/api/expense-standards", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateExpenseStandard(standardId: string, payload: UpdateExpenseStandardPayload) { + return apiRequestData(`/api/expense-standards/${standardId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function deleteExpenseStandard(standardId: string) { + return apiRequestData(`/api/expense-standards/${standardId}`, { + method: "DELETE", + body: JSON.stringify({}), + }); +} diff --git a/client/src/api/feeControl.ts b/client/src/api/feeControl.ts index 77cc711b..9329e0f3 100644 --- a/client/src/api/feeControl.ts +++ b/client/src/api/feeControl.ts @@ -19,6 +19,22 @@ export type RiskFlag = { export type PrecheckDecision = "pass" | "warn" | "manual_review" | "block"; +export type ExpenseStandardCheck = { + itemId: string; + expenseTypeId: string; + expenseTypeName: string; + amount: string; + standardId: string; + standardName: string; + maxAmount: string; + perUnit: "item" | "day" | "trip"; + controlMode: "warn" | "block"; + scopeType: "global" | "department"; + scopeKey: string | null; + exceeded: boolean; + exceededAmount: string; +}; + export type PrecheckResponse = { entityType: "reimbursement" | "purchase_request" | "travel_request"; entityId: string; @@ -29,6 +45,7 @@ export type PrecheckResponse = { controlMode: "warn" | "block" | "manual_review"; } | null; budgetHint: Record | null; + standardChecks: ExpenseStandardCheck[]; warnings: Array<{ code: string; title: string; detail: string }>; blockingReasons: Array<{ code: string; title: string; detail: string }>; riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>; diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 72cf4f2c..406b7ffb 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -1,5 +1,5 @@ import { apiRequestData } from "./http"; -import type { PrecheckResponse, RiskFlag } from "./feeControl"; +import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl"; export type ReimbursementStatus = | "draft" @@ -245,6 +245,7 @@ export type ReimbursementSubmitResponse = { exceededBudget: boolean; } | null; precheckDecision: "pass" | "warn" | "manual_review"; + standardWarnings: ExpenseStandardCheck[]; openedRiskFlagCount: number; }; diff --git a/client/src/api/workflow.ts b/client/src/api/workflow.ts index 70a8ea8a..1f440d2c 100644 --- a/client/src/api/workflow.ts +++ b/client/src/api/workflow.ts @@ -1,4 +1,5 @@ import { apiRequestData } from "./http"; +import type { ExpenseStandardCheck } from "./feeControl"; export type WorkflowTaskSummary = { id: string; @@ -57,6 +58,9 @@ export type WorkflowTaskDetail = { entityType: string; currentStatus: string; currentStepId: string; + contextData: { + standardViolations: ExpenseStandardCheck[]; + }; startedAt: string | null; completedAt: string | null; } | null; diff --git a/client/src/pages/ExpenseStandardsPage.tsx b/client/src/pages/ExpenseStandardsPage.tsx new file mode 100644 index 00000000..ad4b70bf --- /dev/null +++ b/client/src/pages/ExpenseStandardsPage.tsx @@ -0,0 +1,521 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { fetchDepartments } from "../api/departments"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { fetchExpenseTypes } from "../api/expenseTypes"; +import { + createExpenseStandard, + deleteExpenseStandard, + fetchExpenseStandards, + updateExpenseStandard, + type ExpenseStandard, + type ExpenseStandardControlMode, + type ExpenseStandardPerUnit, + type ExpenseStandardScopeType, +} from "../api/expenseStandards"; +import { isApiError } from "../api/types"; +import { PageHeader } from "../components/common/PageHeader"; +import { TableSkeleton } from "../components/common/TableSkeleton"; +import { ConfirmDialog } from "../components/common/ConfirmDialog"; +import { Button } from "../components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; + +type FilterEnabled = "all" | "enabled" | "disabled"; +type FormState = { + expenseTypeId: string; + name: string; + maxAmount: string; + perUnit: ExpenseStandardPerUnit; + controlMode: ExpenseStandardControlMode; + scopeType: ExpenseStandardScopeType; + scopeKey: string; + enabled: boolean; + note: string; +}; + +const PER_UNIT_OPTIONS: Array<{ value: ExpenseStandardPerUnit; label: string }> = [ + { value: "item", label: "每项" }, + { value: "day", label: "每天" }, + { value: "trip", label: "每次" }, +]; + +const CONTROL_MODE_OPTIONS: Array<{ value: ExpenseStandardControlMode; label: string }> = [ + { value: "warn", label: "预警" }, + { value: "block", label: "阻断" }, +]; + +function emptyForm(expenseTypeId = ""): FormState { + return { + expenseTypeId, + name: "", + maxAmount: "", + perUnit: "item", + controlMode: "warn", + scopeType: "global", + scopeKey: "", + enabled: true, + note: "", + }; +} + +function toForm(item: ExpenseStandard): FormState { + return { + expenseTypeId: item.expenseTypeId, + name: item.name, + maxAmount: item.maxAmount, + perUnit: item.perUnit, + controlMode: item.controlMode, + scopeType: item.scopeType, + scopeKey: item.scopeKey ?? "", + enabled: item.enabled, + note: item.note ?? "", + }; +} + +function resolveErrorMessage(error: unknown) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + if (error instanceof Error) { + return error.message; + } + return "请求失败,请稍后重试"; +} + +function toneClassForControlMode(mode: ExpenseStandardControlMode) { + return mode === "block" + ? "border-red-200 bg-red-50 text-red-700" + : "border-amber-200 bg-amber-50 text-amber-700"; +} + +export function ExpenseStandardsPage() { + const queryClient = useQueryClient(); + const [error, setError] = useState(""); + const [selectedExpenseTypeId, setSelectedExpenseTypeId] = useState(""); + const [enabledFilter, setEnabledFilter] = useState("all"); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [form, setForm] = useState(emptyForm()); + const [confirmDelete, setConfirmDelete] = useState(null); + + const expenseTypesQuery = useQuery({ + queryKey: ["expense-types", "expense-standards"], + queryFn: () => fetchExpenseTypes(), + }); + + const departmentsQuery = useQuery({ + queryKey: ["departments", "expense-standards", "active"], + queryFn: () => fetchDepartments({ includeArchived: false }), + }); + + const standardsQuery = useQuery({ + queryKey: ["expense-standards", selectedExpenseTypeId, enabledFilter], + queryFn: () => + fetchExpenseStandards({ + expenseTypeId: selectedExpenseTypeId || undefined, + enabled: + enabledFilter === "all" + ? undefined + : enabledFilter === "enabled", + }), + }); + + useEffect(() => { + if (!dialogOpen) { + return; + } + + if (editingItem) { + setForm(toForm(editingItem)); + return; + } + + const fallbackExpenseTypeId = selectedExpenseTypeId || expenseTypesQuery.data?.items?.[0]?.id || ""; + setForm(emptyForm(fallbackExpenseTypeId)); + }, [dialogOpen, editingItem, expenseTypesQuery.data?.items, selectedExpenseTypeId]); + + const saveMutation = useMutation({ + mutationFn: async () => { + const payload = { + expenseTypeId: form.expenseTypeId, + name: form.name.trim(), + maxAmount: form.maxAmount.trim(), + perUnit: form.perUnit, + controlMode: form.controlMode, + scopeType: form.scopeType, + scopeKey: form.scopeType === "department" ? form.scopeKey || null : null, + enabled: form.enabled, + note: form.note.trim() || null, + }; + + if (editingItem) { + return updateExpenseStandard(editingItem.id, payload); + } + + return createExpenseStandard(payload); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["expense-standards"] }); + setDialogOpen(false); + setEditingItem(null); + setError(""); + toast.success(editingItem ? "标准已更新" : "标准已创建"); + }, + onError: (error) => { + const message = resolveErrorMessage(error); + setError(message); + toast.error(message); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (standardId: string) => deleteExpenseStandard(standardId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["expense-standards"] }); + setConfirmDelete(null); + setError(""); + toast.success("标准已停用"); + }, + onError: (error) => { + const message = resolveErrorMessage(error); + setError(message); + toast.error(message); + }, + }); + + const items = standardsQuery.data?.items ?? []; + const expenseTypeOptions = expenseTypesQuery.data?.items ?? []; + const departmentOptions = departmentsQuery.data?.items ?? []; + const canSubmit = + form.expenseTypeId.trim().length > 0 && + form.name.trim().length > 0 && + form.maxAmount.trim().length > 0 && + (form.scopeType !== "department" || form.scopeKey.trim().length > 0); + + const selectedExpenseTypeName = useMemo(() => { + return expenseTypeOptions.find((item) => item.id === selectedExpenseTypeId)?.name ?? "全部费用类型"; + }, [expenseTypeOptions, selectedExpenseTypeId]); + + return ( + + { + setEditingItem(null); + setDialogOpen(true); + }} + > + 新增标准 + + } + /> + + {error ? ( + {error} + ) : null} + + + + + 费用类型 + setSelectedExpenseTypeId(event.target.value)} + > + 全部费用类型 + {expenseTypeOptions.map((item) => ( + + {item.name} + + ))} + + + + + 启用状态 + setEnabledFilter(event.target.value as FilterEnabled)} + > + 全部 + 启用 + 停用 + + + + + + 当前筛选:{selectedExpenseTypeName} / {enabledFilter === "all" ? "全部状态" : enabledFilter === "enabled" ? "仅启用" : "仅停用"} + + + {standardsQuery.isLoading ? ( + + ) : standardsQuery.isError ? ( + 费用标准加载失败,请刷新页面重试。 + ) : ( + + + + + 费用类型 + 标准名称 + 上限金额 + 计量单位 + 管控模式 + 适用范围 + 启用状态 + 操作 + + + + {items.length ? ( + items.map((item) => ( + + + {item.expenseTypeName ?? item.expenseTypeId} + + + {item.name} + {item.note || "-"} + + ¥{item.maxAmount} + {PER_UNIT_OPTIONS.find((option) => option.value === item.perUnit)?.label ?? item.perUnit} + + + {item.controlMode === "block" ? "阻断" : "预警"} + + + {item.scopeLabel} + {item.enabled ? "启用" : "停用"} + + + { + setEditingItem(item); + setDialogOpen(true); + }} + > + 编辑 + + setConfirmDelete(item)} + > + 删除 + + + + + )) + ) : ( + + + 当前筛选下暂无费用标准。 + + + )} + + + + )} + + + { + setDialogOpen(open); + if (!open) { + setEditingItem(null); + setError(""); + } + }} + > + + + {editingItem ? "编辑费用标准" : "新增费用标准"} + 维护单条明细的金额上限,以及超标后是预警还是阻断提交。 + + + + + 费用类型 + setForm((current) => ({ ...current, expenseTypeId: event.target.value }))} + disabled={Boolean(editingItem)} + > + 请选择费用类型 + {expenseTypeOptions.map((item) => ( + + {item.name} + + ))} + + + + + 标准名称 + setForm((current) => ({ ...current, name: event.target.value }))} + placeholder="例如:日常交通费上限" + /> + + + + 上限金额 + setForm((current) => ({ ...current, maxAmount: event.target.value }))} + placeholder="例如 100.00" + /> + + + + 计量单位 + setForm((current) => ({ ...current, perUnit: event.target.value as ExpenseStandardPerUnit }))} + > + {PER_UNIT_OPTIONS.map((item) => ( + + {item.label} + + ))} + + + + + 管控模式 + setForm((current) => ({ ...current, controlMode: event.target.value as ExpenseStandardControlMode }))} + > + {CONTROL_MODE_OPTIONS.map((item) => ( + + {item.label} + + ))} + + + + + 适用范围 + + setForm((current) => ({ + ...current, + scopeType: event.target.value as ExpenseStandardScopeType, + scopeKey: event.target.value === "department" ? current.scopeKey : "", + })) + } + > + 全局 + 指定部门 + + + + {form.scopeType === "department" ? ( + + 部门 + setForm((current) => ({ ...current, scopeKey: event.target.value }))} + > + 请选择部门 + {departmentOptions.map((item) => ( + + {item.name} + + ))} + + + ) : null} + + + 备注 + setForm((current) => ({ ...current, note: event.target.value }))} + placeholder="可选填写规则说明" + /> + + + + 启用状态 + setForm((current) => ({ ...current, enabled: event.target.value === "true" }))} + > + 启用 + 停用 + + + + + + setDialogOpen(false)}> + 取消 + + saveMutation.mutate()}> + {saveMutation.isPending ? "保存中..." : editingItem ? "保存修改" : "创建标准"} + + + + + + { + if (!open) { + setConfirmDelete(null); + } + }} + title="确认删除该费用标准?" + description={confirmDelete ? `删除后会将“${confirmDelete.name}”停用,不会物理移除历史记录。` : undefined} + confirmLabel="删除" + variant="destructive" + onConfirm={async () => { + if (confirmDelete) { + await deleteMutation.mutateAsync(confirmDelete.id); + } + }} + /> + + ); +} + +export default ExpenseStandardsPage; diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 81be89d3..a68805e3 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchExpenseTypes } from "../api/expenseTypes"; +import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl"; import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations"; import { fetchProjects } from "../api/projects"; import { @@ -92,6 +93,7 @@ export function ReimbursementDetailPage() { const [itemError, setItemError] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false); + const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState(null); const [editorInitialValues, setEditorInitialValues] = useState({ expenseTypeId: "", expenseDate: "", @@ -116,6 +118,13 @@ export function ReimbursementDetailPage() { }); const canManageIntegration = hasPermission(permissions, "integration.manage"); + const canSubmitExpense = hasPermission(permissions, "expense.submit"); + + const reimbursementPrecheckQuery = useQuery({ + enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""), + queryKey: ["reimbursements", reimbursementId, "precheck"], + queryFn: () => precheckReimbursement(reimbursementId), + }); const erpPreviewQuery = useQuery({ enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid", @@ -147,8 +156,12 @@ export function ReimbursementDetailPage() { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), ]); setActionError(""); + if (params.action === "submit") { + setSubmitConfirmPrecheck(null); + } const actionMessages: Record = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" }; toast.success(actionMessages[params.action] ?? "操作成功"); }, @@ -197,6 +210,7 @@ export function ReimbursementDetailPage() { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), ]); setItemEditor(null); setItemError(""); @@ -233,6 +247,7 @@ export function ReimbursementDetailPage() { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }), ]); setItemError(""); toast.success("明细已删除"); @@ -349,6 +364,9 @@ export function ReimbursementDetailPage() { const budgetHint = detail.budgetHint; const canPay = hasPermission(permissions, "expense.finance.pay"); const canEditItems = ["draft", "rejected"].includes(detail.status); + const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? []; + const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block"); + const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn"); const expenseTypes = expenseTypesQuery.data?.items ?? []; @@ -406,7 +424,8 @@ export function ReimbursementDetailPage() { return; } - if ((precheck.decision === "warn" || precheck.decision === "manual_review") && !window.confirm(message)) { + if (precheck.decision === "warn" || precheck.decision === "manual_review") { + setSubmitConfirmPrecheck(precheck); return; } @@ -546,6 +565,10 @@ export function ReimbursementDetailPage() {
{error}
费用标准加载失败,请刷新页面重试。
正在刷新费用标准校验结果...
{submitHint}
撤回报销
正在加载费用标准...
+ 该费用类型上限 ¥{activeStandard.maxAmount}/{activeStandard.perUnit} +
+ 超出费用标准 ¥{activeStandard?.maxAmount},超出 ¥{exceededAmount} +