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_SCHEMA = z.string().trim().regex(/^\d+(\.\d{1,2})?$/); const PER_UNIT_VALUES = ["item", "day", "trip"]; const CONTROL_MODE_VALUES = ["warn", "block"]; const SCOPE_TYPE_VALUES = ["global", "department"]; const LIST_QUERY_SCHEMA = z .object({ expenseTypeId: UUID_SCHEMA.optional(), expense_type_id: UUID_SCHEMA.optional(), scopeType: z.enum(SCOPE_TYPE_VALUES).optional(), scope_type: z.enum(SCOPE_TYPE_VALUES).optional(), enabled: z.enum(["true", "false", "1", "0"]).optional(), }) .strict(); const CREATE_BODY_SCHEMA = z .object({ expenseTypeId: UUID_SCHEMA, name: z.string().trim().min(1).max(100), maxAmount: MONEY_SCHEMA, perUnit: z.enum(PER_UNIT_VALUES).optional(), controlMode: z.enum(CONTROL_MODE_VALUES).optional(), scopeType: z.enum(SCOPE_TYPE_VALUES).optional(), scopeKey: UUID_SCHEMA.nullish(), enabled: z.boolean().optional(), note: z.string().trim().max(500).nullable().optional(), }) .strict(); const UPDATE_BODY_SCHEMA = z .object({ name: z.string().trim().min(1).max(100).optional(), maxAmount: MONEY_SCHEMA.optional(), perUnit: z.enum(PER_UNIT_VALUES).optional(), controlMode: z.enum(CONTROL_MODE_VALUES).optional(), scopeType: z.enum(SCOPE_TYPE_VALUES).optional(), scopeKey: UUID_SCHEMA.nullish().optional(), enabled: z.boolean().optional(), note: z.string().trim().max(500).nullable().optional(), }) .strict(); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function toMoneyString(value) { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric)) { return "0.00"; } return numeric.toFixed(2); } function parseExpenseStandardId(params) { const parsed = UUID_SCHEMA.safeParse(params?.id); 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 : ["true", "1"].includes(enabledRaw) ? true : false; return { expenseTypeId: parsed.data.expenseTypeId ?? parsed.data.expense_type_id ?? null, scopeType: parsed.data.scopeType ?? parsed.data.scope_type ?? null, enabled, }; } function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const data = parsed.data; if ("maxAmount" in data && data.maxAmount != null && Number(data.maxAmount) <= 0) { throw buildValidationError({ maxAmount: "must_be_positive" }); } return data; } async function ensureExpenseTypeExists(tx, expenseTypeId) { const expenseType = await tx.expenseType.findUnique({ where: { id: expenseTypeId }, select: { id: true, code: true, name: true, }, }); if (!expenseType) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { expenseTypeId: "not_found" }, }); } return expenseType; } async function ensureScopeKeyValid(tx, scopeType, scopeKey) { if (scopeType === "global") { return null; } if (!scopeKey) { throw buildValidationError({ scopeKey: "required_for_department_scope" }); } const department = await tx.department.findUnique({ where: { id: scopeKey }, select: { id: true, name: true, archived_at: true, }, }); if (!department || department.archived_at) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { scopeKey: "invalid_department" }, }); } return department; } function buildStandardPayload(body, actorId) { const scopeType = body.scopeType ?? "global"; return { ...(typeof body.name === "string" ? { name: body.name } : {}), ...(typeof body.maxAmount === "string" ? { max_amount: body.maxAmount } : {}), ...(typeof body.perUnit === "string" ? { per_unit: body.perUnit } : {}), ...(typeof body.controlMode === "string" ? { control_mode: body.controlMode } : {}), scope_type: scopeType, scope_key: scopeType === "department" ? body.scopeKey ?? null : null, ...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}), ...(Object.prototype.hasOwnProperty.call(body, "note") ? { note: body.note?.trim?.() || null } : {}), updated_by: actorId, }; } function buildScopeLabel(item, departmentMap) { if (item.scope_type === "global") { return "全局"; } return departmentMap.get(item.scope_key) ?? item.scope_key ?? "指定部门"; } function mapExpenseStandard(item, departmentMap) { return { id: item.id, expenseTypeId: item.expense_type_id, expenseTypeName: item.expense_type?.name ?? null, name: item.name, maxAmount: toMoneyString(item.max_amount), perUnit: item.per_unit, controlMode: item.control_mode, scopeType: item.scope_type, scopeKey: item.scope_key ?? null, scopeLabel: buildScopeLabel(item, departmentMap), enabled: item.enabled, note: item.note ?? null, createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } function normalizeRecordSnapshot(item) { if (!item) { return null; } return { id: item.id, expenseTypeId: item.expense_type_id, name: item.name, maxAmount: toMoneyString(item.max_amount), perUnit: item.per_unit, controlMode: item.control_mode, scopeType: item.scope_type, scopeKey: item.scope_key ?? null, enabled: item.enabled, note: item.note ?? null, }; } async function buildDepartmentMap(prisma, items) { const departmentIds = Array.from( new Set( items .filter((item) => item.scope_type === "department" && item.scope_key) .map((item) => item.scope_key), ), ); if (!departmentIds.length) { return new Map(); } const departments = await prisma.department.findMany({ where: { id: { in: departmentIds }, }, select: { id: true, name: true, }, }); return new Map(departments.map((item) => [item.id, item.name])); } async function expenseStandardsRoutes(fastify) { fastify.get( "/expense-standards", { preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseListQuery(request.query); const canManage = fastify.hasPermission(request.employee, "system.settings"); if (!canManage && (!query.expenseTypeId || query.enabled !== true)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } const items = await fastify.prisma.expenseStandard.findMany({ where: { ...(query.expenseTypeId ? { expense_type_id: query.expenseTypeId } : {}), ...(query.scopeType ? { scope_type: query.scopeType } : {}), ...(query.enabled == null ? {} : { enabled: query.enabled }), }, orderBy: [ { expense_type: { sort_order: "asc" } }, { expense_type: { code: "asc" } }, { scope_type: "asc" }, { name: "asc" }, { created_at: "asc" }, ], select: { id: true, expense_type_id: true, name: true, max_amount: true, per_unit: true, control_mode: true, scope_type: true, scope_key: true, enabled: true, note: true, created_at: true, updated_at: true, expense_type: { select: { id: true, name: true, code: true, }, }, }, }); const departmentMap = await buildDepartmentMap(fastify.prisma, items); return sendData(reply, request, { expenseTypeId: query.expenseTypeId, scopeType: query.scopeType, enabled: query.enabled, items: items.map((item) => mapExpenseStandard(item, departmentMap)), }); }, ); fastify.post( "/expense-standards", { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { const body = parseBody(CREATE_BODY_SCHEMA, request.body); const now = new Date(); let created = null; try { created = await fastify.prisma.$transaction(async (tx) => { const expenseType = await ensureExpenseTypeExists(tx, body.expenseTypeId); const scopeType = body.scopeType ?? "global"; const department = await ensureScopeKeyValid(tx, scopeType, body.scopeKey ?? null); const record = await tx.expenseStandard.create({ data: { expense_type_id: body.expenseTypeId, name: body.name, max_amount: body.maxAmount, per_unit: body.perUnit ?? "item", control_mode: body.controlMode ?? "warn", scope_type: scopeType, scope_key: scopeType === "department" ? department?.id ?? body.scopeKey ?? null : null, enabled: body.enabled ?? true, note: body.note?.trim?.() || null, created_at: now, created_by: request.employee.id, updated_at: now, updated_by: request.employee.id, }, select: { id: true, expense_type_id: true, name: true, max_amount: true, per_unit: true, control_mode: true, scope_type: true, scope_key: true, enabled: true, note: true, created_at: true, updated_at: true, expense_type: { select: { id: true, name: true, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EXPENSE_STANDARD_CREATED", targetType: "expense_standard", targetId: record.id, extra: { expenseTypeId: expenseType.id, expenseTypeName: expenseType.name, scopeType: record.scope_type, scopeKey: record.scope_key, controlMode: record.control_mode, maxAmount: toMoneyString(record.max_amount), after: normalizeRecordSnapshot(record), }, }), }); return record; }); } catch (error) { if (error?.code === "P2002") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { duplicate: "expense_type_scope_conflict" }, }); } throw error; } const departmentMap = await buildDepartmentMap(fastify.prisma, [created]); return sendData(reply, request, mapExpenseStandard(created, departmentMap)); }, ); fastify.patch( "/expense-standards/:id", { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { const standardId = parseExpenseStandardId(request.params); const body = parseBody(UPDATE_BODY_SCHEMA, request.body); if (!Object.keys(body).length) { throw buildValidationError({ body: "empty" }); } const now = new Date(); let updated = null; try { updated = await fastify.prisma.$transaction(async (tx) => { const current = await tx.expenseStandard.findUnique({ where: { id: standardId }, select: { id: true, expense_type_id: true, name: true, max_amount: true, per_unit: true, control_mode: true, scope_type: true, scope_key: true, enabled: true, note: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const mergedBody = { ...body, scopeType: body.scopeType ?? current.scope_type, scopeKey: Object.prototype.hasOwnProperty.call(body, "scopeKey") ? body.scopeKey ?? null : current.scope_key ?? null, }; await ensureScopeKeyValid(tx, mergedBody.scopeType, mergedBody.scopeKey); const record = await tx.expenseStandard.update({ where: { id: standardId }, data: { ...buildStandardPayload(mergedBody, request.employee.id), updated_at: now, }, select: { id: true, expense_type_id: true, name: true, max_amount: true, per_unit: true, control_mode: true, scope_type: true, scope_key: true, enabled: true, note: true, created_at: true, updated_at: true, expense_type: { select: { id: true, name: true, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EXPENSE_STANDARD_UPDATED", targetType: "expense_standard", targetId: standardId, extra: { expenseTypeId: record.expense_type_id, scopeType: record.scope_type, scopeKey: record.scope_key, controlMode: record.control_mode, maxAmount: toMoneyString(record.max_amount), before: normalizeRecordSnapshot(current), after: normalizeRecordSnapshot(record), }, }), }); return record; }); } catch (error) { if (error?.code === "P2002") { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { duplicate: "expense_type_scope_conflict" }, }); } throw error; } const departmentMap = await buildDepartmentMap(fastify.prisma, [updated]); return sendData(reply, request, mapExpenseStandard(updated, departmentMap)); }, ); fastify.delete( "/expense-standards/:id", { preHandler: fastify.requirePermission("system.settings"), }, async (request, reply) => { const standardId = parseExpenseStandardId(request.params); const now = new Date(); const deleted = await fastify.prisma.$transaction(async (tx) => { const current = await tx.expenseStandard.findUnique({ where: { id: standardId }, select: { id: true, expense_type_id: true, name: true, max_amount: true, per_unit: true, control_mode: true, scope_type: true, scope_key: true, enabled: true, note: true, created_at: true, updated_at: true, expense_type: { select: { id: true, name: true, }, }, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const record = await tx.expenseStandard.update({ where: { id: standardId }, data: { enabled: false, updated_at: now, updated_by: request.employee.id, }, select: { id: true, expense_type_id: true, name: true, max_amount: true, per_unit: true, control_mode: true, scope_type: true, scope_key: true, enabled: true, note: true, created_at: true, updated_at: true, expense_type: { select: { id: true, name: true, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EXPENSE_STANDARD_DELETED", targetType: "expense_standard", targetId: standardId, extra: { expenseTypeId: record.expense_type_id, scopeType: record.scope_type, scopeKey: record.scope_key, controlMode: record.control_mode, maxAmount: toMoneyString(record.max_amount), before: normalizeRecordSnapshot(current), after: normalizeRecordSnapshot(record), }, }), }); return record; }); const departmentMap = await buildDepartmentMap(fastify.prisma, [deleted]); return sendData(reply, request, mapExpenseStandard(deleted, departmentMap)); }, ); } module.exports = expenseStandardsRoutes;