const fs = require("node:fs/promises"); const path = require("node:path"); const sharp = require("sharp"); const { z } = require("zod"); const usersRoutes = require("./users"); const { buildDefaultChecklist, resolveEffectiveDate, applyEmployeeTransfer } = require("../services/employeeLifecycle"); const { buildEmployeeStats } = require("../services/employeeStats"); const { currentDateString } = require("../utils/date"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { listOrganizationDepartments } = require("../services/organization"); const { StorageNotFoundError, openReadStream, parseStoragePath, writeBuffer } = require("../services/storage"); const UUID_SCHEMA = z.string().uuid(); const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const REPORTING_CHAIN_MAX_DEPTH = 12; const AVATAR_ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp"]); const AVATAR_MAX_BYTES = 2 * 1024 * 1024; const CONTRACT_ATTACHMENT_ALLOWED_TYPES = new Map([ ["application/pdf", { extension: "pdf", contentType: "application/pdf" }], ["image/jpeg", { extension: "jpg", contentType: "image/jpeg" }], ["image/jpg", { extension: "jpg", contentType: "image/jpeg" }], ["image/png", { extension: "png", contentType: "image/png" }], ]); const CONTRACT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; const CONTRACT_TYPE_SCHEMA = z.enum(["labor", "nda", "non_compete", "internship"]); const TRAINING_TYPE_SCHEMA = z.enum(["internal", "external", "online"]); const TRAINING_STATUS_SCHEMA = z.enum(["planned", "in_progress", "completed", "cancelled"]); const PROFILE_PATCH_SCHEMA = z .object({ departmentId: UUID_SCHEMA.optional(), directManagerId: UUID_SCHEMA.nullable().optional(), title: z.string().trim().min(1).max(100).optional(), hireDate: DATE_SCHEMA.nullable().optional(), probationEndDate: DATE_SCHEMA.nullable().optional(), lastWorkingDate: DATE_SCHEMA.nullable().optional(), workLocation: z.string().trim().max(100).nullable().optional(), emergencyContactName: z.string().trim().max(100).nullable().optional(), emergencyContactPhone: z.string().trim().max(50).nullable().optional(), notes: z.string().trim().max(1000).nullable().optional(), }) .strict() .refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const ONBOARDING_SCHEMA = z.object({ effectiveDate: DATE_SCHEMA.optional(), probationEndDate: DATE_SCHEMA.nullable().optional(), notes: z.string().trim().max(500).nullable().optional(), }).strict(); const REGULARIZE_SCHEMA = z.object({ effectiveDate: DATE_SCHEMA.optional(), notes: z.string().trim().max(500).nullable().optional(), }).strict(); const TRANSFER_SCHEMA = z.object({ effectiveDate: DATE_SCHEMA.optional(), departmentId: UUID_SCHEMA.optional(), directManagerId: UUID_SCHEMA.nullable().optional(), title: z.string().trim().min(1).max(100).optional(), notes: z.string().trim().max(500).nullable().optional(), }).strict(); const OFFBOARDING_SCHEMA = z.object({ effectiveDate: DATE_SCHEMA.optional(), lastWorkingDate: DATE_SCHEMA.nullable().optional(), notes: z.string().trim().max(500).nullable().optional(), }).strict(); const CONTRACT_CREATE_SCHEMA = z.object({ contractNo: z.string().trim().min(1).max(100), contractType: CONTRACT_TYPE_SCHEMA, startDate: DATE_SCHEMA, endDate: DATE_SCHEMA, terminatedDate: DATE_SCHEMA.nullable().optional(), attachmentPath: z.string().trim().max(500).nullable().optional(), }).strict(); const CONTRACT_PATCH_SCHEMA = CONTRACT_CREATE_SCHEMA.partial() .strict() .refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const TRAINING_CREATE_SCHEMA = z.object({ name: z.string().trim().min(1).max(200), trainingType: TRAINING_TYPE_SCHEMA, startDate: DATE_SCHEMA, endDate: DATE_SCHEMA.nullable().optional(), status: TRAINING_STATUS_SCHEMA, notes: z.string().trim().max(1000).nullable().optional(), }).strict(); const TRAINING_PATCH_SCHEMA = TRAINING_CREATE_SCHEMA.partial() .strict() .refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); const CERTIFICATE_CREATE_SCHEMA = z.object({ name: z.string().trim().min(1).max(200), issuer: z.string().trim().min(1).max(200), obtainedDate: DATE_SCHEMA, expiryDate: DATE_SCHEMA.nullable().optional(), certificateNo: z.string().trim().max(100).nullable().optional(), attachmentPath: z.string().trim().max(500).nullable().optional(), }).strict(); const CERTIFICATE_PATCH_SCHEMA = CERTIFICATE_CREATE_SCHEMA.partial() .strict() .refine((value) => Object.keys(value).length > 0, { message: "empty_patch" }); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details }); } function parseId(value) { const parsed = UUID_SCHEMA.safeParse(value); if (!parsed.success) { throw buildValidationError({ id: "invalid_uuid" }); } return parsed.data; } function parseBody(schema, body) { const parsed = schema.safeParse(body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return parsed.data; } async function ensureFeatureEnabled(fastify, request) { const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.employeeLifecycle, departmentId: request.employee.department_id ?? null, strictDepartment: false, }); if (!gate.enabled) { throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409); } } function canManageProfile(fastify, request, employeeId) { return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view"); } function canViewPeopleDirectory(fastify, request) { return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view"); } function canManageLifecycle(fastify, request) { return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*"); } function canUploadAvatar(fastify, request, employeeId) { return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users"); } function buildAvatarUrl(employeeId, avatarPath) { return avatarPath ? `/api/avatars/${employeeId}` : null; } function buildContractAttachmentUrl(employeeId, contractId, attachmentPath) { return attachmentPath ? `/api/employees/${employeeId}/contracts/${contractId}/attachment` : null; } function parseDateOnly(value) { return value ? new Date(`${value}T00:00:00.000Z`) : null; } function validateContractDates(value) { if (value.startDate && value.endDate && value.startDate > value.endDate) { throw buildValidationError({ endDate: "must_be_after_or_equal_startDate" }); } if (value.startDate && value.terminatedDate && value.terminatedDate < value.startDate) { throw buildValidationError({ terminatedDate: "must_be_after_or_equal_startDate" }); } } function validateTrainingDates(value) { if (value.startDate && value.endDate && value.startDate > value.endDate) { throw buildValidationError({ endDate: "must_be_after_or_equal_startDate" }); } } function validateCertificateDates(value) { if (value.obtainedDate && value.expiryDate && value.obtainedDate > value.expiryDate) { throw buildValidationError({ expiryDate: "must_be_after_or_equal_obtainedDate" }); } } function deriveEmployeeContractStatus(contract, timeZone) { if (contract.terminated_date) { return "terminated"; } const today = currentDateString(timeZone ?? "Asia/Shanghai"); const endDate = contract.end_date?.toISOString?.()?.slice(0, 10) ?? null; if (endDate && endDate < today) { return "expired"; } return "active"; } function mapEmployeeContract(contract, timeZone) { return { id: contract.id, employeeId: contract.employee_id, contractNo: contract.contract_no, contractType: contract.contract_type, startDate: contract.start_date?.toISOString?.()?.slice(0, 10) ?? null, endDate: contract.end_date?.toISOString?.()?.slice(0, 10) ?? null, terminatedDate: contract.terminated_date?.toISOString?.()?.slice(0, 10) ?? null, attachmentPath: contract.attachment_path ?? null, attachmentUrl: buildContractAttachmentUrl(contract.employee_id, contract.id, contract.attachment_path), status: deriveEmployeeContractStatus(contract, timeZone), createdAt: contract.created_at?.toISOString?.() ?? null, createdBy: contract.created_by ?? null, updatedAt: contract.updated_at?.toISOString?.() ?? null, updatedBy: contract.updated_by ?? null, }; } function deriveEmployeeCertificateStatus(certificate, timeZone) { const today = currentDateString(timeZone ?? "Asia/Shanghai"); const expiryDate = certificate.expiry_date?.toISOString?.()?.slice(0, 10) ?? null; if (expiryDate && expiryDate < today) { return "expired"; } return "valid"; } function mapEmployeeTraining(training) { return { id: training.id, employeeId: training.employee_id, name: training.name, trainingType: training.training_type, startDate: training.start_date?.toISOString?.()?.slice(0, 10) ?? null, endDate: training.end_date?.toISOString?.()?.slice(0, 10) ?? null, status: training.status, notes: training.notes ?? null, createdAt: training.created_at?.toISOString?.() ?? null, createdBy: training.created_by ?? null, updatedAt: training.updated_at?.toISOString?.() ?? null, updatedBy: training.updated_by ?? null, }; } function mapEmployeeCertificate(certificate, timeZone) { return { id: certificate.id, employeeId: certificate.employee_id, name: certificate.name, issuer: certificate.issuer, obtainedDate: certificate.obtained_date?.toISOString?.()?.slice(0, 10) ?? null, expiryDate: certificate.expiry_date?.toISOString?.()?.slice(0, 10) ?? null, certificateNo: certificate.certificate_no ?? null, attachmentPath: certificate.attachment_path ?? null, status: deriveEmployeeCertificateStatus(certificate, timeZone), createdAt: certificate.created_at?.toISOString?.() ?? null, createdBy: certificate.created_by ?? null, updatedAt: certificate.updated_at?.toISOString?.() ?? null, updatedBy: certificate.updated_by ?? null, }; } function resolveContractAttachmentDescriptor(mimetype) { return CONTRACT_ATTACHMENT_ALLOWED_TYPES.get(mimetype) ?? null; } function inferAttachmentContentType(storagePath) { const extension = path.extname(parseStoragePath(storagePath).key ?? "").toLowerCase(); if (extension === ".pdf") { return "application/pdf"; } if (extension === ".jpg" || extension === ".jpeg") { return "image/jpeg"; } if (extension === ".png") { return "image/png"; } return "application/octet-stream"; } function buildContractAttachmentFilename(contract) { const key = parseStoragePath(contract.attachment_path).key ?? ""; const extension = path.extname(key) || ".bin"; const safeContractNo = String(contract.contract_no ?? "contract").replace(/[^a-zA-Z0-9._-]+/g, "_"); return `${safeContractNo}${extension}`; } function resolveLocalStorageAbsolutePath(config, storagePath) { const parsed = parseStoragePath(storagePath); if (!parsed.key || parsed.driver !== "local") { return null; } const storageRoot = path.resolve(config.storageDir); const absolutePath = path.resolve(storageRoot, parsed.key); if (!absolutePath.startsWith(`${storageRoot}${path.sep}`)) { throw new Error("Invalid storage_path (path traversal detected)"); } return absolutePath; } async function unlinkStoragePath(config, storagePath) { const absolutePath = resolveLocalStorageAbsolutePath(config, storagePath); if (!absolutePath) { return; } try { await fs.unlink(absolutePath); } catch (error) { if (error?.code === "ENOENT") { return; } throw error; } } function mapChecklistTask(item) { return { id: item.id, employeeId: item.employee_id, scene: item.scene, taskType: item.task_type, title: item.title, status: item.status, dueDate: item.due_date?.toISOString?.()?.slice(0, 10) ?? null, completedAt: item.completed_at?.toISOString?.() ?? null, completedBy: item.completed_by ?? null, payloadJson: item.payload_json ?? null, createdAt: item.created_at?.toISOString?.() ?? null, }; } function mapLifecycleEvent(item) { return { id: item.id, employeeId: item.employee_id, eventType: item.event_type, effectiveDate: item.effective_date?.toISOString?.()?.slice(0, 10) ?? null, payloadJson: item.payload_json ?? null, createdAt: item.created_at?.toISOString?.() ?? null, createdBy: item.created_by ?? null, }; } function mapOrganizationNode(item) { return { id: item.id, employeeNo: item.employee_no, name: item.name, title: item.title, status: item.status, avatarUrl: buildAvatarUrl(item.id, item.avatar_path), department: item.department ? { id: item.department.id, name: item.department.name } : null, }; } async function getLifecycleBlockers(prisma, employeeId) { const [openAssetCount, pendingWorkflowTaskCount] = await Promise.all([ prisma.assetCard.count({ where: { assignee_employee_id: employeeId } }), prisma.workflowTask.count({ where: { assignee_id: employeeId, status: "pending" } }), ]); return { openAssetCount, pendingWorkflowTaskCount, canCompleteOffboarding: openAssetCount === 0 && pendingWorkflowTaskCount === 0, }; } async function ensureEmployeeExists(prisma, employeeId) { const employee = await prisma.employee.findUnique({ where: { id: employeeId } }); if (!employee) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } return employee; } async function ensureEmployeeContractExists(prisma, employeeId, contractId) { const contract = await prisma.employeeContract.findFirst({ where: { id: contractId, employee_id: employeeId }, }); if (!contract) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } return contract; } async function ensureEmployeeTrainingExists(prisma, employeeId, trainingId) { const training = await prisma.employeeTraining.findFirst({ where: { id: trainingId, employee_id: employeeId }, }); if (!training) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } return training; } async function ensureEmployeeCertificateExists(prisma, employeeId, certificateId) { const certificate = await prisma.employeeCertificate.findFirst({ where: { id: certificateId, employee_id: employeeId }, }); if (!certificate) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } return certificate; } async function maybeAdvanceLifecycle(tx, employeeId) { const employee = await tx.employee.findUnique({ where: { id: employeeId } }); if (!employee) { return { autoChanged: false, completedOffboarding: false, blockers: null }; } const pendingOnboarding = await tx.employeeChecklistTask.count({ where: { employee_id: employeeId, scene: "onboarding", status: "pending" } }); if (employee.employment_status === "onboarding" && pendingOnboarding === 0) { await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "probation" } }); return { autoChanged: true, completedOffboarding: false, blockers: null }; } const pendingOffboarding = await tx.employeeChecklistTask.count({ where: { employee_id: employeeId, scene: "offboarding", status: "pending" } }); if (employee.employment_status === "offboarding" && pendingOffboarding === 0) { const [openAssetCount, pendingWorkflowTaskCount] = await Promise.all([ tx.assetCard.count({ where: { assignee_employee_id: employeeId } }), tx.workflowTask.count({ where: { assignee_id: employeeId, status: "pending" } }), ]); if (openAssetCount === 0 && pendingWorkflowTaskCount === 0) { await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "inactive", status: "archived", archived_at: new Date(), token_version: { increment: 1 }, }, }); return { autoChanged: true, completedOffboarding: true, blockers: { openAssetCount, pendingWorkflowTaskCount } }; } return { autoChanged: false, completedOffboarding: false, blockers: { openAssetCount, pendingWorkflowTaskCount } }; } return { autoChanged: false, completedOffboarding: false, blockers: null }; } async function buildEmployeeOrganization(prisma, employee) { const reportingChain = []; const seenEmployeeIds = new Set([employee.id]); let currentManagerId = employee.direct_manager_id ?? null; let depth = 0; while (currentManagerId && depth < REPORTING_CHAIN_MAX_DEPTH) { if (seenEmployeeIds.has(currentManagerId)) { break; } const manager = await prisma.employee.findUnique({ where: { id: currentManagerId }, select: { id: true, employee_no: true, name: true, title: true, status: true, avatar_path: true, direct_manager_id: true, department: { select: { id: true, name: true } }, }, }); if (!manager) { break; } reportingChain.push(mapOrganizationNode(manager)); seenEmployeeIds.add(manager.id); currentManagerId = manager.direct_manager_id ?? null; depth += 1; } const directReports = await prisma.employee.findMany({ where: { direct_manager_id: employee.id }, orderBy: [{ status: "asc" }, { name: "asc" }], select: { id: true, employee_no: true, name: true, title: true, status: true, avatar_path: true, department: { select: { id: true, name: true } }, }, }); return { reportingChain: [...reportingChain.reverse(), mapOrganizationNode(employee)], directReports: directReports.map(mapOrganizationNode), }; } async function buildEmployeeProfile(prisma, employeeId) { const [employee, checklistTasks, lifecycleEvents, assignedAssets, blockers] = await Promise.all([ prisma.employee.findUnique({ where: { id: employeeId }, select: { id: true, employee_no: true, name: true, phone: true, email: true, status: true, employment_status: true, department_id: true, title: true, direct_manager_id: true, hire_date: true, probation_end_date: true, last_working_date: true, work_location: true, emergency_contact_name: true, emergency_contact_phone: true, notes: true, avatar_path: true, archived_at: true, roles: { select: { role_code: true } }, department: { select: { id: true, name: true } }, direct_manager: { select: { id: true, employee_no: true, name: true, avatar_path: true } }, }, }), prisma.employeeChecklistTask.findMany({ where: { employee_id: employeeId }, orderBy: [{ created_at: "desc" }] }), prisma.employeeLifecycleEvent.findMany({ where: { employee_id: employeeId }, orderBy: [{ effective_date: "desc" }, { created_at: "desc" }] }), prisma.assetCard.findMany({ where: { assignee_employee_id: employeeId }, orderBy: { asset_no: "asc" }, select: { id: true, asset_no: true, name: true, status: true, location: true }, }), getLifecycleBlockers(prisma, employeeId), ]); if (!employee) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } const organization = await buildEmployeeOrganization(prisma, employee); return { employee: { id: employee.id, employeeNo: employee.employee_no, name: employee.name, phone: employee.phone, email: employee.email, status: employee.status, employmentStatus: employee.employment_status, departmentId: employee.department_id, title: employee.title, directManagerId: employee.direct_manager_id ?? null, hireDate: employee.hire_date?.toISOString?.()?.slice(0, 10) ?? null, probationEndDate: employee.probation_end_date?.toISOString?.()?.slice(0, 10) ?? null, lastWorkingDate: employee.last_working_date?.toISOString?.()?.slice(0, 10) ?? null, workLocation: employee.work_location ?? null, emergencyContactName: employee.emergency_contact_name ?? null, emergencyContactPhone: employee.emergency_contact_phone ?? null, notes: employee.notes ?? null, avatarUrl: buildAvatarUrl(employee.id, employee.avatar_path), archivedAt: employee.archived_at?.toISOString?.() ?? null, roleCodes: employee.roles.map((item) => item.role_code), department: employee.department ? { id: employee.department.id, name: employee.department.name } : null, directManager: employee.direct_manager ? { id: employee.direct_manager.id, employeeNo: employee.direct_manager.employee_no, name: employee.direct_manager.name, avatarUrl: buildAvatarUrl(employee.direct_manager.id, employee.direct_manager.avatar_path), } : null, }, lifecycle: { events: lifecycleEvents.map(mapLifecycleEvent), checklistTasks: checklistTasks.map(mapChecklistTask), blockers, }, organization, assignedAssets: assignedAssets.map((item) => ({ id: item.id, assetNo: item.asset_no, name: item.name, status: item.status, location: item.location ?? null, })), }; } async function employeesRoutes(fastify) { await usersRoutes(fastify, { basePath: "/employees", allowDepartmentFilter: true, listPermissionCodes: ["system.users", "report.dashboard.view"], enableBatchActions: false, }); fastify.get("/avatars/:employeeId", async (request, reply) => { const employeeId = parseId(request.params?.employeeId); const employee = await fastify.prisma.employee.findUnique({ where: { id: employeeId }, select: { avatar_path: true }, }); if (!employee?.avatar_path) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } try { const opened = await openReadStream({ config: fastify.config, storagePath: employee.avatar_path, }); reply.header("cache-control", "no-store"); return reply.type("image/webp").send(opened.stream); } catch (error) { if (error instanceof StorageNotFoundError) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } throw error; } }); fastify.put("/employees/:id/avatar", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); if (!canUploadAvatar(fastify, request, employeeId)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.avatar.write" } }); } await ensureEmployeeExists(fastify.prisma, employeeId); if (!request.isMultipart()) { throw buildValidationError({ file: "invalid_multipart" }); } const filePart = await request.file(); if (!filePart) { throw buildValidationError({ file: "required" }); } if (filePart.fieldname !== "file") { throw buildValidationError({ file: "invalid_field" }); } if (!AVATAR_ALLOWED_MIME_TYPES.has(filePart.mimetype)) { throw buildValidationError({ file: "invalid_type" }); } const rawBuffer = await filePart.toBuffer(); if (!rawBuffer.length) { throw buildValidationError({ file: "empty" }); } if (rawBuffer.length > AVATAR_MAX_BYTES) { throw buildValidationError({ file: "file_too_large" }); } let normalizedBuffer; try { normalizedBuffer = await sharp(rawBuffer) .resize(200, 200, { fit: "cover", position: "centre" }) .webp({ quality: 90 }) .toBuffer(); } catch { throw buildValidationError({ file: "invalid_image" }); } const avatarPath = await writeBuffer({ config: fastify.config, key: `avatars/${employeeId}.webp`, buffer: normalizedBuffer, contentType: "image/webp", }); await fastify.prisma.employee.update({ where: { id: employeeId }, data: { avatar_path: avatarPath, updated_by: request.employee.id, }, }); return sendData(reply, request, { employeeId, avatarUrl: buildAvatarUrl(employeeId, avatarPath), }); }); fastify.delete("/employees/:id/avatar", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); if (!canUploadAvatar(fastify, request, employeeId)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.avatar.write" } }); } const current = await ensureEmployeeExists(fastify.prisma, employeeId); await fastify.prisma.employee.update({ where: { id: employeeId }, data: { avatar_path: null, updated_by: request.employee.id, }, }); if (current.avatar_path) { await unlinkStoragePath(fastify.config, current.avatar_path); } return sendData(reply, request, { employeeId, avatarUrl: null, }); }); fastify.get("/employees/organization", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!canViewPeopleDirectory(fastify, request)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.organization.view" } }); } const items = await listOrganizationDepartments(fastify.prisma); return sendData(reply, request, { items }); }); fastify.get("/employees/stats", { preHandler: fastify.authenticate }, async (request, reply) => { if (!canViewPeopleDirectory(fastify, request)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.stats.view" } }); } const stats = await buildEmployeeStats(fastify.prisma, { timeZone: fastify.config.appTimezone, includeSalarySummary: fastify.hasPermission(request.employee, "system.users"), }); return sendData(reply, request, stats); }); fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); if (!canManageProfile(fastify, request, employeeId)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.view" } }); } const profile = await buildEmployeeProfile(fastify.prisma, employeeId); return sendData(reply, request, profile); }); fastify.get("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); await ensureEmployeeExists(fastify.prisma, employeeId); const items = await fastify.prisma.employeeContract.findMany({ where: { employee_id: employeeId }, orderBy: [{ start_date: "desc" }, { created_at: "desc" }], }); return sendData(reply, request, { items: items.map((item) => mapEmployeeContract(item, fastify.config.appTimezone)), }); }); fastify.get("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const contractId = parseId(request.params?.contractId); const contract = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId); if (!contract.attachment_path) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } try { const opened = await openReadStream({ config: fastify.config, storagePath: contract.attachment_path, }); reply.header("cache-control", "no-store"); reply.header("content-disposition", `attachment; filename="${buildContractAttachmentFilename(contract)}"`); return reply.type(inferAttachmentContentType(contract.attachment_path)).send(opened.stream); } catch (error) { if (error instanceof StorageNotFoundError) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } throw error; } }); fastify.post("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const body = parseBody(CONTRACT_CREATE_SCHEMA, request.body); validateContractDates(body); await ensureEmployeeExists(fastify.prisma, employeeId); try { const contract = await fastify.prisma.employeeContract.create({ data: { employee_id: employeeId, contract_no: body.contractNo, contract_type: body.contractType, start_date: parseDateOnly(body.startDate), end_date: parseDateOnly(body.endDate), terminated_date: parseDateOnly(body.terminatedDate ?? null), attachment_path: body.attachmentPath ?? null, created_by: request.employee.id, updated_by: request.employee.id, }, }); return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone)); } catch (error) { if (error?.code === "P2002") { throw buildValidationError({ contractNo: "duplicate" }); } throw error; } }); fastify.patch("/employees/:id/contracts/:contractId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const contractId = parseId(request.params?.contractId); const body = parseBody(CONTRACT_PATCH_SCHEMA, request.body); await ensureEmployeeExists(fastify.prisma, employeeId); const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId); validateContractDates({ startDate: body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10), endDate: body.endDate ?? current.end_date?.toISOString?.()?.slice(0, 10), terminatedDate: body.terminatedDate !== undefined ? body.terminatedDate : current.terminated_date?.toISOString?.()?.slice(0, 10), }); try { const contract = await fastify.prisma.employeeContract.update({ where: { id: contractId }, data: { ...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}), ...(body.contractType !== undefined ? { contract_type: body.contractType } : {}), ...(body.startDate !== undefined ? { start_date: parseDateOnly(body.startDate) } : {}), ...(body.endDate !== undefined ? { end_date: parseDateOnly(body.endDate) } : {}), ...(body.terminatedDate !== undefined ? { terminated_date: parseDateOnly(body.terminatedDate ?? null) } : {}), ...(body.attachmentPath !== undefined ? { attachment_path: body.attachmentPath ?? null } : {}), updated_by: request.employee.id, }, }); return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone)); } catch (error) { if (error?.code === "P2002") { throw buildValidationError({ contractNo: "duplicate" }); } throw error; } }); fastify.put("/employees/:id/contracts/:contractId/attachment", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const contractId = parseId(request.params?.contractId); await ensureEmployeeExists(fastify.prisma, employeeId); const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId); if (!request.isMultipart()) { throw buildValidationError({ file: "invalid_multipart" }); } const filePart = await request.file(); if (!filePart) { throw buildValidationError({ file: "required" }); } if (filePart.fieldname !== "file") { throw buildValidationError({ file: "invalid_field" }); } const descriptor = resolveContractAttachmentDescriptor(filePart.mimetype); if (!descriptor) { throw buildValidationError({ file: "invalid_type" }); } const rawBuffer = await filePart.toBuffer(); if (!rawBuffer.length) { throw buildValidationError({ file: "empty" }); } if (rawBuffer.length > CONTRACT_ATTACHMENT_MAX_BYTES) { throw buildValidationError({ file: "file_too_large" }); } const nextAttachmentPath = await writeBuffer({ config: fastify.config, key: `contracts/${contractId}.${descriptor.extension}`, buffer: rawBuffer, contentType: descriptor.contentType, }); const contract = await fastify.prisma.employeeContract.update({ where: { id: contractId }, data: { attachment_path: nextAttachmentPath, updated_by: request.employee.id, }, }); if (current.attachment_path && current.attachment_path !== nextAttachmentPath) { await unlinkStoragePath(fastify.config, current.attachment_path); } return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone)); }); fastify.delete("/employees/:id/contracts/:contractId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const contractId = parseId(request.params?.contractId); await ensureEmployeeExists(fastify.prisma, employeeId); const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId); const currentStatus = deriveEmployeeContractStatus(current, fastify.config.appTimezone); if (currentStatus === "active") { throw buildValidationError({ contract: "active_contract_must_be_terminated" }, 409); } await fastify.prisma.employeeContract.delete({ where: { id: contractId } }); if (current.attachment_path) { await unlinkStoragePath(fastify.config, current.attachment_path); } await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EMPLOYEE_CONTRACT_DELETED", targetType: "employee_contract", targetId: contractId, extra: { employeeId, contractNo: current.contract_no, status: currentStatus, attachmentPath: current.attachment_path ?? null, }, }), }); return sendData(reply, request, { contractId, deleted: true, }); }); fastify.get("/employees/:id/trainings", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); await ensureEmployeeExists(fastify.prisma, employeeId); const items = await fastify.prisma.employeeTraining.findMany({ where: { employee_id: employeeId }, orderBy: [{ start_date: "desc" }, { created_at: "desc" }], }); return sendData(reply, request, { items: items.map(mapEmployeeTraining) }); }); fastify.post("/employees/:id/trainings", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const body = parseBody(TRAINING_CREATE_SCHEMA, request.body); validateTrainingDates(body); await ensureEmployeeExists(fastify.prisma, employeeId); const training = await fastify.prisma.employeeTraining.create({ data: { employee_id: employeeId, name: body.name, training_type: body.trainingType, start_date: parseDateOnly(body.startDate), end_date: parseDateOnly(body.endDate ?? null), status: body.status, notes: body.notes ?? null, created_by: request.employee.id, updated_by: request.employee.id, }, }); return sendData(reply, request, mapEmployeeTraining(training)); }); fastify.patch("/employees/:id/trainings/:trainingId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const trainingId = parseId(request.params?.trainingId); const body = parseBody(TRAINING_PATCH_SCHEMA, request.body); await ensureEmployeeExists(fastify.prisma, employeeId); const current = await ensureEmployeeTrainingExists(fastify.prisma, employeeId, trainingId); validateTrainingDates({ startDate: body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10), endDate: body.endDate !== undefined ? body.endDate : current.end_date?.toISOString?.()?.slice(0, 10), }); const training = await fastify.prisma.employeeTraining.update({ where: { id: trainingId }, data: { ...(body.name !== undefined ? { name: body.name } : {}), ...(body.trainingType !== undefined ? { training_type: body.trainingType } : {}), ...(body.startDate !== undefined ? { start_date: parseDateOnly(body.startDate) } : {}), ...(body.endDate !== undefined ? { end_date: parseDateOnly(body.endDate ?? null) } : {}), ...(body.status !== undefined ? { status: body.status } : {}), ...(body.notes !== undefined ? { notes: body.notes ?? null } : {}), updated_by: request.employee.id, }, }); return sendData(reply, request, mapEmployeeTraining(training)); }); fastify.get("/employees/:id/certificates", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); await ensureEmployeeExists(fastify.prisma, employeeId); const items = await fastify.prisma.employeeCertificate.findMany({ where: { employee_id: employeeId }, orderBy: [{ obtained_date: "desc" }, { created_at: "desc" }], }); return sendData(reply, request, { items: items.map((item) => mapEmployeeCertificate(item, fastify.config.appTimezone)), }); }); fastify.post("/employees/:id/certificates", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const body = parseBody(CERTIFICATE_CREATE_SCHEMA, request.body); validateCertificateDates(body); await ensureEmployeeExists(fastify.prisma, employeeId); const certificate = await fastify.prisma.employeeCertificate.create({ data: { employee_id: employeeId, name: body.name, issuer: body.issuer, obtained_date: parseDateOnly(body.obtainedDate), expiry_date: parseDateOnly(body.expiryDate ?? null), certificate_no: body.certificateNo ?? null, attachment_path: body.attachmentPath ?? null, created_by: request.employee.id, updated_by: request.employee.id, }, }); return sendData(reply, request, mapEmployeeCertificate(certificate, fastify.config.appTimezone)); }); fastify.patch("/employees/:id/certificates/:certificateId", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!fastify.hasPermission(request.employee, "system.users")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const certificateId = parseId(request.params?.certificateId); const body = parseBody(CERTIFICATE_PATCH_SCHEMA, request.body); await ensureEmployeeExists(fastify.prisma, employeeId); const current = await ensureEmployeeCertificateExists(fastify.prisma, employeeId, certificateId); validateCertificateDates({ obtainedDate: body.obtainedDate ?? current.obtained_date?.toISOString?.()?.slice(0, 10), expiryDate: body.expiryDate !== undefined ? body.expiryDate : current.expiry_date?.toISOString?.()?.slice(0, 10), }); const certificate = await fastify.prisma.employeeCertificate.update({ where: { id: certificateId }, data: { ...(body.name !== undefined ? { name: body.name } : {}), ...(body.issuer !== undefined ? { issuer: body.issuer } : {}), ...(body.obtainedDate !== undefined ? { obtained_date: parseDateOnly(body.obtainedDate) } : {}), ...(body.expiryDate !== undefined ? { expiry_date: parseDateOnly(body.expiryDate ?? null) } : {}), ...(body.certificateNo !== undefined ? { certificate_no: body.certificateNo ?? null } : {}), ...(body.attachmentPath !== undefined ? { attachment_path: body.attachmentPath ?? null } : {}), updated_by: request.employee.id, }, }); return sendData(reply, request, mapEmployeeCertificate(certificate, fastify.config.appTimezone)); }); fastify.patch("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); if (!canManageProfile(fastify, request, employeeId)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.write" } }); } const body = parseBody(PROFILE_PATCH_SCHEMA, request.body); const managerMode = canManageLifecycle(fastify, request); const selfEditableKeys = ["workLocation", "emergencyContactName", "emergencyContactPhone", "notes"]; if (!managerMode) { if (request.employee.id !== employeeId) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.write" } }); } const invalidKeys = Object.keys(body).filter((key) => !selfEditableKeys.includes(key)); if (invalidKeys.length) { throw buildValidationError({ body: `self_profile_fields_forbidden:${invalidKeys.join(",")}` }, 403); } } await ensureEmployeeExists(fastify.prisma, employeeId); await fastify.prisma.employee.update({ where: { id: employeeId }, data: { ...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}), ...(body.directManagerId !== undefined ? { direct_manager_id: body.directManagerId } : {}), ...(body.title !== undefined ? { title: body.title } : {}), ...(body.hireDate !== undefined ? { hire_date: body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : null } : {}), ...(body.probationEndDate !== undefined ? { probation_end_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null } : {}), ...(body.lastWorkingDate !== undefined ? { last_working_date: body.lastWorkingDate ? new Date(`${body.lastWorkingDate}T00:00:00.000Z`) : null } : {}), ...(body.workLocation !== undefined ? { work_location: body.workLocation ?? null } : {}), ...(body.emergencyContactName !== undefined ? { emergency_contact_name: body.emergencyContactName ?? null } : {}), ...(body.emergencyContactPhone !== undefined ? { emergency_contact_phone: body.emergencyContactPhone ?? null } : {}), ...(body.notes !== undefined ? { notes: body.notes ?? null } : {}), updated_by: request.employee.id, }, }); const profile = await buildEmployeeProfile(fastify.prisma, employeeId); return sendData(reply, request, profile); }); fastify.post("/employees/:id/onboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!canManageLifecycle(fastify, request)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const body = parseBody(ONBOARDING_SCHEMA, request.body); const effectiveDate = resolveEffectiveDate(body.effectiveDate); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "onboarding", probation_end_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null, notes: body.notes !== undefined ? body.notes ?? null : undefined, updated_by: request.employee.id, }, }); await tx.employeeLifecycleEvent.create({ data: { employee_id: employeeId, event_type: "onboard", effective_date: effectiveDate, payload_json: { notes: body.notes ?? null }, created_by: request.employee.id, }, }); const checklist = buildDefaultChecklist("onboarding", employeeId).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null })); await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false }); }); const profile = await buildEmployeeProfile(fastify.prisma, employeeId); return sendData(reply, request, profile); }); fastify.post("/employees/:id/regularize", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!canManageLifecycle(fastify, request)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const body = parseBody(REGULARIZE_SCHEMA, request.body); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "active", updated_by: request.employee.id } }); await tx.employeeLifecycleEvent.create({ data: { employee_id: employeeId, event_type: "regularize", effective_date: resolveEffectiveDate(body.effectiveDate), payload_json: { notes: body.notes ?? null }, created_by: request.employee.id, }, }); }); const profile = await buildEmployeeProfile(fastify.prisma, employeeId); return sendData(reply, request, profile); }); fastify.post("/employees/:id/transfer", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!canManageLifecycle(fastify, request)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const body = parseBody(TRANSFER_SCHEMA, request.body); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); await applyEmployeeTransfer(tx, { employeeId, actorId: request.employee.id, departmentId: body.departmentId, directManagerId: body.directManagerId, title: body.title, effectiveDate: body.effectiveDate, notes: body.notes, }); }); const profile = await buildEmployeeProfile(fastify.prisma, employeeId); return sendData(reply, request, profile); }); fastify.post("/employees/:id/offboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); if (!canManageLifecycle(fastify, request)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } }); } const employeeId = parseId(request.params?.id); const body = parseBody(OFFBOARDING_SCHEMA, request.body); await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "offboarding", ...(body.lastWorkingDate !== undefined ? { last_working_date: body.lastWorkingDate ? new Date(`${body.lastWorkingDate}T00:00:00.000Z`) : null } : {}), updated_by: request.employee.id, }, }); await tx.employeeLifecycleEvent.create({ data: { employee_id: employeeId, event_type: "offboard", effective_date: resolveEffectiveDate(body.effectiveDate), payload_json: { notes: body.notes ?? null, lastWorkingDate: body.lastWorkingDate ?? null }, created_by: request.employee.id, }, }); await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId) }); }); const profile = await buildEmployeeProfile(fastify.prisma, employeeId); return sendData(reply, request, profile); }); fastify.get("/employees/:id/lifecycle", { preHandler: fastify.authenticate }, async (request, reply) => { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); if (!canManageProfile(fastify, request, employeeId)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.lifecycle.view" } }); } const profile = await buildEmployeeProfile(fastify.prisma, employeeId); return sendData(reply, request, profile.lifecycle); }); async function updateChecklistTaskStatus(request, reply, nextStatus) { await ensureFeatureEnabled(fastify, request); const employeeId = parseId(request.params?.id); const taskId = parseId(request.params?.taskId); if (!canManageProfile(fastify, request, employeeId)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.lifecycle.write" } }); } const result = await fastify.prisma.$transaction(async (tx) => { await ensureEmployeeExists(tx, employeeId); const task = await tx.employeeChecklistTask.findFirst({ where: { id: taskId, employee_id: employeeId } }); if (!task) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } await tx.employeeChecklistTask.update({ where: { id: taskId }, data: { status: nextStatus, completed_at: nextStatus === "completed" ? new Date() : task.completed_at, completed_by: nextStatus === "completed" ? request.employee.id : task.completed_by, }, }); return maybeAdvanceLifecycle(tx, employeeId); }); if (result.completedOffboarding) { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "EMPLOYEE_OFFBOARDING_COMPLETED", targetType: "employee", targetId: employeeId, extra: result.blockers ?? {}, }), }); } const profile = await buildEmployeeProfile(fastify.prisma, employeeId); return sendData(reply, request, { ...profile.lifecycle, autoAdvanced: result.autoChanged }); } fastify.post("/employees/:id/checklist-tasks/:taskId/complete", { preHandler: fastify.authenticate }, async (request, reply) => { return updateChecklistTaskStatus(request, reply, "completed"); }); fastify.post("/employees/:id/checklist-tasks/:taskId/skip", { preHandler: fastify.authenticate }, async (request, reply) => { return updateChecklistTaskStatus(request, reply, "skipped"); }); } module.exports = employeesRoutes; module.exports.__testables = { buildEmployeeOrganization, buildEmployeeProfile, deriveEmployeeCertificateStatus, deriveEmployeeContractStatus, mapEmployeeCertificate, mapEmployeeContract, mapEmployeeTraining, mapOrganizationNode, };