const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { z } = require("zod"); const { hasPermission } = require("../constants/permissions"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { sendData } = require("../utils/reply"); const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow"); const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().datetime()); const UUID_SCHEMA = z.string().uuid(); const CORRECTION_BODY_SCHEMA = z .object({ kind: z.enum(["missed_punch", "out", "overtime"]), workDate: DATE_SCHEMA, missedPunchAt: DATE_TIME_SCHEMA.optional(), startAt: DATE_TIME_SCHEMA.optional(), endAt: DATE_TIME_SCHEMA.optional(), reason: z.string().trim().min(1).max(500), locationSummary: z.string().trim().max(200).optional(), compensationPolicy: z.string().trim().max(200).optional(), }) .strict() .superRefine((value, ctx) => { if (value.kind === "missed_punch" && !value.missedPunchAt) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["missedPunchAt"], message: "required", }); } if ((value.kind === "out" || value.kind === "overtime") && (!value.startAt || !value.endAt)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["startAt"], message: "start_and_end_required", }); } if (value.startAt && value.endAt) { const start = new Date(value.startAt); const end = new Date(value.endAt); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["endAt"], message: "must_be_after_or_equal_startAt", }); } } }); const LIST_QUERY_SCHEMA = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/).optional(), employeeId: UUID_SCHEMA.optional(), status: z.enum(["reviewing", "approved", "rejected", "cancelled"]).optional(), kind: z.enum(["missed_punch", "out", "overtime"]).optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), }); function parseInput(schema, value) { const parsed = schema.safeParse(value ?? {}); if (!parsed.success) { throw buildBusinessValidationError("attendance_correction.invalid_input", parsed.error.flatten(), 400); } return parsed.data; } function parseUuid(value, key = "id") { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildBusinessValidationError("attendance_correction.invalid_uuid", { [key]: "invalid_uuid" }, 400); } return parsed.data; } function parseDateOnly(value) { return new Date(`${value}T00:00:00.000Z`); } function parseOptionalDateTime(value) { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) { throw buildBusinessValidationError("attendance_correction.datetime_invalid", { datetime: "invalid" }, 400); } return date; } function formatDateOnly(value) { return value?.toISOString?.().slice(0, 10) ?? null; } function parseMonthRange(month) { if (!month) return null; const [year, monthNo] = month.split("-").map(Number); const startDate = new Date(Date.UTC(year, monthNo - 1, 1)); const endDate = new Date(Date.UTC(year, monthNo, 0)); return { startDate, endDate }; } async function ensureUnifiedFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.attendanceUnified, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildBusinessValidationError("attendance_correction.feature_flag_attendance_unified_v1_disabled", { featureFlag: "attendance_unified_v1_disabled", reason: gate.reason }, 409); } } function mapEmployee(employee) { if (!employee) return null; return { id: employee.id, employeeNo: employee.employee_no, name: employee.name, departmentId: employee.department_id ?? null, departmentName: employee.department?.name ?? null, }; } function mapCorrection(record) { return { id: record.id, kind: record.kind, status: record.status, workDate: formatDateOnly(record.work_date), missedPunchAt: record.missed_punch_at?.toISOString?.() ?? null, startAt: record.start_at?.toISOString?.() ?? null, endAt: record.end_at?.toISOString?.() ?? null, reason: record.reason, locationSummary: record.location_summary ?? null, compensationPolicy: record.compensation_policy ?? null, owner: mapEmployee(record.owner), createdAt: record.created_at?.toISOString?.() ?? null, updatedAt: record.updated_at?.toISOString?.() ?? null, workflow: record.workflow_instances?.[0] ? { id: record.workflow_instances[0].id, status: record.workflow_instances[0].current_status, currentStepId: record.workflow_instances[0].current_step_id, } : null, }; } function hasGlobalAttendanceScope(request) { const roleCodes = request.employee.roleCodes ?? []; const permissionCodes = request.employee.permissionCodes ?? []; return ( roleCodes.includes("admin") || roleCodes.includes("hr") || hasPermission(permissionCodes, "*") ); } async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) { for (const skipped of skippedSteps) { await tx.workflowHistory.create({ data: { instance_id: workflowInstanceId, actor_id: actorId, action: "skip", payload: { step_id: skipped.step.id, reason: skipped.reason, owner_id: ownerId, depth_checked: skipped.depthChecked, }, }, }); } } function canReadCorrection(request, correction) { if (correction.owner_id === request.employee.id) { return true; } if (hasGlobalAttendanceScope(request)) { return true; } return ( hasPermission(request.employee.permissionCodes ?? [], "attendance.view_all") && correction.owner?.direct_manager_id === request.employee.id ); } async function loadCorrectionForRead(tx, correctionId, request) { const item = await tx.attendanceCorrection.findUnique({ where: { id: correctionId }, include: { owner: { select: { id: true, employee_no: true, name: true, department_id: true, direct_manager_id: true, department: { select: { id: true, name: true } }, }, }, workflow_instances: { orderBy: { created_at: "desc" }, take: 1, select: { id: true, current_status: true, current_step_id: true, }, }, }, }); if (!item) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (!canReadCorrection(request, item)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } return item; } async function attendanceCorrectionsRoutes(fastify) { fastify.post( "/attendance-corrections", { schema: { tags: ["Attendance"], summary: "Submit Attendance Correction" }, preHandler: fastify.requireEntityPermission("attendance_correction", "create"), }, async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); const body = parseInput(CORRECTION_BODY_SCHEMA, request.body); const actor = await fastify.prisma.employee.findUnique({ where: { id: request.employee.id }, select: { id: true, department_id: true, status: true, }, }); if (!actor || actor.status !== "active") { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } let createdId = null; let workflowInstanceId = null; let workflowTaskId = null; await fastify.prisma.$transaction(async (tx) => { const correction = await tx.attendanceCorrection.create({ data: { owner_id: request.employee.id, department_id: actor.department_id, kind: body.kind, status: "reviewing", work_date: parseDateOnly(body.workDate), missed_punch_at: parseOptionalDateTime(body.missedPunchAt), start_at: parseOptionalDateTime(body.startAt), end_at: parseOptionalDateTime(body.endAt), reason: body.reason, location_summary: body.locationSummary || null, compensation_policy: body.compensationPolicy || null, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true }, }); createdId = correction.id; const workflowDefinition = await tx.workflowDefinition.findFirst({ where: { entity_type: "attendance_correction", is_active: true }, orderBy: { version: "desc" }, select: { id: true, definition_json: true }, }); if (!workflowDefinition) { throw buildBusinessValidationError("attendance_correction.workflow_missing_definition", { workflow: "missing_definition" }, 409); } const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json); const { actionable, skipped } = await resolveActionableStep({ tx, steps: workflowSteps, startIndex: 0, ownerId: request.employee.id, logger: fastify.log, blockOnUnresolvedAssignee: true, }); if (!actionable) { throw buildBusinessValidationError("attendance_correction.workflow_no_actionable_approver", { workflow: "no_actionable_approver" }, 409); } const workflowInstance = await tx.workflowInstance.create({ data: { workflow_id: workflowDefinition.id, entity_type: "attendance_correction", entity_id: correction.id, attendance_correction_id: correction.id, submitter_id: request.employee.id, current_status: "pending", current_step_id: actionable.step.id, context_data: { owner_id: request.employee.id, department_id: actor.department_id, correction_kind: body.kind, work_date: body.workDate, workflow_steps_snapshot: workflowSteps, }, context_department_id: actor.department_id, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true }, }); workflowInstanceId = workflowInstance.id; await createSkipHistoryRecords( tx, workflowInstance.id, request.employee.id, request.employee.id, skipped, ); const workflowTask = await tx.workflowTask.create({ data: { instance_id: workflowInstance.id, step_id: actionable.step.id, step_name: actionable.step.name ?? actionable.step.id, assignee_id: actionable.assigneeId, assignee_role: actionable.assigneeRole, }, select: { id: true }, }); workflowTaskId = workflowTask.id; await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, task_id: workflowTask.id, actor_id: request.employee.id, action: "start", payload: { step_id: actionable.step.id, skipped_step_count: skipped.length, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ATTENDANCE_CORRECTION_SUBMITTED", targetType: "attendance_correction", targetId: correction.id, extra: { kind: body.kind, workDate: body.workDate, workflowInstanceId: workflowInstance.id, workflowTaskId: workflowTask.id, }, }), }); }); const item = await loadCorrectionForRead(fastify.prisma, createdId, request); return sendData(reply, request, { item: mapCorrection(item), workflowInstanceId, currentTaskId: workflowTaskId, }); }, ); fastify.get( "/attendance-corrections", { schema: { tags: ["Attendance"], summary: "List Attendance Corrections" }, preHandler: fastify.authenticate, }, async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); fastify.assertEntityPermission(request.employee, "attendance_correction", "view_own"); const query = parseInput(LIST_QUERY_SCHEMA, request.query); const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const canViewTeam = hasPermission(request.employee.permissionCodes ?? [], "attendance.view_all"); const canViewGlobal = hasGlobalAttendanceScope(request); if (query.employeeId && query.employeeId !== request.employee.id && !canViewTeam && !canViewGlobal) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } if (query.employeeId && query.employeeId !== request.employee.id && !canViewGlobal) { const target = await fastify.prisma.employee.findUnique({ where: { id: query.employeeId }, select: { id: true, status: true, direct_manager_id: true }, }); if (!target || target.status !== "active") { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (target.direct_manager_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } } const monthRange = parseMonthRange(query.month); const ownerScopeWhere = query.employeeId ? { owner_id: query.employeeId } : canViewGlobal ? {} : canViewTeam ? { OR: [{ owner_id: request.employee.id }, { owner: { direct_manager_id: request.employee.id } }] } : { owner_id: request.employee.id }; const where = { ...ownerScopeWhere, ...(query.status ? { status: query.status } : {}), ...(query.kind ? { kind: query.kind } : {}), ...(monthRange ? { work_date: { gte: monthRange.startDate, lte: monthRange.endDate, }, } : {}), }; const [items, total] = await Promise.all([ fastify.prisma.attendanceCorrection.findMany({ where, orderBy: [{ work_date: "desc" }, { created_at: "desc" }], skip: (page - 1) * pageSize, take: pageSize, include: { owner: { select: { id: true, employee_no: true, name: true, department_id: true, department: { select: { id: true, name: true } }, }, }, workflow_instances: { orderBy: { created_at: "desc" }, take: 1, select: { id: true, current_status: true, current_step_id: true, }, }, }, }), fastify.prisma.attendanceCorrection.count({ where }), ]); return sendData(reply, request, { page, pageSize, total, items: items.map(mapCorrection), }); }, ); fastify.get( "/attendance-corrections/:id", { schema: { tags: ["Attendance"], summary: "Get Attendance Correction" }, preHandler: fastify.authenticate, }, async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); fastify.assertEntityPermission(request.employee, "attendance_correction", "view_own"); const correctionId = parseUuid(request.params?.id); const item = await loadCorrectionForRead(fastify.prisma, correctionId, request); return sendData(reply, request, { item: mapCorrection(item) }); }, ); fastify.post( "/attendance-corrections/:id/cancel", { schema: { tags: ["Attendance"], summary: "Cancel Attendance Correction" }, preHandler: fastify.requireEntityPermission("attendance_correction", "submit"), }, async (request, reply) => { await ensureUnifiedFeatureEnabled(fastify, request); const correctionId = parseUuid(request.params?.id); await fastify.prisma.$transaction(async (tx) => { const correction = await tx.attendanceCorrection.findUnique({ where: { id: correctionId }, select: { id: true, owner_id: true, status: true }, }); if (!correction) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (correction.owner_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } if (correction.status !== "reviewing") { throw buildBusinessValidationError("attendance_correction.status_invalid_cancel_status", { status: "invalid_cancel_status" }, 409); } const workflowInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "attendance_correction", entity_id: correctionId, current_status: "pending", }, orderBy: { created_at: "desc" }, select: { id: true }, }); await tx.attendanceCorrection.update({ where: { id: correctionId }, data: { status: "cancelled", updated_by: request.employee.id, }, }); if (workflowInstance) { await tx.workflowTask.updateMany({ where: { instance_id: workflowInstance.id, status: "pending" }, data: { status: "cancelled", completed_at: new Date(), completed_by: request.employee.id, }, }); await tx.workflowInstance.update({ where: { id: workflowInstance.id }, data: { current_status: "cancelled", completed_at: new Date(), updated_by: request.employee.id, }, }); await tx.workflowHistory.create({ data: { instance_id: workflowInstance.id, actor_id: request.employee.id, action: "cancel", payload: { source: "attendance_correction_cancel" }, }, }); } await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "ATTENDANCE_CORRECTION_CANCELLED", targetType: "attendance_correction", targetId: correctionId, }), }); }); const item = await loadCorrectionForRead(fastify.prisma, correctionId, request); return sendData(reply, request, { item: mapCorrection(item) }); }, ); } attendanceCorrectionsRoutes.__testables = { ensureUnifiedFeatureEnabled, mapCorrection, parseMonthRange, }; module.exports = attendanceCorrectionsRoutes;