feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力
关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
这个提交包含在:
@@ -0,0 +1,227 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const ENTITY_TYPE_SCHEMA = z.enum(["all", "reimbursement", "purchase_request", "travel_request"]);
|
||||
const CONTROL_MODE_SCHEMA = z.enum(["warn", "block", "manual_review"]);
|
||||
|
||||
const LIST_QUERY_SCHEMA = z.object({
|
||||
entityType: ENTITY_TYPE_SCHEMA.optional(),
|
||||
enabled: z.enum(["true", "false"]).optional(),
|
||||
});
|
||||
|
||||
const WRITE_SCHEMA = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
entityType: ENTITY_TYPE_SCHEMA,
|
||||
departmentId: UUID_SCHEMA.nullable().optional(),
|
||||
legalEntityId: UUID_SCHEMA.nullable().optional(),
|
||||
costCenterId: UUID_SCHEMA.nullable().optional(),
|
||||
expenseTypeId: UUID_SCHEMA.nullable().optional(),
|
||||
controlMode: CONTROL_MODE_SCHEMA,
|
||||
priority: z.coerce.number().int().min(1).max(9999).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
notes: z.string().trim().max(500).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, {
|
||||
message: "empty_patch",
|
||||
});
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
function parseRuleId(value) {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ id: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseQuery(query) {
|
||||
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return {
|
||||
entityType: parsed.data.entityType ?? null,
|
||||
enabled: parsed.data.enabled == null ? null : parsed.data.enabled === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
|
||||
if (parsed.data.costCenterId && !parsed.data.legalEntityId) {
|
||||
throw buildValidationError({ costCenterId: "legalEntityId_required" }, 409);
|
||||
}
|
||||
if (parsed.data.entityType !== "reimbursement" && parsed.data.expenseTypeId) {
|
||||
throw buildValidationError({ expenseTypeId: "only_reimbursement_supported" }, 409);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function mapRule(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
entityType: item.entity_type,
|
||||
departmentId: item.department_id ?? null,
|
||||
legalEntityId: item.legal_entity_id ?? null,
|
||||
costCenterId: item.cost_center_id ?? null,
|
||||
expenseTypeId: item.expense_type_id ?? null,
|
||||
controlMode: item.control_mode,
|
||||
priority: item.priority,
|
||||
enabled: item.enabled,
|
||||
notes: item.notes ?? null,
|
||||
archivedAt: item.archived_at?.toISOString?.() ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function budgetControlRulesRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/budget-control-rules",
|
||||
{ preHandler: fastify.requirePermission("budget.manage") },
|
||||
async (request, reply) => {
|
||||
const query = parseQuery(request.query);
|
||||
const items = await fastify.prisma.budgetControlRule.findMany({
|
||||
where: {
|
||||
...(query.entityType ? { entity_type: query.entityType } : {}),
|
||||
...(query.enabled == null ? {} : { enabled: query.enabled }),
|
||||
archived_at: null,
|
||||
},
|
||||
orderBy: [{ priority: "asc" }, { created_at: "asc" }],
|
||||
});
|
||||
|
||||
return sendData(reply, request, { items: items.map(mapRule) });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/budget-control-rules",
|
||||
{ preHandler: fastify.requirePermission("budget.manage") },
|
||||
async (request, reply) => {
|
||||
const body = parseBody(WRITE_SCHEMA, request.body);
|
||||
const created = await fastify.prisma.budgetControlRule.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
entity_type: body.entityType,
|
||||
department_id: body.departmentId ?? null,
|
||||
legal_entity_id: body.legalEntityId ?? null,
|
||||
cost_center_id: body.costCenterId ?? null,
|
||||
expense_type_id: body.expenseTypeId ?? null,
|
||||
control_mode: body.controlMode,
|
||||
priority: body.priority ?? 100,
|
||||
enabled: body.enabled ?? true,
|
||||
notes: body.notes ?? null,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "BUDGET_CONTROL_RULE_CREATED",
|
||||
targetType: "budget_control_rule",
|
||||
targetId: created.id,
|
||||
extra: mapRule(created),
|
||||
}),
|
||||
});
|
||||
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapRule(created) } });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/budget-control-rules/:id",
|
||||
{ preHandler: fastify.requirePermission("budget.manage") },
|
||||
async (request, reply) => {
|
||||
const ruleId = parseRuleId(request.params?.id);
|
||||
const body = parseBody(PATCH_SCHEMA, request.body);
|
||||
|
||||
const current = await fastify.prisma.budgetControlRule.findUnique({ where: { id: ruleId } });
|
||||
if (!current || current.archived_at) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
const updated = await fastify.prisma.budgetControlRule.update({
|
||||
where: { id: ruleId },
|
||||
data: {
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.entityType !== undefined ? { entity_type: body.entityType } : {}),
|
||||
...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}),
|
||||
...(body.legalEntityId !== undefined ? { legal_entity_id: body.legalEntityId } : {}),
|
||||
...(body.costCenterId !== undefined ? { cost_center_id: body.costCenterId } : {}),
|
||||
...(body.expenseTypeId !== undefined ? { expense_type_id: body.expenseTypeId } : {}),
|
||||
...(body.controlMode !== undefined ? { control_mode: body.controlMode } : {}),
|
||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
|
||||
...(body.notes !== undefined ? { notes: body.notes } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "BUDGET_CONTROL_RULE_UPDATED",
|
||||
targetType: "budget_control_rule",
|
||||
targetId: updated.id,
|
||||
extra: { fields: Object.keys(body) },
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, { item: mapRule(updated) });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/budget-control-rules/:id/archive",
|
||||
{ preHandler: fastify.requirePermission("budget.manage") },
|
||||
async (request, reply) => {
|
||||
const ruleId = parseRuleId(request.params?.id);
|
||||
const current = await fastify.prisma.budgetControlRule.findUnique({ where: { id: ruleId } });
|
||||
if (!current || current.archived_at) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
const archived = await fastify.prisma.budgetControlRule.update({
|
||||
where: { id: ruleId },
|
||||
data: {
|
||||
enabled: false,
|
||||
archived_at: new Date(),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "BUDGET_CONTROL_RULE_ARCHIVED",
|
||||
targetType: "budget_control_rule",
|
||||
targetId: archived.id,
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, { item: mapRule(archived) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = budgetControlRulesRoutes;
|
||||
在新工单中引用
屏蔽一个用户