const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { resolveActionableStep } = require("../utils/workflow"); const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const createLeaveSchema = z .object({ startDate: DATE_ONLY_SCHEMA, endDate: DATE_ONLY_SCHEMA, reason: z.string().trim().max(500).optional(), }) .strict(); const LEAVE_WORKFLOW_STEPS_V1 = [ { id: "leave_approval_manager", name: "部门审批", type: "approval", assignee_rule: { type: "department_manager" }, }, ]; function parseLeaveId(value) { const parsed = z.string().uuid().safeParse(value); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { id: "invalid_uuid" }, }); } return parsed.data; } function parseCreateBody(body) { const parsed = createLeaveSchema.safeParse(body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten(), }); } const start = new Date(`${parsed.data.startDate}T00:00:00.000Z`); const end = new Date(`${parsed.data.endDate}T00:00:00.000Z`); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { date: "invalid" }, }); } if (end.getTime() < start.getTime()) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { endDate: "before_start" }, }); } return { startDate: start, endDate: end, reason: parsed.data.reason?.trim() ?? 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 leaveRoutes(fastify) { fastify.post( "/leave-requests", { preHandler: fastify.authenticate, }, async (request, reply) => { const body = parseCreateBody(request.body); const leave = await fastify.prisma.leaveRequest.create({ data: { owner_id: request.employee.id, department_id: request.employee.department_id, start_date: body.startDate, end_date: body.endDate, reason: body.reason, status: "draft", created_by: request.employee.id, updated_by: request.employee.id, }, select: { id: true, status: true, start_date: true, end_date: true, }, }); return sendData(reply, request, { id: leave.id, status: leave.status, startDate: leave.start_date.toISOString().slice(0, 10), endDate: leave.end_date.toISOString().slice(0, 10), }); }, ); fastify.get( "/leave-requests/:id", { 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, start_date: true, end_date: true, reason: true, status: true, created_at: true, updated_at: true, }, }); if (!leave) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const canViewAll = fastify.hasPermission(request.employee, "expense.view_all"); if (!canViewAll && leave.owner_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } return sendData(reply, request, { id: leave.id, ownerId: leave.owner_id, departmentId: leave.department_id, status: leave.status, startDate: leave.start_date.toISOString().slice(0, 10), endDate: leave.end_date.toISOString().slice(0, 10), reason: leave.reason ?? null, createdAt: leave.created_at.toISOString(), updatedAt: leave.updated_at.toISOString(), }); }, ); fastify.post( "/leave-requests/:id/submit", { preHandler: fastify.authenticate, }, 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, 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]; if (leave.owner_id !== request.employee.id) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, }); } if (!["draft", "rejected"].includes(leave.status)) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { status: "invalid_submit_status" }, }); } const activeInstance = await tx.workflowInstance.findFirst({ where: { entity_type: "leave", entity_id: leaveId, current_status: "pending", }, select: { id: true }, }); if (activeInstance) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { submit: "already_in_review" }, }); } const workflowDefinition = await tx.workflowDefinition.findFirst({ where: { entity_type: "leave", is_active: true }, orderBy: { version: "desc" }, select: { id: true }, }); if (!workflowDefinition) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: { workflow: "missing_definition" }, }); } const { actionable, skipped } = await resolveActionableStep({ tx, steps: LEAVE_WORKFLOW_STEPS_V1, startIndex: 0, ownerId: leave.owner_id, logger: fastify.log, }); if (!actionable) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { workflow: "no_actionable_approver" }, }); } 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, }, }, }); submitResult = { leaveRequestId: leaveId, workflowInstanceId: workflowInstance.id, currentTaskId: workflowTask.id, status: "reviewing", }; }); return sendData(reply, request, submitResult); }, ); } module.exports = leaveRoutes;