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} + +
+
+
+ + +
+ +
+ + +
+
+ +
+ 当前筛选:{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 ? "启用" : "停用"} + +
+ + +
+
+
+ )) + ) : ( + + + 当前筛选下暂无费用标准。 + + + )} +
+
+
+ )} +
+ + { + setDialogOpen(open); + if (!open) { + setEditingItem(null); + setError(""); + } + }} + > + + + {editingItem ? "编辑费用标准" : "新增费用标准"} + 维护单条明细的金额上限,以及超标后是预警还是阻断提交。 + + +
+
+ + +
+ +
+ + setForm((current) => ({ ...current, name: event.target.value }))} + placeholder="例如:日常交通费上限" + /> +
+ +
+ + setForm((current) => ({ ...current, maxAmount: event.target.value }))} + placeholder="例如 100.00" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {form.scopeType === "department" ? ( +
+ + +
+ ) : null} + +
+ + setForm((current) => ({ ...current, note: event.target.value }))} + placeholder="可选填写规则说明" + /> +
+ +
+ + +
+
+ + + + + +
+
+ + { + 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() {

) : null} + {reimbursementPrecheckQuery.isLoading && canEditItems ? ( +

正在刷新费用标准校验结果...

+ ) : null} +
@@ -560,7 +583,17 @@ export function ReimbursementDetailPage() { {detail.items.length ? ( detail.items.map((item) => ( - + check.itemId === item.id) + ? "bg-red-50/70" + : warnStandardChecks.some((check) => check.itemId === item.id) + ? "bg-amber-50/70" + : "" + } + >
{item.expenseType?.name ?? item.expenseTypeId} @@ -568,8 +601,33 @@ export function ReimbursementDetailPage() {
{item.expenseType?.code ?? "-"}
{item.expenseDate ?? "-"} - {item.amount} - {item.description ?? "-"} + +
{item.amount}
+ {precheckStandardChecks + .filter((check) => check.itemId === item.id) + .map((check) => ( +
+ 标准 ¥{check.maxAmount}/{check.perUnit} + {check.exceeded ? `,超出 ¥${check.exceededAmount}` : ""} +
+ ))} +
+ +
{item.description ?? "-"}
+ {precheckStandardChecks + .filter((check) => check.itemId === item.id && check.exceeded) + .map((check) => ( +
+ {check.standardName}:当前金额超出费用标准。 +
+ ))} +
{canEditItems ? (
@@ -614,6 +672,7 @@ export function ReimbursementDetailPage() { expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} + departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} @@ -785,6 +844,8 @@ export function ReimbursementDetailPage() { status={detail.status} canPay={canPay} actionError={actionError} + submitDisabled={blockStandardChecks.length > 0} + submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""} isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} @@ -828,6 +889,60 @@ export function ReimbursementDetailPage() { + { + if (!open) { + setSubmitConfirmPrecheck(null); + } + }} + > + + + 确认提交报销单? + 以下校验项不会阻止提交,但会作为预警或复核信息进入审批流程。 + + + {submitConfirmPrecheck ? ( +
+
+ {buildPrecheckDialogMessage(submitConfirmPrecheck)} +
+ + {submitConfirmPrecheck.standardChecks.some((item) => item.exceeded && item.controlMode === "warn") ? ( +
+

超出费用标准的明细

+ {submitConfirmPrecheck.standardChecks + .filter((item) => item.exceeded && item.controlMode === "warn") + .map((item: ExpenseStandardCheck) => ( +
+
{item.expenseTypeName}
+
+ 当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit} / 超出 ¥{item.exceededAmount} +
+
{item.standardName}
+
+ ))} +
+ ) : null} + +
+ + +
+
+ ) : null} +
+
+ { if (!open) setConfirmDeleteItemId(""); }} diff --git a/client/src/pages/SystemSettingsPage.tsx b/client/src/pages/SystemSettingsPage.tsx index d36138c5..80d4038f 100644 --- a/client/src/pages/SystemSettingsPage.tsx +++ b/client/src/pages/SystemSettingsPage.tsx @@ -5,6 +5,7 @@ const ITEMS = [ { to: "/system/workspace", title: "工作区设置", description: "调整小团队模式的导航与模块显隐。" }, { to: "/people", title: "人员管理", description: "处理入职、转正、调岗、离职与档案。" }, { to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" }, + { to: "/system/expense-standards", title: "费用标准", description: "维护报销费用类型上限与 warn/block 管控模式。" }, { to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" }, { to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" }, { to: "/system/integrations/erp", title: "外部系统桥接", description: "查看财务与外部系统桥接能力及出入口。" }, diff --git a/client/src/pages/WorkflowTaskDetailPage.tsx b/client/src/pages/WorkflowTaskDetailPage.tsx index 11c0ddd8..ff3afe97 100644 --- a/client/src/pages/WorkflowTaskDetailPage.tsx +++ b/client/src/pages/WorkflowTaskDetailPage.tsx @@ -289,6 +289,31 @@ export function WorkflowTaskDetailPage() {
+ {detail.workflowInstance?.entityType === "reimbursement" && + detail.workflowInstance.contextData.standardViolations.length ? ( + +
+ {detail.workflowInstance.contextData.standardViolations.map((item) => ( +
+
{item.expenseTypeName}
+
+ 当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit} +
+
超出 ¥{item.exceededAmount} · {item.standardName}
+
管控模式:{item.controlMode === "block" ? "阻断" : "预警"}
+
+ ))} +
+
+ ) : null} +
{actionError ? ( diff --git a/client/src/pages/reimbursement-detail/ReimbursementActions.tsx b/client/src/pages/reimbursement-detail/ReimbursementActions.tsx index aac23040..4a59a49c 100644 --- a/client/src/pages/reimbursement-detail/ReimbursementActions.tsx +++ b/client/src/pages/reimbursement-detail/ReimbursementActions.tsx @@ -16,6 +16,8 @@ export interface ReimbursementActionsProps { status: ReimbursementStatus; canPay: boolean; actionError: string; + submitDisabled?: boolean; + submitHint?: string; isPending: boolean; onSubmit: () => void; onWithdraw: (comment: string) => void; @@ -26,6 +28,8 @@ export function ReimbursementActions({ status, canPay, actionError, + submitDisabled = false, + submitHint = "", isPending, onSubmit, onWithdraw, @@ -52,13 +56,14 @@ export function ReimbursementActions({
+ {submitHint ?

{submitHint}

: null}

撤回报销

diff --git a/client/src/pages/reimbursement-detail/ReimbursementItemEditor.tsx b/client/src/pages/reimbursement-detail/ReimbursementItemEditor.tsx index 3dfe3a3f..bcc6ba2c 100644 --- a/client/src/pages/reimbursement-detail/ReimbursementItemEditor.tsx +++ b/client/src/pages/reimbursement-detail/ReimbursementItemEditor.tsx @@ -1,5 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import type { ExpenseType } from "../../api/expenseTypes"; +import { fetchExpenseStandards } from "../../api/expenseStandards"; import { Button } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; import { Textarea } from "../../components/ui/textarea"; @@ -25,6 +27,7 @@ export interface ReimbursementItemEditorProps { expenseTypes: ExpenseType[]; expenseTypesLoading: boolean; expenseTypesError: boolean; + departmentId: string; initialValues: ItemEditorInitialValues; onSave: (data: ItemEditorSaveData) => void; onCancel: () => void; @@ -36,6 +39,7 @@ export function ReimbursementItemEditor({ expenseTypes, expenseTypesLoading, expenseTypesError, + departmentId, initialValues, onSave, onCancel, @@ -54,6 +58,30 @@ export function ReimbursementItemEditor({ setDescription(initialValues.description); }, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]); + const standardsQuery = useQuery({ + enabled: Boolean(expenseTypeId), + queryKey: ["expense-standards", "editor", expenseTypeId], + queryFn: () => + fetchExpenseStandards({ + expenseTypeId, + enabled: true, + }), + }); + + const activeStandard = useMemo(() => { + const items = standardsQuery.data?.items ?? []; + return ( + items.find((item) => item.scopeType === "department" && item.scopeKey === departmentId) ?? + items.find((item) => item.scopeType === "global") ?? + null + ); + }, [departmentId, standardsQuery.data?.items]); + + const numericAmount = Number(amount || 0); + const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0); + const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount; + const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00"; + const handleSave = () => { onSave({ expenseTypeId, expenseDate, amount, description }); }; @@ -120,6 +148,19 @@ export function ReimbursementItemEditor({ placeholder="例如 123.45" value={amount} /> + {standardsQuery.isLoading ? ( +

正在加载费用标准...

+ ) : null} + {activeStandard ? ( +

+ 该费用类型上限 ¥{activeStandard.maxAmount}/{activeStandard.perUnit} +

+ ) : null} + {isExceeded ? ( +

+ 超出费用标准 ¥{activeStandard?.maxAmount},超出 ¥{exceededAmount} +

+ ) : null}
diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 332473b0..168bb9bc 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -21,6 +21,7 @@ import { DelegationsPage, DepartmentsPage, ErpIntegrationPage, + ExpenseStandardsPage, FeatureFlagsPage, InventoryChecksPage, InvoicePoolPage, @@ -441,6 +442,15 @@ export function AppRouter() { } /> + + {withRouteFallback()} + + } + /> + import("./CutoverConsolePage")); export const DelegationsPage = lazy(() => import("./DelegationsPage")); export const DepartmentsPage = lazy(() => import("./DepartmentsPage")); export const ErpIntegrationPage = lazy(() => import("./ErpIntegrationPage")); +export const ExpenseStandardsPage = lazy(() => import("./ExpenseStandardsPage")); export const FeatureFlagsPage = lazy(() => import("./FeatureFlagsPage")); export const InventoryChecksPage = lazy(() => import("./InventoryChecksPage")); export const InvoicePoolPage = lazy(() => import("./InvoicePoolPage")); diff --git a/issues/2026-04-08_r56-expense-standards.csv b/issues/2026-04-08_r56-expense-standards.csv new file mode 100644 index 00000000..720470fb --- /dev/null +++ b/issues/2026-04-08_r56-expense-standards.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R56 执行合同建档与范围冻结","创建并校验 R56 费用标准管控执行合同 CSV,冻结范围仅包含 ExpenseStandard 模型与 CRUD、报销提交与 precheck 标准校验、费用标准管理页、报销与审批侧超标提示;明确本轮允许 1 个 Prisma 迁移,不改报销状态机与 approve/reject 主流程。","issues/2026-04-08_r56-expense-standards.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R56 A1-A6,不混入城市/职级维度、工作流主流程改造、历史标准版本回放或导出增强。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-08_r56-expense-standards.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A6 依赖链符合 backend/frontend→reviewer→qa 收口;3) 方案明确 global/department 严格唯一、DELETE 仅软删除、费用标准校验不受 budget_precheck_v1 开关影响。","manual","DONE","DONE","DONE","issues/2026-04-08_r56-expense-standards.csv","[]","回滚路径:删除本次 CSV 不影响业务。2026-04-08:Issue CSV 已创建并通过 validate_issues_csv.py 校验,范围与依赖已冻结。" +"A2","ExpenseStandard 模型、迁移与 CRUD 路由","新增 ExpenseStandard Prisma 模型、严格唯一索引迁移、expense standards CRUD 路由与审计日志,支撑费用类型 × 全局/部门标准维护。","schema.prisma 含 ExpenseStandard 与 ExpenseType.standards relation;迁移目录为 20260408090000_r56_expense_standards,含 global/department 严格唯一索引;/api/expense-standards 支持列表、创建、更新、软删除,权限统一 system.settings,冲突返回 409 VALID_001,并记录 EXPENSE_STANDARD_CREATED/UPDATED/DELETED 审计。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && npx prisma migrate status && node --check src/routes/expenseStandards.js && npm test -- tests/unit/routes/expenseStandards.test.js。业务:1) 可创建 global 与 department 标准;2) global 重复或同部门重复被拒绝;3) 删除仅将 enabled 置为 false;4) 非 system.settings 用户访问返回 403。","manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/20260408090000_r56_expense_standards/*|server/src/routes/expenseStandards.js|server/src/app.js|server/tests/unit/routes/expenseStandards.test.js","[""A1""]","回滚路径:回退路由与注册,并在回滚迁移中 drop expense_standards 表及部分唯一索引。2026-04-08:ExpenseStandard 模型、迁移、CRUD 路由与 10 条路由单测已完成,临时校验库 migrate status up to date。" +"A3","报销费用标准校验接入 precheck 与 submit","在 feeControl 中新增费用标准匹配与超标判断,并接入报销 precheck、submit 与工作流 context_data.standardViolations。","checkExpenseStandards 仅对报销生效,按 department > global 匹配启用标准,并返回 standardChecks;reimbursement precheck 与 fee-control precheck 在 reimbursement 时附带 standardChecks;submit 在 block 超标时返回 400 + standardViolations,warn 超标继续提交并返回 standardWarnings,同时将 exceeded 项写入 workflow_instance.context_data.standardViolations。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/reimbursements.js && node --check src/services/feeControl.js && npm test -- tests/unit/services/expenseStandards.check.test.js tests/unit/routes/reimbursements.expense-standards.test.js tests/unit/routes/workflow.task-detail.test.js。业务:1) department 标准覆盖 global;2) warn 超标仅预警不阻断;3) block 超标阻断提交;4) 无标准的明细不受影响;5) warn 提交成功后 workflow task detail 可读到 standardViolations。","manual","DONE","DONE","DONE","server/src/services/feeControl.js|server/src/routes/reimbursements.js|server/src/routes/feeControl.js|server/src/routes/workflow.js|server/tests/unit/services/expenseStandards.check.test.js|server/tests/unit/routes/reimbursements.expense-standards.test.js|server/tests/unit/routes/workflow.task-detail.test.js","[""A1"",""A2""]","回滚路径:删除费用标准检查 helper 与 submit/precheck 增强,保留现有预算 precheck 行为。2026-04-08:费用标准检查已接入 reimbursement precheck/submit/workflow detail,6 条服务单测与 4 条路由级回归通过。" +"A4","费用标准管理页、API、路由与系统设置入口","新增费用标准前端 API、管理页面、系统设置入口与受保护路由。","/system/expense-standards 仅 system.settings 用户可访问;页面包含费用类型/启用状态筛选、标准表格、新增/编辑弹窗与软删除确认;前端通过独立 expenseStandards API 调用后端 CRUD,并在 SystemSettingsPage 显示入口卡片。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 可在系统设置进入费用标准页;2) 可新增、编辑、软删除标准;3) department 范围可选择部门;4) 列表中 warn/block badge 与适用范围文案正确。","manual","DONE","DONE","DONE","client/src/api/expenseStandards.ts|client/src/pages/ExpenseStandardsPage.tsx|client/src/pages/SystemSettingsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ExpenseStandardsPage.ts|client/src/router/lazy/index.ts","[""A1"",""A2""]","回滚路径:移除新页面、API、路由和系统设置入口卡片。2026-04-08:费用标准管理页、系统设置入口与路由懒加载已完成,client typecheck/build 通过。" +"A5","报销明细与审批侧超标提示","增强报销明细编辑器、报销详情页和审批任务详情页,展示费用标准提示、超标样式与提交流程确认。","ReimbursementItemEditor 会基于费用类型与部门显示当前有效标准和 warn/block 超标提示;ReimbursementDetailPage 在 draft/rejected 阶段展示 precheck standardChecks、对超标明细加橙/红边框、block 时禁用提交、warn 时展示确认弹窗;WorkflowTaskDetailPage 对 reimbursement 任务展示 standardViolations 高亮信息。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 编辑明细时可见标准上限与实时超标提示;2) block 超标时提交按钮禁用且提示修改;3) warn 超标时提交前需确认;4) 审批人可在任务详情中看到超标明细、标准与超出金额。","manual","DONE","DONE","DONE","client/src/pages/reimbursement-detail/ReimbursementItemEditor.tsx|client/src/pages/reimbursement-detail/ReimbursementActions.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx|client/src/api/reimbursements.ts|client/src/api/feeControl.ts|client/src/api/workflow.ts","[""A1"",""A2"",""A3"",""A4""]","回滚路径:移除前端 standardChecks/standardViolations 展示与提交确认逻辑。2026-04-08:编辑器实时提示、详情页阻断/确认交互与审批任务详情展示已完成。" +"A6","Reviewer、QA、CSV 回填与提交收口","执行 reviewer 自检、post-check、远端迁移核验项准备与 CSV 回填,完成提交收口。","review 结论覆盖严格唯一索引、department 优先级、block/warn 行为、context_data.standardViolations 写入、不依赖 budget_precheck_v1、无标准时跳过与迁移回滚;post-check 全部通过;issues/2026-04-08_r56-expense-standards.csv 的 Dev_Status/Review1_Status/Regression_Status 依据实测结果回填;完成 git commit。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && npx prisma migrate status && node --check src/routes/reimbursements.js && node --check src/routes/expenseStandards.js && node --check src/services/feeControl.js && npm test -- tests/unit/routes/expenseStandards.test.js tests/unit/services/expenseStandards.check.test.js tests/unit/routes/reimbursements.expense-standards.test.js tests/unit/routes/workflow.task-detail.test.js && cd ../client && npm run typecheck && npm run build。部署准备:记录远端需执行 docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server npx prisma migrate status。业务:1) warn/block 标准分别完成提交流程验证;2) 审批任务详情可见超标信息;3) CSV 状态已回填。","manual","DONE","DONE","DONE","issues/2026-04-08_r56-expense-standards.csv|server/prisma/schema.prisma|server/prisma/migrations/20260408090000_r56_expense_standards/*|server/src/routes/expenseStandards.js|server/src/services/feeControl.js|server/src/routes/reimbursements.js|server/src/routes/workflow.js|server/tests/unit/routes/expenseStandards.test.js|server/tests/unit/services/expenseStandards.check.test.js|server/tests/unit/routes/reimbursements.expense-standards.test.js|server/tests/unit/routes/workflow.task-detail.test.js|client/src/api/expenseStandards.ts|client/src/pages/ExpenseStandardsPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/reimbursement-detail/ReimbursementItemEditor.tsx|client/src/pages/reimbursement-detail/ReimbursementActions.tsx|client/src/pages/WorkflowTaskDetailPage.tsx|client/src/pages/SystemSettingsPage.tsx|client/src/router/index.tsx|client/src/router/lazy/ExpenseStandardsPage.ts|client/src/router/lazy/index.ts|client/src/api/reimbursements.ts|client/src/api/feeControl.ts|client/src/api/workflow.ts","[""A2"",""A3"",""A4"",""A5""]","回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码与迁移。2026-04-08:reviewer 自检确认严格唯一由部分唯一索引保证、department 优先于 global、block 以 400 阻断提交、warn 仅附加 standardWarnings 与 workflow context_data.standardViolations、预算开关关闭时标准校验仍生效、无标准明细自动跳过;post-check 已在临时校验库 hyb_oa_r56_check 上通过,新增 precheck/submit/task detail 路由回归 4 条通过。" diff --git a/server/prisma/migrations/20260408090000_r56_expense_standards/migration.sql b/server/prisma/migrations/20260408090000_r56_expense_standards/migration.sql new file mode 100644 index 00000000..db20432d --- /dev/null +++ b/server/prisma/migrations/20260408090000_r56_expense_standards/migration.sql @@ -0,0 +1,34 @@ +CREATE TABLE "expense_standards" ( + "id" UUID NOT NULL, + "expense_type_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "max_amount" DECIMAL(14,2) NOT NULL, + "per_unit" VARCHAR(20) NOT NULL DEFAULT 'item', + "control_mode" VARCHAR(16) NOT NULL DEFAULT 'warn', + "scope_type" VARCHAR(32) NOT NULL DEFAULT 'global', + "scope_key" UUID, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "note" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_by" UUID, + + CONSTRAINT "expense_standards_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "expense_standards" +ADD CONSTRAINT "expense_standards_expense_type_id_fkey" +FOREIGN KEY ("expense_type_id") REFERENCES "expense_types"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE INDEX "idx_expense_standards_enabled_type" +ON "expense_standards"("enabled", "expense_type_id"); + +CREATE UNIQUE INDEX "expense_standards_type_scope_global_ux" +ON "expense_standards"("expense_type_id", "scope_type") +WHERE "scope_key" IS NULL; + +CREATE UNIQUE INDEX "expense_standards_type_scope_scoped_ux" +ON "expense_standards"("expense_type_id", "scope_type", "scope_key") +WHERE "scope_key" IS NOT NULL; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 0d41ce33..83dfe80b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -812,12 +812,36 @@ model ExpenseType { updated_at DateTime @default(now()) @updatedAt updated_by String? @db.Uuid + standards ExpenseStandard[] reimbursement_items ReimbursementItem[] @@index([enabled, sort_order]) @@map("expense_types") } +model ExpenseStandard { + id String @id @default(uuid()) @db.Uuid + expense_type_id String @db.Uuid + name String @db.VarChar(100) + max_amount Decimal @db.Decimal(14, 2) + per_unit String @default("item") @db.VarChar(20) + control_mode String @default("warn") @db.VarChar(16) + scope_type String @default("global") @db.VarChar(32) + scope_key String? @db.Uuid + enabled Boolean @default(true) + note String? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @default(now()) @updatedAt + updated_by String? @db.Uuid + + expense_type ExpenseType @relation(fields: [expense_type_id], references: [id], onDelete: Restrict) + + @@index([enabled, expense_type_id], map: "idx_expense_standards_enabled_type") + @@map("expense_standards") +} + model DocumentSequence { document_type String @id prefix String diff --git a/server/src/app.js b/server/src/app.js index 6ee745a0..3e88ddca 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -34,6 +34,7 @@ const employeesRoutes = require("./routes/employees"); const performanceRoutes = require("./routes/performance"); const payrollRoutes = require("./routes/payroll"); const expenseTypesRoutes = require("./routes/expenseTypes"); +const expenseStandardsRoutes = require("./routes/expenseStandards"); const configReadRoutes = require("./routes/configRead"); const approvalRulesRoutes = require("./routes/approvalRules"); const featureFlagsRoutes = require("./routes/featureFlags"); @@ -322,6 +323,7 @@ async function buildApp() { await app.register(masterDataHolidayCalendarsRoutes, { prefix: "/api" }); await app.register(masterDataSuppliersRoutes, { prefix: "/api" }); await app.register(expenseTypesRoutes, { prefix: "/api" }); + await app.register(expenseStandardsRoutes, { prefix: "/api" }); await app.register(configReadRoutes, { prefix: "/api" }); await app.register(approvalRulesRoutes, { prefix: "/api" }); await app.register(featureFlagsRoutes, { prefix: "/api" }); diff --git a/server/src/routes/expenseStandards.js b/server/src/routes/expenseStandards.js new file mode 100644 index 00000000..d6887c17 --- /dev/null +++ b/server/src/routes/expenseStandards.js @@ -0,0 +1,617 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { buildAuditLogData } = require("../utils/audit"); +const { sendData } = require("../utils/reply"); + +const UUID_SCHEMA = z.string().uuid(); +const MONEY_SCHEMA = z.string().trim().regex(/^\d+(\.\d{1,2})?$/); +const PER_UNIT_VALUES = ["item", "day", "trip"]; +const CONTROL_MODE_VALUES = ["warn", "block"]; +const SCOPE_TYPE_VALUES = ["global", "department"]; + +const LIST_QUERY_SCHEMA = z + .object({ + expenseTypeId: UUID_SCHEMA.optional(), + expense_type_id: UUID_SCHEMA.optional(), + scopeType: z.enum(SCOPE_TYPE_VALUES).optional(), + scope_type: z.enum(SCOPE_TYPE_VALUES).optional(), + enabled: z.enum(["true", "false", "1", "0"]).optional(), + }) + .strict(); + +const CREATE_BODY_SCHEMA = z + .object({ + expenseTypeId: UUID_SCHEMA, + name: z.string().trim().min(1).max(100), + maxAmount: MONEY_SCHEMA, + perUnit: z.enum(PER_UNIT_VALUES).optional(), + controlMode: z.enum(CONTROL_MODE_VALUES).optional(), + scopeType: z.enum(SCOPE_TYPE_VALUES).optional(), + scopeKey: UUID_SCHEMA.nullish(), + enabled: z.boolean().optional(), + note: z.string().trim().max(500).nullable().optional(), + }) + .strict(); + +const UPDATE_BODY_SCHEMA = z + .object({ + name: z.string().trim().min(1).max(100).optional(), + maxAmount: MONEY_SCHEMA.optional(), + perUnit: z.enum(PER_UNIT_VALUES).optional(), + controlMode: z.enum(CONTROL_MODE_VALUES).optional(), + scopeType: z.enum(SCOPE_TYPE_VALUES).optional(), + scopeKey: UUID_SCHEMA.nullish().optional(), + enabled: z.boolean().optional(), + note: z.string().trim().max(500).nullable().optional(), + }) + .strict(); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function toMoneyString(value) { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric)) { + return "0.00"; + } + return numeric.toFixed(2); +} + +function parseExpenseStandardId(params) { + const parsed = UUID_SCHEMA.safeParse(params?.id); + if (!parsed.success) { + throw buildValidationError({ id: "invalid_uuid" }); + } + return parsed.data; +} + +function parseListQuery(query) { + const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + const enabledRaw = parsed.data.enabled; + const enabled = + enabledRaw == null + ? null + : ["true", "1"].includes(enabledRaw) + ? true + : false; + + return { + expenseTypeId: parsed.data.expenseTypeId ?? parsed.data.expense_type_id ?? null, + scopeType: parsed.data.scopeType ?? parsed.data.scope_type ?? null, + enabled, + }; +} + +function parseBody(schema, body) { + const parsed = schema.safeParse(body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + + const data = parsed.data; + if ("maxAmount" in data && data.maxAmount != null && Number(data.maxAmount) <= 0) { + throw buildValidationError({ maxAmount: "must_be_positive" }); + } + + return data; +} + +async function ensureExpenseTypeExists(tx, expenseTypeId) { + const expenseType = await tx.expenseType.findUnique({ + where: { id: expenseTypeId }, + select: { + id: true, + code: true, + name: true, + }, + }); + + if (!expenseType) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { expenseTypeId: "not_found" }, + }); + } + + return expenseType; +} + +async function ensureScopeKeyValid(tx, scopeType, scopeKey) { + if (scopeType === "global") { + return null; + } + + if (!scopeKey) { + throw buildValidationError({ scopeKey: "required_for_department_scope" }); + } + + const department = await tx.department.findUnique({ + where: { id: scopeKey }, + select: { + id: true, + name: true, + archived_at: true, + }, + }); + + if (!department || department.archived_at) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { scopeKey: "invalid_department" }, + }); + } + + return department; +} + +function buildStandardPayload(body, actorId) { + const scopeType = body.scopeType ?? "global"; + return { + ...(typeof body.name === "string" ? { name: body.name } : {}), + ...(typeof body.maxAmount === "string" ? { max_amount: body.maxAmount } : {}), + ...(typeof body.perUnit === "string" ? { per_unit: body.perUnit } : {}), + ...(typeof body.controlMode === "string" ? { control_mode: body.controlMode } : {}), + scope_type: scopeType, + scope_key: scopeType === "department" ? body.scopeKey ?? null : null, + ...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "note") ? { note: body.note?.trim?.() || null } : {}), + updated_by: actorId, + }; +} + +function buildScopeLabel(item, departmentMap) { + if (item.scope_type === "global") { + return "全局"; + } + return departmentMap.get(item.scope_key) ?? item.scope_key ?? "指定部门"; +} + +function mapExpenseStandard(item, departmentMap) { + return { + id: item.id, + expenseTypeId: item.expense_type_id, + expenseTypeName: item.expense_type?.name ?? null, + name: item.name, + maxAmount: toMoneyString(item.max_amount), + perUnit: item.per_unit, + controlMode: item.control_mode, + scopeType: item.scope_type, + scopeKey: item.scope_key ?? null, + scopeLabel: buildScopeLabel(item, departmentMap), + enabled: item.enabled, + note: item.note ?? null, + createdAt: item.created_at?.toISOString?.() ?? null, + updatedAt: item.updated_at?.toISOString?.() ?? null, + }; +} + +function normalizeRecordSnapshot(item) { + if (!item) { + return null; + } + + return { + id: item.id, + expenseTypeId: item.expense_type_id, + name: item.name, + maxAmount: toMoneyString(item.max_amount), + perUnit: item.per_unit, + controlMode: item.control_mode, + scopeType: item.scope_type, + scopeKey: item.scope_key ?? null, + enabled: item.enabled, + note: item.note ?? null, + }; +} + +async function buildDepartmentMap(prisma, items) { + const departmentIds = Array.from( + new Set( + items + .filter((item) => item.scope_type === "department" && item.scope_key) + .map((item) => item.scope_key), + ), + ); + + if (!departmentIds.length) { + return new Map(); + } + + const departments = await prisma.department.findMany({ + where: { + id: { in: departmentIds }, + }, + select: { + id: true, + name: true, + }, + }); + + return new Map(departments.map((item) => [item.id, item.name])); +} + +async function expenseStandardsRoutes(fastify) { + fastify.get( + "/expense-standards", + { + preHandler: fastify.authenticate, + }, + async (request, reply) => { + const query = parseListQuery(request.query); + const canManage = fastify.hasPermission(request.employee, "system.settings"); + + if (!canManage && (!query.expenseTypeId || query.enabled !== true)) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + + const items = await fastify.prisma.expenseStandard.findMany({ + where: { + ...(query.expenseTypeId ? { expense_type_id: query.expenseTypeId } : {}), + ...(query.scopeType ? { scope_type: query.scopeType } : {}), + ...(query.enabled == null ? {} : { enabled: query.enabled }), + }, + orderBy: [ + { expense_type: { sort_order: "asc" } }, + { expense_type: { code: "asc" } }, + { scope_type: "asc" }, + { name: "asc" }, + { created_at: "asc" }, + ], + select: { + id: true, + expense_type_id: true, + name: true, + max_amount: true, + per_unit: true, + control_mode: true, + scope_type: true, + scope_key: true, + enabled: true, + note: true, + created_at: true, + updated_at: true, + expense_type: { + select: { + id: true, + name: true, + code: true, + }, + }, + }, + }); + + const departmentMap = await buildDepartmentMap(fastify.prisma, items); + return sendData(reply, request, { + expenseTypeId: query.expenseTypeId, + scopeType: query.scopeType, + enabled: query.enabled, + items: items.map((item) => mapExpenseStandard(item, departmentMap)), + }); + }, + ); + + fastify.post( + "/expense-standards", + { + preHandler: fastify.requirePermission("system.settings"), + }, + async (request, reply) => { + const body = parseBody(CREATE_BODY_SCHEMA, request.body); + const now = new Date(); + + let created = null; + try { + created = await fastify.prisma.$transaction(async (tx) => { + const expenseType = await ensureExpenseTypeExists(tx, body.expenseTypeId); + const scopeType = body.scopeType ?? "global"; + const department = await ensureScopeKeyValid(tx, scopeType, body.scopeKey ?? null); + + const record = await tx.expenseStandard.create({ + data: { + expense_type_id: body.expenseTypeId, + name: body.name, + max_amount: body.maxAmount, + per_unit: body.perUnit ?? "item", + control_mode: body.controlMode ?? "warn", + scope_type: scopeType, + scope_key: scopeType === "department" ? department?.id ?? body.scopeKey ?? null : null, + enabled: body.enabled ?? true, + note: body.note?.trim?.() || null, + created_at: now, + created_by: request.employee.id, + updated_at: now, + updated_by: request.employee.id, + }, + select: { + id: true, + expense_type_id: true, + name: true, + max_amount: true, + per_unit: true, + control_mode: true, + scope_type: true, + scope_key: true, + enabled: true, + note: true, + created_at: true, + updated_at: true, + expense_type: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "EXPENSE_STANDARD_CREATED", + targetType: "expense_standard", + targetId: record.id, + extra: { + expenseTypeId: expenseType.id, + expenseTypeName: expenseType.name, + scopeType: record.scope_type, + scopeKey: record.scope_key, + controlMode: record.control_mode, + maxAmount: toMoneyString(record.max_amount), + after: normalizeRecordSnapshot(record), + }, + }), + }); + + return record; + }); + } catch (error) { + if (error?.code === "P2002") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { duplicate: "expense_type_scope_conflict" }, + }); + } + throw error; + } + + const departmentMap = await buildDepartmentMap(fastify.prisma, [created]); + return sendData(reply, request, mapExpenseStandard(created, departmentMap)); + }, + ); + + fastify.patch( + "/expense-standards/:id", + { + preHandler: fastify.requirePermission("system.settings"), + }, + async (request, reply) => { + const standardId = parseExpenseStandardId(request.params); + const body = parseBody(UPDATE_BODY_SCHEMA, request.body); + if (!Object.keys(body).length) { + throw buildValidationError({ body: "empty" }); + } + + const now = new Date(); + let updated = null; + try { + updated = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.expenseStandard.findUnique({ + where: { id: standardId }, + select: { + id: true, + expense_type_id: true, + name: true, + max_amount: true, + per_unit: true, + control_mode: true, + scope_type: true, + scope_key: true, + enabled: true, + note: true, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const mergedBody = { + ...body, + scopeType: body.scopeType ?? current.scope_type, + scopeKey: + Object.prototype.hasOwnProperty.call(body, "scopeKey") + ? body.scopeKey ?? null + : current.scope_key ?? null, + }; + + await ensureScopeKeyValid(tx, mergedBody.scopeType, mergedBody.scopeKey); + + const record = await tx.expenseStandard.update({ + where: { id: standardId }, + data: { + ...buildStandardPayload(mergedBody, request.employee.id), + updated_at: now, + }, + select: { + id: true, + expense_type_id: true, + name: true, + max_amount: true, + per_unit: true, + control_mode: true, + scope_type: true, + scope_key: true, + enabled: true, + note: true, + created_at: true, + updated_at: true, + expense_type: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "EXPENSE_STANDARD_UPDATED", + targetType: "expense_standard", + targetId: standardId, + extra: { + expenseTypeId: record.expense_type_id, + scopeType: record.scope_type, + scopeKey: record.scope_key, + controlMode: record.control_mode, + maxAmount: toMoneyString(record.max_amount), + before: normalizeRecordSnapshot(current), + after: normalizeRecordSnapshot(record), + }, + }), + }); + + return record; + }); + } catch (error) { + if (error?.code === "P2002") { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { duplicate: "expense_type_scope_conflict" }, + }); + } + throw error; + } + + const departmentMap = await buildDepartmentMap(fastify.prisma, [updated]); + return sendData(reply, request, mapExpenseStandard(updated, departmentMap)); + }, + ); + + fastify.delete( + "/expense-standards/:id", + { + preHandler: fastify.requirePermission("system.settings"), + }, + async (request, reply) => { + const standardId = parseExpenseStandardId(request.params); + const now = new Date(); + + const deleted = await fastify.prisma.$transaction(async (tx) => { + const current = await tx.expenseStandard.findUnique({ + where: { id: standardId }, + select: { + id: true, + expense_type_id: true, + name: true, + max_amount: true, + per_unit: true, + control_mode: true, + scope_type: true, + scope_key: true, + enabled: true, + note: true, + created_at: true, + updated_at: true, + expense_type: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + if (!current) { + throw new AppError({ + code: "SYS_404", + message: "Not Found", + statusCode: 404, + }); + } + + const record = await tx.expenseStandard.update({ + where: { id: standardId }, + data: { + enabled: false, + updated_at: now, + updated_by: request.employee.id, + }, + select: { + id: true, + expense_type_id: true, + name: true, + max_amount: true, + per_unit: true, + control_mode: true, + scope_type: true, + scope_key: true, + enabled: true, + note: true, + created_at: true, + updated_at: true, + expense_type: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "EXPENSE_STANDARD_DELETED", + targetType: "expense_standard", + targetId: standardId, + extra: { + expenseTypeId: record.expense_type_id, + scopeType: record.scope_type, + scopeKey: record.scope_key, + controlMode: record.control_mode, + maxAmount: toMoneyString(record.max_amount), + before: normalizeRecordSnapshot(current), + after: normalizeRecordSnapshot(record), + }, + }), + }); + + return record; + }); + + const departmentMap = await buildDepartmentMap(fastify.prisma, [deleted]); + return sendData(reply, request, mapExpenseStandard(deleted, departmentMap)); + }, + ); +} + +module.exports = expenseStandardsRoutes; diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index c93b3a04..c08afdad 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -663,16 +663,37 @@ function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) { } function buildPrecheckBlockedError(precheck) { + const standardViolations = Array.isArray(precheck?.standardChecks) + ? precheck.standardChecks.filter((item) => item.exceeded && item.controlMode === "block") + : []; + return new AppError({ code: "VALID_001", message: "参数校验失败", - statusCode: 409, + statusCode: standardViolations.length ? 400 : 409, details: { precheck: precheck.blockingReasons, + ...(standardViolations.length ? { standardViolations } : {}), }, }); } +function listExceededStandardViolations(precheck, controlMode = null) { + if (!Array.isArray(precheck?.standardChecks)) { + return []; + } + + return precheck.standardChecks.filter((item) => { + if (!item.exceeded) { + return false; + } + if (!controlMode) { + return true; + } + return item.controlMode === controlMode; + }); +} + function buildSkippedNotificationResult(reason) { return { skipped: true, reason }; } @@ -2079,6 +2100,8 @@ async function reimbursementRoutes(fastify) { if (authoritativePrecheck.decision === "block") { throw buildPrecheckBlockedError(authoritativePrecheck); } + const standardViolations = listExceededStandardViolations(authoritativePrecheck); + const standardWarnings = listExceededStandardViolations(authoritativePrecheck, "warn"); const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, { entityType: "reimbursement", entityId: reimbursementId, @@ -2238,6 +2261,7 @@ async function reimbursementRoutes(fastify) { department_id: reimbursement.department_id, workflow_steps_snapshot: workflowSteps, approval_rule_id: approvalRule.id, + standardViolations, }, context_department_id: reimbursement.department_id, created_by: request.employee.id, @@ -2319,6 +2343,7 @@ async function reimbursementRoutes(fastify) { status: "reviewing", budget: budgetCheck, precheckDecision: authoritativePrecheck.decision, + standardWarnings, openedRiskFlagCount: openedRiskFlags.length, }, sideEffectsContext: { diff --git a/server/src/routes/workflow.js b/server/src/routes/workflow.js index 266092b3..e6ea208f 100644 --- a/server/src/routes/workflow.js +++ b/server/src/routes/workflow.js @@ -1450,6 +1450,7 @@ async function workflowRoutes(fastify) { entity_id: true, current_status: true, current_step_id: true, + context_data: true, started_at: true, completed_at: true, reimbursement_form: { @@ -1676,6 +1677,11 @@ async function workflowRoutes(fastify) { entityType: task.workflow_instance.entity_type, currentStatus: task.workflow_instance.current_status, currentStepId: task.workflow_instance.current_step_id, + contextData: { + standardViolations: Array.isArray(task.workflow_instance.context_data?.standardViolations) + ? task.workflow_instance.context_data.standardViolations + : [], + }, startedAt: isoOrNull(task.workflow_instance.started_at), completedAt: isoOrNull(task.workflow_instance.completed_at), } diff --git a/server/src/services/feeControl.js b/server/src/services/feeControl.js index 50624ae6..07114a07 100644 --- a/server/src/services/feeControl.js +++ b/server/src/services/feeControl.js @@ -56,6 +56,7 @@ function buildBasePrecheck(entityType, entityId) { decision: "pass", matchedBudgetControlRule: null, budgetHint: null, + standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [], @@ -70,6 +71,7 @@ function buildDisabledPrecheck(entityType, entityId) { decision: "pass", matchedBudgetControlRule: null, budgetHint: null, + standardChecks: [], warnings: [], blockingReasons: [], riskFlags: [], @@ -272,6 +274,165 @@ function appendBlockingReasons(response, issues) { return response; } +function buildStandardIssueDetail(check) { + return `标准:${check.standardName},上限 ¥${check.maxAmount}/${check.perUnit},当前 ¥${check.amount},超出 ¥${check.exceededAmount}`; +} + +function buildStandardIssue(check) { + return buildIssue( + `expense_standard_${check.controlMode}_${check.itemId}`, + `${check.expenseTypeName} 超出费用标准`, + buildStandardIssueDetail(check), + ); +} + +async function checkExpenseStandards(tx, { reimbursementId }) { + const reimbursement = await tx.reimbursementForm.findUnique({ + where: { id: reimbursementId }, + select: { + id: true, + department_id: true, + items: { + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + expense_type_id: true, + amount: true, + expense_type: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }); + + if (!reimbursement) { + throw buildNotFoundError(); + } + + const expenseTypeIds = Array.from( + new Set(reimbursement.items.map((item) => item.expense_type_id).filter(Boolean)), + ); + + if (!expenseTypeIds.length) { + return []; + } + + const standards = await tx.expenseStandard.findMany({ + where: { + enabled: true, + expense_type_id: { in: expenseTypeIds }, + OR: [ + { scope_type: "global" }, + ...(reimbursement.department_id + ? [{ scope_type: "department", scope_key: reimbursement.department_id }] + : []), + ], + }, + orderBy: [{ created_at: "asc" }, { id: "asc" }], + select: { + id: true, + expense_type_id: true, + name: true, + max_amount: true, + per_unit: true, + control_mode: true, + scope_type: true, + scope_key: true, + }, + }); + + const standardMap = new Map(); + for (const standard of standards) { + const current = standardMap.get(standard.expense_type_id); + if (!current) { + standardMap.set(standard.expense_type_id, standard); + continue; + } + + const currentPriority = current.scope_type === "department" ? 2 : 1; + const nextPriority = standard.scope_type === "department" ? 2 : 1; + if (nextPriority > currentPriority) { + standardMap.set(standard.expense_type_id, standard); + } + } + + return reimbursement.items + .map((item) => { + const standard = standardMap.get(item.expense_type_id); + if (!standard) { + return null; + } + + const amount = toMoneyNumber(item.amount); + const maxAmount = toMoneyNumber(standard.max_amount); + const exceededAmount = Math.max(0, toMoneyNumber(amount - maxAmount)); + + return { + itemId: item.id, + expenseTypeId: item.expense_type_id, + expenseTypeName: item.expense_type?.name ?? item.expense_type_id, + amount: toMoneyString(amount), + standardId: standard.id, + standardName: standard.name, + maxAmount: toMoneyString(maxAmount), + perUnit: standard.per_unit, + controlMode: standard.control_mode, + scopeType: standard.scope_type, + scopeKey: standard.scope_key ?? null, + exceeded: amount > maxAmount, + exceededAmount: toMoneyString(exceededAmount), + }; + }) + .filter(Boolean); +} + +function mergeReimbursementPrecheck(precheck, standardChecks) { + const response = { + ...precheck, + warnings: [...precheck.warnings], + blockingReasons: [...precheck.blockingReasons], + riskFlags: [...precheck.riskFlags], + nextActions: [...precheck.nextActions], + standardChecks, + }; + + const warnChecks = standardChecks.filter((item) => item.exceeded && item.controlMode === "warn"); + const blockChecks = standardChecks.filter((item) => item.exceeded && item.controlMode === "block"); + + if (warnChecks.length) { + response.warnings.push(...warnChecks.map((item) => buildStandardIssue(item))); + } + if (blockChecks.length) { + response.blockingReasons.push(...blockChecks.map((item) => buildStandardIssue(item))); + } + + if (response.blockingReasons.length) { + response.decision = "block"; + response.nextActions = ["修正阻断项后再提交"]; + return response; + } + + if (response.riskFlags.length) { + response.decision = "manual_review"; + response.nextActions = ["允许提交,但会生成待复核风险项"]; + return response; + } + + if (response.warnings.length) { + response.decision = "warn"; + response.nextActions = ["确认提示后可继续提交"]; + return response; + } + + response.decision = "pass"; + response.nextActions = ["可直接提交"]; + return response; +} + async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId, precheck, source = "precheck" }) { if (precheck.decision !== "manual_review" || !precheck.riskFlags.length) { return []; @@ -308,6 +469,7 @@ async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId, openedByDecision: precheck.decision, matchedBudgetControlRule: precheck.matchedBudgetControlRule, budgetHint: precheck.budgetHint, + standardChecks: precheck.standardChecks ?? [], }, created_by: actorId ?? null, }, @@ -843,15 +1005,27 @@ async function resolveEntityPrecheck({ }); if (!gate.enabled) { - return buildDisabledPrecheck(entityType, entityId); + const disabledPrecheck = buildDisabledPrecheck(entityType, entityId); + if (entityType !== "reimbursement") { + return disabledPrecheck; + } + const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId }); + return mergeReimbursementPrecheck(disabledPrecheck, standardChecks); } - return buildEntityPrecheck(prisma, { + const precheck = await buildEntityPrecheck(prisma, { entityType, entityId, config, logger, }); + + if (entityType !== "reimbursement") { + return precheck; + } + + const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId }); + return mergeReimbursementPrecheck(precheck, standardChecks); } module.exports = { @@ -870,6 +1044,8 @@ module.exports = { buildReimbursementPrecheck, buildPurchaseRequestPrecheck, buildTravelRequestPrecheck, + checkExpenseStandards, loadPrecheckEntityContext, + mergeReimbursementPrecheck, resolveEntityPrecheck, }; diff --git a/server/tests/unit/routes/expenseStandards.test.js b/server/tests/unit/routes/expenseStandards.test.js new file mode 100644 index 00000000..02572427 --- /dev/null +++ b/server/tests/unit/routes/expenseStandards.test.js @@ -0,0 +1,457 @@ +const Fastify = require("fastify"); + +const expenseStandardsRoutes = require("../../../src/routes/expenseStandards"); +const { AppError } = require("../../../src/utils/AppError"); + +const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const EXPENSE_TYPE_ID = "11111111-1111-4111-8111-111111111111"; +const DEPARTMENT_ID = "22222222-2222-4222-8222-222222222222"; +const STANDARD_ID = "33333333-3333-4333-8333-333333333333"; + +function cloneStandard(item) { + return { + ...item, + created_at: new Date(item.created_at), + updated_at: new Date(item.updated_at), + expense_type: item.expense_type ? { ...item.expense_type } : null, + }; +} + +function buildStandard(overrides = {}) { + return { + id: STANDARD_ID, + expense_type_id: EXPENSE_TYPE_ID, + name: "日常交通费上限", + max_amount: "120.00", + per_unit: "item", + control_mode: "warn", + scope_type: "global", + scope_key: null, + enabled: true, + note: "默认提示", + created_at: new Date("2026-04-08T09:00:00.000Z"), + updated_at: new Date("2026-04-08T09:00:00.000Z"), + expense_type: { + id: EXPENSE_TYPE_ID, + code: "traffic", + name: "交通费", + }, + ...overrides, + }; +} + +function findDuplicate(items, candidate, currentId = null) { + return items.find((item) => { + if (currentId && item.id === currentId) { + return false; + } + return ( + item.expense_type_id === candidate.expense_type_id && + item.scope_type === candidate.scope_type && + (item.scope_key ?? null) === (candidate.scope_key ?? null) + ); + }); +} + +function makeSelectStandard(state, item) { + const expenseType = state.expenseTypes.find((record) => record.id === item.expense_type_id) ?? null; + return { + ...item, + expense_type: expenseType + ? { + id: expenseType.id, + code: expenseType.code, + name: expenseType.name, + } + : null, + }; +} + +async function buildApp(options = {}) { + const app = Fastify(); + const actor = options.actor ?? { + id: ACTOR_ID, + permissionCodes: ["system.settings"], + department_id: DEPARTMENT_ID, + }; + + const state = { + expenseTypes: options.expenseTypes ?? [ + { + id: EXPENSE_TYPE_ID, + code: "traffic", + name: "交通费", + }, + ], + departments: options.departments ?? [ + { + id: DEPARTMENT_ID, + name: "财务部", + archived_at: null, + }, + ], + standards: (options.standards ?? [buildStandard()]).map((item) => cloneStandard(item)), + auditLogs: [], + }; + + function buildPrismaLike(stateRef) { + return { + expenseStandard: { + findMany: vi.fn(async ({ where } = {}) => { + return stateRef.standards + .filter((item) => { + if (where?.expense_type_id && item.expense_type_id !== where.expense_type_id) { + return false; + } + if (where?.scope_type && item.scope_type !== where.scope_type) { + return false; + } + if (typeof where?.enabled === "boolean" && item.enabled !== where.enabled) { + return false; + } + return true; + }) + .map((item) => makeSelectStandard(stateRef, cloneStandard(item))); + }), + findUnique: vi.fn(async ({ where }) => { + const found = stateRef.standards.find((item) => item.id === where.id) ?? null; + return found ? makeSelectStandard(stateRef, cloneStandard(found)) : null; + }), + create: vi.fn(async ({ data }) => { + if (findDuplicate(stateRef.standards, data)) { + const error = new Error("duplicate"); + error.code = "P2002"; + throw error; + } + + const record = buildStandard({ + id: `standard-${stateRef.standards.length + 1}`, + expense_type_id: data.expense_type_id, + name: data.name, + max_amount: data.max_amount, + per_unit: data.per_unit, + control_mode: data.control_mode, + scope_type: data.scope_type, + scope_key: data.scope_key ?? null, + enabled: data.enabled, + note: data.note ?? null, + created_at: data.created_at, + updated_at: data.updated_at, + }); + stateRef.standards.push(record); + return makeSelectStandard(stateRef, cloneStandard(record)); + }), + update: vi.fn(async ({ where, data }) => { + const index = stateRef.standards.findIndex((item) => item.id === where.id); + if (index < 0) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } + const next = { + ...stateRef.standards[index], + ...data, + }; + if (findDuplicate(stateRef.standards, next, where.id)) { + const error = new Error("duplicate"); + error.code = "P2002"; + throw error; + } + stateRef.standards[index] = next; + return makeSelectStandard(stateRef, cloneStandard(next)); + }), + }, + expenseType: { + findUnique: vi.fn(async ({ where }) => stateRef.expenseTypes.find((item) => item.id === where.id) ?? null), + }, + department: { + findUnique: vi.fn(async ({ where }) => stateRef.departments.find((item) => item.id === where.id) ?? null), + findMany: vi.fn(async ({ where }) => { + const ids = where?.id?.in ?? []; + return stateRef.departments.filter((item) => ids.includes(item.id)); + }), + }, + auditLog: { + create: vi.fn(async ({ data }) => { + stateRef.auditLogs.push(data); + return { id: `audit-${stateRef.auditLogs.length}` }; + }), + }, + }; + } + + const prisma = buildPrismaLike(state); + prisma.$transaction = vi.fn(async (callback) => { + const txState = { + expenseTypes: state.expenseTypes.map((item) => ({ ...item })), + departments: state.departments.map((item) => ({ ...item })), + standards: state.standards.map((item) => cloneStandard(item)), + auditLogs: state.auditLogs.map((item) => ({ ...item })), + }; + const tx = buildPrismaLike(txState); + const result = await callback(tx); + state.expenseTypes = txState.expenseTypes; + state.departments = txState.departments; + state.standards = txState.standards; + state.auditLogs = txState.auditLogs; + return result; + }); + + app.decorate("authenticate", async (request) => { + request.employee = actor; + }); + app.decorate("requirePermission", (permissionCode) => async (request) => { + request.employee = actor; + const permissionCodes = actor.permissionCodes ?? []; + if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) { + throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); + } + }); + app.decorate("hasPermission", (employee, permissionCode) => { + const permissionCodes = employee.permissionCodes ?? []; + return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); + }); + app.decorate("prisma", prisma); + app.decorate("config", {}); + app.setErrorHandler((error, request, reply) => { + reply.status(error.statusCode || 500).send({ + requestId: request.id, + error: { + code: error.code || "SYS_001", + message: error.message, + details: error.details ?? null, + }, + }); + }); + + await app.register(expenseStandardsRoutes, { prefix: "/api" }); + return { app, state, prisma }; +} + +describe("expense standards routes", () => { + it("lists standards with filters and scope label", async () => { + const { app } = await buildApp({ + standards: [ + buildStandard(), + buildStandard({ + id: "44444444-4444-4444-8444-444444444444", + scope_type: "department", + scope_key: DEPARTMENT_ID, + control_mode: "block", + }), + ], + }); + + const response = await app.inject({ + method: "GET", + url: `/api/expense-standards?expenseTypeId=${EXPENSE_TYPE_ID}&enabled=true`, + }); + + expect(response.statusCode).toBe(200); + const payload = response.json(); + expect(payload.data.items).toHaveLength(2); + expect(payload.data.items[0]).toMatchObject({ + expenseTypeId: EXPENSE_TYPE_ID, + expenseTypeName: "交通费", + scopeLabel: expect.any(String), + }); + + await app.close(); + }); + + it("allows authenticated non-admin to read scoped enabled standards for item editor", async () => { + const { app } = await buildApp({ + actor: { + id: ACTOR_ID, + permissionCodes: ["expense.submit"], + department_id: DEPARTMENT_ID, + }, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/expense-standards?expenseTypeId=${EXPENSE_TYPE_ID}&enabled=true`, + }); + + expect(response.statusCode).toBe(200); + await app.close(); + }); + + it("rejects broad list read for non-admin users", async () => { + const { app } = await buildApp({ + actor: { + id: ACTOR_ID, + permissionCodes: ["expense.submit"], + department_id: DEPARTMENT_ID, + }, + }); + + const response = await app.inject({ + method: "GET", + url: "/api/expense-standards", + }); + + expect(response.statusCode).toBe(403); + await app.close(); + }); + + it("creates a department scoped standard and writes audit log", async () => { + const { app, state } = await buildApp({ standards: [] }); + + const response = await app.inject({ + method: "POST", + url: "/api/expense-standards", + payload: { + expenseTypeId: EXPENSE_TYPE_ID, + name: "出差餐补上限", + maxAmount: "180.00", + perUnit: "day", + controlMode: "block", + scopeType: "department", + scopeKey: DEPARTMENT_ID, + note: "财务要求", + }, + }); + + expect(response.statusCode).toBe(200); + expect(state.standards).toHaveLength(1); + expect(state.standards[0]).toMatchObject({ + scope_type: "department", + scope_key: DEPARTMENT_ID, + control_mode: "block", + per_unit: "day", + }); + expect(state.auditLogs).toHaveLength(1); + expect(state.auditLogs[0]).toMatchObject({ + action: "EXPENSE_STANDARD_CREATED", + target_type: "expense_standard", + }); + + await app.close(); + }); + + it("rejects create when expense type does not exist", async () => { + const { app } = await buildApp({ standards: [] }); + + const response = await app.inject({ + method: "POST", + url: "/api/expense-standards", + payload: { + expenseTypeId: "55555555-5555-4555-8555-555555555555", + name: "无效标准", + maxAmount: "50.00", + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ expenseTypeId: "not_found" }); + + await app.close(); + }); + + it("rejects department scope without scopeKey", async () => { + const { app } = await buildApp({ standards: [] }); + + const response = await app.inject({ + method: "POST", + url: "/api/expense-standards", + payload: { + expenseTypeId: EXPENSE_TYPE_ID, + name: "部门标准", + maxAmount: "88.00", + scopeType: "department", + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error.details).toEqual({ scopeKey: "required_for_department_scope" }); + + await app.close(); + }); + + it("rejects duplicate type and scope via unique conflict", async () => { + const { app } = await buildApp(); + + const response = await app.inject({ + method: "POST", + url: "/api/expense-standards", + payload: { + expenseTypeId: EXPENSE_TYPE_ID, + name: "重复 global", + maxAmount: "88.00", + }, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual({ duplicate: "expense_type_scope_conflict" }); + + await app.close(); + }); + + it("updates a standard and writes before/after audit snapshot", async () => { + const { app, state } = await buildApp(); + + const response = await app.inject({ + method: "PATCH", + url: `/api/expense-standards/${STANDARD_ID}`, + payload: { + maxAmount: "150.00", + controlMode: "block", + note: "已升级为阻断", + }, + }); + + expect(response.statusCode).toBe(200); + expect(state.standards[0]).toMatchObject({ + max_amount: "150.00", + control_mode: "block", + note: "已升级为阻断", + }); + expect(state.auditLogs.at(-1)).toMatchObject({ + action: "EXPENSE_STANDARD_UPDATED", + extra: expect.objectContaining({ + before: expect.any(Object), + after: expect.any(Object), + }), + }); + + await app.close(); + }); + + it("soft deletes by setting enabled=false and writes delete audit", async () => { + const { app, state } = await buildApp(); + + const response = await app.inject({ + method: "DELETE", + url: `/api/expense-standards/${STANDARD_ID}`, + }); + + expect(response.statusCode).toBe(200); + expect(state.standards[0].enabled).toBe(false); + expect(state.auditLogs.at(-1)).toMatchObject({ + action: "EXPENSE_STANDARD_DELETED", + }); + + await app.close(); + }); + + it("blocks write operations without system.settings permission", async () => { + const { app } = await buildApp({ + actor: { + id: ACTOR_ID, + permissionCodes: ["expense.submit"], + }, + }); + + const response = await app.inject({ + method: "POST", + url: "/api/expense-standards", + payload: { + expenseTypeId: EXPENSE_TYPE_ID, + name: "受限写操作", + maxAmount: "30.00", + }, + }); + + expect(response.statusCode).toBe(403); + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/reimbursements.expense-standards.test.js b/server/tests/unit/routes/reimbursements.expense-standards.test.js new file mode 100644 index 00000000..03322132 --- /dev/null +++ b/server/tests/unit/routes/reimbursements.expense-standards.test.js @@ -0,0 +1,363 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const budgetPool = require("../../../src/services/budgetPool"); +const feeControl = require("../../../src/services/feeControl"); +const notifications = require("../../../src/services/notifications"); + +const resolveApplicableBudgetPoolByContextMock = vi.fn(); +const resolveEntityPrecheckMock = vi.fn(); +const createRiskFlagsFromPrecheckMock = vi.fn(); +const resolveRecipientsForAssigneeMock = vi.fn(); +const createNotificationsWithRecordsMock = vi.fn(async () => []); + +const originalResolveApplicableBudgetPoolByContext = budgetPool.resolveApplicableBudgetPoolByContext; +const originalResolveEntityPrecheck = feeControl.resolveEntityPrecheck; +const originalCreateRiskFlagsFromPrecheck = feeControl.createRiskFlagsFromPrecheck; +const originalResolveRecipientsForAssignee = notifications.resolveRecipientsForAssignee; +const originalCreateNotificationsWithRecords = notifications.createNotificationsWithRecords; + +budgetPool.resolveApplicableBudgetPoolByContext = resolveApplicableBudgetPoolByContextMock; +feeControl.resolveEntityPrecheck = resolveEntityPrecheckMock; +feeControl.createRiskFlagsFromPrecheck = createRiskFlagsFromPrecheckMock; +notifications.resolveRecipientsForAssignee = resolveRecipientsForAssigneeMock; +notifications.createNotificationsWithRecords = createNotificationsWithRecordsMock; + +delete require.cache[require.resolve("../../../src/routes/reimbursements")]; +const reimbursementRoutes = require("../../../src/routes/reimbursements"); + +const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; +const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222"; +const DEPARTMENT_ID = "33333333-3333-4333-8333-333333333333"; + +function buildPrecheck(overrides = {}) { + return { + entityType: "reimbursement", + entityId: REIMBURSEMENT_ID, + decision: "pass", + matchedBudgetControlRule: null, + budgetHint: null, + standardChecks: [], + warnings: [], + blockingReasons: [], + riskFlags: [], + nextActions: ["可直接提交"], + ...overrides, + }; +} + +async function buildApp(options = {}) { + const app = Fastify(); + const actor = options.actor ?? { + id: EMPLOYEE_ID, + department_id: DEPARTMENT_ID, + roleCodes: [], + permissionCodes: ["expense.submit"], + }; + const reimbursement = options.reimbursement ?? { + id: REIMBURSEMENT_ID, + owner_id: EMPLOYEE_ID, + submitter_id: EMPLOYEE_ID, + department_id: DEPARTMENT_ID, + reimbursement_date: new Date("2026-04-08T00:00:00.000Z"), + status: "draft", + form_number: "BX-20260408-001", + total_amount: "180.00", + }; + const state = { + workflowInstanceCreatePayload: null, + }; + + const tx = { + $queryRaw: vi.fn(async () => [reimbursement]), + workflowInstance: { + findFirst: vi.fn(async () => null), + create: vi.fn(async ({ data, select }) => { + state.workflowInstanceCreatePayload = data; + return { + id: "44444444-4444-4444-8444-444444444444", + current_step_id: data.current_step_id, + ...(select?.current_step_id ? {} : {}), + }; + }), + }, + invoicePool: { + count: vi.fn(async () => 1), + }, + budgetLedgerEntry: { + create: vi.fn(async () => ({ id: "budget-ledger-1" })), + }, + budgetPool: { + update: vi.fn(async () => ({ id: "budget-pool-1" })), + }, + approvalRule: { + findFirst: vi.fn(async () => ({ + id: "55555555-5555-4555-8555-555555555555", + level1_role: "finance", + level2_role: null, + level3_role: null, + priority: 1, + })), + }, + workflowDefinition: { + findFirst: vi.fn(async () => ({ + id: "66666666-6666-4666-8666-666666666666", + })), + }, + reimbursementForm: { + update: vi.fn(async () => ({ + id: reimbursement.id, + })), + }, + workflowTask: { + create: vi.fn(async () => ({ + id: "77777777-7777-4777-8777-777777777777", + assignee_id: null, + assignee_role: "finance", + })), + }, + workflowHistory: { + create: vi.fn(async () => ({ id: "history-1" })), + }, + auditLog: { + create: vi.fn(async () => ({ id: "audit-1" })), + }, + }; + + const prisma = { + $transaction: vi.fn(async (callback) => callback(tx)), + reimbursementForm: { + findUnique: vi.fn(async () => ({ + id: reimbursement.id, + form_number: reimbursement.form_number, + total_amount: reimbursement.total_amount, + owner: { + id: EMPLOYEE_ID, + name: "申请人", + }, + })), + }, + }; + + app.decorate("prisma", prisma); + app.decorate("config", { + appTimezone: "Asia/Shanghai", + budgetControlMode: "warn", + imNotifierEnabled: false, + emailEnabled: false, + }); + app.decorate("hasPermission", (employee, permissionCode) => { + const permissionCodes = employee?.permissionCodes ?? []; + return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); + }); + app.decorate("requirePermission", (permissionCode) => async (request) => { + request.employee = actor; + const permissionCodes = actor.permissionCodes ?? []; + if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) { + throw new AppError({ + code: "PERM_001", + message: "无权限", + statusCode: 403, + }); + } + }); + app.setErrorHandler((error, request, reply) => { + reply.status(error.statusCode || 500).send({ + requestId: request.id, + error: { + code: error.code || "SYS_001", + message: error.message, + details: error.details ?? null, + }, + }); + }); + + await app.register(reimbursementRoutes, { prefix: "/api" }); + return { app, prisma, tx, state }; +} + +describe("reimbursements routes expense standard integration", () => { + afterAll(() => { + budgetPool.resolveApplicableBudgetPoolByContext = originalResolveApplicableBudgetPoolByContext; + feeControl.resolveEntityPrecheck = originalResolveEntityPrecheck; + feeControl.createRiskFlagsFromPrecheck = originalCreateRiskFlagsFromPrecheck; + notifications.resolveRecipientsForAssignee = originalResolveRecipientsForAssignee; + notifications.createNotificationsWithRecords = originalCreateNotificationsWithRecords; + }); + + beforeEach(() => { + vi.clearAllMocks(); + resolveApplicableBudgetPoolByContextMock.mockResolvedValue(null); + createRiskFlagsFromPrecheckMock.mockResolvedValue([]); + resolveRecipientsForAssigneeMock.mockResolvedValue([]); + }); + + it("returns standardChecks from reimbursement precheck", async () => { + resolveEntityPrecheckMock.mockResolvedValue( + buildPrecheck({ + decision: "warn", + standardChecks: [ + { + itemId: "item-1", + expenseTypeId: "88888888-8888-4888-8888-888888888888", + expenseTypeName: "交通费", + amount: "180.00", + standardId: "99999999-9999-4999-8999-999999999999", + standardName: "交通费上限", + maxAmount: "120.00", + perUnit: "item", + controlMode: "warn", + scopeType: "global", + scopeKey: null, + exceeded: true, + exceededAmount: "60.00", + }, + ], + }), + ); + + const { app } = await buildApp(); + + const response = await app.inject({ + method: "POST", + url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.standardChecks).toEqual([ + expect.objectContaining({ + itemId: "item-1", + controlMode: "warn", + exceededAmount: "60.00", + }), + ]); + expect(resolveEntityPrecheckMock).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "reimbursement", + entityId: REIMBURSEMENT_ID, + actorDepartmentId: DEPARTMENT_ID, + }), + ); + + await app.close(); + }); + + it("blocks submit with 400 and standardViolations when block standard is exceeded", async () => { + resolveEntityPrecheckMock.mockResolvedValue( + buildPrecheck({ + decision: "block", + blockingReasons: [ + { + code: "expense_standard_blocked", + title: "费用标准超限", + detail: "交通费超出上限", + }, + ], + standardChecks: [ + { + itemId: "item-block", + expenseTypeId: "88888888-8888-4888-8888-888888888888", + expenseTypeName: "交通费", + amount: "260.00", + standardId: "99999999-9999-4999-8999-999999999999", + standardName: "交通费阻断上限", + maxAmount: "120.00", + perUnit: "item", + controlMode: "block", + scopeType: "global", + scopeKey: null, + exceeded: true, + exceededAmount: "140.00", + }, + ], + }), + ); + + const { app, tx } = await buildApp(); + + const response = await app.inject({ + method: "POST", + url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toEqual( + expect.objectContaining({ + code: "VALID_001", + details: expect.objectContaining({ + standardViolations: [ + expect.objectContaining({ + itemId: "item-block", + controlMode: "block", + }), + ], + }), + }), + ); + expect(tx.workflowInstance.create).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("allows warn submit and persists standardViolations into workflow context", async () => { + resolveEntityPrecheckMock.mockResolvedValue( + buildPrecheck({ + decision: "warn", + warnings: [ + { + code: "expense_standard_warning", + title: "费用标准预警", + detail: "交通费超出上限", + }, + ], + standardChecks: [ + { + itemId: "item-warn", + expenseTypeId: "88888888-8888-4888-8888-888888888888", + expenseTypeName: "交通费", + amount: "180.00", + standardId: "99999999-9999-4999-8999-999999999999", + standardName: "交通费预警上限", + maxAmount: "120.00", + perUnit: "item", + controlMode: "warn", + scopeType: "department", + scopeKey: DEPARTMENT_ID, + exceeded: true, + exceededAmount: "60.00", + }, + ], + }), + ); + + const { app, state } = await buildApp(); + + const response = await app.inject({ + method: "POST", + url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + reimbursementId: REIMBURSEMENT_ID, + status: "reviewing", + precheckDecision: "warn", + standardWarnings: [ + expect.objectContaining({ + itemId: "item-warn", + controlMode: "warn", + }), + ], + }), + ); + expect(state.workflowInstanceCreatePayload?.context_data?.standardViolations).toEqual([ + expect.objectContaining({ + itemId: "item-warn", + exceededAmount: "60.00", + }), + ]); + + await app.close(); + }); +}); diff --git a/server/tests/unit/routes/workflow.task-detail.test.js b/server/tests/unit/routes/workflow.task-detail.test.js new file mode 100644 index 00000000..64589007 --- /dev/null +++ b/server/tests/unit/routes/workflow.task-detail.test.js @@ -0,0 +1,134 @@ +const Fastify = require("fastify"); + +const workflowRoutes = require("../../../src/routes/workflow"); + +const TASK_ID = "11111111-1111-4111-8111-111111111111"; +const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222"; + +async function buildApp(options = {}) { + const app = Fastify(); + const actor = options.actor ?? { + id: EMPLOYEE_ID, + roleCodes: [], + permissionCodes: [], + }; + + const prisma = { + delegationGrant: { + findMany: vi.fn(async () => []), + }, + workflowTask: { + findFirst: vi.fn(async () => ({ + id: TASK_ID, + instance_id: "33333333-3333-4333-8333-333333333333", + step_id: "approval_1_finance", + step_name: "财务审批", + assignee_id: EMPLOYEE_ID, + assignee_role: null, + status: "completed", + outcome: "approved", + comment: "已核验", + created_at: new Date("2026-04-08T08:00:00.000Z"), + completed_at: new Date("2026-04-08T09:00:00.000Z"), + workflow_instance: { + id: "33333333-3333-4333-8333-333333333333", + entity_type: "reimbursement", + entity_id: "44444444-4444-4444-8444-444444444444", + current_status: "pending", + current_step_id: "approval_1_finance", + context_data: { + standardViolations: [ + { + itemId: "item-1", + expenseTypeName: "交通费", + amount: "180.00", + standardName: "交通费上限", + maxAmount: "120.00", + exceededAmount: "60.00", + controlMode: "warn", + }, + ], + }, + started_at: new Date("2026-04-08T08:00:00.000Z"), + completed_at: null, + reimbursement_form: { + id: "44444444-4444-4444-8444-444444444444", + form_number: "BX-20260408-001", + status: "reviewing", + total_amount: "180.00", + reimbursement_date: new Date("2026-04-08T00:00:00.000Z"), + description: "测试报销", + owner: { + id: EMPLOYEE_ID, + employee_no: "E001", + name: "申请人", + }, + submitter: { + id: EMPLOYEE_ID, + employee_no: "E001", + name: "申请人", + }, + items: [], + }, + leave_request: null, + purchase_request: null, + travel_request: null, + }, + })), + findUnique: vi.fn(async () => ({ + id: TASK_ID, + })), + }, + workflowHistory: { + findMany: vi.fn(async () => []), + }, + }; + + app.decorate("prisma", prisma); + app.decorate("config", { + imNotifierEnabled: false, + emailEnabled: false, + }); + app.decorate("authenticate", async (request) => { + request.employee = actor; + }); + app.decorate("requirePermission", () => async (request) => { + request.employee = actor; + }); + app.decorate("assertEntityPermission", () => {}); + app.setErrorHandler((error, request, reply) => { + reply.status(error.statusCode || 500).send({ + requestId: request.id, + error: { + code: error.code || "SYS_001", + message: error.message, + details: error.details ?? null, + }, + }); + }); + + await app.register(workflowRoutes, { prefix: "/api" }); + return { app, prisma }; +} + +describe("workflow task detail route", () => { + it("returns reimbursement workflow contextData.standardViolations", async () => { + const { app } = await buildApp(); + + const response = await app.inject({ + method: "GET", + url: `/api/workflow/tasks/${TASK_ID}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.workflowInstance.contextData.standardViolations).toEqual([ + expect.objectContaining({ + itemId: "item-1", + controlMode: "warn", + exceededAmount: "60.00", + }), + ]); + + await app.close(); + }); +}); diff --git a/server/tests/unit/services/expenseStandards.check.test.js b/server/tests/unit/services/expenseStandards.check.test.js new file mode 100644 index 00000000..96c21a8d --- /dev/null +++ b/server/tests/unit/services/expenseStandards.check.test.js @@ -0,0 +1,306 @@ +const { + checkExpenseStandards, + mergeReimbursementPrecheck, +} = require("../../../src/services/feeControl"); + +const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; +const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222"; +const OTHER_EXPENSE_TYPE_ID = "33333333-3333-4333-8333-333333333333"; +const DEPARTMENT_ID = "44444444-4444-4444-8444-444444444444"; + +function buildTx(options = {}) { + return { + reimbursementForm: { + findUnique: vi.fn().mockResolvedValue( + options.reimbursement ?? { + id: REIMBURSEMENT_ID, + department_id: DEPARTMENT_ID, + items: [ + { + id: "item-1", + expense_type_id: EXPENSE_TYPE_ID, + amount: "150.00", + expense_type: { + id: EXPENSE_TYPE_ID, + name: "交通费", + }, + }, + ], + }, + ), + }, + expenseStandard: { + findMany: vi.fn().mockResolvedValue( + options.standards ?? [ + { + id: "standard-global", + expense_type_id: EXPENSE_TYPE_ID, + name: "交通费全局上限", + max_amount: "120.00", + per_unit: "item", + control_mode: "warn", + scope_type: "global", + scope_key: null, + created_at: new Date("2026-04-08T09:00:00.000Z"), + }, + ], + ), + }, + }; +} + +describe("checkExpenseStandards", () => { + it("prefers department scoped standard over global standard", async () => { + const tx = buildTx({ + standards: [ + { + id: "standard-global", + expense_type_id: EXPENSE_TYPE_ID, + name: "交通费全局上限", + max_amount: "120.00", + per_unit: "item", + control_mode: "warn", + scope_type: "global", + scope_key: null, + created_at: new Date("2026-04-08T09:00:00.000Z"), + }, + { + id: "standard-department", + expense_type_id: EXPENSE_TYPE_ID, + name: "交通费部门上限", + max_amount: "100.00", + per_unit: "item", + control_mode: "block", + scope_type: "department", + scope_key: DEPARTMENT_ID, + created_at: new Date("2026-04-08T10:00:00.000Z"), + }, + ], + }); + + const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); + + expect(result).toEqual([ + expect.objectContaining({ + standardId: "standard-department", + standardName: "交通费部门上限", + controlMode: "block", + exceeded: true, + exceededAmount: "50.00", + }), + ]); + }); + + it("returns warn level check without blocking when amount only exceeds warn standard", async () => { + const tx = buildTx(); + const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); + const merged = mergeReimbursementPrecheck( + { + entityType: "reimbursement", + entityId: REIMBURSEMENT_ID, + decision: "pass", + matchedBudgetControlRule: null, + budgetHint: null, + standardChecks: [], + warnings: [], + blockingReasons: [], + riskFlags: [], + nextActions: ["可直接提交"], + }, + result, + ); + + expect(result[0]).toMatchObject({ + controlMode: "warn", + exceeded: true, + }); + expect(merged.decision).toBe("warn"); + expect(merged.warnings).toHaveLength(1); + expect(merged.blockingReasons).toHaveLength(0); + }); + + it("turns decision into block when exceeded item hits block standard", async () => { + const tx = buildTx({ + standards: [ + { + id: "standard-block", + expense_type_id: EXPENSE_TYPE_ID, + name: "交通费阻断上限", + max_amount: "120.00", + per_unit: "item", + control_mode: "block", + scope_type: "global", + scope_key: null, + created_at: new Date("2026-04-08T09:00:00.000Z"), + }, + ], + }); + + const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); + const merged = mergeReimbursementPrecheck( + { + entityType: "reimbursement", + entityId: REIMBURSEMENT_ID, + decision: "pass", + matchedBudgetControlRule: null, + budgetHint: null, + standardChecks: [], + warnings: [], + blockingReasons: [], + riskFlags: [], + nextActions: ["可直接提交"], + }, + result, + ); + + expect(result[0].controlMode).toBe("block"); + expect(merged.decision).toBe("block"); + expect(merged.blockingReasons).toHaveLength(1); + }); + + it("skips items that do not match any standard", async () => { + const tx = buildTx({ + reimbursement: { + id: REIMBURSEMENT_ID, + department_id: DEPARTMENT_ID, + items: [ + { + id: "item-2", + expense_type_id: OTHER_EXPENSE_TYPE_ID, + amount: "88.00", + expense_type: { + id: OTHER_EXPENSE_TYPE_ID, + name: "办公费", + }, + }, + ], + }, + standards: [ + { + id: "standard-global", + expense_type_id: EXPENSE_TYPE_ID, + name: "交通费全局上限", + max_amount: "120.00", + per_unit: "item", + control_mode: "warn", + scope_type: "global", + scope_key: null, + created_at: new Date("2026-04-08T09:00:00.000Z"), + }, + ], + }); + + const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); + + expect(result).toEqual([]); + }); + + it("keeps perUnit as display metadata without any day or trip aggregation", async () => { + const tx = buildTx({ + reimbursement: { + id: REIMBURSEMENT_ID, + department_id: DEPARTMENT_ID, + items: [ + { + id: "item-1", + expense_type_id: EXPENSE_TYPE_ID, + amount: "300.00", + expense_type: { + id: EXPENSE_TYPE_ID, + name: "交通费", + }, + }, + { + id: "item-2", + expense_type_id: EXPENSE_TYPE_ID, + amount: "300.00", + expense_type: { + id: EXPENSE_TYPE_ID, + name: "交通费", + }, + }, + ], + }, + standards: [ + { + id: "standard-trip", + expense_type_id: EXPENSE_TYPE_ID, + name: "差旅交通上限", + max_amount: "250.00", + per_unit: "trip", + control_mode: "warn", + scope_type: "global", + scope_key: null, + created_at: new Date("2026-04-08T09:00:00.000Z"), + }, + ], + }); + + const result = await checkExpenseStandards(tx, { reimbursementId: REIMBURSEMENT_ID }); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + perUnit: "trip", + exceededAmount: "50.00", + }); + expect(result[1]).toMatchObject({ + perUnit: "trip", + exceededAmount: "50.00", + }); + }); + + it("preserves manual_review decision priority over warn and under block", () => { + const mergedWarn = mergeReimbursementPrecheck( + { + entityType: "reimbursement", + entityId: REIMBURSEMENT_ID, + decision: "manual_review", + matchedBudgetControlRule: null, + budgetHint: null, + standardChecks: [], + warnings: [], + blockingReasons: [], + riskFlags: [{ code: "budget_exceeded", level: "high", title: "预算超用", detail: "需要复核" }], + nextActions: ["允许提交,但会生成待复核风险项"], + }, + [ + { + itemId: "item-1", + expenseTypeId: EXPENSE_TYPE_ID, + expenseTypeName: "交通费", + amount: "150.00", + standardId: "standard-global", + standardName: "交通费全局上限", + maxAmount: "120.00", + perUnit: "item", + controlMode: "warn", + scopeType: "global", + scopeKey: null, + exceeded: true, + exceededAmount: "30.00", + }, + ], + ); + + const mergedBlock = mergeReimbursementPrecheck(mergedWarn, [ + { + itemId: "item-2", + expenseTypeId: EXPENSE_TYPE_ID, + expenseTypeName: "交通费", + amount: "200.00", + standardId: "standard-block", + standardName: "交通费阻断上限", + maxAmount: "120.00", + perUnit: "item", + controlMode: "block", + scopeType: "global", + scopeKey: null, + exceeded: true, + exceededAmount: "80.00", + }, + ]); + + expect(mergedWarn.decision).toBe("manual_review"); + expect(mergedBlock.decision).toBe("block"); + }); +});