295 行
8.8 KiB
JavaScript
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;
|
|
|