feat: 完成 F18 审批规则维护(API+前端)\n\n- 新增 /api/approval-rules 管理接口(system.settings)并写审计\n- 前端新增审批规则页与菜单入口(system.settings)\n- 更新 P5 蓝图 v3 中 F18 状态为 DONE
这个提交包含在:
@@ -0,0 +1,68 @@
|
|||||||
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
|
export type ApprovalRoleCode =
|
||||||
|
| "employee"
|
||||||
|
| "manager"
|
||||||
|
| "ceo"
|
||||||
|
| "finance"
|
||||||
|
| "finance_mgr"
|
||||||
|
| "administration"
|
||||||
|
| "hr"
|
||||||
|
| "admin";
|
||||||
|
|
||||||
|
export type ApprovalRule = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
minAmount: string;
|
||||||
|
maxAmount: string | null;
|
||||||
|
level1Role: ApprovalRoleCode;
|
||||||
|
level2Role: ApprovalRoleCode | null;
|
||||||
|
level3Role: ApprovalRoleCode | null;
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApprovalRulesListResponse = {
|
||||||
|
enabled: boolean | null;
|
||||||
|
items: ApprovalRule[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchApprovalRules(params?: { enabled?: boolean }) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.enabled != null) {
|
||||||
|
qs.set("enabled", params.enabled ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = qs.toString();
|
||||||
|
return apiRequestData<ApprovalRulesListResponse>(`/api/approval-rules${query ? `?${query}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApprovalRuleCreatePayload = {
|
||||||
|
name: string;
|
||||||
|
minAmount: string;
|
||||||
|
maxAmount: string | null;
|
||||||
|
level1Role: ApprovalRoleCode;
|
||||||
|
level2Role: ApprovalRoleCode | null;
|
||||||
|
level3Role: ApprovalRoleCode | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
priority?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createApprovalRule(payload: ApprovalRuleCreatePayload) {
|
||||||
|
return apiRequestData<ApprovalRule>("/api/approval-rules", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApprovalRulePatchPayload = Partial<ApprovalRuleCreatePayload>;
|
||||||
|
|
||||||
|
export async function patchApprovalRule(approvalRuleId: string, payload: ApprovalRulePatchPayload) {
|
||||||
|
return apiRequestData<ApprovalRule>(`/api/approval-rules/${approvalRuleId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -46,6 +46,11 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
label: "部门管理",
|
label: "部门管理",
|
||||||
anyOf: ["system.users"],
|
anyOf: ["system.users"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: "/system/approval-rules",
|
||||||
|
label: "审批规则",
|
||||||
|
anyOf: ["system.settings"],
|
||||||
|
},
|
||||||
{ to: "/settings/profile", label: "个人设置" },
|
{ to: "/settings/profile", label: "个人设置" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import {
|
||||||
|
createApprovalRule,
|
||||||
|
fetchApprovalRules,
|
||||||
|
patchApprovalRule,
|
||||||
|
type ApprovalRoleCode,
|
||||||
|
type ApprovalRule,
|
||||||
|
} from "../api/approvalRules";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card } from "../components/ui/card";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const ROLE_OPTIONS: Array<{ code: ApprovalRoleCode; label: string }> = [
|
||||||
|
{ code: "manager", label: "manager" },
|
||||||
|
{ code: "ceo", label: "ceo" },
|
||||||
|
{ code: "finance", label: "finance" },
|
||||||
|
{ code: "finance_mgr", label: "finance_mgr" },
|
||||||
|
{ code: "admin", label: "admin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatAmount(value: string | null) {
|
||||||
|
if (!value) return "-";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApprovalRulesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [minAmount, setMinAmount] = useState("");
|
||||||
|
const [maxAmount, setMaxAmount] = useState("");
|
||||||
|
const [priority, setPriority] = useState("");
|
||||||
|
const [level1Role, setLevel1Role] = useState<ApprovalRoleCode>("manager");
|
||||||
|
const [level2Role, setLevel2Role] = useState<ApprovalRoleCode | "">("");
|
||||||
|
const [level3Role, setLevel3Role] = useState<ApprovalRoleCode | "">("");
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editRule, setEditRule] = useState<ApprovalRule | null>(null);
|
||||||
|
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["approval-rules"],
|
||||||
|
queryFn: () => fetchApprovalRules(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = listQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
const createPayload = useMemo(() => {
|
||||||
|
return {
|
||||||
|
name: name.trim(),
|
||||||
|
minAmount: minAmount.trim(),
|
||||||
|
maxAmount: maxAmount.trim() ? maxAmount.trim() : null,
|
||||||
|
level1Role,
|
||||||
|
level2Role: level2Role ? (level2Role as ApprovalRoleCode) : null,
|
||||||
|
level3Role: level3Role ? (level3Role as ApprovalRoleCode) : null,
|
||||||
|
enabled,
|
||||||
|
priority: priority.trim() ? Number(priority) : undefined,
|
||||||
|
};
|
||||||
|
}, [enabled, level1Role, level2Role, level3Role, maxAmount, minAmount, name, priority]);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createApprovalRule(createPayload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["approval-rules"] });
|
||||||
|
setName("");
|
||||||
|
setMinAmount("");
|
||||||
|
setMaxAmount("");
|
||||||
|
setPriority("");
|
||||||
|
setLevel1Role("manager");
|
||||||
|
setLevel2Role("");
|
||||||
|
setLevel3Role("");
|
||||||
|
setEnabled(true);
|
||||||
|
setError("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : "创建审批规则失败");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleEnabledMutation = useMutation({
|
||||||
|
mutationFn: (rule: ApprovalRule) => patchApprovalRule(rule.id, { enabled: !rule.enabled }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["approval-rules"] });
|
||||||
|
setError("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : "更新审批规则失败");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const editMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!editRule) {
|
||||||
|
throw new Error("请选择要编辑的规则");
|
||||||
|
}
|
||||||
|
|
||||||
|
return patchApprovalRule(editRule.id, createPayload);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["approval-rules"] });
|
||||||
|
setEditOpen(false);
|
||||||
|
setEditRule(null);
|
||||||
|
setError("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (isApiError(err)) {
|
||||||
|
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err.message : "更新审批规则失败");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editOpen || !editRule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setName(editRule.name);
|
||||||
|
setMinAmount(editRule.minAmount);
|
||||||
|
setMaxAmount(editRule.maxAmount ?? "");
|
||||||
|
setPriority(String(editRule.priority));
|
||||||
|
setEnabled(editRule.enabled);
|
||||||
|
setLevel1Role(editRule.level1Role);
|
||||||
|
setLevel2Role(editRule.level2Role ?? "");
|
||||||
|
setLevel3Role(editRule.level3Role ?? "");
|
||||||
|
}, [editOpen, editRule]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="审批规则"
|
||||||
|
subtitle="维护金额段与审批层级(仅影响新提交的报销单,历史流程不回写)。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card className="space-y-4 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">新建审批规则</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="ruleName">规则名称</Label>
|
||||||
|
<Input
|
||||||
|
id="ruleName"
|
||||||
|
data-testid="approval-rules-create-name"
|
||||||
|
placeholder="5000 以下走经理"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="minAmount">最小金额</Label>
|
||||||
|
<Input
|
||||||
|
id="minAmount"
|
||||||
|
data-testid="approval-rules-create-min"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(event) => setMinAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="maxAmount">最大金额(可空)</Label>
|
||||||
|
<Input
|
||||||
|
id="maxAmount"
|
||||||
|
data-testid="approval-rules-create-max"
|
||||||
|
placeholder="5000.00"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(event) => setMaxAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="priority">优先级(越小越先匹配)</Label>
|
||||||
|
<Input
|
||||||
|
id="priority"
|
||||||
|
data-testid="approval-rules-create-priority"
|
||||||
|
placeholder="1"
|
||||||
|
value={priority}
|
||||||
|
onChange={(event) => setPriority(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="level1Role">一级审批角色</Label>
|
||||||
|
<select
|
||||||
|
id="level1Role"
|
||||||
|
data-testid="approval-rules-create-level1"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
value={level1Role}
|
||||||
|
onChange={(event) => setLevel1Role(event.target.value as ApprovalRoleCode)}
|
||||||
|
>
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<option key={role.code} value={role.code}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="level2Role">二级审批角色(可空)</Label>
|
||||||
|
<select
|
||||||
|
id="level2Role"
|
||||||
|
data-testid="approval-rules-create-level2"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
value={level2Role}
|
||||||
|
onChange={(event) => setLevel2Role(event.target.value as ApprovalRoleCode)}
|
||||||
|
>
|
||||||
|
<option value="">无</option>
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<option key={role.code} value={role.code}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="level3Role">三级审批角色(可空)</Label>
|
||||||
|
<select
|
||||||
|
id="level3Role"
|
||||||
|
data-testid="approval-rules-create-level3"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
value={level3Role}
|
||||||
|
onChange={(event) => setLevel3Role(event.target.value as ApprovalRoleCode)}
|
||||||
|
>
|
||||||
|
<option value="">无</option>
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<option key={role.code} value={role.code}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-6">
|
||||||
|
<input
|
||||||
|
id="ruleEnabled"
|
||||||
|
data-testid="approval-rules-create-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(event) => setEnabled(event.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="ruleEnabled">启用</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button
|
||||||
|
data-testid="approval-rules-create-submit"
|
||||||
|
type="button"
|
||||||
|
disabled={createMutation.isPending || !name.trim() || !minAmount.trim()}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "创建中..." : "创建"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="space-y-4 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">规则列表</h3>
|
||||||
|
|
||||||
|
{listQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
|
||||||
|
|
||||||
|
{listQuery.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{isApiError(listQuery.error)
|
||||||
|
? `${listQuery.error.code}: ${mapApiErrorMessage(
|
||||||
|
listQuery.error.code,
|
||||||
|
listQuery.error.details,
|
||||||
|
listQuery.error.message,
|
||||||
|
)}`
|
||||||
|
: "加载审批规则失败"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!listQuery.isLoading && !listQuery.isError ? (
|
||||||
|
<div className="rounded-lg border border-slate-200">
|
||||||
|
<Table data-testid="approval-rules-table">
|
||||||
|
<TableHeader>
|
||||||
|
<tr>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>金额段</TableHead>
|
||||||
|
<TableHead>层级</TableHead>
|
||||||
|
<TableHead>优先级</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead className="w-44">操作</TableHead>
|
||||||
|
</tr>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rules.length ? (
|
||||||
|
rules.map((rule) => (
|
||||||
|
<TableRow key={rule.id} data-testid="approval-rule-row">
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-slate-900">{rule.name}</div>
|
||||||
|
<div className="font-mono text-xs text-slate-500">{rule.id}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-slate-700">
|
||||||
|
{formatAmount(rule.minAmount)} ~ {rule.maxAmount ? formatAmount(rule.maxAmount) : "∞"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-slate-700">
|
||||||
|
{[rule.level1Role, rule.level2Role, rule.level3Role].filter(Boolean).join(" -> ") || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{rule.priority}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{rule.enabled ? <span className="text-emerald-600">启用</span> : <span className="text-slate-500">停用</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
data-testid="approval-rule-edit"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditRule(rule);
|
||||||
|
setEditOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="approval-rule-toggle"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={toggleEnabledMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm(`确认${rule.enabled ? "停用" : "启用"}该审批规则?`)) return;
|
||||||
|
toggleEnabledMutation.mutate(rule);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rule.enabled ? "停用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="py-3 text-slate-500" colSpan={6}>
|
||||||
|
暂无审批规则
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑审批规则</DialogTitle>
|
||||||
|
<DialogDescription>修改后只影响新提交的报销单。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<Label htmlFor="editRuleName">规则名称</Label>
|
||||||
|
<Input
|
||||||
|
id="editRuleName"
|
||||||
|
data-testid="approval-rule-edit-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="editRuleMin">最小金额</Label>
|
||||||
|
<Input
|
||||||
|
id="editRuleMin"
|
||||||
|
data-testid="approval-rule-edit-min"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(event) => setMinAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="editRuleMax">最大金额(可空)</Label>
|
||||||
|
<Input
|
||||||
|
id="editRuleMax"
|
||||||
|
data-testid="approval-rule-edit-max"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(event) => setMaxAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="editRulePriority">优先级</Label>
|
||||||
|
<Input
|
||||||
|
id="editRulePriority"
|
||||||
|
data-testid="approval-rule-edit-priority"
|
||||||
|
value={priority}
|
||||||
|
onChange={(event) => setPriority(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="editRuleLevel1">一级审批角色</Label>
|
||||||
|
<select
|
||||||
|
id="editRuleLevel1"
|
||||||
|
data-testid="approval-rule-edit-level1"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
value={level1Role}
|
||||||
|
onChange={(event) => setLevel1Role(event.target.value as ApprovalRoleCode)}
|
||||||
|
>
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<option key={role.code} value={role.code}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="editRuleLevel2">二级审批角色(可空)</Label>
|
||||||
|
<select
|
||||||
|
id="editRuleLevel2"
|
||||||
|
data-testid="approval-rule-edit-level2"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
value={level2Role}
|
||||||
|
onChange={(event) => setLevel2Role(event.target.value as ApprovalRoleCode)}
|
||||||
|
>
|
||||||
|
<option value="">无</option>
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<option key={role.code} value={role.code}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="editRuleLevel3">三级审批角色(可空)</Label>
|
||||||
|
<select
|
||||||
|
id="editRuleLevel3"
|
||||||
|
data-testid="approval-rule-edit-level3"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
value={level3Role}
|
||||||
|
onChange={(event) => setLevel3Role(event.target.value as ApprovalRoleCode)}
|
||||||
|
>
|
||||||
|
<option value="">无</option>
|
||||||
|
{ROLE_OPTIONS.map((role) => (
|
||||||
|
<option key={role.code} value={role.code}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-6">
|
||||||
|
<input
|
||||||
|
id="editRuleEnabled"
|
||||||
|
data-testid="approval-rule-edit-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(event) => setEnabled(event.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="editRuleEnabled">启用</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
data-testid="approval-rule-edit-save"
|
||||||
|
type="button"
|
||||||
|
disabled={editMutation.isPending}
|
||||||
|
onClick={() => editMutation.mutate()}
|
||||||
|
>
|
||||||
|
{editMutation.isPending ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="approval-rule-edit-cancel"
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={editMutation.isPending}
|
||||||
|
onClick={() => setEditOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
|||||||
import { AppShellLayout } from "../layouts/AppShellLayout";
|
import { AppShellLayout } from "../layouts/AppShellLayout";
|
||||||
import { AuthLayout } from "../layouts/AuthLayout";
|
import { AuthLayout } from "../layouts/AuthLayout";
|
||||||
import { AuditLogsPage } from "../pages/AuditLogsPage";
|
import { AuditLogsPage } from "../pages/AuditLogsPage";
|
||||||
|
import { ApprovalRulesPage } from "../pages/ApprovalRulesPage";
|
||||||
import { ChangePasswordPage } from "../pages/ChangePasswordPage";
|
import { ChangePasswordPage } from "../pages/ChangePasswordPage";
|
||||||
import { DashboardPage } from "../pages/DashboardPage";
|
import { DashboardPage } from "../pages/DashboardPage";
|
||||||
import { DepartmentsPage } from "../pages/DepartmentsPage";
|
import { DepartmentsPage } from "../pages/DepartmentsPage";
|
||||||
@@ -138,6 +139,15 @@ export function AppRouter() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="system/approval-rules"
|
||||||
|
element={
|
||||||
|
<RequirePermission anyOf={["system.settings"]}>
|
||||||
|
<ApprovalRulesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="settings/profile" element={<ProfilePage />} />
|
<Route path="settings/profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ F14,站内通知基础设施(DB+API),"新增 notifications 表与 API(list
|
|||||||
F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关","EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务","manual: 配置 SMTP 并触发一次待办通知/重置密码邮件",manual,DONE,DONE,DONE,"server/src/services/email/* | server/src/config.js | server/src/routes/auth.js | server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/package.json | server/README.md","[""F14""]","优先复用成熟 nodemailer;写回滚说明"
|
F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待办/审批结果/付款完成/密码重置等;失败降级不阻塞主流程;可配置开关","EMAIL_ENABLED=false 时不发送;开启后可发出测试邮件;失败仅记录 audit/日志不影响业务","manual: 配置 SMTP 并触发一次待办通知/重置密码邮件",manual,DONE,DONE,DONE,"server/src/services/email/* | server/src/config.js | server/src/routes/auth.js | server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/package.json | server/README.md","[""F14""]","优先复用成熟 nodemailer;写回滚说明"
|
||||||
F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送"
|
F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送"
|
||||||
F17,报表导出(CSV/Excel),"新增报销/票据/审计的导出接口(权限控制),前端提供下载入口","导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作","manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作",manual,TODO,TODO,TODO,"server/src/routes/exports.js | client/src/pages/* | server/package.json","[""F6"",""F16""]","推荐 exceljs 或 csv-stringify(二选一并写 Notes:用途/版本/回滚)"
|
F17,报表导出(CSV/Excel),"新增报销/票据/审计的导出接口(权限控制),前端提供下载入口","导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作","manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作",manual,TODO,TODO,TODO,"server/src/routes/exports.js | client/src/pages/* | server/package.json","[""F6"",""F16""]","推荐 exceljs 或 csv-stringify(二选一并写 Notes:用途/版本/回滚)"
|
||||||
F18,审批规则维护(system.settings),"补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护","规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更","cd server && ./scripts/regression-workflow-actions.sh",manual,TODO,TODO,TODO,"server/src/routes/approvalRules.js | client/src/pages/ApprovalRulesPage.tsx | server/src/app.js","[""F5""]","只做最小可运营;复杂可视化后置"
|
F18,审批规则维护(system.settings),"补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护","规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更","cd server && ./scripts/regression-workflow-actions.sh",manual,DONE,DONE,DONE,"server/src/routes/approvalRules.js | server/src/app.js | client/src/pages/ApprovalRulesPage.tsx | client/src/api/approvalRules.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx","[""F5""]","只做最小可运营;复杂可视化后置"
|
||||||
F19,作业系统升级(pg-boss 基础设施),"引入 pg-boss(Postgres 队列)作为作业基础设施:提供统一注册/执行框架与开关;保持 node-cron 作业可回滚;并文档化单机用法","pg-boss 可在单机/多进程下可靠运行;最小示例 job 可跑通;不影响现有回归","cd server && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/* | server/src/app.js | server/package.json | deploy/trialrun/* | server/README.md","[""F1""]","选择 pg-boss(成熟/无 Redis);回滚:关闭开关并切回 node-cron"
|
F19,作业系统升级(pg-boss 基础设施),"引入 pg-boss(Postgres 队列)作为作业基础设施:提供统一注册/执行框架与开关;保持 node-cron 作业可回滚;并文档化单机用法","pg-boss 可在单机/多进程下可靠运行;最小示例 job 可跑通;不影响现有回归","cd server && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/* | server/src/app.js | server/package.json | deploy/trialrun/* | server/README.md","[""F1""]","选择 pg-boss(成熟/无 Redis);回滚:关闭开关并切回 node-cron"
|
||||||
F19b,作业迁移(OCR -> pg-boss),"将 OCR 异步处理迁移到 pg-boss:保持并发锁语义、幂等与回归脚本不变","mock/tencent 两种 provider 下 OCR 仍可跑;并发下单条仅处理一次;回归全绿","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/ocrJob.js | server/src/services/ocr/* | server/README.md","[""F19""]","迁移后移除/停用对应 node-cron tick(保留回滚路径)"
|
F19b,作业迁移(OCR -> pg-boss),"将 OCR 异步处理迁移到 pg-boss:保持并发锁语义、幂等与回归脚本不变","mock/tencent 两种 provider 下 OCR 仍可跑;并发下单条仅处理一次;回归全绿","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/ocrJob.js | server/src/services/ocr/* | server/README.md","[""F19""]","迁移后移除/停用对应 node-cron tick(保留回滚路径)"
|
||||||
F19c,作业迁移(委派回收 -> pg-boss),"将委派过期回收迁移到 pg-boss:保持 FOR UPDATE SKIP LOCKED + UPDATE RETURNING 原子认领语义","多进程并发下审计不重复写;回收逻辑不变;回归全绿","cd server && RUN_DELEGATION_CONCURRENCY_CHECK=1 ./scripts/regression-workflow-actions.sh",manual,TODO,TODO,TODO,"server/src/jobs/delegationRevokeJob.js | server/README.md","[""F19""]","仍需保留原子认领+审计只基于 RETURNING 结果集"
|
F19c,作业迁移(委派回收 -> pg-boss),"将委派过期回收迁移到 pg-boss:保持 FOR UPDATE SKIP LOCKED + UPDATE RETURNING 原子认领语义","多进程并发下审计不重复写;回收逻辑不变;回归全绿","cd server && RUN_DELEGATION_CONCURRENCY_CHECK=1 ./scripts/regression-workflow-actions.sh",manual,TODO,TODO,TODO,"server/src/jobs/delegationRevokeJob.js | server/README.md","[""F19""]","仍需保留原子认领+审计只基于 RETURNING 结果集"
|
||||||
|
|||||||
|
@@ -26,6 +26,7 @@ const departmentsRoutes = require("./routes/departments");
|
|||||||
const employeesRoutes = require("./routes/employees");
|
const employeesRoutes = require("./routes/employees");
|
||||||
const expenseTypesRoutes = require("./routes/expenseTypes");
|
const expenseTypesRoutes = require("./routes/expenseTypes");
|
||||||
const configReadRoutes = require("./routes/configRead");
|
const configReadRoutes = require("./routes/configRead");
|
||||||
|
const approvalRulesRoutes = require("./routes/approvalRules");
|
||||||
const notificationsRoutes = require("./routes/notifications");
|
const notificationsRoutes = require("./routes/notifications");
|
||||||
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
||||||
const { startOcrJob } = require("./jobs/ocrJob");
|
const { startOcrJob } = require("./jobs/ocrJob");
|
||||||
@@ -201,6 +202,7 @@ async function buildApp() {
|
|||||||
await app.register(departmentsRoutes, { prefix: "/api" });
|
await app.register(departmentsRoutes, { prefix: "/api" });
|
||||||
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
await app.register(expenseTypesRoutes, { prefix: "/api" });
|
||||||
await app.register(configReadRoutes, { prefix: "/api" });
|
await app.register(configReadRoutes, { prefix: "/api" });
|
||||||
|
await app.register(approvalRulesRoutes, { prefix: "/api" });
|
||||||
await app.register(notificationsRoutes, { prefix: "/api" });
|
await app.register(notificationsRoutes, { prefix: "/api" });
|
||||||
|
|
||||||
startDelegationRevokeJob(app);
|
startDelegationRevokeJob(app);
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const { AppError } = require("../utils/AppError");
|
||||||
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
|
const { sendData } = require("../utils/reply");
|
||||||
|
const { ROLE_PERMISSIONS } = require("../constants/permissions");
|
||||||
|
|
||||||
|
const ROLE_CODES = Object.keys(ROLE_PERMISSIONS);
|
||||||
|
const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(\.\d{1,2})?$/);
|
||||||
|
|
||||||
|
const CREATE_BODY_SCHEMA = z.object({
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
minAmount: MONEY_STRING_SCHEMA,
|
||||||
|
maxAmount: MONEY_STRING_SCHEMA.nullable().optional(),
|
||||||
|
level1Role: z.enum(ROLE_CODES),
|
||||||
|
level2Role: z.enum(ROLE_CODES).nullable().optional(),
|
||||||
|
level3Role: z.enum(ROLE_CODES).nullable().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
priority: z.number().int().min(0).max(10000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UPDATE_BODY_SCHEMA = CREATE_BODY_SCHEMA.partial();
|
||||||
|
|
||||||
|
function parseBody(schema, body) {
|
||||||
|
const parsed = schema.safeParse(body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseApprovalRuleId(params) {
|
||||||
|
const parsed = z.string().uuid().safeParse(params?.id);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { id: "invalid_uuid" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAmountRangeValid(minAmount, maxAmount) {
|
||||||
|
if (maxAmount == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minValue = Number(minAmount);
|
||||||
|
const maxValue = Number(maxAmount);
|
||||||
|
if (!Number.isFinite(minValue) || !Number.isFinite(maxValue)) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { amount: "invalid" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxValue <= minValue) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { maxAmount: "must_be_greater_than_minAmount" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApprovalRule(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
minAmount: item.min_amount?.toString?.() ?? String(item.min_amount ?? "0"),
|
||||||
|
maxAmount:
|
||||||
|
item.max_amount == null
|
||||||
|
? null
|
||||||
|
: item.max_amount?.toString?.() ?? String(item.max_amount),
|
||||||
|
level1Role: item.level1_role,
|
||||||
|
level2Role: item.level2_role ?? null,
|
||||||
|
level3Role: item.level3_role ?? null,
|
||||||
|
enabled: item.enabled,
|
||||||
|
priority: item.priority,
|
||||||
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||||
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approvalRulesRoutes(fastify) {
|
||||||
|
fastify.post(
|
||||||
|
"/approval-rules",
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.settings"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = parseBody(CREATE_BODY_SCHEMA, request.body);
|
||||||
|
const maxAmount = Object.prototype.hasOwnProperty.call(body, "maxAmount") ? body.maxAmount : null;
|
||||||
|
ensureAmountRangeValid(body.minAmount, maxAmount);
|
||||||
|
|
||||||
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
let priority = body.priority;
|
||||||
|
if (priority == null) {
|
||||||
|
const aggregate = await tx.approvalRule.aggregate({ _max: { priority: true } });
|
||||||
|
priority = (aggregate._max.priority ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await tx.approvalRule.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
min_amount: body.minAmount,
|
||||||
|
max_amount: maxAmount ?? null,
|
||||||
|
level1_role: body.level1Role,
|
||||||
|
level2_role: body.level2Role ?? null,
|
||||||
|
level3_role: body.level3Role ?? null,
|
||||||
|
enabled: body.enabled ?? true,
|
||||||
|
priority,
|
||||||
|
created_by: request.employee.id,
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
min_amount: true,
|
||||||
|
max_amount: true,
|
||||||
|
level1_role: true,
|
||||||
|
level2_role: true,
|
||||||
|
level3_role: true,
|
||||||
|
enabled: true,
|
||||||
|
priority: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "APPROVAL_RULE_CREATED",
|
||||||
|
targetType: "approval_rule",
|
||||||
|
targetId: created.id,
|
||||||
|
extra: {
|
||||||
|
name: created.name,
|
||||||
|
minAmount: created.min_amount?.toString?.() ?? String(created.min_amount),
|
||||||
|
maxAmount:
|
||||||
|
created.max_amount == null
|
||||||
|
? null
|
||||||
|
: created.max_amount?.toString?.() ?? String(created.max_amount),
|
||||||
|
level1Role: created.level1_role,
|
||||||
|
level2Role: created.level2_role ?? null,
|
||||||
|
level3Role: created.level3_role ?? null,
|
||||||
|
enabled: created.enabled,
|
||||||
|
priority: created.priority,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, mapApprovalRule(result));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.patch(
|
||||||
|
"/approval-rules/:id",
|
||||||
|
{
|
||||||
|
preHandler: fastify.requirePermission("system.settings"),
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const approvalRuleId = parseApprovalRuleId(request.params);
|
||||||
|
const body = parseBody(UPDATE_BODY_SCHEMA, request.body);
|
||||||
|
|
||||||
|
if (Object.keys(body).length === 0) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "VALID_001",
|
||||||
|
message: "参数校验失败",
|
||||||
|
statusCode: 400,
|
||||||
|
details: { body: "empty_patch" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||||
|
const locked = await tx.$queryRaw`
|
||||||
|
SELECT id
|
||||||
|
FROM "approval_rules"
|
||||||
|
WHERE id = ${approvalRuleId}::uuid
|
||||||
|
FOR UPDATE
|
||||||
|
`;
|
||||||
|
if (!Array.isArray(locked) || locked.length === 0) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await tx.approvalRule.findUnique({
|
||||||
|
where: { id: approvalRuleId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
min_amount: true,
|
||||||
|
max_amount: true,
|
||||||
|
level1_role: true,
|
||||||
|
level2_role: true,
|
||||||
|
level3_role: true,
|
||||||
|
enabled: true,
|
||||||
|
priority: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!current) {
|
||||||
|
throw new AppError({
|
||||||
|
code: "SYS_404",
|
||||||
|
message: "Not Found",
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMin = body.minAmount ?? (current.min_amount?.toString?.() ?? String(current.min_amount));
|
||||||
|
const nextMax =
|
||||||
|
Object.prototype.hasOwnProperty.call(body, "maxAmount")
|
||||||
|
? body.maxAmount
|
||||||
|
: current.max_amount == null
|
||||||
|
? null
|
||||||
|
: current.max_amount?.toString?.() ?? String(current.max_amount);
|
||||||
|
|
||||||
|
ensureAmountRangeValid(nextMin, nextMax);
|
||||||
|
|
||||||
|
const updated = await tx.approvalRule.update({
|
||||||
|
where: { id: approvalRuleId },
|
||||||
|
data: {
|
||||||
|
...(body.name != null ? { name: body.name } : {}),
|
||||||
|
...(body.minAmount != null ? { min_amount: body.minAmount } : {}),
|
||||||
|
...(Object.prototype.hasOwnProperty.call(body, "maxAmount")
|
||||||
|
? { max_amount: body.maxAmount ?? null }
|
||||||
|
: {}),
|
||||||
|
...(body.level1Role != null ? { level1_role: body.level1Role } : {}),
|
||||||
|
...(Object.prototype.hasOwnProperty.call(body, "level2Role")
|
||||||
|
? { level2_role: body.level2Role ?? null }
|
||||||
|
: {}),
|
||||||
|
...(Object.prototype.hasOwnProperty.call(body, "level3Role")
|
||||||
|
? { level3_role: body.level3Role ?? null }
|
||||||
|
: {}),
|
||||||
|
...(body.enabled != null ? { enabled: body.enabled } : {}),
|
||||||
|
...(body.priority != null ? { priority: body.priority } : {}),
|
||||||
|
updated_by: request.employee.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
min_amount: true,
|
||||||
|
max_amount: true,
|
||||||
|
level1_role: true,
|
||||||
|
level2_role: true,
|
||||||
|
level3_role: true,
|
||||||
|
enabled: true,
|
||||||
|
priority: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action: "APPROVAL_RULE_UPDATED",
|
||||||
|
targetType: "approval_rule",
|
||||||
|
targetId: approvalRuleId,
|
||||||
|
extra: {
|
||||||
|
patch: body,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendData(reply, request, mapApprovalRule(result));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = approvalRulesRoutes;
|
||||||
|
|
||||||
在新工单中引用
屏蔽一个用户