diff --git a/client/src/api/approvalRules.ts b/client/src/api/approvalRules.ts new file mode 100644 index 00000000..cbf7d176 --- /dev/null +++ b/client/src/api/approvalRules.ts @@ -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(`/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("/api/approval-rules", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export type ApprovalRulePatchPayload = Partial; + +export async function patchApprovalRule(approvalRuleId: string, payload: ApprovalRulePatchPayload) { + return apiRequestData(`/api/approval-rules/${approvalRuleId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index fda60de3..c01f0d90 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -46,6 +46,11 @@ export const NAV_ITEMS: NavItem[] = [ label: "部门管理", anyOf: ["system.users"], }, + { + to: "/system/approval-rules", + label: "审批规则", + anyOf: ["system.settings"], + }, { to: "/settings/profile", label: "个人设置" }, ]; diff --git a/client/src/pages/ApprovalRulesPage.tsx b/client/src/pages/ApprovalRulesPage.tsx new file mode 100644 index 00000000..4cc8d54d --- /dev/null +++ b/client/src/pages/ApprovalRulesPage.tsx @@ -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("manager"); + const [level2Role, setLevel2Role] = useState(""); + const [level3Role, setLevel3Role] = useState(""); + const [enabled, setEnabled] = useState(true); + + const [editOpen, setEditOpen] = useState(false); + const [editRule, setEditRule] = useState(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 ( +
+ + + {error ? ( +

+ {error} +

+ ) : null} + + +

新建审批规则

+ +
+
+ + setName(event.target.value)} + /> +
+ +
+ + setMinAmount(event.target.value)} + /> +
+ +
+ + setMaxAmount(event.target.value)} + /> +
+ +
+ + setPriority(event.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ setEnabled(event.target.checked)} + type="checkbox" + /> + +
+ +
+ +
+
+
+ + +

规则列表

+ + {listQuery.isLoading ?

加载中...

: null} + + {listQuery.isError ? ( +

+ {isApiError(listQuery.error) + ? `${listQuery.error.code}: ${mapApiErrorMessage( + listQuery.error.code, + listQuery.error.details, + listQuery.error.message, + )}` + : "加载审批规则失败"} +

+ ) : null} + + {!listQuery.isLoading && !listQuery.isError ? ( +
+ + + + 名称 + 金额段 + 层级 + 优先级 + 状态 + 操作 + + + + {rules.length ? ( + rules.map((rule) => ( + + +
{rule.name}
+
{rule.id}
+
+ + {formatAmount(rule.minAmount)} ~ {rule.maxAmount ? formatAmount(rule.maxAmount) : "∞"} + + + {[rule.level1Role, rule.level2Role, rule.level3Role].filter(Boolean).join(" -> ") || "-"} + + {rule.priority} + + {rule.enabled ? 启用 : 停用} + + +
+ + +
+
+
+ )) + ) : ( + + + 暂无审批规则 + + + )} +
+
+
+ ) : null} +
+ + + + + 编辑审批规则 + 修改后只影响新提交的报销单。 + + +
+
+ + setName(event.target.value)} + /> +
+ +
+ + setMinAmount(event.target.value)} + /> +
+ +
+ + setMaxAmount(event.target.value)} + /> +
+ +
+ + setPriority(event.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ setEnabled(event.target.checked)} + type="checkbox" + /> + +
+
+ + + + + +
+
+
+ ); +} + diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 55e1cc71..38e50c86 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AppShellLayout } from "../layouts/AppShellLayout"; import { AuthLayout } from "../layouts/AuthLayout"; import { AuditLogsPage } from "../pages/AuditLogsPage"; +import { ApprovalRulesPage } from "../pages/ApprovalRulesPage"; import { ChangePasswordPage } from "../pages/ChangePasswordPage"; import { DashboardPage } from "../pages/DashboardPage"; import { DepartmentsPage } from "../pages/DepartmentsPage"; @@ -138,6 +139,15 @@ export function AppRouter() { } /> + + + + } + /> + } /> diff --git a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv index eadde3ec..d3665cc0 100644 --- a/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv +++ b/issues/2026-02-09_13-39-02-p5-blueprint-v3.csv @@ -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;写回滚说明" 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:用途/版本/回滚)" -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" 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 结果集" diff --git a/server/src/app.js b/server/src/app.js index adea3e09..0c85b4fe 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -26,6 +26,7 @@ const departmentsRoutes = require("./routes/departments"); const employeesRoutes = require("./routes/employees"); const expenseTypesRoutes = require("./routes/expenseTypes"); const configReadRoutes = require("./routes/configRead"); +const approvalRulesRoutes = require("./routes/approvalRules"); const notificationsRoutes = require("./routes/notifications"); const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob"); const { startOcrJob } = require("./jobs/ocrJob"); @@ -201,6 +202,7 @@ async function buildApp() { await app.register(departmentsRoutes, { prefix: "/api" }); await app.register(expenseTypesRoutes, { prefix: "/api" }); await app.register(configReadRoutes, { prefix: "/api" }); + await app.register(approvalRulesRoutes, { prefix: "/api" }); await app.register(notificationsRoutes, { prefix: "/api" }); startDelegationRevokeJob(app); diff --git a/server/src/routes/approvalRules.js b/server/src/routes/approvalRules.js new file mode 100644 index 00000000..fb8ed98d --- /dev/null +++ b/server/src/routes/approvalRules.js @@ -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; +