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 LIST_QUERY_SCHEMA = z.object({ includeArchived: z.enum(["0", "1", "true", "false"]).optional(), }); const CREATE_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100), parentId: z.string().uuid().nullable().optional(), }); const UPDATE_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100).optional(), parentId: z.string().uuid().nullable().optional(), }); function parseDepartmentId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } return parsed.data; } function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const raw = parsed.data.includeArchived ?? "0"; const includeArchived = raw === "1" || raw === "true"; return { includeArchived }; } function parseCreateBody(body) { const parsed = CREATE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } return { name: parsed.data.name.trim(), parentId: parsed.data.parentId ?? null, }; } function parseUpdateBody(body) { const parsed = UPDATE_BODY_SCHEMA.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const hasUpdate = typeof parsed.data.name === "string" || "parentId" in parsed.data; if (!hasUpdate) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { body: "empty" }, }); } return { ...(typeof parsed.data.name === "string" ? { name: parsed.data.name.trim() } : {}), ...(Object.prototype.hasOwnProperty.call(parsed.data, "parentId") ? { parentId: parsed.data.parentId ?? null } : {}), }; } function mapDepartmentItem(record) { return { id: record.id, name: record.name, parentId: record.parent_id, archivedAt: record.archived_at ? record.archived_at.toISOString() : null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } function buildDepartmentTree(items) { const nodesById = new Map(); for (const record of items) { nodesById.set(record.id, { ...mapDepartmentItem(record), children: [] }); } const roots = []; for (const record of items) { const node = nodesById.get(record.id); if (!node) continue; const parentId = record.parent_id; if (parentId && nodesById.has(parentId)) { nodesById.get(parentId).children.push(node); } else { roots.push(node); } } return roots; } function detectParentCycle(departments, departmentId, parentId) { const parentMap = new Map(departments.map((item) => [item.id, item.parent_id])); let cursor = parentId; const visited = new Set(); while (cursor) { if (visited.has(cursor)) { break; } visited.add(cursor); if (cursor === departmentId) { return true; } cursor = parentMap.get(cursor) ?? null; } return false; } async function departmentsRoutes(fastify) { fastify.get( "/departments", { preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseListQuery(request.query); const where = query.includeArchived ? {} : { archived_at: null }; const items = await fastify.prisma.department.findMany({ where, orderBy: [{ name: "asc" }, { created_at: "asc" }], select: { id: true, name: true, parent_id: true, archived_at: true, created_at: true, updated_at: true, }, }); return sendData(reply, request, { includeArchived: query.includeArchived, items: items.map(mapDepartmentItem), tree: buildDepartmentTree(items), }); }, ); fastify.post( "/departments", { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const input = parseCreateBody(request.body); const now = new Date(); if (input.parentId) { const parent = await fastify.prisma.department.findUnique({ where: { id: input.parentId }, select: { id: true, archived_at: true }, }); if (!parent) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { parentId: "not_found" }, }); } if (parent.archived_at) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { parentId: "archived" }, }); } } const created = await fastify.prisma.$transaction(async (tx) => { const department = await tx.department.create({ data: { name: input.name, parent_id: input.parentId, created_by: request.employee.id, updated_by: request.employee.id, created_at: now, }, select: { id: true, name: true, parent_id: true, archived_at: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "DEPARTMENT_CREATE", targetType: "department", targetId: department.id, extra: { name: department.name, parentId: department.parent_id, }, }), }); return department; }); return sendData(reply, request, { department: mapDepartmentItem(created) }); }, ); fastify.patch( "/departments/:id", { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const departmentId = parseDepartmentId(request.params?.id); const input = parseUpdateBody(request.body); const now = new Date(); const updated = await fastify.prisma.$transaction(async (tx) => { const current = await tx.department.findUnique({ where: { id: departmentId }, select: { id: true, name: true, parent_id: true, archived_at: true, created_at: true, updated_at: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (current.archived_at) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "archived" }, }); } const parentId = Object.prototype.hasOwnProperty.call(input, "parentId") ? input.parentId : current.parent_id; if (parentId === departmentId) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { parentId: "self_not_allowed" }, }); } if (parentId) { const parent = await tx.department.findUnique({ where: { id: parentId }, select: { id: true, archived_at: true }, }); if (!parent) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { parentId: "not_found" }, }); } if (parent.archived_at) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { parentId: "archived" }, }); } const allDepartments = await tx.department.findMany({ select: { id: true, parent_id: true }, }); if (detectParentCycle(allDepartments, departmentId, parentId)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { parentId: "cycle_not_allowed" }, }); } } const changes = {}; if (typeof input.name === "string" && input.name !== current.name) { changes.name = { before: current.name, after: input.name }; } if (Object.prototype.hasOwnProperty.call(input, "parentId")) { changes.parentId = { before: current.parent_id, after: parentId }; } const department = await tx.department.update({ where: { id: departmentId }, data: { ...(typeof input.name === "string" ? { name: input.name } : {}), ...(Object.prototype.hasOwnProperty.call(input, "parentId") ? { parent_id: parentId } : {}), updated_by: request.employee.id, updated_at: now, }, select: { id: true, name: true, parent_id: true, archived_at: true, created_at: true, updated_at: true, }, }); if (Object.keys(changes).length) { await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "DEPARTMENT_UPDATE", targetType: "department", targetId: department.id, extra: { changes }, }), }); } return department; }); return sendData(reply, request, { department: mapDepartmentItem(updated) }); }, ); fastify.post( "/departments/:id/archive", { preHandler: fastify.requirePermission("system.users"), }, async (request, reply) => { const departmentId = parseDepartmentId(request.params?.id); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { const current = await tx.department.findUnique({ where: { id: departmentId }, select: { id: true, name: true, parent_id: true, archived_at: true, created_at: true, updated_at: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } if (current.archived_at) { return { department: current, outcome: "already_archived" }; } const [childCount, activeEmployeeCount] = await Promise.all([ tx.department.count({ where: { parent_id: departmentId } }), tx.employee.count({ where: { department_id: departmentId, status: "active" } }), ]); if (childCount > 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { department: "has_children" }, }); } if (activeEmployeeCount > 0) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { department: "has_active_employees" }, }); } const department = await tx.department.update({ where: { id: departmentId }, data: { archived_at: now, archived_by: request.employee.id, updated_by: request.employee.id, updated_at: now, }, select: { id: true, name: true, parent_id: true, archived_at: true, created_at: true, updated_at: true, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "DEPARTMENT_ARCHIVE", targetType: "department", targetId: department.id, extra: { name: department.name }, }), }); return { department, outcome: "archived" }; }); return sendData(reply, request, { department: mapDepartmentItem(result.department), outcome: result.outcome, }); }, ); } module.exports = departmentsRoutes;