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 DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const HOLIDAY_DAY_TYPE_VALUES = ["holiday", "workday", "adjusted_workday"]; const holidayCalendarListQuerySchema = z.object({ includeArchived: BOOLEAN_QUERY_SCHEMA, enabled: BOOLEAN_QUERY_SCHEMA, legalEntityId: UUID_SCHEMA.optional(), }); const holidayCalendarCreateBodySchema = z .object({ code: z.string().trim().min(2).max(64), name: z.string().trim().min(1).max(120), timezone: z.string().trim().min(1).max(64).optional(), legalEntityId: UUID_SCHEMA.nullable().optional(), enabled: z.boolean().optional(), }) .strict(); const holidayCalendarPatchBodySchema = holidayCalendarCreateBodySchema.partial(); const holidayCalendarDaySchema = z .object({ date: DATE_ONLY_SCHEMA, dayType: z.enum(HOLIDAY_DAY_TYPE_VALUES), name: z.string().trim().min(1).max(120), note: z.string().trim().max(500).nullable().optional(), }) .strict(); const holidayCalendarDaysReplaceBodySchema = z .object({ items: z.array(holidayCalendarDaySchema).max(500), }) .strict(); 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(query) { const parsed = holidayCalendarListQuerySchema.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(body) { const parsed = holidayCalendarCreateBodySchema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parsePatchBody(body) { const parsed = holidayCalendarPatchBodySchema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } if (!Object.keys(parsed.data).length) { throw buildValidationError({ body: "empty" }); } return parsed.data; } function parseReplaceDaysBody(body) { const parsed = holidayCalendarDaysReplaceBodySchema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const seenDates = new Set(); for (const item of parsed.data.items) { if (seenDates.has(item.date)) { throw buildValidationError({ date: "duplicate" }, 409); } seenDates.add(item.date); } return parsed.data.items; } function normalizeCalendarPayload(body) { return { code: typeof body.code === "string" ? body.code.trim().toUpperCase() : undefined, name: typeof body.name === "string" ? body.name.trim() : undefined, timezone: typeof body.timezone === "string" ? body.timezone.trim() : undefined, legalEntityId: Object.prototype.hasOwnProperty.call(body, "legalEntityId") ? body.legalEntityId ?? null : undefined, enabled: body.enabled, }; } function normalizeHolidayCalendarDay(item) { return { date: item.date, dayType: item.dayType, name: item.name.trim(), note: item.note?.trim() ? item.note.trim() : null, }; } function toDateOnly(value) { return new Date(`${value}T00:00:00.000Z`); } function mapHolidayCalendar(record) { const defaultCalendarId = record.legal_entity?.default_holiday_calendar_id ?? null; return { id: record.id, code: record.code, name: record.name, timezone: record.timezone, legalEntityId: record.legal_entity_id ?? null, enabled: Boolean(record.enabled), archivedAt: record.archived_at?.toISOString?.() ?? null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, daysCount: typeof record.days_count === "number" ? record.days_count : typeof record._count?.days === "number" ? record._count.days : 0, isDefaultForLegalEntity: defaultCalendarId === record.id, legalEntity: record.legal_entity ? { id: record.legal_entity.id, code: record.legal_entity.code, name: record.legal_entity.name, } : null, }; } function mapHolidayCalendarDay(record) { return { id: record.id, date: record.date.toISOString().slice(0, 10), dayType: record.day_type, name: record.name, note: record.note ?? null, createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, }; } async function ensureLegalEntityAvailable(tx, legalEntityId) { if (!legalEntityId) { return null; } const legalEntity = await tx.legalEntity.findUnique({ where: { id: legalEntityId }, select: { id: true, code: true, name: true, archived_at: true, default_holiday_calendar_id: true, }, }); if (!legalEntity) { throw buildValidationError({ legalEntityId: "not_found" }); } if (legalEntity.archived_at) { throw buildValidationError({ legalEntityId: "archived" }, 409); } return legalEntity; } function assertMasterDataViewPermission(fastify, 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 masterDataHolidayCalendarsRoutes(fastify) { fastify.get( "/master-data/holiday-calendars", { preHandler: fastify.authenticate, schema: { tags: ["master-data"], summary: "List holiday calendars", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { assertMasterDataViewPermission(fastify, request); const query = parseListQuery(request.query); const items = await fastify.prisma.holidayCalendar.findMany({ where: { ...(query.includeArchived ? {} : { archived_at: null }), ...(query.enabled == null ? {} : { enabled: query.enabled }), ...(query.legalEntityId ? { legal_entity_id: query.legalEntityId } : {}), }, orderBy: [{ code: "asc" }], select: { id: true, code: true, name: true, timezone: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, default_holiday_calendar_id: true, }, }, _count: { select: { days: true }, }, }, }); return sendData(reply, request, { includeArchived: query.includeArchived, items: items.map(mapHolidayCalendar), }); }, ); fastify.post( "/master-data/holiday-calendars", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Create holiday calendar", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const body = normalizeCalendarPayload(parseCreateBody(request.body)); try { const result = await fastify.prisma.$transaction(async (tx) => { const legalEntity = await ensureLegalEntityAvailable(tx, body.legalEntityId ?? null); const holidayCalendar = await tx.holidayCalendar.create({ data: { code: body.code, name: body.name, timezone: body.timezone ?? "Asia/Shanghai", legal_entity_id: body.legalEntityId ?? null, enabled: body.enabled ?? true, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, code: true, name: true, timezone: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, default_holiday_calendar_id: true, }, }, _count: { select: { days: true }, }, }, }); if (legalEntity && !legalEntity.default_holiday_calendar_id) { await tx.legalEntity.update({ where: { id: legalEntity.id }, data: { default_holiday_calendar_id: holidayCalendar.id, updated_by: request.employee.id, }, }); holidayCalendar.legal_entity.default_holiday_calendar_id = holidayCalendar.id; } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "HOLIDAY_CALENDAR_CREATE", targetType: "holiday_calendar", targetId: holidayCalendar.id, extra: { code: holidayCalendar.code, legalEntityId: holidayCalendar.legal_entity_id, }, }), }); return holidayCalendar; }); return sendData(reply, request, { holidayCalendar: mapHolidayCalendar(result) }); } catch (error) { if (error?.code === "P2002") { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.patch( "/master-data/holiday-calendars/:id", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Patch holiday calendar", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const holidayCalendarId = parseUuid(request.params?.id); const body = normalizeCalendarPayload(parsePatchBody(request.body)); const current = await fastify.prisma.holidayCalendar.findUnique({ where: { id: holidayCalendarId }, select: { id: true, code: 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); } try { const result = await fastify.prisma.$transaction(async (tx) => { if (body.legalEntityId !== undefined) { await ensureLegalEntityAvailable(tx, body.legalEntityId); } const holidayCalendar = await tx.holidayCalendar.update({ where: { id: holidayCalendarId }, data: { ...(body.code !== undefined ? { code: body.code } : {}), ...(body.name !== undefined ? { name: body.name } : {}), ...(body.timezone !== undefined ? { timezone: body.timezone } : {}), ...(body.legalEntityId !== undefined ? { legal_entity_id: body.legalEntityId } : {}), ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), updated_by: request.employee.id, }, select: { id: true, code: true, name: true, timezone: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, default_holiday_calendar_id: true, }, }, _count: { select: { days: true }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "HOLIDAY_CALENDAR_UPDATE", targetType: "holiday_calendar", targetId: holidayCalendar.id, extra: { code: holidayCalendar.code, legalEntityId: holidayCalendar.legal_entity_id, }, }), }); return holidayCalendar; }); return sendData(reply, request, { holidayCalendar: mapHolidayCalendar(result) }); } catch (error) { if (error?.code === "P2002") { throw buildValidationError({ code: "duplicate" }, 409); } throw error; } }, ); fastify.get( "/master-data/holiday-calendars/:id/days", { preHandler: fastify.authenticate, schema: { tags: ["master-data"], summary: "Get holiday calendar days", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { assertMasterDataViewPermission(fastify, request); const holidayCalendarId = parseUuid(request.params?.id); const holidayCalendar = await fastify.prisma.holidayCalendar.findUnique({ where: { id: holidayCalendarId }, select: { id: true }, }); if (!holidayCalendar) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const items = await fastify.prisma.holidayCalendarDay.findMany({ where: { calendar_id: holidayCalendarId }, orderBy: [{ date: "asc" }, { created_at: "asc" }], select: { id: true, date: true, day_type: true, name: true, note: true, created_at: true, updated_at: true, }, }); return sendData(reply, request, { items: items.map(mapHolidayCalendarDay) }); }, ); fastify.put( "/master-data/holiday-calendars/:id/days", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Replace holiday calendar days", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const holidayCalendarId = parseUuid(request.params?.id); const items = parseReplaceDaysBody(request.body).map(normalizeHolidayCalendarDay); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { const current = await tx.holidayCalendar.findUnique({ where: { id: holidayCalendarId }, select: { id: true, code: 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); } await tx.holidayCalendarDay.deleteMany({ where: { calendar_id: holidayCalendarId }, }); if (items.length) { await tx.holidayCalendarDay.createMany({ data: items.map((item) => ({ calendar_id: holidayCalendarId, date: toDateOnly(item.date), day_type: item.dayType, name: item.name, note: item.note, created_at: now, created_by: request.employee.id, updated_at: now, updated_by: request.employee.id, })), }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "HOLIDAY_CALENDAR_DAYS_REPLACE", targetType: "holiday_calendar", targetId: holidayCalendarId, extra: { count: items.length, code: current.code, }, }), }); return current; }); return sendData(reply, request, { holidayCalendarId: result.id, replacedCount: items.length, }); }, ); fastify.post( "/master-data/holiday-calendars/:id/archive", { preHandler: fastify.requirePermission("masterdata.manage"), schema: { tags: ["master-data"], summary: "Archive holiday calendar", security: [{ bearerAuth: [] }], }, }, async (request, reply) => { const holidayCalendarId = parseUuid(request.params?.id); const now = new Date(); const result = await fastify.prisma.$transaction(async (tx) => { const current = await tx.holidayCalendar.findUnique({ where: { id: holidayCalendarId }, select: { id: true, code: true, name: true, timezone: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, default_holiday_calendar_id: true, }, }, _count: { select: { days: true }, }, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (current.archived_at) { return { holidayCalendar: current, outcome: "already_archived" }; } const boundLegalEntity = await tx.legalEntity.findFirst({ where: { default_holiday_calendar_id: holidayCalendarId, archived_at: null, }, select: { id: true, code: true, name: true }, }); if (boundLegalEntity) { throw buildValidationError( { holidayCalendar: "bound_as_default", legalEntityCode: boundLegalEntity.code, }, 409, ); } const holidayCalendar = await tx.holidayCalendar.update({ where: { id: holidayCalendarId }, data: { enabled: false, archived_at: now, updated_by: request.employee.id, }, select: { id: true, code: true, name: true, timezone: true, legal_entity_id: true, enabled: true, archived_at: true, created_at: true, updated_at: true, legal_entity: { select: { id: true, code: true, name: true, default_holiday_calendar_id: true, }, }, _count: { select: { days: true }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "HOLIDAY_CALENDAR_ARCHIVE", targetType: "holiday_calendar", targetId: holidayCalendar.id, extra: { code: holidayCalendar.code, legalEntityId: holidayCalendar.legal_entity_id, }, }), }); return { holidayCalendar, outcome: "archived" }; }); return sendData(reply, request, { holidayCalendar: mapHolidayCalendar(result.holidayCalendar), outcome: result.outcome, }); }, ); } module.exports = masterDataHolidayCalendarsRoutes;