feat: 完成 F18 审批规则维护(API+前端)\n\n- 新增 /api/approval-rules 管理接口(system.settings)并写审计\n- 前端新增审批规则页与菜单入口(system.settings)\n- 更新 P5 蓝图 v3 中 F18 状态为 DONE

这个提交包含在:
2026-02-09 19:20:53 +08:00
父节点 38b66b01a6
当前提交 2c58061ec7
共修改 7 个文件,包含 892 行新增和 1 行删除
+68
查看文件
@@ -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: "个人设置" },
]; ];
+512
查看文件
@@ -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>
);
}
+10
查看文件
@@ -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 结果集"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
16 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;写回滚说明
17 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 轻量,不做复杂订阅推送
18 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:用途/版本/回滚)
19 F18 审批规则维护(system.settings) 补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护 规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更 cd server && ./scripts/regression-workflow-actions.sh manual TODO DONE TODO DONE TODO DONE server/src/routes/approvalRules.js | client/src/pages/ApprovalRulesPage.tsx | server/src/app.js 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"] 只做最小可运营;复杂可视化后置
20 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
21 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(保留回滚路径)
22 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 结果集
+2
查看文件
@@ -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);
+294
查看文件
@@ -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;