R45 A2-A7 员工附件与培训证书管理
这个提交包含在:
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户