R45 A2-A7 员工附件与培训证书管理
这个提交包含在:
+491
-1
@@ -1,3 +1,6 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const sharp = require("sharp");
|
||||
const { z } = require("zod");
|
||||
|
||||
@@ -10,14 +13,23 @@ 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, writeBuffer } = require("../services/storage");
|
||||
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(),
|
||||
@@ -72,6 +84,32 @@ 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 });
|
||||
}
|
||||
@@ -125,6 +163,10 @@ 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;
|
||||
}
|
||||
@@ -138,6 +180,18 @@ function validateContractDates(value) {
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -162,6 +216,7 @@ function mapEmployeeContract(contract, timeZone) {
|
||||
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,
|
||||
@@ -170,6 +225,106 @@ function mapEmployeeContract(contract, timeZone) {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -240,6 +395,26 @@ async function ensureEmployeeContractExists(prisma, employeeId, contractId) {
|
||||
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) {
|
||||
@@ -522,6 +697,32 @@ async function employeesRoutes(fastify) {
|
||||
});
|
||||
});
|
||||
|
||||
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)) {
|
||||
@@ -572,6 +773,37 @@ async function employeesRoutes(fastify) {
|
||||
});
|
||||
});
|
||||
|
||||
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")) {
|
||||
@@ -651,6 +883,261 @@ async function employeesRoutes(fastify) {
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -871,7 +1358,10 @@ module.exports = employeesRoutes;
|
||||
module.exports.__testables = {
|
||||
buildEmployeeOrganization,
|
||||
buildEmployeeProfile,
|
||||
deriveEmployeeCertificateStatus,
|
||||
deriveEmployeeContractStatus,
|
||||
mapEmployeeCertificate,
|
||||
mapEmployeeContract,
|
||||
mapEmployeeTraining,
|
||||
mapOrganizationNode,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ async function buildEmployeeStats(prisma, options = {}) {
|
||||
const next30DateTo = addDays(today, 30);
|
||||
const activeEmployeeWhere = { status: "active" };
|
||||
|
||||
const [employmentStatusRows, departmentRows, departments, recentHires, recentDepartures, expiringContracts] = await Promise.all([
|
||||
const [employmentStatusRows, departmentRows, departments, recentHires, recentDepartures, expiringContracts, validCertificateHolders, expiringCertificates] = await Promise.all([
|
||||
prisma.employee.groupBy({
|
||||
by: ["employment_status"],
|
||||
where: activeEmployeeWhere,
|
||||
@@ -55,6 +55,29 @@ async function buildEmployeeStats(prisma, options = {}) {
|
||||
employee: activeEmployeeWhere,
|
||||
},
|
||||
}),
|
||||
prisma.employeeCertificate.groupBy({
|
||||
by: ["employee_id"],
|
||||
where: {
|
||||
employee: activeEmployeeWhere,
|
||||
OR: [
|
||||
{ expiry_date: null },
|
||||
{
|
||||
expiry_date: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
prisma.employeeCertificate.count({
|
||||
where: {
|
||||
employee: activeEmployeeWhere,
|
||||
expiry_date: {
|
||||
gte: today,
|
||||
lte: new Date(`${next30DateTo.toISOString().slice(0, 10)}T23:59:59.999Z`),
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const departmentMap = new Map(departments.map((item) => [item.id, item.name]));
|
||||
@@ -88,6 +111,12 @@ async function buildEmployeeStats(prisma, options = {}) {
|
||||
dateTo: next30DateTo.toISOString().slice(0, 10),
|
||||
count: expiringContracts,
|
||||
},
|
||||
certificateSummary: {
|
||||
dateFrom: todayString,
|
||||
dateTo: next30DateTo.toISOString().slice(0, 10),
|
||||
validHolderCount: validCertificateHolders.length,
|
||||
expiringSoonCount: expiringCertificates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户