文件
hyb-oa/server/src/routes/approvalRules.js
T

295 行
8.8 KiB
JavaScript

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;