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

这个提交包含在:
2026-03-29 13:31:53 +08:00
父节点 d11f5593d1
当前提交 48f395e18d
共修改 14 个文件,包含 2291 行新增和 22 行删除
@@ -0,0 +1,59 @@
-- CreateEnum
CREATE TYPE "EmployeeTrainingType" AS ENUM ('internal', 'external', 'online');
-- CreateEnum
CREATE TYPE "EmployeeTrainingStatus" AS ENUM ('planned', 'in_progress', 'completed', 'cancelled');
-- CreateTable
CREATE TABLE "employee_trainings" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"training_type" "EmployeeTrainingType" NOT NULL,
"start_date" DATE NOT NULL,
"end_date" DATE,
"status" "EmployeeTrainingStatus" NOT NULL,
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "employee_trainings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "employee_certificates" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"issuer" TEXT NOT NULL,
"obtained_date" DATE NOT NULL,
"expiry_date" DATE,
"certificate_no" TEXT,
"attachment_path" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "employee_certificates_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "idx_employee_trainings_employee_start" ON "employee_trainings"("employee_id", "start_date");
-- CreateIndex
CREATE INDEX "idx_employee_trainings_employee_status" ON "employee_trainings"("employee_id", "status");
-- CreateIndex
CREATE INDEX "idx_employee_certificates_employee_obtained" ON "employee_certificates"("employee_id", "obtained_date");
-- CreateIndex
CREATE INDEX "idx_employee_certificates_employee_expiry" ON "employee_certificates"("employee_id", "expiry_date");
-- AddForeignKey
ALTER TABLE "employee_trainings" ADD CONSTRAINT "employee_trainings_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "employee_certificates" ADD CONSTRAINT "employee_certificates_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+59
查看文件
@@ -350,6 +350,19 @@ enum EmployeeContractType {
internship
}
enum EmployeeTrainingType {
internal
external
online
}
enum EmployeeTrainingStatus {
planned
in_progress
completed
cancelled
}
enum WeeklyReportStatus {
draft
submitted
@@ -594,6 +607,8 @@ model Employee {
owned_contract_registries ContractRegistry[] @relation("ContractRegistryOwner")
owned_confidential_document_refs ConfidentialDocumentRef[] @relation("ConfidentialDocumentRefOwner")
contracts EmployeeContract[]
trainings EmployeeTraining[]
certificates EmployeeCertificate[]
@@index([department_id])
@@index([legal_entity_id], map: "idx_employees_legal_entity")
@@ -2007,6 +2022,50 @@ model EmployeeContract {
@@map("employee_contracts")
}
model EmployeeTraining {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
name String
training_type EmployeeTrainingType
start_date DateTime @db.Date
end_date DateTime? @db.Date
status EmployeeTrainingStatus
notes String?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@index([employee_id, start_date], map: "idx_employee_trainings_employee_start")
@@index([employee_id, status], map: "idx_employee_trainings_employee_status")
@@map("employee_trainings")
}
model EmployeeCertificate {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
name String
issuer String
obtained_date DateTime @db.Date
expiry_date DateTime? @db.Date
certificate_no String?
attachment_path String?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@index([employee_id, obtained_date], map: "idx_employee_certificates_employee_obtained")
@@index([employee_id, expiry_date], map: "idx_employee_certificates_employee_expiry")
@@map("employee_certificates")
}
model ProjectDeliveryRecord {
id String @id @default(uuid()) @db.Uuid
project_id String @db.Uuid
+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,
},
};
}
@@ -0,0 +1,391 @@
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const Fastify = require("fastify");
const multipart = require("@fastify/multipart");
const employeesRoutes = require("../../../src/routes/employees");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const CONTRACT_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildMultipartRequest({ filename, contentType, content }) {
const boundary = "----codex-employee-attachment-boundary";
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([head, Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"), tail]);
return {
payload: body,
headers: {
"content-type": `multipart/form-data; boundary=${boundary}`,
},
};
}
function buildFeatureFlagRecord() {
return {
id: "flag-1",
flag_key: "employee_lifecycle_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-03-28T00:00:00.000Z"),
created_by: null,
updated_at: new Date("2026-03-28T00:00:00.000Z"),
updated_by: null,
};
}
function buildEmployeeRow(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "Alice",
phone: "13800000000",
email: "[email protected]",
status: "active",
employment_status: "active",
department_id: DEPARTMENT_ID,
title: "员工",
direct_manager_id: null,
hire_date: new Date("2026-01-10T00:00:00.000Z"),
probation_end_date: null,
last_working_date: null,
work_location: "上海",
emergency_contact_name: null,
emergency_contact_phone: null,
notes: null,
avatar_path: null,
archived_at: null,
roles: [],
department: { id: DEPARTMENT_ID, name: "行政部" },
direct_manager: null,
...overrides,
};
}
function buildContractRow(overrides = {}) {
return {
id: CONTRACT_ID,
employee_id: EMPLOYEE_ID,
contract_no: "LAB-001",
contract_type: "labor",
start_date: new Date("2026-01-01T00:00:00.000Z"),
end_date: new Date("2026-12-31T00:00:00.000Z"),
terminated_date: null,
attachment_path: null,
created_at: new Date("2026-01-01T00:00:00.000Z"),
created_by: "actor-1",
updated_at: new Date("2026-01-01T00:00:00.000Z"),
updated_by: "actor-1",
...overrides,
};
}
async function buildApp({
actor = { id: "actor-1", department_id: DEPARTMENT_ID, permissionCodes: ["system.users"] },
employee = {},
contract = {},
} = {}) {
const app = Fastify();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r45-attachments-"));
const state = {
employee: buildEmployeeRow(employee),
contract: buildContractRow(contract),
};
const employeeUpdate = vi.fn(async ({ data }) => {
state.employee = {
...state.employee,
...(data.avatar_path !== undefined ? { avatar_path: data.avatar_path } : {}),
...(data.updated_by !== undefined ? { updated_by: data.updated_by } : {}),
};
return state.employee;
});
const contractUpdate = vi.fn(async ({ data }) => {
state.contract = {
...state.contract,
...(data.attachment_path !== undefined ? { attachment_path: data.attachment_path } : {}),
...(data.updated_by !== undefined ? { updated_by: data.updated_by } : {}),
};
return state.contract;
});
const contractDelete = vi.fn(async () => {
const deleted = state.contract;
state.contract = null;
return deleted;
});
const auditCreate = vi.fn().mockResolvedValue({});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
employeeLifecycleEnabled: true,
storageDir: tempDir,
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employeeRecord, permissionCode) => {
const permissionCodes = employeeRecord.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("prisma", {
featureFlag: {
findUnique: vi.fn().mockResolvedValue(buildFeatureFlagRecord()),
},
employee: {
findUnique: vi.fn(async ({ where }) => (where.id === EMPLOYEE_ID ? state.employee : null)),
update: employeeUpdate,
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeContract: {
findFirst: vi.fn(async ({ where }) => {
if (!state.contract) {
return null;
}
if (where.id === state.contract.id && where.employee_id === state.contract.employee_id) {
return state.contract;
}
return null;
}),
findMany: vi.fn(async () => (state.contract ? [state.contract] : [])),
update: contractUpdate,
delete: contractDelete,
create: vi.fn(),
count: vi.fn().mockResolvedValue(0),
},
employeeTraining: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
update: vi.fn(),
},
employeeCertificate: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
update: vi.fn(),
groupBy: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeChecklistTask: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeLifecycleEvent: {
findMany: vi.fn().mockResolvedValue([]),
},
assetCard: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
workflowTask: {
count: vi.fn().mockResolvedValue(0),
},
auditLog: {
create: auditCreate,
},
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? null,
},
});
}
return reply.status(500).send({
requestId: request.id,
error: {
code: error.code ?? "SYS_001",
message: error.message ?? "服务器内部错误",
details: error.details ?? null,
},
});
});
await app.register(multipart, {
attachFieldsToBody: false,
limits: { files: 1, fileSize: 10 * 1024 * 1024 },
});
await app.register(employeesRoutes, { prefix: "/api" });
return { app, auditCreate, contractDelete, contractUpdate, employeeUpdate, state, tempDir };
}
describe("employee attachment and delete routes", () => {
it("uploads, replaces and downloads contract attachments for system.users", async () => {
const { app, state, tempDir } = await buildApp({
contract: {
attachment_path: `contracts/${CONTRACT_ID}.png`,
},
});
const oldAttachmentPath = path.join(tempDir, "contracts", `${CONTRACT_ID}.png`);
await fs.mkdir(path.dirname(oldAttachmentPath), { recursive: true });
await fs.writeFile(oldAttachmentPath, Buffer.from("old-image"));
const uploadResponse = await app.inject({
method: "PUT",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}/attachment`,
...buildMultipartRequest({
filename: "labor.pdf",
contentType: "application/pdf",
content: "new-pdf",
}),
});
expect(uploadResponse.statusCode).toBe(200);
expect(uploadResponse.json().data).toMatchObject({
attachmentPath: `contracts/${CONTRACT_ID}.pdf`,
attachmentUrl: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}/attachment`,
});
expect(state.contract.attachment_path).toBe(`contracts/${CONTRACT_ID}.pdf`);
expect(await fs.readFile(path.join(tempDir, "contracts", `${CONTRACT_ID}.pdf`), "utf8")).toBe("new-pdf");
await expect(fs.access(oldAttachmentPath)).rejects.toMatchObject({ code: "ENOENT" });
const downloadResponse = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}/attachment`,
});
expect(downloadResponse.statusCode).toBe(200);
expect(downloadResponse.headers["content-type"]).toContain("application/pdf");
expect(downloadResponse.headers["content-disposition"]).toContain('filename="LAB-001.pdf"');
expect(downloadResponse.body).toBe("new-pdf");
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("rejects contract attachment upload for non-admin users", async () => {
const { app, tempDir } = await buildApp({
actor: { id: "actor-2", department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
});
const response = await app.inject({
method: "PUT",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}/attachment`,
...buildMultipartRequest({
filename: "labor.pdf",
contentType: "application/pdf",
content: "new-pdf",
}),
});
expect(response.statusCode).toBe(403);
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("allows self-service avatar deletion and clears stored file", async () => {
const { app, state, tempDir } = await buildApp({
actor: { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
employee: { avatar_path: `avatars/${EMPLOYEE_ID}.webp` },
});
const avatarPath = path.join(tempDir, "avatars", `${EMPLOYEE_ID}.webp`);
await fs.mkdir(path.dirname(avatarPath), { recursive: true });
await fs.writeFile(avatarPath, Buffer.from("avatar"));
const response = await app.inject({
method: "DELETE",
url: `/api/employees/${EMPLOYEE_ID}/avatar`,
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({
employeeId: EMPLOYEE_ID,
avatarUrl: null,
});
expect(state.employee.avatar_path).toBeNull();
await expect(fs.access(avatarPath)).rejects.toMatchObject({ code: "ENOENT" });
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("rejects deleting another employee avatar without system.users", async () => {
const { app, tempDir } = await buildApp({
actor: { id: "actor-2", department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
});
const response = await app.inject({
method: "DELETE",
url: `/api/employees/${EMPLOYEE_ID}/avatar`,
});
expect(response.statusCode).toBe(403);
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("rejects deleting active contracts and allows deleting expired contracts without local file", async () => {
const activeApp = await buildApp();
const activeResponse = await activeApp.app.inject({
method: "DELETE",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}`,
});
expect(activeResponse.statusCode).toBe(409);
expect(activeResponse.json().error.code).toBe("VALID_001");
expect(activeApp.state.contract).not.toBeNull();
await activeApp.app.close();
await fs.rm(activeApp.tempDir, { recursive: true, force: true });
const expiredApp = await buildApp({
contract: {
end_date: new Date("2025-12-31T00:00:00.000Z"),
attachment_path: `contracts/${CONTRACT_ID}.pdf`,
},
});
const expiredResponse = await expiredApp.app.inject({
method: "DELETE",
url: `/api/employees/${EMPLOYEE_ID}/contracts/${CONTRACT_ID}`,
});
expect(expiredResponse.statusCode).toBe(200);
expect(expiredResponse.json().data).toEqual({
contractId: CONTRACT_ID,
deleted: true,
});
expect(expiredApp.state.contract).toBeNull();
expect(expiredApp.auditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "EMPLOYEE_CONTRACT_DELETED",
target_id: CONTRACT_ID,
}),
}),
);
await expiredApp.app.close();
await fs.rm(expiredApp.tempDir, { recursive: true, force: true });
});
});
@@ -0,0 +1,232 @@
const Fastify = require("fastify");
const employeesRoutes = require("../../../src/routes/employees");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildFeatureFlagRecord() {
return {
id: "flag-1",
flag_key: "employee_lifecycle_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-03-28T00:00:00.000Z"),
created_by: null,
updated_at: new Date("2026-03-28T00:00:00.000Z"),
updated_by: null,
};
}
function buildCertificateRow(overrides = {}) {
return {
id: "44444444-4444-4444-8444-444444444444",
employee_id: EMPLOYEE_ID,
name: "PMP",
issuer: "PMI",
obtained_date: new Date("2025-01-01T00:00:00.000Z"),
expiry_date: null,
certificate_no: "CERT-001",
attachment_path: "certificates/pmp.pdf",
created_at: new Date("2025-01-01T00:00:00.000Z"),
created_by: "actor-1",
updated_at: new Date("2025-01-01T00:00:00.000Z"),
updated_by: "actor-1",
...overrides,
};
}
async function buildApp({ actor = { id: "actor-1", department_id: DEPARTMENT_ID, permissionCodes: ["system.users"] } } = {}) {
const app = Fastify();
const state = {
certificate: null,
};
const certificateCreate = vi.fn(async ({ data }) => {
state.certificate = buildCertificateRow({
name: data.name,
issuer: data.issuer,
obtained_date: data.obtained_date,
expiry_date: data.expiry_date,
certificate_no: data.certificate_no,
attachment_path: data.attachment_path,
created_by: data.created_by,
updated_by: data.updated_by,
});
return state.certificate;
});
const certificateUpdate = vi.fn(async ({ data }) => {
state.certificate = {
...state.certificate,
...(data.name !== undefined ? { name: data.name } : {}),
...(data.issuer !== undefined ? { issuer: data.issuer } : {}),
...(data.obtained_date !== undefined ? { obtained_date: data.obtained_date } : {}),
...(data.expiry_date !== undefined ? { expiry_date: data.expiry_date } : {}),
...(data.certificate_no !== undefined ? { certificate_no: data.certificate_no } : {}),
...(data.attachment_path !== undefined ? { attachment_path: data.attachment_path } : {}),
...(data.updated_by !== undefined ? { updated_by: data.updated_by } : {}),
};
return state.certificate;
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
employeeLifecycleEnabled: true,
storageDir: "/tmp",
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employeeRecord, permissionCode) => {
const permissionCodes = employeeRecord.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("prisma", {
featureFlag: {
findUnique: vi.fn().mockResolvedValue(buildFeatureFlagRecord()),
},
employee: {
findUnique: vi.fn(async ({ where }) => (where.id === EMPLOYEE_ID ? { id: EMPLOYEE_ID } : null)),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(),
},
employeeContract: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
},
employeeTraining: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
update: vi.fn(),
},
employeeCertificate: {
findMany: vi.fn(async () => (state.certificate ? [state.certificate] : [])),
findFirst: vi.fn(async ({ where }) => (state.certificate?.id === where.id && state.certificate?.employee_id === where.employee_id ? state.certificate : null)),
create: certificateCreate,
update: certificateUpdate,
groupBy: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeChecklistTask: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeLifecycleEvent: {
findMany: vi.fn().mockResolvedValue([]),
},
assetCard: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
workflowTask: {
count: vi.fn().mockResolvedValue(0),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
});
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(employeesRoutes, { prefix: "/api" });
return { app, certificateCreate, certificateUpdate, state };
}
describe("employee certificate routes", () => {
it("allows system.users to create, update and list certificates with derived status", async () => {
const { app, certificateCreate, certificateUpdate, state } = await buildApp();
const createResponse = await app.inject({
method: "POST",
url: `/api/employees/${EMPLOYEE_ID}/certificates`,
payload: {
name: "PMP",
issuer: "PMI",
obtainedDate: "2025-01-01",
expiryDate: null,
certificateNo: "CERT-001",
attachmentPath: "certificates/pmp.pdf",
},
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json().data).toMatchObject({
name: "PMP",
issuer: "PMI",
status: "valid",
attachmentPath: "certificates/pmp.pdf",
});
expect(certificateCreate).toHaveBeenCalledTimes(1);
const patchResponse = await app.inject({
method: "PATCH",
url: `/api/employees/${EMPLOYEE_ID}/certificates/${state.certificate.id}`,
payload: {
expiryDate: "2025-02-01",
},
});
expect(patchResponse.statusCode).toBe(200);
expect(patchResponse.json().data).toMatchObject({
expiryDate: "2025-02-01",
status: "expired",
});
expect(certificateUpdate).toHaveBeenCalledTimes(1);
const listResponse = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/certificates`,
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toEqual([
expect.objectContaining({
id: state.certificate.id,
status: "expired",
}),
]);
await app.close();
});
it("rejects non-admin users accessing certificates", async () => {
const { app } = await buildApp({
actor: { id: "actor-2", department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
});
const response = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/certificates`,
});
expect(response.statusCode).toBe(403);
await app.close();
});
});
@@ -96,6 +96,7 @@ describe("employee profile organization", () => {
name: true,
title: true,
status: true,
avatar_path: true,
department: { select: { id: true, name: true } },
},
});
@@ -183,6 +184,7 @@ describe("employee profile organization", () => {
name: "Helen",
title: "主管",
status: "active",
avatarUrl: null,
department: {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
name: "行政部",
@@ -21,6 +21,12 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
{ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "研发部" },
]);
const contractCount = vi.fn().mockResolvedValue(2);
const certificateGroupBy = vi.fn().mockResolvedValue([
{ employee_id: "emp-1" },
{ employee_id: "emp-2" },
{ employee_id: "emp-3" },
]);
const certificateCount = vi.fn().mockResolvedValue(4);
app.decorate("prisma", {
employee: {
@@ -36,6 +42,11 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
count: stats?.employeeContractCount ?? contractCount,
findMany: vi.fn().mockResolvedValue([]),
},
employeeCertificate: {
groupBy: stats?.employeeCertificateGroupBy ?? certificateGroupBy,
count: stats?.employeeCertificateCount ?? certificateCount,
findMany: vi.fn().mockResolvedValue([]),
},
});
app.decorate("authenticate", async (request) => {
request.employee = {
@@ -68,12 +79,12 @@ async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
});
await app.register(employeesRoutes, { prefix: "/api" });
return { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount };
return { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount, certificateGroupBy, certificateCount };
}
describe("employees stats route", () => {
it("returns aggregated employee stats for read-only viewers", async () => {
const { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount } = await buildApp({
const { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount, certificateGroupBy, certificateCount } = await buildApp({
permissionCodes: ["report.dashboard.view"],
});
@@ -92,10 +103,18 @@ describe("employees stats route", () => {
expect(payload.data.recentMovements.hires).toBe(3);
expect(payload.data.recentMovements.departures).toBe(1);
expect(payload.data.expiringContracts.count).toBe(2);
expect(payload.data.certificateSummary).toEqual({
dateFrom: payload.data.asOfDate,
dateTo: expect.any(String),
validHolderCount: 3,
expiringSoonCount: 4,
});
expect(employeeGroupBy).toHaveBeenCalledTimes(2);
expect(employeeCount).toHaveBeenCalledTimes(2);
expect(departmentFindMany).toHaveBeenCalledTimes(1);
expect(contractCount).toHaveBeenCalledTimes(1);
expect(certificateGroupBy).toHaveBeenCalledTimes(1);
expect(certificateCount).toHaveBeenCalledTimes(1);
await app.close();
});
@@ -0,0 +1,234 @@
const Fastify = require("fastify");
const employeesRoutes = require("../../../src/routes/employees");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
function buildFeatureFlagRecord() {
return {
id: "flag-1",
flag_key: "employee_lifecycle_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-03-28T00:00:00.000Z"),
created_by: null,
updated_at: new Date("2026-03-28T00:00:00.000Z"),
updated_by: null,
};
}
function buildTrainingRow(overrides = {}) {
return {
id: "33333333-3333-4333-8333-333333333333",
employee_id: EMPLOYEE_ID,
name: "入职培训",
training_type: "internal",
start_date: new Date("2026-03-01T00:00:00.000Z"),
end_date: new Date("2026-03-02T00:00:00.000Z"),
status: "planned",
notes: "首次培训",
created_at: new Date("2026-03-01T00:00:00.000Z"),
created_by: "actor-1",
updated_at: new Date("2026-03-01T00:00:00.000Z"),
updated_by: "actor-1",
...overrides,
};
}
async function buildApp({ actor = { id: "actor-1", department_id: DEPARTMENT_ID, permissionCodes: ["system.users"] } } = {}) {
const app = Fastify();
const state = {
training: null,
};
const trainingCreate = vi.fn(async ({ data }) => {
state.training = buildTrainingRow({
name: data.name,
training_type: data.training_type,
start_date: data.start_date,
end_date: data.end_date,
status: data.status,
notes: data.notes,
created_by: data.created_by,
updated_by: data.updated_by,
});
return state.training;
});
const trainingUpdate = vi.fn(async ({ data }) => {
state.training = {
...state.training,
...(data.name !== undefined ? { name: data.name } : {}),
...(data.training_type !== undefined ? { training_type: data.training_type } : {}),
...(data.start_date !== undefined ? { start_date: data.start_date } : {}),
...(data.end_date !== undefined ? { end_date: data.end_date } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
...(data.notes !== undefined ? { notes: data.notes } : {}),
...(data.updated_by !== undefined ? { updated_by: data.updated_by } : {}),
};
return state.training;
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
employeeLifecycleEnabled: true,
storageDir: "/tmp",
});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
const permissionCodes = actor.permissionCodes ?? [];
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employeeRecord, permissionCode) => {
const permissionCodes = employeeRecord.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("prisma", {
featureFlag: {
findUnique: vi.fn().mockResolvedValue(buildFeatureFlagRecord()),
},
employee: {
findUnique: vi.fn(async ({ where }) => (where.id === EMPLOYEE_ID ? { id: EMPLOYEE_ID } : null)),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(),
},
employeeContract: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
},
employeeTraining: {
findMany: vi.fn(async () => (state.training ? [state.training] : [])),
findFirst: vi.fn(async ({ where }) => (state.training?.id === where.id && state.training?.employee_id === where.employee_id ? state.training : null)),
create: trainingCreate,
update: trainingUpdate,
},
employeeCertificate: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn(),
update: vi.fn(),
groupBy: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeChecklistTask: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
employeeLifecycleEvent: {
findMany: vi.fn().mockResolvedValue([]),
},
assetCard: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
workflowTask: {
count: vi.fn().mockResolvedValue(0),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
});
app.setErrorHandler((error, request, reply) => {
reply.status(error.statusCode || 500).send({
requestId: request.id,
error: {
code: error.code || "SYS_001",
message: error.message,
details: error.details ?? null,
},
});
});
await app.register(employeesRoutes, { prefix: "/api" });
return { app, state, trainingCreate, trainingUpdate };
}
describe("employee training routes", () => {
it("allows system.users to create, update and list employee trainings", async () => {
const { app, state, trainingCreate, trainingUpdate } = await buildApp();
const createResponse = await app.inject({
method: "POST",
url: `/api/employees/${EMPLOYEE_ID}/trainings`,
payload: {
name: "安全培训",
trainingType: "external",
startDate: "2026-03-10",
endDate: "2026-03-12",
status: "planned",
notes: "第一阶段",
},
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json().data).toMatchObject({
name: "安全培训",
trainingType: "external",
status: "planned",
notes: "第一阶段",
});
expect(trainingCreate).toHaveBeenCalledTimes(1);
const patchResponse = await app.inject({
method: "PATCH",
url: `/api/employees/${EMPLOYEE_ID}/trainings/${state.training.id}`,
payload: {
status: "completed",
endDate: "2026-03-15",
},
});
expect(patchResponse.statusCode).toBe(200);
expect(patchResponse.json().data).toMatchObject({
status: "completed",
endDate: "2026-03-15",
});
expect(trainingUpdate).toHaveBeenCalledTimes(1);
const listResponse = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/trainings`,
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toEqual([
expect.objectContaining({
id: state.training.id,
name: "安全培训",
status: "completed",
}),
]);
await app.close();
});
it("rejects non-admin users managing trainings", async () => {
const { app } = await buildApp({
actor: { id: "actor-2", department_id: DEPARTMENT_ID, permissionCodes: ["employee"] },
});
const response = await app.inject({
method: "GET",
url: `/api/employees/${EMPLOYEE_ID}/trainings`,
});
expect(response.statusCode).toBe(403);
await app.close();
});
});