1369 行
54 KiB
JavaScript
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,
|
|
};
|