R45 A2-A7 员工附件与培训证书管理

这个提交包含在:
2026-03-29 13:31:53 +08:00
父节点 d11f5593d1
当前提交 48f395e18d
共修改 14 个文件,包含 2291 行新增和 22 行删除
+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,
};
+30 -1
查看文件
@@ -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,
},
};
}