const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { resolveActionableStep } = require("../utils/workflow"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { ensureCutoverAllowed } = require("../services/cutover"); const { DEFAULT_ATTACHMENT_MIME_TYPES, calculateRequestedDays, calculateRequestedDaysPreviewByWorkingCalendar, createLeaveLedgerEntry, formatDateOnly, leaveRangesOverlap, loadLeaveBalanceItems, loadLeaveBalanceSnapshot, loadLeaveLedgerSummaryForRequest, lockEmployeeForLeave, normalizeAttachmentMimeTypes, persistLeaveAttachment, releaseReservedLeave, toHalfDayUnits, } = require("../services/leave"); const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const SESSION_SCHEMA = z.enum(["am", "pm"]); const STATUS_SCHEMA = z.enum(["draft", "reviewing", "approved", "rejected", "cancelled"]); const createLeaveSchema = z .object({ leaveTypeId: z.string().uuid(), startDate: DATE_ONLY_SCHEMA, endDate: DATE_ONLY_SCHEMA, startSession: SESSION_SCHEMA.optional(), endSession: SESSION_SCHEMA.optional(), reason: z.string().trim().max(500).optional(), }) .strict(); const workingDaysPreviewQuerySchema = z.object({ startDate: DATE_ONLY_SCHEMA, endDate: DATE_ONLY_SCHEMA, startSession: SESSION_SCHEMA, endSession: SESSION_SCHEMA, }); const leaveListQuerySchema = z.object({ status: STATUS_SCHEMA.optional(), scope: z.enum(["own", "all"]).optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), dateFrom: DATE_ONLY_SCHEMA.optional(), dateTo: DATE_ONLY_SCHEMA.optional(), departmentId: z.string().uuid().optional(), }); const leaveBalanceQuerySchema = z.object({ employeeId: z.string().uuid().optional(), }); const withdrawBodySchema = z .object({ comment: z.string().trim().max(500).optional(), }) .strict(); const LEAVE_WORKFLOW_STEPS_V1 = [ { id: "leave_approval_manager", name: "部门审批", type: "approval", assignee_rule: { type: "department_manager" }, }, ]; const LEAVE_TYPE_SELECT = { id: true, code: true, name: true, description: true, enabled: true, requires_attachment: true, attachment_mime_types: true, sort_order: true, created_at: true, updated_at: true, leave_policy: { select: { id: true, annual_quota_days: true, enabled: true, description: true, }, }, }; const LEAVE_WORKFLOW_SELECT = { orderBy: { created_at: "desc" }, take: 1, select: { id: true, current_status: true, current_step_id: true, workflow_tasks: { where: { status: "pending" }, orderBy: { created_at: "asc" }, take: 1, select: { id: true, step_id: true, step_name: true, assignee_id: true, assignee_role: true, }, }, workflow_history: { orderBy: { created_at: "desc" }, take: 8, select: { id: true, task_id: true, actor_id: true, action: true, comment: true, created_at: true, actor: { select: { employee_no: true, name: true, }, }, }, }, }, }; function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function parseLeaveId(value) { const parsed = z.string().uuid().safeParse(value); if (!parsed.success) { throw buildValidationError({ id: "invalid_uuid" }); } return parsed.data; } function parseLeaveListQuery(query) { const parsed = leaveListQuerySchema.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const dateFrom = parsed.data.dateFrom ? new Date(`${parsed.data.dateFrom}T00:00:00.000Z`) : null; const dateTo = parsed.data.dateTo ? new Date(`${parsed.data.dateTo}T00:00:00.000Z`) : null; if (dateFrom && Number.isNaN(dateFrom.getTime())) { throw buildValidationError({ dateFrom: "invalid" }); } if (dateTo && Number.isNaN(dateTo.getTime())) { throw buildValidationError({ dateTo: "invalid" }); } if (dateFrom && dateTo && dateFrom > dateTo) { throw buildValidationError({ dateTo: "must_be_after_dateFrom" }); } if (parsed.data.departmentId && parsed.data.scope !== "all") { throw buildValidationError({ departmentId: "scope_all_only" }); } return { status: parsed.data.status, scope: parsed.data.scope ?? "own", page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, dateFrom, dateTo, departmentId: parsed.data.departmentId ?? null, }; } function parseLeaveBalanceQuery(query) { const parsed = leaveBalanceQuerySchema.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } function parseWithdrawBody(body) { const parsed = withdrawBodySchema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { comment: parsed.data.comment?.trim() ?? null, }; } function parseCreateBody(body) { const parsed = createLeaveSchema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const startDate = new Date(`${parsed.data.startDate}T00:00:00.000Z`); const endDate = new Date(`${parsed.data.endDate}T00:00:00.000Z`); if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { throw buildValidationError({ date: "invalid" }); } const startSession = parsed.data.startSession ?? "am"; const endSession = parsed.data.endSession ?? "pm"; const requestedDays = calculateRequestedDays({ startDate, endDate, startSession, endSession, }); return { leaveTypeId: parsed.data.leaveTypeId, startDate, endDate, startSession, endSession, requestedDays, reason: parsed.data.reason?.trim() ?? null, }; } function parseWorkingDaysPreviewQuery(query) { const parsed = workingDaysPreviewQuerySchema.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const startDate = new Date(`${parsed.data.startDate}T00:00:00.000Z`); const endDate = new Date(`${parsed.data.endDate}T00:00:00.000Z`); if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { throw buildValidationError({ date: "invalid" }); } const requestedDays = calculateRequestedDays({ startDate, endDate, startSession: parsed.data.startSession, endSession: parsed.data.endSession, }); return { startDate, endDate, startSession: parsed.data.startSession, endSession: parsed.data.endSession, requestedDays, }; } function buildNaturalWorkingDaysPreview(body) { return { requestedDays: body.requestedDays, naturalRequestedDays: body.requestedDays, holidayDayCount: 0, weekendDayCount: 0, adjustedWorkdayCount: 0, calendarApplied: false, isNonWorkingRange: false, }; } async function resolveWorkingDaysPreview(fastify, employee, body, options = {}) { const allowNonWorkingRange = Boolean(options.allowNonWorkingRange); if (!employee?.legal_entity_id) { return buildNaturalWorkingDaysPreview(body); } const featureFlag = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.leaveHolidayCalendar, departmentId: employee.department_id ?? null, strictDepartment: false, }); if (!featureFlag.enabled) { return buildNaturalWorkingDaysPreview(body); } const legalEntity = await fastify.prisma.legalEntity.findUnique({ where: { id: employee.legal_entity_id }, select: { default_holiday_calendar: { select: { id: true, archived_at: true, days: { where: { date: { gte: body.startDate, lte: body.endDate, }, }, select: { date: true, day_type: true, }, }, }, }, }, }); const calendar = legalEntity?.default_holiday_calendar; if (!calendar || calendar.archived_at) { return buildNaturalWorkingDaysPreview(body); } return calculateRequestedDaysPreviewByWorkingCalendar({ startDate: body.startDate, endDate: body.endDate, startSession: body.startSession, endSession: body.endSession, calendarDays: calendar.days, allowNonWorkingRange, }); } async function resolveRequestedDaysForCreate(fastify, employee, body) { const preview = await resolveWorkingDaysPreview(fastify, employee, body, { allowNonWorkingRange: false, }); return preview.requestedDays; } async function collectCreatePayload(request) { if (typeof request.isMultipart === "function" && request.isMultipart()) { const fields = {}; let filePart = null; try { for await (const part of request.parts()) { if (part.type === "file") { if (filePart) { throw buildValidationError({ file: "only_single_file_supported" }); } filePart = part; } else { fields[part.fieldname] = part.value; } } } catch (error) { if (error?.code === "FST_REQ_FILE_TOO_LARGE") { throw buildValidationError({ file: "file_too_large" }); } throw error; } return { body: parseCreateBody(fields), filePart, }; } return { body: parseCreateBody(request.body), filePart: null, }; } function mapLeaveTypeSummary(leaveType) { return { id: leaveType.id, code: leaveType.code, name: leaveType.name, requiresAttachment: Boolean(leaveType.requires_attachment), attachmentMimeTypes: normalizeAttachmentMimeTypes(leaveType.attachment_mime_types), }; } function mapWorkflowHistoryItem(item) { return { id: item.id, taskId: item.task_id ?? null, actorId: item.actor_id, actorName: item.actor?.name ?? null, actorEmployeeNo: item.actor?.employee_no ?? null, action: item.action, comment: item.comment ?? null, createdAt: item.created_at?.toISOString?.() ?? null, }; } function mapPendingWorkflow(instance) { if (!instance || instance.current_status !== "pending") { return null; } const task = instance.workflow_tasks?.[0] ?? null; return { instanceId: instance.id, currentStatus: instance.current_status, currentStepId: instance.current_step_id, task: task ? { id: task.id, stepId: task.step_id, stepName: task.step_name, assigneeId: task.assignee_id, assigneeRole: task.assignee_role, } : null, }; } function mapLeaveRequestResponse(leave) { const latestWorkflow = leave.workflow_instances?.[0] ?? null; return { id: leave.id, ownerId: leave.owner_id, departmentId: leave.department_id, status: leave.status, startDate: formatDateOnly(leave.start_date), endDate: formatDateOnly(leave.end_date), startSession: leave.start_session, endSession: leave.end_session, requestedDays: Number(leave.requested_days), reason: leave.reason ?? null, attachment: leave.attachment_original_name || leave.attachment_mime_type ? { originalName: leave.attachment_original_name ?? null, mimeType: leave.attachment_mime_type ?? null, } : null, leaveType: leave.leave_type ? mapLeaveTypeSummary(leave.leave_type) : null, owner: leave.owner ? { id: leave.owner.id, employeeNo: leave.owner.employee_no, name: leave.owner.name, } : null, pendingWorkflow: mapPendingWorkflow(latestWorkflow), approvalHistory: (latestWorkflow?.workflow_history ?? []).map(mapWorkflowHistoryItem), createdAt: leave.created_at?.toISOString?.() ?? null, updatedAt: leave.updated_at?.toISOString?.() ?? null, }; } 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, }, }, }); } } async function loadLeaveTypeOrThrow(prisma, leaveTypeId) { const leaveType = await prisma.leaveType.findUnique({ where: { id: leaveTypeId }, select: LEAVE_TYPE_SELECT, }); if (!leaveType || !leaveType.enabled || !leaveType.leave_policy || !leaveType.leave_policy.enabled) { throw buildValidationError({ leaveTypeId: "missing_active_policy" }, 409); } return leaveType; } async function resolveLeaveTypeAttachment( fastify, leaveType, filePart, employeeId, { requireWhenMissing = true } = {}, ) { const requiresAttachment = Boolean(leaveType.requires_attachment); const allowedMimeTypes = normalizeAttachmentMimeTypes(leaveType.attachment_mime_types); if (!filePart) { if (requireWhenMissing && requiresAttachment) { throw buildValidationError({ file: "required" }, 422); } return null; } const mimeAllowlist = new Set(allowedMimeTypes.length ? allowedMimeTypes : DEFAULT_ATTACHMENT_MIME_TYPES); if (!mimeAllowlist.has(filePart.mimetype)) { throw buildValidationError({ file: "unsupported_mime_type" }); } return persistLeaveAttachment({ config: fastify.config, employeeId, filePart, }); } async function leaveRoutes(fastify) { fastify.get( "/leave-balances", { schema: { tags: ["Leave"], summary: "Get Leave Balances" }, preHandler: fastify.requireEntityPermission("leave", "view_own"), }, async (request, reply) => { const query = parseLeaveBalanceQuery(request.query); const canViewAll = fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage"); const targetEmployeeId = query.employeeId ?? request.employee.id; if (targetEmployeeId !== request.employee.id && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } let items = []; await fastify.prisma.$transaction(async (tx) => { await lockEmployeeForLeave(tx, targetEmployeeId); items = await loadLeaveBalanceItems(tx, { employeeId: targetEmployeeId, actorId: request.employee.id, }); }); return sendData(reply, request, { employeeId: targetEmployeeId, items, }); }, ); fastify.get( "/leave-requests", { schema: { tags: ["Leave"], summary: "List Leave Requests" }, preHandler: fastify.requireEntityPermission("leave", "view_own"), }, async (request, reply) => { const query = parseLeaveListQuery(request.query); const canViewAll = fastify.hasPermission(request.employee, "leave.view_all"); const canManageAll = fastify.hasPermission(request.employee, "leave.manage"); if (query.scope === "all" && !canViewAll) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if ( query.departmentId && !canManageAll && query.departmentId !== (request.employee.department_id ?? null) ) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { departmentId: "forbidden" }, }); } const page = query.page; const pageSize = query.pageSize; const skip = (page - 1) * pageSize; const where = { ...(query.status ? { status: query.status } : {}), ...(query.scope === "all" && canViewAll ? {} : { owner_id: request.employee.id }), ...(query.scope === "all" && query.departmentId ? { department_id: query.departmentId } : {}), ...(query.dateFrom || query.dateTo ? { AND: [ ...(query.dateFrom ? [{ end_date: { gte: query.dateFrom } }] : []), ...(query.dateTo ? [{ start_date: { lte: query.dateTo } }] : []), ], } : {}), }; const [items, total] = await Promise.all([ fastify.prisma.leaveRequest.findMany({ where, orderBy: { updated_at: "desc" }, skip, take: pageSize, select: { id: true, owner_id: true, department_id: true, leave_type_id: true, start_date: true, end_date: true, start_session: true, end_session: true, requested_days: true, reason: true, attachment_original_name: true, attachment_mime_type: true, status: true, created_at: true, updated_at: true, leave_type: { select: { id: true, code: true, name: true, requires_attachment: true, attachment_mime_types: true, }, }, owner: { select: { id: true, employee_no: true, name: true, }, }, workflow_instances: LEAVE_WORKFLOW_SELECT, }, }), fastify.prisma.leaveRequest.count({ where }), ]); return sendData(reply, request, { page, pageSize, total, scope: query.scope === "all" && canViewAll ? "all" : "own", items: items.map(mapLeaveRequestResponse), }); }, ); fastify.get( "/leave-requests/working-days", { schema: { tags: ["Leave"], summary: "Preview Leave Working Days" }, preHandler: fastify.authenticate, }, async (request, reply) => { const query = parseWorkingDaysPreviewQuery(request.query); const preview = await resolveWorkingDaysPreview(fastify, request.employee, query, { allowNonWorkingRange: true, }); return sendData(reply, request, preview); }, ); fastify.post( "/leave-requests", { schema: { tags: ["Leave"], summary: "Create Leave Request" }, preHandler: fastify.requireEntityPermission("leave", "create"), }, async (request, reply) => { await ensureCutoverAllowed({ prisma: fastify.prisma, config: fastify.config, request, domain: "leave", departmentId: request.employee.department_id ?? null, }); const { body, filePart } = await collectCreatePayload(request); const leaveType = await loadLeaveTypeOrThrow(fastify.prisma, body.leaveTypeId); const attachment = await resolveLeaveTypeAttachment( fastify, leaveType, filePart, request.employee.id, ); const requestedDays = await resolveRequestedDaysForCreate(fastify, request.employee, body); const leave = await fastify.prisma.leaveRequest.create({ data: { owner_id: request.employee.id, department_id: request.employee.department_id, leave_type_id: body.leaveTypeId, start_date: body.startDate, end_date: body.endDate, start_session: body.startSession, end_session: body.endSession, requested_days: requestedDays, reason: body.reason, attachment_storage_path: attachment?.storagePath ?? null, attachment_original_name: attachment?.originalName ?? null, attachment_mime_type: attachment?.mimeType ?? null, status: "draft", created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, owner_id: true, department_id: true, leave_type_id: true, start_date: true, end_date: true, start_session: true, end_session: true, requested_days: true, reason: true, attachment_original_name: true, attachment_mime_type: true, status: true, created_at: true, updated_at: true, leave_type: { select: { id: true, code: true, name: true, requires_attachment: true, attachment_mime_types: true, }, }, owner: { select: { id: true, employee_no: true, name: true, }, }, workflow_instances: LEAVE_WORKFLOW_SELECT, }, }); return sendData(reply, request, mapLeaveRequestResponse(leave)); }, ); fastify.patch( "/leave-requests/:id", { schema: { tags: ["Leave"], summary: "Update Leave Request" }, preHandler: fastify.requireEntityPermission("leave", "submit"), }, async (request, reply) => { const leaveId = parseLeaveId(request.params?.id); const { body, filePart } = await collectCreatePayload(request); let response = null; await fastify.prisma.$transaction(async (tx) => { const leaveRows = await tx.$queryRaw` SELECT id, owner_id, department_id, attachment_storage_path, attachment_original_name, attachment_mime_type, status FROM "leave_requests" WHERE id = ${leaveId}::uuid FOR UPDATE `; if (!Array.isArray(leaveRows) || leaveRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const leave = leaveRows[0]; await ensureCutoverAllowed({ prisma: tx, config: fastify.config, request, domain: "leave", departmentId: leave.department_id ?? request.employee.department_id ?? null, }); if (leave.owner_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (!["draft", "rejected"].includes(leave.status)) { throw buildValidationError({ status: "invalid_edit_status" }, 409); } const leaveType = await loadLeaveTypeOrThrow(tx, body.leaveTypeId); const attachment = await resolveLeaveTypeAttachment( fastify, leaveType, filePart, request.employee.id, { requireWhenMissing: false }, ); const requestedDays = await resolveRequestedDaysForCreate( fastify, request.employee, body, ); const updatedLeave = await tx.leaveRequest.update({ where: { id: leaveId }, data: { department_id: request.employee.department_id ?? leave.department_id ?? null, leave_type_id: body.leaveTypeId, start_date: body.startDate, end_date: body.endDate, start_session: body.startSession, end_session: body.endSession, requested_days: requestedDays, reason: body.reason, attachment_storage_path: attachment?.storagePath ?? leave.attachment_storage_path ?? null, attachment_original_name: attachment ? attachment.originalName ?? null : leave.attachment_original_name ?? null, attachment_mime_type: attachment ? attachment.mimeType ?? null : leave.attachment_mime_type ?? null, updated_by: request.employee.id, }, select: { id: true, owner_id: true, department_id: true, leave_type_id: true, start_date: true, end_date: true, start_session: true, end_session: true, requested_days: true, reason: true, attachment_original_name: true, attachment_mime_type: true, status: true, created_at: true, updated_at: true, leave_type: { select: { id: true, code: true, name: true, requires_attachment: true, attachment_mime_types: true, }, }, owner: { select: { id: true, employee_no: true, name: true, }, }, }, }); response = mapLeaveRequestResponse(updatedLeave); }); return sendData(reply, request, response); }, ); fastify.get( "/leave-requests/:id", { schema: { tags: ["Leave"], summary: "Get Leave Request" }, preHandler: fastify.authenticate, }, async (request, reply) => { const leaveId = parseLeaveId(request.params?.id); const leave = await fastify.prisma.leaveRequest.findUnique({ where: { id: leaveId }, select: { id: true, owner_id: true, department_id: true, leave_type_id: true, start_date: true, end_date: true, start_session: true, end_session: true, requested_days: true, reason: true, attachment_original_name: true, attachment_mime_type: true, status: true, created_at: true, updated_at: true, leave_type: { select: { id: true, code: true, name: true, requires_attachment: true, attachment_mime_types: true, }, }, owner: { select: { id: true, employee_no: true, name: true, }, }, workflow_instances: LEAVE_WORKFLOW_SELECT, }, }); if (!leave) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const canViewAll = fastify.hasPermission(request.employee, "leave.view_all"); if (!canViewAll && leave.owner_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } return sendData(reply, request, mapLeaveRequestResponse(leave)); }, ); fastify.post( "/leave-requests/:id/submit", { schema: { tags: ["Leave"], summary: "Submit Leave Request" }, preHandler: fastify.requireEntityPermission("leave", "submit"), }, async (request, reply) => { const leaveId = parseLeaveId(request.params?.id); let submitResult = null; await fastify.prisma.$transaction(async (tx) => { const leaveRows = await tx.$queryRaw` SELECT id, owner_id, department_id, leave_type_id, start_date, end_date, start_session::text AS start_session, end_session::text AS end_session, requested_days::text AS requested_days, attachment_storage_path, status FROM "leave_requests" WHERE id = ${leaveId}::uuid FOR UPDATE `; if (!Array.isArray(leaveRows) || leaveRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const leave = leaveRows[0]; await ensureCutoverAllowed({ prisma: tx, config: fastify.config, request, domain: "leave", departmentId: leave.department_id ?? request.employee.department_id ?? null, }); if (leave.owner_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (!["draft", "rejected"].includes(leave.status)) { throw buildValidationError({ status: "invalid_submit_status" }, 409); } const activeInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "leave", entity_id: leaveId, current_status: "pending", }, select: { id: true }, }); if (activeInstance) { throw buildValidationError({ submit: "already_in_review" }, 409); } const leaveType = await loadLeaveTypeOrThrow(tx, leave.leave_type_id); if (leaveType.requires_attachment && !leave.attachment_storage_path) { throw buildValidationError({ file: "required" }, 422); } const overlappingRequests = await tx.leaveRequest.findMany({ where: { owner_id: leave.owner_id, id: { not: leaveId }, status: { in: ["reviewing", "approved"] }, start_date: { lte: leave.end_date }, end_date: { gte: leave.start_date }, }, select: { id: true, start_date: true, end_date: true, start_session: true, end_session: true, }, }); const conflicting = overlappingRequests.find((item) => leaveRangesOverlap( { startDate: leave.start_date, endDate: leave.end_date, startSession: leave.start_session, endSession: leave.end_session, }, { startDate: item.start_date, endDate: item.end_date, startSession: item.start_session, endSession: item.end_session, }, ), ); if (conflicting) { throw buildValidationError( { leave: "conflict_range", conflictLeaveRequestId: conflicting.id }, 422, ); } await lockEmployeeForLeave(tx, leave.owner_id); const balanceSnapshot = await loadLeaveBalanceSnapshot(tx, { employeeId: leave.owner_id, leaveType, actorId: request.employee.id, referenceDate: leave.start_date, }); if ( toHalfDayUnits(balanceSnapshot.summary.available) < toHalfDayUnits(leave.requested_days) ) { throw buildValidationError({ leave: "insufficient_balance" }, 422); } const workflowDefinition = await tx.workflowDefinition.findFirst({ where: { entity_type: "leave", is_active: true }, orderBy: { version: "desc" }, select: { id: true }, }); if (!workflowDefinition) { throw buildValidationError({ workflow: "missing_definition" }); } const { actionable, skipped } = await resolveActionableStep({ tx, steps: LEAVE_WORKFLOW_STEPS_V1, startIndex: 0, ownerId: leave.owner_id, logger: fastify.log, blockOnUnresolvedAssignee: true, }); if (!actionable) { throw buildValidationError({ workflow: "no_actionable_approver" }, 409); } await tx.leaveRequest.update({ where: { id: leaveId }, data: { status: "reviewing", updated_by: request.employee.id, }, }); const workflowInstance = await tx.workflowInstance.create({ data: { workflow_id: workflowDefinition.id, entity_type: "leave", entity_id: leaveId, leave_request_id: leaveId, submitter_id: request.employee.id, current_status: "pending", current_step_id: actionable.step.id, context_data: { owner_id: leave.owner_id, department_id: leave.department_id, workflow_steps_snapshot: LEAVE_WORKFLOW_STEPS_V1, }, context_department_id: leave.department_id, created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, current_step_id: true, }, }); await createSkipHistoryRecords( tx, workflowInstance.id, request.employee.id, leave.owner_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 }, }); 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 createLeaveLedgerEntry(tx, { employeeId: leave.owner_id, leaveTypeId: leave.leave_type_id, leavePolicyId: balanceSnapshot.policy.id, leaveRequestId: leaveId, entryType: "reserve", days: Number(leave.requested_days), periodStart: balanceSnapshot.periodStart, periodEnd: balanceSnapshot.periodEnd, note: "submit_reserve_leave", payload: { source: "leave_submit", workflow_instance_id: workflowInstance.id, }, createdBy: request.employee.id, }); submitResult = { leaveRequestId: leaveId, workflowInstanceId: workflowInstance.id, currentTaskId: workflowTask.id, status: "reviewing", }; }); return sendData(reply, request, submitResult); }, ); fastify.post( "/leave-requests/:id/withdraw", { schema: { tags: ["Leave"], summary: "Withdraw Leave Request" }, preHandler: fastify.requireEntityPermission("leave", "submit"), }, async (request, reply) => { const leaveId = parseLeaveId(request.params?.id); const { comment } = parseWithdrawBody(request.body); let response = null; await fastify.prisma.$transaction(async (tx) => { const leaveRows = await tx.$queryRaw` SELECT id, owner_id, leave_type_id, requested_days::text AS requested_days, status FROM "leave_requests" WHERE id = ${leaveId}::uuid FOR UPDATE `; if (!Array.isArray(leaveRows) || leaveRows.length === 0) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const leave = leaveRows[0]; await ensureCutoverAllowed({ prisma: tx, config: fastify.config, request, domain: "leave", departmentId: leave.department_id ?? request.employee.department_id ?? null, }); if (leave.owner_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (leave.status !== "reviewing") { throw buildValidationError({ status: "invalid_withdraw_status" }, 409); } const activeInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "leave", entity_id: leaveId, current_status: "pending", }, orderBy: { created_at: "desc" }, select: { id: true }, }); if (!activeInstance) { throw buildValidationError({ withdraw: "no_pending_instance" }, 409); } const processedTaskCount = await tx.workflowTask.count({ where: { instance_id: activeInstance.id, status: { in: ["completed", "rejected"] }, }, }); if (processedTaskCount > 0) { throw buildValidationError({ withdraw: "already_processed_by_approver" }, 409); } const ledger = await loadLeaveLedgerSummaryForRequest(tx, leaveId); const reserveAnchor = ledger.entries.find((item) => item.entry_type === "reserve") ?? null; const now = new Date(); await tx.workflowTask.updateMany({ where: { instance_id: activeInstance.id, status: "pending", }, data: { status: "cancelled", completed_at: now, completed_by: request.employee.id, }, }); await tx.workflowInstance.update({ where: { id: activeInstance.id }, data: { current_status: "cancelled", completed_at: now, updated_by: request.employee.id, }, }); await tx.leaveRequest.update({ where: { id: leaveId }, data: { status: "draft", updated_by: request.employee.id, }, }); await releaseReservedLeave(tx, { leaveRequestId: leaveId, employeeId: leave.owner_id, leaveTypeId: leave.leave_type_id, leavePolicyId: reserveAnchor?.leave_policy_id ?? null, periodStart: reserveAnchor?.period_start ?? null, periodEnd: reserveAnchor?.period_end ?? null, note: "withdraw_release_leave", payload: { source: "leave_withdraw" }, createdBy: request.employee.id, }); await tx.workflowHistory.create({ data: { instance_id: activeInstance.id, actor_id: request.employee.id, action: "withdraw", comment: comment ?? null, payload: { leave_request_id: leaveId, }, }, }); response = { leaveRequestId: leaveId, outcome: "withdrawn", workflowInstanceId: activeInstance.id, leaveStatus: "draft", }; }); return sendData(reply, request, response); }, ); } module.exports = leaveRoutes;