feat: 完成 F18 审批规则维护(API+前端)\n\n- 新增 /api/approval-rules 管理接口(system.settings)并写审计\n- 前端新增审批规则页与菜单入口(system.settings)\n- 更新 P5 蓝图 v3 中 F18 状态为 DONE
这个提交包含在:
@@ -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;
|
||||
|
||||
在新工单中引用
屏蔽一个用户