A2/A4/A6 实现员工头像后端、组织架构接口与合同模型
这个提交包含在:
+299
-2
@@ -1,14 +1,21 @@
|
||||
const sharp = require("sharp");
|
||||
const { z } = require("zod");
|
||||
|
||||
const usersRoutes = require("./users");
|
||||
const { currentDateString } = require("../utils/date");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { listOrganizationDepartments } = require("../services/organization");
|
||||
const { StorageNotFoundError, openReadStream, 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_TYPE_SCHEMA = z.enum(["labor", "nda", "non_compete", "internship"]);
|
||||
const PROFILE_PATCH_SCHEMA = z
|
||||
.object({
|
||||
departmentId: UUID_SCHEMA.optional(),
|
||||
@@ -50,6 +57,19 @@ const OFFBOARDING_SCHEMA = z.object({
|
||||
notes: z.string().trim().max(500).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const CONTRACT_CREATE_SCHEMA = z.object({
|
||||
contractNo: z.string().trim().min(1).max(100),
|
||||
contractType: CONTRACT_TYPE_SCHEMA,
|
||||
startDate: DATE_SCHEMA,
|
||||
endDate: DATE_SCHEMA,
|
||||
terminatedDate: DATE_SCHEMA.nullable().optional(),
|
||||
attachmentPath: z.string().trim().max(500).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
const CONTRACT_PATCH_SCHEMA = CONTRACT_CREATE_SCHEMA.partial()
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
@@ -91,10 +111,67 @@ function canManageProfile(fastify, request, employeeId) {
|
||||
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view");
|
||||
}
|
||||
|
||||
function canViewPeopleDirectory(fastify, request) {
|
||||
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view");
|
||||
}
|
||||
|
||||
function canManageLifecycle(fastify, request) {
|
||||
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*");
|
||||
}
|
||||
|
||||
function canUploadAvatar(fastify, request, employeeId) {
|
||||
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users");
|
||||
}
|
||||
|
||||
function buildAvatarUrl(employeeId, avatarPath) {
|
||||
return avatarPath ? `/api/avatars/${employeeId}` : null;
|
||||
}
|
||||
|
||||
function parseDateOnly(value) {
|
||||
return value ? new Date(`${value}T00:00:00.000Z`) : null;
|
||||
}
|
||||
|
||||
function validateContractDates(value) {
|
||||
if (value.startDate && value.endDate && value.startDate > value.endDate) {
|
||||
throw buildValidationError({ endDate: "must_be_after_or_equal_startDate" });
|
||||
}
|
||||
if (value.startDate && value.terminatedDate && value.terminatedDate < value.startDate) {
|
||||
throw buildValidationError({ terminatedDate: "must_be_after_or_equal_startDate" });
|
||||
}
|
||||
}
|
||||
|
||||
function deriveEmployeeContractStatus(contract, timeZone) {
|
||||
if (contract.terminated_date) {
|
||||
return "terminated";
|
||||
}
|
||||
|
||||
const today = currentDateString(timeZone ?? "Asia/Shanghai");
|
||||
const endDate = contract.end_date?.toISOString?.()?.slice(0, 10) ?? null;
|
||||
if (endDate && endDate < today) {
|
||||
return "expired";
|
||||
}
|
||||
|
||||
return "active";
|
||||
}
|
||||
|
||||
function mapEmployeeContract(contract, timeZone) {
|
||||
return {
|
||||
id: contract.id,
|
||||
employeeId: contract.employee_id,
|
||||
contractNo: contract.contract_no,
|
||||
contractType: contract.contract_type,
|
||||
startDate: contract.start_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
endDate: contract.end_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
terminatedDate: contract.terminated_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
attachmentPath: contract.attachment_path ?? null,
|
||||
status: deriveEmployeeContractStatus(contract, timeZone),
|
||||
createdAt: contract.created_at?.toISOString?.() ?? null,
|
||||
createdBy: contract.created_by ?? null,
|
||||
updatedAt: contract.updated_at?.toISOString?.() ?? null,
|
||||
updatedBy: contract.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapChecklistTask(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -130,6 +207,7 @@ function mapOrganizationNode(item) {
|
||||
name: item.name,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
avatarUrl: buildAvatarUrl(item.id, item.avatar_path),
|
||||
department: item.department ? { id: item.department.id, name: item.department.name } : null,
|
||||
};
|
||||
}
|
||||
@@ -154,6 +232,16 @@ async function ensureEmployeeExists(prisma, employeeId) {
|
||||
return employee;
|
||||
}
|
||||
|
||||
async function ensureEmployeeContractExists(prisma, employeeId, contractId) {
|
||||
const contract = await prisma.employeeContract.findFirst({
|
||||
where: { id: contractId, employee_id: employeeId },
|
||||
});
|
||||
if (!contract) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
return contract;
|
||||
}
|
||||
|
||||
function buildDefaultChecklist(scene, employeeId, actorId) {
|
||||
const common = { employee_id: employeeId };
|
||||
if (scene === "onboarding") {
|
||||
@@ -231,6 +319,7 @@ async function buildEmployeeOrganization(prisma, employee) {
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
avatar_path: true,
|
||||
direct_manager_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
@@ -255,6 +344,7 @@ async function buildEmployeeOrganization(prisma, employee) {
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
avatar_path: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
@@ -287,10 +377,11 @@ async function buildEmployeeProfile(prisma, employeeId) {
|
||||
emergency_contact_name: true,
|
||||
emergency_contact_phone: true,
|
||||
notes: true,
|
||||
avatar_path: true,
|
||||
archived_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
department: { select: { id: true, name: true } },
|
||||
direct_manager: { select: { id: true, employee_no: true, name: true } },
|
||||
direct_manager: { select: { id: true, employee_no: true, name: true, avatar_path: true } },
|
||||
},
|
||||
}),
|
||||
prisma.employeeChecklistTask.findMany({ where: { employee_id: employeeId }, orderBy: [{ created_at: "desc" }] }),
|
||||
@@ -328,10 +419,18 @@ async function buildEmployeeProfile(prisma, employeeId) {
|
||||
emergencyContactName: employee.emergency_contact_name ?? null,
|
||||
emergencyContactPhone: employee.emergency_contact_phone ?? null,
|
||||
notes: employee.notes ?? null,
|
||||
avatarUrl: buildAvatarUrl(employee.id, employee.avatar_path),
|
||||
archivedAt: employee.archived_at?.toISOString?.() ?? null,
|
||||
roleCodes: employee.roles.map((item) => item.role_code),
|
||||
department: employee.department ? { id: employee.department.id, name: employee.department.name } : null,
|
||||
directManager: employee.direct_manager ? { id: employee.direct_manager.id, employeeNo: employee.direct_manager.employee_no, name: employee.direct_manager.name } : null,
|
||||
directManager: employee.direct_manager
|
||||
? {
|
||||
id: employee.direct_manager.id,
|
||||
employeeNo: employee.direct_manager.employee_no,
|
||||
name: employee.direct_manager.name,
|
||||
avatarUrl: buildAvatarUrl(employee.direct_manager.id, employee.direct_manager.avatar_path),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
lifecycle: {
|
||||
events: lifecycleEvents.map(mapLifecycleEvent),
|
||||
@@ -352,6 +451,104 @@ async function buildEmployeeProfile(prisma, employeeId) {
|
||||
async function employeesRoutes(fastify) {
|
||||
await usersRoutes(fastify, { basePath: "/employees", allowDepartmentFilter: true });
|
||||
|
||||
fastify.get("/avatars/:employeeId", async (request, reply) => {
|
||||
const employeeId = parseId(request.params?.employeeId);
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
select: { avatar_path: true },
|
||||
});
|
||||
|
||||
if (!employee?.avatar_path) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const opened = await openReadStream({
|
||||
config: fastify.config,
|
||||
storagePath: employee.avatar_path,
|
||||
});
|
||||
reply.header("cache-control", "no-store");
|
||||
return reply.type("image/webp").send(opened.stream);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageNotFoundError) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
fastify.put("/employees/:id/avatar", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const employeeId = parseId(request.params?.id);
|
||||
if (!canUploadAvatar(fastify, request, employeeId)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.avatar.write" } });
|
||||
}
|
||||
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
if (!request.isMultipart()) {
|
||||
throw buildValidationError({ file: "invalid_multipart" });
|
||||
}
|
||||
|
||||
const filePart = await request.file();
|
||||
if (!filePart) {
|
||||
throw buildValidationError({ file: "required" });
|
||||
}
|
||||
if (filePart.fieldname !== "file") {
|
||||
throw buildValidationError({ file: "invalid_field" });
|
||||
}
|
||||
if (!AVATAR_ALLOWED_MIME_TYPES.has(filePart.mimetype)) {
|
||||
throw buildValidationError({ file: "invalid_type" });
|
||||
}
|
||||
|
||||
const rawBuffer = await filePart.toBuffer();
|
||||
if (!rawBuffer.length) {
|
||||
throw buildValidationError({ file: "empty" });
|
||||
}
|
||||
if (rawBuffer.length > AVATAR_MAX_BYTES) {
|
||||
throw buildValidationError({ file: "file_too_large" });
|
||||
}
|
||||
|
||||
let normalizedBuffer;
|
||||
try {
|
||||
normalizedBuffer = await sharp(rawBuffer)
|
||||
.resize(200, 200, { fit: "cover", position: "centre" })
|
||||
.webp({ quality: 90 })
|
||||
.toBuffer();
|
||||
} catch {
|
||||
throw buildValidationError({ file: "invalid_image" });
|
||||
}
|
||||
|
||||
const avatarPath = await writeBuffer({
|
||||
config: fastify.config,
|
||||
key: `avatars/${employeeId}.webp`,
|
||||
buffer: normalizedBuffer,
|
||||
contentType: "image/webp",
|
||||
});
|
||||
|
||||
await fastify.prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: {
|
||||
avatar_path: avatarPath,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
employeeId,
|
||||
avatarUrl: buildAvatarUrl(employeeId, avatarPath),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/employees/organization", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!canViewPeopleDirectory(fastify, request)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.organization.view" } });
|
||||
}
|
||||
|
||||
const items = await listOrganizationDepartments(fastify.prisma);
|
||||
return sendData(reply, request, { items });
|
||||
});
|
||||
|
||||
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const employeeId = parseId(request.params?.id);
|
||||
@@ -362,6 +559,104 @@ async function employeesRoutes(fastify) {
|
||||
return sendData(reply, request, profile);
|
||||
});
|
||||
|
||||
fastify.get("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
|
||||
const employeeId = parseId(request.params?.id);
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
|
||||
const items = await fastify.prisma.employeeContract.findMany({
|
||||
where: { employee_id: employeeId },
|
||||
orderBy: [{ start_date: "desc" }, { created_at: "desc" }],
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
items: items.map((item) => mapEmployeeContract(item, fastify.config.appTimezone)),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/employees/:id/contracts", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const body = parseBody(CONTRACT_CREATE_SCHEMA, request.body);
|
||||
validateContractDates(body);
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
|
||||
try {
|
||||
const contract = await fastify.prisma.employeeContract.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
contract_no: body.contractNo,
|
||||
contract_type: body.contractType,
|
||||
start_date: parseDateOnly(body.startDate),
|
||||
end_date: parseDateOnly(body.endDate),
|
||||
terminated_date: parseDateOnly(body.terminatedDate ?? null),
|
||||
attachment_path: body.attachmentPath ?? null,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone));
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw buildValidationError({ contractNo: "duplicate" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
fastify.patch("/employees/:id/contracts/:contractId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!fastify.hasPermission(request.employee, "system.users")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
|
||||
}
|
||||
|
||||
const employeeId = parseId(request.params?.id);
|
||||
const contractId = parseId(request.params?.contractId);
|
||||
const body = parseBody(CONTRACT_PATCH_SCHEMA, request.body);
|
||||
await ensureEmployeeExists(fastify.prisma, employeeId);
|
||||
|
||||
const current = await ensureEmployeeContractExists(fastify.prisma, employeeId, contractId);
|
||||
validateContractDates({
|
||||
startDate: body.startDate ?? current.start_date?.toISOString?.()?.slice(0, 10),
|
||||
endDate: body.endDate ?? current.end_date?.toISOString?.()?.slice(0, 10),
|
||||
terminatedDate:
|
||||
body.terminatedDate !== undefined
|
||||
? body.terminatedDate
|
||||
: current.terminated_date?.toISOString?.()?.slice(0, 10),
|
||||
});
|
||||
|
||||
try {
|
||||
const contract = await fastify.prisma.employeeContract.update({
|
||||
where: { id: contractId },
|
||||
data: {
|
||||
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
|
||||
...(body.contractType !== undefined ? { contract_type: body.contractType } : {}),
|
||||
...(body.startDate !== undefined ? { start_date: parseDateOnly(body.startDate) } : {}),
|
||||
...(body.endDate !== undefined ? { end_date: parseDateOnly(body.endDate) } : {}),
|
||||
...(body.terminatedDate !== undefined ? { terminated_date: parseDateOnly(body.terminatedDate ?? null) } : {}),
|
||||
...(body.attachmentPath !== undefined ? { attachment_path: body.attachmentPath ?? null } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
return sendData(reply, request, mapEmployeeContract(contract, fastify.config.appTimezone));
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") {
|
||||
throw buildValidationError({ contractNo: "duplicate" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
fastify.patch("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const employeeId = parseId(request.params?.id);
|
||||
@@ -599,5 +894,7 @@ module.exports = employeesRoutes;
|
||||
module.exports.__testables = {
|
||||
buildEmployeeOrganization,
|
||||
buildEmployeeProfile,
|
||||
deriveEmployeeContractStatus,
|
||||
mapEmployeeContract,
|
||||
mapOrganizationNode,
|
||||
};
|
||||
|
||||
@@ -186,6 +186,7 @@ function mapEmployeeSummary(row) {
|
||||
status: row.status,
|
||||
departmentId: row.department_id,
|
||||
title: row.title,
|
||||
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
|
||||
roleCodes: row.roles.map((item) => item.role_code),
|
||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||
@@ -785,6 +786,7 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
avatar_path: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
function buildAvatarUrl(employeeId, avatarPath) {
|
||||
return avatarPath ? `/api/avatars/${employeeId}` : null;
|
||||
}
|
||||
|
||||
function compareByEmployeeNo(left, right) {
|
||||
return left.employee_no.localeCompare(right.employee_no, "en");
|
||||
}
|
||||
|
||||
function mapDepartmentSummary(department) {
|
||||
if (!department) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: department.id,
|
||||
name: department.name,
|
||||
};
|
||||
}
|
||||
|
||||
function mapExternalManager(employee, departmentId, activeEmployeeById) {
|
||||
if (!employee.direct_manager_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manager = activeEmployeeById.get(employee.direct_manager_id) ?? null;
|
||||
if (!manager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (manager.department_id === departmentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: manager.id,
|
||||
employeeNo: manager.employee_no,
|
||||
name: manager.name,
|
||||
department: mapDepartmentSummary(manager.department),
|
||||
};
|
||||
}
|
||||
|
||||
function mapOrganizationNode(employee, departmentId, activeEmployeeById, childrenByManagerId, stack) {
|
||||
const nextStack = new Set(stack);
|
||||
nextStack.add(employee.id);
|
||||
|
||||
const children = (childrenByManagerId.get(employee.id) ?? [])
|
||||
.slice()
|
||||
.sort(compareByEmployeeNo)
|
||||
.filter((item) => !nextStack.has(item.id))
|
||||
.map((item) => mapOrganizationNode(item, departmentId, activeEmployeeById, childrenByManagerId, nextStack));
|
||||
|
||||
return {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
title: employee.title,
|
||||
status: employee.status,
|
||||
avatarUrl: buildAvatarUrl(employee.id, employee.avatar_path),
|
||||
department: mapDepartmentSummary(employee.department),
|
||||
externalManager: mapExternalManager(employee, departmentId, activeEmployeeById),
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDepartmentSection(department, employees, activeEmployeeById) {
|
||||
const departmentId = department.id;
|
||||
const departmentEmployeeById = new Map(employees.map((item) => [item.id, item]));
|
||||
const childrenByManagerId = new Map();
|
||||
|
||||
for (const employee of employees) {
|
||||
childrenByManagerId.set(employee.id, []);
|
||||
}
|
||||
|
||||
for (const employee of employees) {
|
||||
if (!employee.direct_manager_id) {
|
||||
continue;
|
||||
}
|
||||
const manager = activeEmployeeById.get(employee.direct_manager_id);
|
||||
if (!manager || manager.department_id !== departmentId) {
|
||||
continue;
|
||||
}
|
||||
if (!departmentEmployeeById.has(manager.id)) {
|
||||
continue;
|
||||
}
|
||||
childrenByManagerId.get(manager.id).push(employee);
|
||||
}
|
||||
|
||||
const roots = employees
|
||||
.filter((employee) => {
|
||||
if (!employee.direct_manager_id) {
|
||||
return true;
|
||||
}
|
||||
const manager = activeEmployeeById.get(employee.direct_manager_id);
|
||||
return !manager || manager.department_id !== departmentId;
|
||||
})
|
||||
.slice()
|
||||
.sort(compareByEmployeeNo)
|
||||
.map((employee) => mapOrganizationNode(employee, departmentId, activeEmployeeById, childrenByManagerId, new Set()));
|
||||
|
||||
return {
|
||||
department: mapDepartmentSummary(department),
|
||||
roots,
|
||||
};
|
||||
}
|
||||
|
||||
async function listOrganizationDepartments(prisma) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { status: "active" },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
avatar_path: true,
|
||||
department_id: true,
|
||||
direct_manager_id: true,
|
||||
department: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const activeEmployeeById = new Map(employees.map((item) => [item.id, item]));
|
||||
const departmentGroups = new Map();
|
||||
|
||||
for (const employee of employees) {
|
||||
const departmentId = employee.department?.id ?? employee.department_id;
|
||||
if (!departmentId || !employee.department) {
|
||||
continue;
|
||||
}
|
||||
if (!departmentGroups.has(departmentId)) {
|
||||
departmentGroups.set(departmentId, {
|
||||
department: employee.department,
|
||||
employees: [],
|
||||
});
|
||||
}
|
||||
departmentGroups.get(departmentId).employees.push(employee);
|
||||
}
|
||||
|
||||
return [...departmentGroups.values()]
|
||||
.sort((left, right) => left.department.name.localeCompare(right.department.name, "zh-CN"))
|
||||
.map((group) => buildDepartmentSection(group.department, group.employees, activeEmployeeById));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listOrganizationDepartments,
|
||||
__testables: {
|
||||
buildDepartmentSection,
|
||||
mapOrganizationNode,
|
||||
},
|
||||
};
|
||||
@@ -108,6 +108,7 @@ function mapPresenceEmployee(row) {
|
||||
employeeNo: row.employee_no,
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
|
||||
employmentStatus: row.employment_status,
|
||||
department: row.department ? { id: row.department.id, name: row.department.name } : null,
|
||||
};
|
||||
@@ -309,6 +310,7 @@ async function listActivePresenceEmployees(prisma, filters = {}) {
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
avatar_path: true,
|
||||
employment_status: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
|
||||
在新工单中引用
屏蔽一个用户