const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const BOOLEAN_QUERY_SCHEMA = z.enum(["0", "1", "true", "false"]).optional(); const UUID_SCHEMA = z.string().uuid(); const DOCUMENT_CATEGORY_ENTITY_TYPES = [ "reimbursement", "leave", "purchase_request", "asset_card", "general", ]; const RESET_PERIOD_VALUES = ["none", "daily", "monthly", "yearly"]; const legalEntityListQuerySchema = z.object({ includeArchived: BOOLEAN_QUERY_SCHEMA, enabled: BOOLEAN_QUERY_SCHEMA, }); const costCenterListQuerySchema = z.object({ includeArchived: BOOLEAN_QUERY_SCHEMA, enabled: BOOLEAN_QUERY_SCHEMA, legalEntityId: UUID_SCHEMA.optional(), }); const documentCategoryListQuerySchema = z.object({ includeArchived: BOOLEAN_QUERY_SCHEMA, enabled: BOOLEAN_QUERY_SCHEMA, entityType: z.enum(DOCUMENT_CATEGORY_ENTITY_TYPES).optional(), }); const legalEntityCreateBodySchema = z .object({ code: z.string().trim().min(2).max(32), name: z.string().trim().min(1).max(120), shortName: z.string().trim().max(120).nullable().optional(), currencyCode: z.string().trim().min(3).max(8), taxNo: z.string().trim().max(64).nullable().optional(), enabled: z.boolean().optional(), }) .strict(); const legalEntityPatchBodySchema = legalEntityCreateBodySchema.partial(); const costCenterCreateBodySchema = z .object({ legalEntityId: UUID_SCHEMA, code: z.string().trim().min(2).max(32), name: z.string().trim().min(1).max(120), parentId: UUID_SCHEMA.nullable().optional(), ownerEmployeeId: UUID_SCHEMA.nullable().optional(), enabled: z.boolean().optional(), }) .strict(); const costCenterPatchBodySchema = costCenterCreateBodySchema.partial(); const documentCategoryCreateBodySchema = z .object({ code: z.string().trim().min(2).max(64), name: z.string().trim().min(1).max(120), entityType: z.enum(DOCUMENT_CATEGORY_ENTITY_TYPES), sequencePrefix: z.string().trim().min(1).max(20), numberLength: z.number().int().min(2).max(8).optional(), resetPeriod: z.enum(RESET_PERIOD_VALUES), defaultWorkflowKey: z.string().trim().max(64).nullable().optional(), enabled: z.boolean().optional(), }) .strict(); const documentCategoryPatchBodySchema = documentCategoryCreateBodySchema.partial(); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseUuid(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ id: "invalid_uuid" }); } return parsed.data; } function parseBooleanQueryValue(rawValue) { if (rawValue == null) { return null; } return rawValue === "1" || rawValue === "true"; } function parseListQuery(schema, query) { const parsed = schema.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { ...parsed.data, includeArchived: parseBooleanQueryValue(parsed.data.includeArchived) ?? false, enabled: parseBooleanQueryValue(parsed.data.enabled), }; } function parseCreateBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parsePatchBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { throw buildValidationError({ body: "empty" }); } return parsed.data; } function isUniqueConstraint(error, targetField) { if (!error || error.code !== "P2002") { return false; } if (!targetField) { return true; } const target = error.meta?.target; if (Array.isArray(target)) { return target.includes(targetField); } return typeof target === "string" && target.includes(targetField); } function mapLegalEntity(record) { return { id: record.id, code: record.code, name: record.name, shortName: record.short_name ?? null, currencyCode: record.currency_code, taxNo: record.tax_no ?? null, enabled: Boolean(record.enabled), archivedAt: record.archived_at?.toISOString?.() ?? null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } function mapCostCenter(record) { return { id: record.id, legalEntityId: record.legal_entity_id, code: record.code, name: record.name, parentId: record.parent_id ?? null, ownerEmployeeId: record.owner_employee_id ?? null, enabled: Boolean(record.enabled), archivedAt: record.archived_at?.toISOString?.() ?? null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, legalEntity: record.legal_entity ? { id: record.legal_entity.id, code: record.legal_entity.code, name: record.legal_entity.name, } : null, parent: record.parent ? { id: record.parent.id, name: record.parent.name, } : null, owner: record.owner_employee ? { id: record.owner_employee.id, employeeNo: record.owner_employee.employee_no, name: record.owner_employee.name, } : null, }; } function mapDocumentCategory(record) { return { id: record.id, code: record.code, name: record.name, entityType: record.entity_type, sequencePrefix: record.sequence_prefix, numberLength: record.number_length, resetPeriod: record.reset_period, defaultWorkflowKey: record.default_workflow_key ?? null, enabled: Boolean(record.enabled), archivedAt: record.archived_at?.toISOString?.() ?? null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } function normalizeLegalEntityPayload(body) { return { code: typeof body.code === "string" ? body.code.trim().toUpperCase() : undefined, name: typeof body.name === "string" ? body.name.trim() : undefined, shortName: body.shortName === undefined ? undefined : body.shortName?.trim() ? body.shortName.trim() : null, currencyCode: typeof body.currencyCode === "string" ? body.currencyCode.trim().toUpperCase() : undefined, taxNo: body.taxNo === undefined ? undefined : body.taxNo?.trim() ? body.taxNo.trim() : null, enabled: body.enabled, }; } function normalizeCostCenterPayload(body) { return { legalEntityId: body.legalEntityId, code: typeof body.code === "string" ? body.code.trim().toUpperCase() : undefined, name: typeof body.name === "string" ? body.name.trim() : undefined, parentId: Object.prototype.hasOwnProperty.call(body, "parentId") ? body.parentId ?? null : undefined, ownerEmployeeId: Object.prototype.hasOwnProperty.call(body, "ownerEmployeeId") ? body.ownerEmployeeId ?? null : undefined, enabled: body.enabled, }; } function normalizeDocumentCategoryPayload(body) { return { code: typeof body.code === "string" ? body.code.trim().toLowerCase() : undefined, name: typeof body.name === "string" ? body.name.trim() : undefined, entityType: body.entityType, sequencePrefix: typeof body.sequencePrefix === "string" ? body.sequencePrefix.trim().toUpperCase() : undefined, numberLength: body.numberLength, resetPeriod: body.resetPeriod, defaultWorkflowKey: body.defaultWorkflowKey === undefined ? undefined : body.defaultWorkflowKey?.trim() ? body.defaultWorkflowKey.trim() : null, enabled: body.enabled, }; } function detectParentCycle(items, selfId, parentId) { const parentMap = new Map(items.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 === selfId) { return true; } cursor = parentMap.get(cursor) ?? null; } return false; } async function ensureLegalEntityAvailable(tx, legalEntityId) { const legalEntity = await tx.legalEntity.findUnique({ where: { id: legalEntityId }, select: { id: true, code: true, name: true, archived_at: true }, }); if (!legalEntity) { throw buildValidationError({ legalEntityId: "not_found" }); } if (legalEntity.archived_at) { throw buildValidationError({ legalEntityId: "archived" }, 409); } return legalEntity; } async function ensureOwnerEmployeeAvailable(tx, ownerEmployeeId) { if (!ownerEmployeeId) { return null; } const employee = await tx.employee.findUnique({ where: { id: ownerEmployeeId }, select: { id: true, name: true, employee_no: true, status: true }, }); if (!employee || employee.status !== "active") { throw buildValidationError({ ownerEmployeeId: "not_found" }); } return employee; } async function ensureCostCenterParent(tx, { parentId, legalEntityId, selfId }) { if (!parentId) { return null; } if (selfId && parentId === selfId) { throw buildValidationError({ parentId: "self_not_allowed" }, 409); } const parent = await tx.costCenter.findUnique({ where: { id: parentId }, select: { id: true, name: true, legal_entity_id: true, archived_at: true, }, }); if (!parent) { throw buildValidationError({ parentId: "not_found" }); } if (parent.archived_at) { throw buildValidationError({ parentId: "archived" }, 409); } if (parent.legal_entity_id !== legalEntityId) { throw buildValidationError({ parentId: "cross_legal_entity_not_allowed" }, 409); } if (selfId) { const items = await tx.costCenter.findMany({ select: { id: true, parent_id: true }, }); if (detectParentCycle(items, selfId, parentId)) { throw buildValidationError({ parentId: "cycle_not_allowed" }, 409); } } return parent; } function buildMasterDataViewPreHandler(fastify) { return async (request) => { await fastify.authenticate(request); const canView = fastify.hasPermission(request.employee, "masterdata.view") || fastify.hasPermission(request.employee, "masterdata.manage"); if (!canView) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "masterdata.view" }, }); } }; } async function masterDataRoutes(fastify) { const requireMasterDataView = buildMasterDataViewPreHandler(fastify); fastify.get( "/master-data/legal-entities", { preHandler: requireMasterDataView, schema: { tags: ["master-data"], summary: "List legal entities", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const query = parseListQuery(legalEntityListQuerySchema, request.query); const items = await fastify.prisma.legalEntity.findMany({ where: { ...(query.includeArchived ? {} : { archived_at: null }), ...(query.enabled == null ? {} : { enabled: query.enabled }), }, orderBy: [{ code: "asc" }], }); return sendData(reply, request, { includeArchived: query.includeArchived, items: items.map(mapLegalEntity), }); }, ); fastify.post( "/master-data/legal-entities", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Create legal entity", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const body = normalizeLegalEntityPayload(parseCreateBody(legalEntityCreateBodySchema, request.body)); try { const legalEntity = await fastify.prisma.legalEntity.create({ data: { code: body.code, name: body.name, short_name: body.shortName ?? null, currency_code: body.currencyCode, tax_no: body.taxNo ?? null, enabled: body.enabled ?? true, created_by: request.employee.id, updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "LEGAL_ENTITY_CREATE", targetType: "legal_entity", targetId: legalEntity.id, extra: { code: legalEntity.code, name: legalEntity.name }, }), }); return sendData(reply, request, { legalEntity: mapLegalEntity(legalEntity) }); } catch (error) { if (isUniqueConstraint(error, "code")) { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.patch( "/master-data/legal-entities/:id", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Patch legal entity", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const legalEntityId = parseUuid(request.params?.id); const body = normalizeLegalEntityPayload(parsePatchBody(legalEntityPatchBodySchema, request.body)); const current = await fastify.prisma.legalEntity.findUnique({ where: { id: legalEntityId }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.archived_at) { throw buildValidationError({ status: "archived" }, 409); } try { const legalEntity = await fastify.prisma.legalEntity.update({ where: { id: legalEntityId }, data: { ...(body.code !== undefined ? { code: body.code } : {}), ...(body.name !== undefined ? { name: body.name } : {}), ...(body.shortName !== undefined ? { short_name: body.shortName } : {}), ...(body.currencyCode !== undefined ? { currency_code: body.currencyCode } : {}), ...(body.taxNo !== undefined ? { tax_no: body.taxNo } : {}), ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "LEGAL_ENTITY_UPDATE", targetType: "legal_entity", targetId: legalEntity.id, extra: { code: legalEntity.code, name: legalEntity.name }, }), }); return sendData(reply, request, { legalEntity: mapLegalEntity(legalEntity) }); } catch (error) { if (isUniqueConstraint(error, "code")) { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.post( "/master-data/legal-entities/:id/archive", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Archive legal entity", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const legalEntityId = parseUuid(request.params?.id); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { const current = await tx.legalEntity.findUnique({ where: { id: legalEntityId } }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.archived_at) { return { legalEntity: current, outcome: "already_archived" }; } const activeCostCenterCount = await tx.costCenter.count({ where: { legal_entity_id: legalEntityId, archived_at: null }, }); if (activeCostCenterCount > 0) { throw buildValidationError({ legalEntity: "has_cost_centers" }, 409); } const legalEntity = await tx.legalEntity.update({ where: { id: legalEntityId }, data: { enabled: false, archived_at: now, updated_by: request.employee.id, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "LEGAL_ENTITY_ARCHIVE", targetType: "legal_entity", targetId: legalEntity.id, extra: { code: legalEntity.code, name: legalEntity.name }, }), }); return { legalEntity, outcome: "archived" }; }); return sendData(reply, request, { legalEntity: mapLegalEntity(result.legalEntity), outcome: result.outcome, }); }, ); fastify.get( "/master-data/cost-centers", { preHandler: requireMasterDataView, schema: { tags: ["master-data"], summary: "List cost centers", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const query = parseListQuery(costCenterListQuerySchema, request.query); const items = await fastify.prisma.costCenter.findMany({ where: { ...(query.includeArchived ? {} : { archived_at: null }), ...(query.enabled == null ? {} : { enabled: query.enabled }), ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), }, orderBy: [{ legal_entity_id: "asc" }, { code: "asc" }], select: { id: true, legal_entity_id: true, code: true, name: true, parent_id: true, owner_employee_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true } }, parent: { select: { id: true, name: true } }, owner_employee: { select: { id: true, employee_no: true, name: true } }, }, }); return sendData(reply, request, { includeArchived: query.includeArchived, items: items.map(mapCostCenter), }); }, ); fastify.post( "/master-data/cost-centers", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Create cost center", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const body = normalizeCostCenterPayload(parseCreateBody(costCenterCreateBodySchema, request.body)); try { const costCenter = await fastify.prisma.$transaction(async (tx) => { await ensureLegalEntityAvailable(tx, body.legalEntityId); await ensureOwnerEmployeeAvailable(tx, body.ownerEmployeeId ?? null); await ensureCostCenterParent(tx, { parentId: body.parentId ?? null, legalEntityId: body.legalEntityId, selfId: null, }); const created = await tx.costCenter.create({ data: { legal_entity_id: body.legalEntityId, code: body.code, name: body.name, parent_id: body.parentId ?? null, owner_employee_id: body.ownerEmployeeId ?? null, enabled: body.enabled ?? true, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, legal_entity_id: true, code: true, name: true, parent_id: true, owner_employee_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true } }, parent: { select: { id: true, name: true } }, owner_employee: { select: { id: true, employee_no: true, name: true } }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "COST_CENTER_CREATE", targetType: "cost_center", targetId: created.id, extra: { code: created.code, name: created.name, legalEntityId: created.legal_entity_id }, }), }); return created; }); return sendData(reply, request, { costCenter: mapCostCenter(costCenter) }); } catch (error) { if (isUniqueConstraint(error)) { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.patch( "/master-data/cost-centers/:id", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Patch cost center", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const costCenterId = parseUuid(request.params?.id); const body = normalizeCostCenterPayload(parsePatchBody(costCenterPatchBodySchema, request.body)); try { const costCenter = await fastify.prisma.$transaction(async (tx) => { const current = await tx.costCenter.findUnique({ where: { id: costCenterId }, select: { id: true, legal_entity_id: true, code: true, name: true, parent_id: true, owner_employee_id: true, enabled: true, archived_at: true, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.archived_at) { throw buildValidationError({ status: "archived" }, 409); } const nextLegalEntityId = body.legalEntityId ?? current.legal_entity_id; const nextParentId = body.parentId !== undefined ? body.parentId : current.parent_id; const nextOwnerEmployeeId = body.ownerEmployeeId !== undefined ? body.ownerEmployeeId : current.owner_employee_id; await ensureLegalEntityAvailable(tx, nextLegalEntityId); await ensureOwnerEmployeeAvailable(tx, nextOwnerEmployeeId ?? null); await ensureCostCenterParent(tx, { parentId: nextParentId ?? null, legalEntityId: nextLegalEntityId, selfId: costCenterId, }); const updated = await tx.costCenter.update({ where: { id: costCenterId }, data: { ...(body.legalEntityId !== undefined ? { legal_entity_id: body.legalEntityId } : {}), ...(body.code !== undefined ? { code: body.code } : {}), ...(body.name !== undefined ? { name: body.name } : {}), ...(body.parentId !== undefined ? { parent_id: body.parentId } : {}), ...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}), ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), updated_by: request.employee.id, }, select: { id: true, legal_entity_id: true, code: true, name: true, parent_id: true, owner_employee_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true } }, parent: { select: { id: true, name: true } }, owner_employee: { select: { id: true, employee_no: true, name: true } }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "COST_CENTER_UPDATE", targetType: "cost_center", targetId: updated.id, extra: { code: updated.code, name: updated.name, legalEntityId: updated.legal_entity_id }, }), }); return updated; }); return sendData(reply, request, { costCenter: mapCostCenter(costCenter) }); } catch (error) { if (isUniqueConstraint(error)) { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.post( "/master-data/cost-centers/:id/archive", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Archive cost center", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const costCenterId = parseUuid(request.params?.id); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { const current = await tx.costCenter.findUnique({ where: { id: costCenterId }, select: { id: true, legal_entity_id: true, code: true, name: true, parent_id: true, owner_employee_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true } }, parent: { select: { id: true, name: true } }, owner_employee: { select: { id: true, employee_no: true, name: true } }, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.archived_at) { return { costCenter: current, outcome: "already_archived" }; } const childCount = await tx.costCenter.count({ where: { parent_id: costCenterId, archived_at: null }, }); if (childCount > 0) { throw buildValidationError({ costCenter: "has_children" }, 409); } const costCenter = await tx.costCenter.update({ where: { id: costCenterId }, data: { enabled: false, archived_at: now, updated_by: request.employee.id, }, select: { id: true, legal_entity_id: true, code: true, name: true, parent_id: true, owner_employee_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true } }, parent: { select: { id: true, name: true } }, owner_employee: { select: { id: true, employee_no: true, name: true } }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "COST_CENTER_ARCHIVE", targetType: "cost_center", targetId: costCenter.id, extra: { code: costCenter.code, name: costCenter.name }, }), }); return { costCenter, outcome: "archived" }; }); return sendData(reply, request, { costCenter: mapCostCenter(result.costCenter), outcome: result.outcome, }); }, ); fastify.get( "/master-data/document-categories", { preHandler: requireMasterDataView, schema: { tags: ["master-data"], summary: "List document categories", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const query = parseListQuery(documentCategoryListQuerySchema, request.query); const items = await fastify.prisma.documentCategory.findMany({ where: { ...(query.includeArchived ? {} : { archived_at: null }), ...(query.enabled == null ? {} : { enabled: query.enabled }), ...(query.entityType ? { entity_type: query.entityType } : {}), }, orderBy: [{ entity_type: "asc" }, { code: "asc" }], }); return sendData(reply, request, { includeArchived: query.includeArchived, items: items.map(mapDocumentCategory), }); }, ); fastify.post( "/master-data/document-categories", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Create document category", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const body = normalizeDocumentCategoryPayload( parseCreateBody(documentCategoryCreateBodySchema, request.body), ); try { const documentCategory = await fastify.prisma.documentCategory.create({ data: { code: body.code, name: body.name, entity_type: body.entityType, sequence_prefix: body.sequencePrefix, number_length: body.numberLength ?? 4, reset_period: body.resetPeriod, default_workflow_key: body.defaultWorkflowKey ?? null, enabled: body.enabled ?? true, created_by: request.employee.id, updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "DOCUMENT_CATEGORY_CREATE", targetType: "document_category", targetId: documentCategory.id, extra: { code: documentCategory.code, entityType: documentCategory.entity_type }, }), }); return sendData(reply, request, { documentCategory: mapDocumentCategory(documentCategory) }); } catch (error) { if (isUniqueConstraint(error, "code")) { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.patch( "/master-data/document-categories/:id", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Patch document category", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const documentCategoryId = parseUuid(request.params?.id); const body = normalizeDocumentCategoryPayload( parsePatchBody(documentCategoryPatchBodySchema, request.body), ); const current = await fastify.prisma.documentCategory.findUnique({ where: { id: documentCategoryId }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.archived_at) { throw buildValidationError({ status: "archived" }, 409); } try { const documentCategory = await fastify.prisma.documentCategory.update({ where: { id: documentCategoryId }, data: { ...(body.code !== undefined ? { code: body.code } : {}), ...(body.name !== undefined ? { name: body.name } : {}), ...(body.entityType !== undefined ? { entity_type: body.entityType } : {}), ...(body.sequencePrefix !== undefined ? { sequence_prefix: body.sequencePrefix } : {}), ...(body.numberLength !== undefined ? { number_length: body.numberLength } : {}), ...(body.resetPeriod !== undefined ? { reset_period: body.resetPeriod } : {}), ...(body.defaultWorkflowKey !== undefined ? { default_workflow_key: body.defaultWorkflowKey } : {}), ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), updated_by: request.employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "DOCUMENT_CATEGORY_UPDATE", targetType: "document_category", targetId: documentCategory.id, extra: { code: documentCategory.code, entityType: documentCategory.entity_type }, }), }); return sendData(reply, request, { documentCategory: mapDocumentCategory(documentCategory) }); } catch (error) { if (isUniqueConstraint(error, "code")) { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.post( "/master-data/document-categories/:id/archive", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Archive document category", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const documentCategoryId = parseUuid(request.params?.id); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { const current = await tx.documentCategory.findUnique({ where: { id: documentCategoryId } }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.archived_at) { return { documentCategory: current, outcome: "already_archived" }; } const documentCategory = await tx.documentCategory.update({ where: { id: documentCategoryId }, data: { enabled: false, archived_at: now, updated_by: request.employee.id, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "DOCUMENT_CATEGORY_ARCHIVE", targetType: "document_category", targetId: documentCategory.id, extra: { code: documentCategory.code, entityType: documentCategory.entity_type }, }), }); return { documentCategory, outcome: "archived" }; }); return sendData(reply, request, { documentCategory: mapDocumentCategory(result.documentCategory), outcome: result.outcome, }); }, ); } module.exports = masterDataRoutes;