文件
hyb-oa/server/src/routes/employees.js
T

1369 行
54 KiB
JavaScript

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,
};