const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const UUID_SCHEMA = z.string().uuid(); const MONEY_STRING_SCHEMA = z.string().trim().regex(/^\d+(?:\.\d{1,2})?$/); const TEMPLATE_ITEM_SCHEMA = z .object({ expenseTypeId: UUID_SCHEMA, description: z.string().trim().min(1).max(500), defaultAmount: MONEY_STRING_SCHEMA.optional(), }) .strict() .superRefine((value, ctx) => { if (value.defaultAmount != null && Number(value.defaultAmount) <= 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "must_be_positive", path: ["defaultAmount"], }); } }); const LIST_QUERY_SCHEMA = z .object({ enabled: z.enum(["true", "false", "1", "0"]).optional(), }) .strict(); const CREATE_BODY_SCHEMA = z .object({ name: z.string().trim().min(1).max(100), description: z.string().trim().max(500).nullable().optional(), items: z.array(TEMPLATE_ITEM_SCHEMA).max(50).optional(), sortOrder: z.number().int().min(0).max(10000).optional(), enabled: z.boolean().optional(), }) .strict(); const UPDATE_BODY_SCHEMA = z .object({ name: z.string().trim().min(1).max(100).optional(), description: z.string().trim().max(500).nullable().optional(), items: z.array(TEMPLATE_ITEM_SCHEMA).max(50).optional(), sortOrder: z.number().int().min(0).max(10000).optional(), enabled: z.boolean().optional(), }) .strict(); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseTemplateId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ id: "invalid_uuid" }); } return parsed.data; } function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const enabledRaw = parsed.data.enabled; const enabled = enabledRaw == null ? null : enabledRaw === "true" || enabledRaw === "1"; return { enabled }; } function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function normalizeTemplateItems(items) { return (items ?? []).map((item) => ({ expenseTypeId: item.expenseTypeId, description: item.description.trim(), ...(item.defaultAmount == null ? {} : { defaultAmount: item.defaultAmount.trim() }), })); } async function ensureExpenseTypesEnabled(tx, items) { if (!items.length) { return; } const expenseTypeIds = Array.from(new Set(items.map((item) => item.expenseTypeId))); const expenseTypes = await tx.expenseType.findMany({ where: { id: { in: expenseTypeIds }, enabled: true, }, select: { id: true, enabled: true, }, }); const enabledIds = new Set(expenseTypes.map((item) => item.id)); const invalidItems = items .map((item, index) => ({ index, expenseTypeId: item.expenseTypeId })) .filter((item) => !enabledIds.has(item.expenseTypeId)); if (invalidItems.length) { throw buildValidationError( { items: invalidItems.map((item) => ({ index: item.index, expenseTypeId: item.expenseTypeId, reason: "expense_type_not_found_or_disabled", })), }, 409, ); } } function mapTemplate(record) { return { id: record.id, name: record.name, description: record.description ?? null, items: Array.isArray(record.items) ? record.items : [], sortOrder: record.sort_order, enabled: record.enabled, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } function normalizeSnapshot(record) { if (!record) { return null; } return { id: record.id, name: record.name, description: record.description ?? null, items: Array.isArray(record.items) ? record.items : [], sortOrder: record.sort_order, enabled: record.enabled, }; } async function reimbursementTemplatesRoutes(fastify) { fastify.get( "/reimbursement-templates", { preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseListQuery(request.query); const items = await fastify.prisma.reimbursementTemplate.findMany({ where: { ...(query.enabled == null ? {} : { enabled: query.enabled }), }, orderBy: [{ sort_order: "asc" }, { created_at: "asc" }, { name: "asc" }], select: { id: true, name: true, description: true, items: true, sort_order: true, enabled: true, created_at: true, updated_at: true, }, }); return sendData(reply, request, { enabled: query.enabled, items: items.map(mapTemplate), }); }, ); fastify.post( "/reimbursement-templates", { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { const body = parseBody(CREATE_BODY_SCHEMA, request.body); const items = normalizeTemplateItems(body.items); await ensureExpenseTypesEnabled(fastify.prisma, items); const now = new Date(); const created = await fastify.prisma.$transaction(async (tx) => { const record = await tx.reimbursementTemplate.create({ data: { name: body.name.trim(), description: body.description?.trim?.() || null, items, sort_order: body.sortOrder ?? 0, enabled: body.enabled ?? true, created_at: now, created_by: request.employee.id, updated_at: now, updated_by: request.employee.id, }, select: { id: true, name: true, description: true, items: true, sort_order: true, enabled: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_TEMPLATE_CREATED", targetType: "reimbursement_template", targetId: record.id, extra: { after: normalizeSnapshot(record), }, }), }); return record; }); return sendData(reply, request, mapTemplate(created)); }, ); fastify.patch( "/reimbursement-templates/:id", { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { const templateId = parseTemplateId(request.params?.id); const body = parseBody(UPDATE_BODY_SCHEMA, request.body); if (!Object.keys(body).length) { throw buildValidationError({ body: "empty" }); } const now = new Date(); const updated = await fastify.prisma.$transaction(async (tx) => { const current = await tx.reimbursementTemplate.findUnique({ where: { id: templateId }, select: { id: true, name: true, description: true, items: true, sort_order: true, enabled: true, created_at: true, updated_at: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const nextItems = body.items == null ? Array.isArray(current.items) ? current.items : [] : normalizeTemplateItems(body.items); await ensureExpenseTypesEnabled(tx, nextItems); const record = await tx.reimbursementTemplate.update({ where: { id: templateId }, data: { ...(body.name === undefined ? {} : { name: body.name.trim() }), ...(body.description === undefined ? {} : { description: body.description?.trim?.() || null }), ...(body.items === undefined ? {} : { items: nextItems }), ...(body.sortOrder === undefined ? {} : { sort_order: body.sortOrder }), ...(body.enabled === undefined ? {} : { enabled: body.enabled }), updated_at: now, updated_by: request.employee.id, }, select: { id: true, name: true, description: true, items: true, sort_order: true, enabled: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_TEMPLATE_UPDATED", targetType: "reimbursement_template", targetId: record.id, extra: { before: normalizeSnapshot(current), after: normalizeSnapshot(record), }, }), }); return record; }); return sendData(reply, request, mapTemplate(updated)); }, ); fastify.delete( "/reimbursement-templates/:id", { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { const templateId = parseTemplateId(request.params?.id); const now = new Date(); const deleted = await fastify.prisma.$transaction(async (tx) => { const current = await tx.reimbursementTemplate.findUnique({ where: { id: templateId }, select: { id: true, name: true, description: true, items: true, sort_order: true, enabled: true, created_at: true, updated_at: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (!current.enabled) { return current; } const record = await tx.reimbursementTemplate.update({ where: { id: templateId }, data: { enabled: false, updated_at: now, updated_by: request.employee.id, }, select: { id: true, name: true, description: true, items: true, sort_order: true, enabled: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "REIMBURSEMENT_TEMPLATE_DELETED", targetType: "reimbursement_template", targetId: record.id, extra: { before: normalizeSnapshot(current), after: normalizeSnapshot(record), }, }), }); return record; }); return sendData(reply, request, mapTemplate(deleted)); }, ); } module.exports = reimbursementTemplatesRoutes;