R44 A2-A8 完成员工高级筛选批量操作与统计页

这个提交包含在:
2026-03-29 12:27:44 +08:00
父节点 c3873999c2
当前提交 d11f5593d1
共修改 17 个文件,包含 1640 行新增和 337 行删除
+29 -52
查看文件
@@ -2,6 +2,8 @@ const sharp = require("sharp");
const { z } = require("zod");
const usersRoutes = require("./users");
const { buildDefaultChecklist, resolveEffectiveDate, applyEmployeeTransfer } = require("../services/employeeLifecycle");
const { buildEmployeeStats } = require("../services/employeeStats");
const { currentDateString } = require("../utils/date");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
@@ -103,10 +105,6 @@ async function ensureFeatureEnabled(fastify, request) {
}
}
function resolveEffectiveDate(rawValue) {
return rawValue ? new Date(`${rawValue}T00:00:00.000Z`) : new Date();
}
function canManageProfile(fastify, request, employeeId) {
return request.employee.id === employeeId || fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "report.dashboard.view");
}
@@ -242,26 +240,6 @@ async function ensureEmployeeContractExists(prisma, employeeId, contractId) {
return contract;
}
function buildDefaultChecklist(scene, employeeId, actorId) {
const common = { employee_id: employeeId };
if (scene === "onboarding") {
return [
{ ...common, scene, task_type: "account_setup", title: "完成账号与权限初始化", status: "pending" },
{ ...common, scene, task_type: "asset_issue", title: "确认办公资产发放", status: "pending" },
{ ...common, scene, task_type: "policy_ack", title: "确认制度与入职须知", status: "pending" },
];
}
if (scene === "transfer") {
return [{ ...common, scene, task_type: "handover_note", title: "确认调岗交接说明", status: "pending" }];
}
return [
{ ...common, scene, task_type: "asset_return", title: "确认资产归还/转交", status: "pending" },
{ ...common, scene, task_type: "access_revoke", title: "确认账号和权限回收", status: "pending" },
{ ...common, scene, task_type: "expense_clearance", title: "确认费用与审批待办已清理", status: "pending" },
{ ...common, scene, task_type: "handover_note", title: "确认离职交接说明", status: "pending" },
];
}
async function maybeAdvanceLifecycle(tx, employeeId) {
const employee = await tx.employee.findUnique({ where: { id: employeeId } });
if (!employee) {
@@ -449,7 +427,12 @@ async function buildEmployeeProfile(prisma, employeeId) {
}
async function employeesRoutes(fastify) {
await usersRoutes(fastify, { basePath: "/employees", allowDepartmentFilter: true });
await usersRoutes(fastify, {
basePath: "/employees",
allowDepartmentFilter: true,
listPermissionCodes: ["system.users", "report.dashboard.view"],
enableBatchActions: false,
});
fastify.get("/avatars/:employeeId", async (request, reply) => {
const employeeId = parseId(request.params?.employeeId);
@@ -549,6 +532,17 @@ async function employeesRoutes(fastify) {
return sendData(reply, request, { items });
});
fastify.get("/employees/stats", { preHandler: fastify.authenticate }, async (request, reply) => {
if (!canViewPeopleDirectory(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.stats.view" } });
}
const stats = await buildEmployeeStats(fastify.prisma, {
timeZone: fastify.config.appTimezone,
});
return sendData(reply, request, stats);
});
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
@@ -726,7 +720,7 @@ async function employeesRoutes(fastify) {
created_by: request.employee.id,
},
});
const checklist = buildDefaultChecklist("onboarding", employeeId, request.employee.id).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null }));
const checklist = buildDefaultChecklist("onboarding", employeeId).map((item) => ({ ...item, due_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null }));
await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false });
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
@@ -766,31 +760,14 @@ async function employeesRoutes(fastify) {
const body = parseBody(TRANSFER_SCHEMA, request.body);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
await tx.employee.update({
where: { id: employeeId },
data: {
...(body.departmentId !== undefined ? { department_id: body.departmentId } : {}),
...(body.directManagerId !== undefined ? { direct_manager_id: body.directManagerId } : {}),
...(body.title !== undefined ? { title: body.title } : {}),
updated_by: request.employee.id,
},
});
await tx.employeeLifecycleEvent.create({
data: {
employee_id: employeeId,
event_type: "transfer",
effective_date: resolveEffectiveDate(body.effectiveDate),
payload_json: {
departmentId: body.departmentId ?? null,
directManagerId: body.directManagerId ?? null,
title: body.title ?? null,
notes: body.notes ?? null,
},
created_by: request.employee.id,
},
});
await tx.employeeChecklistTask.createMany({
data: buildDefaultChecklist("transfer", employeeId, request.employee.id),
await applyEmployeeTransfer(tx, {
employeeId,
actorId: request.employee.id,
departmentId: body.departmentId,
directManagerId: body.directManagerId,
title: body.title,
effectiveDate: body.effectiveDate,
notes: body.notes,
});
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
@@ -823,7 +800,7 @@ async function employeesRoutes(fastify) {
created_by: request.employee.id,
},
});
await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId, request.employee.id) });
await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId) });
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
+17 -20
查看文件
@@ -3,6 +3,11 @@ const { Readable } = require("node:stream");
const ExcelJS = require("exceljs");
const { z } = require("zod");
const {
EMPLOYMENT_STATUS_VALUES,
buildEmployeeDirectoryWhere,
parseEmployeeDirectoryFilters,
} = require("../services/employeeDirectoryFilters");
const {
HISTORY_STATUS_VALUES,
buildPresenceValidationError,
@@ -61,6 +66,11 @@ const EMPLOYEE_EXPORT_QUERY_SCHEMA = z.object({
status: z.enum(["active", "archived"]).optional(),
departmentId: z.string().uuid().optional(),
q: z.string().max(100).optional(),
title: z.string().max(100).optional(),
managerId: z.string().uuid().optional(),
hireDateFrom: DATE_ONLY_SCHEMA.optional(),
hireDateTo: DATE_ONLY_SCHEMA.optional(),
employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).optional(),
limit: z.coerce.number().int().min(1).max(20000).optional(),
});
@@ -193,25 +203,6 @@ function buildDashboardWhere(request, query) {
};
}
function buildEmployeeExportWhere(query) {
const q = query.q?.trim() ?? "";
return {
...(query.status ? { status: query.status } : {}),
...(query.departmentId ? { department_id: query.departmentId } : {}),
...(q
? {
OR: [
{ employee_no: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
{ phone: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
],
}
: {}),
};
}
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
yield "\ufeff";
yield csvRow([
@@ -907,7 +898,8 @@ async function exportsRoutes(fastify) {
},
async (request, reply) => {
const query = parseQuery(EMPLOYEE_EXPORT_QUERY_SCHEMA, request.query);
const where = buildEmployeeExportWhere(query);
const filters = parseEmployeeDirectoryFilters(query, { allowDepartmentFilter: true });
const where = buildEmployeeDirectoryWhere(filters);
const format = resolveExportFormat(query);
const limit = query.limit ?? 5000;
const total = await fastify.prisma.employee.count({ where });
@@ -922,6 +914,11 @@ async function exportsRoutes(fastify) {
status: query.status ?? null,
departmentId: query.departmentId ?? null,
q: query.q?.trim() || null,
title: query.title?.trim() || null,
managerId: query.managerId ?? null,
hireDateFrom: query.hireDateFrom ?? null,
hireDateTo: query.hireDateTo ?? null,
employmentStatus: query.employmentStatus ?? null,
limit,
matchedTotal: total,
},
+407 -178
查看文件
@@ -5,6 +5,13 @@ const { Readable } = require("node:stream");
const ExcelJS = require("exceljs");
const { z } = require("zod");
const {
EMPLOYMENT_STATUS_VALUES,
buildEmployeeDirectoryWhere,
parseEmployeeDirectoryFilters,
} = require("../services/employeeDirectoryFilters");
const { applyEmployeeTransfer } = require("../services/employeeLifecycle");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
@@ -30,14 +37,9 @@ const roleCodeSchema = z.enum([
"admin",
]);
const listQuerySchema = z.object({
const listPaginationSchema = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(["active", "archived"]).optional(),
q: z.string().max(100).optional(),
});
const listQueryWithDepartmentSchema = listQuerySchema.extend({
departmentId: z.string().uuid().optional(),
});
const createBodySchema = z.object({
@@ -90,6 +92,18 @@ const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);
const USER_IMPORT_MAX_ROWS = 500;
const BATCH_EMPLOYEE_IDS_SCHEMA = z.array(z.string().uuid()).min(1);
const BATCH_TRANSFER_DEPARTMENT_SCHEMA = z.object({
employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA,
departmentId: z.string().uuid(),
}).strict();
const BATCH_TOGGLE_SCHEMA = z.object({
employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA,
}).strict();
const BATCH_SET_MANAGER_SCHEMA = z.object({
employeeIds: BATCH_EMPLOYEE_IDS_SCHEMA,
managerId: z.string().uuid(),
}).strict();
function parseUserId(params) {
const parsed = z.string().uuid().safeParse(params?.id);
@@ -105,9 +119,7 @@ function parseUserId(params) {
}
function parseListQuery(query, options = {}) {
const allowDepartmentFilter = options.allowDepartmentFilter === true;
const schema = allowDepartmentFilter ? listQueryWithDepartmentSchema : listQuerySchema;
const parsed = schema.safeParse(query ?? {});
const parsed = listPaginationSchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
@@ -120,9 +132,7 @@ function parseListQuery(query, options = {}) {
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
status: parsed.data.status,
q: parsed.data.q?.trim() ?? "",
departmentId: allowDepartmentFilter ? parsed.data.departmentId : undefined,
...parseEmployeeDirectoryFilters(query, options),
};
}
@@ -185,7 +195,18 @@ function mapEmployeeSummary(row) {
email: row.email,
status: row.status,
departmentId: row.department_id,
department: row.department ? { id: row.department.id, name: row.department.name } : null,
directManagerId: row.direct_manager_id ?? null,
directManager: row.direct_manager
? {
id: row.direct_manager.id,
employeeNo: row.direct_manager.employee_no,
name: row.direct_manager.name,
}
: null,
title: row.title,
employmentStatus: row.employment_status ?? null,
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
avatarUrl: row.avatar_path ? `/api/avatars/${row.id}` : null,
roleCodes: row.roles.map((item) => item.role_code),
createdAt: row.created_at?.toISOString?.() ?? null,
@@ -258,6 +279,163 @@ function buildValidationError(details, statusCode = 400) {
});
}
function parseBatchBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const employeeIds = [...new Set(parsed.data.employeeIds)];
if (employeeIds.length > 50) {
throw buildValidationError({ employeeIds: "too_many" }, 409);
}
return {
...parsed.data,
employeeIds,
};
}
function ensureHasAnyPermission(fastify, request, permissionCodes) {
if (permissionCodes.some((code) => fastify.hasPermission(request.employee, code))) {
return;
}
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
async function ensureLifecycleFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.employeeLifecycle,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "employee_lifecycle_v1_disabled", reason: gate.reason }, 409);
}
}
async function ensureDepartmentUsable(prisma, departmentId, fieldName = "departmentId") {
const department = await prisma.department.findUnique({
where: { id: departmentId },
select: { id: true, archived_at: true },
});
if (!department) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { [fieldName]: "not_found" },
});
}
if (department.archived_at) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { [fieldName]: "archived" },
});
}
}
async function ensureManagerUsable(prisma, managerId, options = {}) {
const fieldName = options.fieldName ?? "directManagerId";
const disallowedEmployeeIds = options.disallowedEmployeeIds ?? [];
if (disallowedEmployeeIds.includes(managerId)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { [fieldName]: "self_not_allowed" },
});
}
const manager = await prisma.employee.findUnique({
where: { id: managerId },
select: { id: true, status: true },
});
if (!manager) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { [fieldName]: "not_found" },
});
}
if (manager.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { [fieldName]: "inactive" },
});
}
}
async function ensureEmployeesExist(prisma, employeeIds) {
const employees = await prisma.employee.findMany({
where: { id: { in: employeeIds } },
select: { id: true },
});
if (employees.length !== employeeIds.length) {
const existingIds = new Set(employees.map((item) => item.id));
const missingId = employeeIds.find((item) => !existingIds.has(item));
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
details: { employeeId: missingId ?? null },
});
}
}
async function setEmployeeAccountStatus(tx, request, options) {
const updated = await tx.employee.update({
where: { id: options.employeeId },
data: {
status: options.status,
archived_at: options.status === "archived" ? options.now : null,
token_version: { increment: 1 },
updated_by: options.actorId,
},
select: {
id: true,
status: true,
token_version: true,
},
});
await tx.authSession.updateMany({
where: { employee_id: options.employeeId, revoked_at: null },
data: { revoked_at: options.now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: options.actorId,
action: options.status === "archived" ? "SYSTEM_USER_DISABLED" : "SYSTEM_USER_ENABLED",
targetType: "employee",
targetId: options.employeeId,
extra: {},
}),
});
return updated;
}
function generateOneTimePassword() {
const password = `ResetA1${crypto.randomBytes(6).toString("hex")}`;
const errors = validatePassword(password);
@@ -720,7 +898,10 @@ async function createSystemUser(tx, options) {
email: true,
status: true,
department_id: true,
direct_manager_id: true,
title: true,
employment_status: true,
hire_date: true,
created_at: true,
updated_at: true,
},
@@ -745,31 +926,22 @@ async function usersRoutes(fastify, opts = {}) {
const basePath = basePathRaw.startsWith("/") ? basePathRaw : `/${basePathRaw}`;
const routeBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
const allowDepartmentFilter = opts.allowDepartmentFilter === true;
const listPermissionCodes =
Array.isArray(opts.listPermissionCodes) && opts.listPermissionCodes.length
? [...new Set(opts.listPermissionCodes)]
: ["system.users"];
const enableBatchActions = routeBasePath === "/users" && opts.enableBatchActions !== false;
fastify.get(
routeBasePath,
{
preHandler: fastify.requirePermission("system.users"),
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensureHasAnyPermission(fastify, request, listPermissionCodes);
const query = parseListQuery(request.query, { allowDepartmentFilter });
const skip = (query.page - 1) * query.pageSize;
const q = query.q;
const where = {
...(query.status ? { status: query.status } : {}),
...(query.departmentId ? { department_id: query.departmentId } : {}),
...(q
? {
OR: [
{ employee_no: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
{ phone: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
],
}
: {}),
};
const where = buildEmployeeDirectoryWhere(query);
const [items, total] = await Promise.all([
fastify.prisma.employee.findMany({
@@ -785,10 +957,15 @@ async function usersRoutes(fastify, opts = {}) {
email: true,
status: true,
department_id: true,
direct_manager_id: true,
title: true,
employment_status: true,
hire_date: true,
avatar_path: true,
created_at: true,
updated_at: true,
department: { select: { id: true, name: true } },
direct_manager: { select: { id: true, employee_no: true, name: true } },
roles: { select: { role_code: true } },
},
}),
@@ -874,49 +1051,10 @@ async function usersRoutes(fastify, opts = {}) {
const hireDate = body.hireDate ? new Date(`${body.hireDate}T00:00:00.000Z`) : new Date();
const departmentId = body.departmentId ?? request.employee.department_id;
const department = await fastify.prisma.department.findUnique({
where: { id: departmentId },
select: { id: true, archived_at: true },
});
if (!department) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { departmentId: "not_found" },
});
}
if (department.archived_at) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { departmentId: "archived" },
});
}
await ensureDepartmentUsable(fastify.prisma, departmentId);
if (typeof body.directManagerId === "string") {
const manager = await fastify.prisma.employee.findUnique({
where: { id: body.directManagerId },
select: { id: true, status: true },
});
if (!manager) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { directManagerId: "not_found" },
});
}
if (manager.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { directManagerId: "inactive" },
});
}
await ensureManagerUsable(fastify.prisma, body.directManagerId);
}
const now = new Date();
@@ -980,6 +1118,192 @@ async function usersRoutes(fastify, opts = {}) {
},
);
if (enableBatchActions) {
fastify.post(
`${routeBasePath}/batch/transfer-department`,
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
await ensureLifecycleFeatureEnabled(fastify, request);
const body = parseBatchBody(BATCH_TRANSFER_DEPARTMENT_SCHEMA, request.body);
await ensureDepartmentUsable(fastify.prisma, body.departmentId);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeesExist(tx, body.employeeIds);
for (const employeeId of body.employeeIds) {
await applyEmployeeTransfer(tx, {
employeeId,
actorId: request.employee.id,
departmentId: body.departmentId,
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USERS_BATCH_TRANSFER_DEPARTMENT",
targetType: "employee_batch",
extra: {
employeeIds: body.employeeIds,
departmentId: body.departmentId,
summary: {
total: body.employeeIds.length,
updated: body.employeeIds.length,
},
},
}),
});
});
return sendData(reply, request, {
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
employeeIds: body.employeeIds,
});
},
);
fastify.post(
`${routeBasePath}/batch/set-manager`,
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
await ensureLifecycleFeatureEnabled(fastify, request);
const body = parseBatchBody(BATCH_SET_MANAGER_SCHEMA, request.body);
await ensureManagerUsable(fastify.prisma, body.managerId, {
fieldName: "managerId",
disallowedEmployeeIds: body.employeeIds,
});
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeesExist(tx, body.employeeIds);
for (const employeeId of body.employeeIds) {
await applyEmployeeTransfer(tx, {
employeeId,
actorId: request.employee.id,
directManagerId: body.managerId,
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USERS_BATCH_SET_MANAGER",
targetType: "employee_batch",
extra: {
employeeIds: body.employeeIds,
managerId: body.managerId,
summary: {
total: body.employeeIds.length,
updated: body.employeeIds.length,
},
},
}),
});
});
return sendData(reply, request, {
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
employeeIds: body.employeeIds,
});
},
);
fastify.post(
`${routeBasePath}/batch/disable`,
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body);
if (body.employeeIds.includes(request.employee.id)) {
throw buildValidationError({ user: "self_disable_not_allowed" }, 409);
}
const now = new Date();
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeesExist(tx, body.employeeIds);
for (const employeeId of body.employeeIds) {
await setEmployeeAccountStatus(tx, request, {
employeeId,
actorId: request.employee.id,
status: "archived",
now,
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USERS_BATCH_DISABLED",
targetType: "employee_batch",
extra: {
employeeIds: body.employeeIds,
summary: {
total: body.employeeIds.length,
updated: body.employeeIds.length,
},
},
}),
});
});
return sendData(reply, request, {
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
employeeIds: body.employeeIds,
});
},
);
fastify.post(
`${routeBasePath}/batch/enable`,
{
preHandler: fastify.requirePermission("system.users"),
},
async (request, reply) => {
const body = parseBatchBody(BATCH_TOGGLE_SCHEMA, request.body);
const now = new Date();
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeesExist(tx, body.employeeIds);
for (const employeeId of body.employeeIds) {
await setEmployeeAccountStatus(tx, request, {
employeeId,
actorId: request.employee.id,
status: "active",
now,
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USERS_BATCH_ENABLED",
targetType: "employee_batch",
extra: {
employeeIds: body.employeeIds,
summary: {
total: body.employeeIds.length,
updated: body.employeeIds.length,
},
},
}),
});
});
return sendData(reply, request, {
summary: { total: body.employeeIds.length, updated: body.employeeIds.length },
employeeIds: body.employeeIds,
});
},
);
}
if (routeBasePath === "/users") {
fastify.post(
`${routeBasePath}/import/dry-run`,
@@ -1146,59 +1470,14 @@ async function usersRoutes(fastify, opts = {}) {
}
if (typeof body.departmentId === "string") {
const department = await fastify.prisma.department.findUnique({
where: { id: body.departmentId },
select: { id: true, archived_at: true },
});
if (!department) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { departmentId: "not_found" },
});
}
if (department.archived_at) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { departmentId: "archived" },
});
}
await ensureDepartmentUsable(fastify.prisma, body.departmentId);
}
if (Object.prototype.hasOwnProperty.call(body, "directManagerId")) {
if (body.directManagerId === userId) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { directManagerId: "self_not_allowed" },
});
}
if (typeof body.directManagerId === "string") {
const manager = await fastify.prisma.employee.findUnique({
where: { id: body.directManagerId },
select: { id: true, status: true },
await ensureManagerUsable(fastify.prisma, body.directManagerId, {
disallowedEmployeeIds: [userId],
});
if (!manager) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { directManagerId: "not_found" },
});
}
if (manager.status !== "active") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { directManagerId: "inactive" },
});
}
}
}
@@ -1605,37 +1884,12 @@ async function usersRoutes(fastify, opts = {}) {
const now = new Date();
const employee = await fastify.prisma.$transaction(async (tx) => {
const updated = await tx.employee.update({
where: { id: userId },
data: {
status: "archived",
archived_at: now,
token_version: { increment: 1 },
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
token_version: true,
},
return setEmployeeAccountStatus(tx, request, {
employeeId: userId,
actorId: request.employee.id,
status: "archived",
now,
});
await tx.authSession.updateMany({
where: { employee_id: userId, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_DISABLED",
targetType: "employee",
targetId: userId,
extra: {},
}),
});
return updated;
});
return sendData(reply, request, {
@@ -1655,37 +1909,12 @@ async function usersRoutes(fastify, opts = {}) {
const now = new Date();
const employee = await fastify.prisma.$transaction(async (tx) => {
const updated = await tx.employee.update({
where: { id: userId },
data: {
status: "active",
archived_at: null,
token_version: { increment: 1 },
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
token_version: true,
},
return setEmployeeAccountStatus(tx, request, {
employeeId: userId,
actorId: request.employee.id,
status: "active",
now,
});
await tx.authSession.updateMany({
where: { employee_id: userId, revoked_at: null },
data: { revoked_at: now },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "SYSTEM_USER_ENABLED",
targetType: "employee",
targetId: userId,
extra: {},
}),
});
return updated;
});
return sendData(reply, request, {
@@ -0,0 +1,98 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const EMPLOYMENT_STATUS_VALUES = ["onboarding", "probation", "active", "offboarding", "inactive"];
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseEmployeeDirectoryFilters(query, options = {}) {
const allowDepartmentFilter = options.allowDepartmentFilter === true;
const schema = z.object({
status: z.enum(["active", "archived"]).optional(),
q: z.string().max(100).optional(),
title: z.string().max(100).optional(),
managerId: z.string().uuid().optional(),
hireDateFrom: DATE_ONLY_SCHEMA.optional(),
hireDateTo: DATE_ONLY_SCHEMA.optional(),
employmentStatus: z.enum(EMPLOYMENT_STATUS_VALUES).optional(),
...(allowDepartmentFilter ? { departmentId: z.string().uuid().optional() } : {}),
});
const parsed = schema.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const filters = {
status: parsed.data.status,
q: parsed.data.q?.trim() ?? "",
title: parsed.data.title?.trim() ?? "",
managerId: parsed.data.managerId,
hireDateFrom: parsed.data.hireDateFrom,
hireDateTo: parsed.data.hireDateTo,
employmentStatus: parsed.data.employmentStatus,
departmentId: allowDepartmentFilter ? parsed.data.departmentId : undefined,
};
if (filters.hireDateFrom && filters.hireDateTo && filters.hireDateFrom > filters.hireDateTo) {
throw buildValidationError({ hireDateTo: "must_be_after_or_equal_hireDateFrom" });
}
return filters;
}
function buildEmployeeDirectoryWhere(filters = {}) {
const q = filters.q?.trim() ?? "";
const title = filters.title?.trim() ?? "";
const and = [];
if (q) {
and.push({
OR: [
{ employee_no: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
{ phone: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
],
});
}
if (title) {
and.push({
title: { contains: title, mode: "insensitive" },
});
}
return {
...(filters.status ? { status: filters.status } : {}),
...(filters.departmentId ? { department_id: filters.departmentId } : {}),
...(filters.managerId ? { direct_manager_id: filters.managerId } : {}),
...(filters.employmentStatus ? { employment_status: filters.employmentStatus } : {}),
...(filters.hireDateFrom || filters.hireDateTo
? {
hire_date: {
...(filters.hireDateFrom ? { gte: new Date(`${filters.hireDateFrom}T00:00:00.000Z`) } : {}),
...(filters.hireDateTo ? { lte: new Date(`${filters.hireDateTo}T23:59:59.999Z`) } : {}),
},
}
: {}),
...(and.length ? { AND: and } : {}),
};
}
module.exports = {
DATE_ONLY_SCHEMA,
EMPLOYMENT_STATUS_VALUES,
buildEmployeeDirectoryWhere,
buildValidationError,
parseEmployeeDirectoryFilters,
};
+63
查看文件
@@ -0,0 +1,63 @@
function resolveEffectiveDate(rawValue) {
return rawValue ? new Date(`${rawValue}T00:00:00.000Z`) : new Date();
}
function buildDefaultChecklist(scene, employeeId) {
const common = { employee_id: employeeId };
if (scene === "onboarding") {
return [
{ ...common, scene, task_type: "account_setup", title: "完成账号与权限初始化", status: "pending" },
{ ...common, scene, task_type: "asset_issue", title: "确认办公资产发放", status: "pending" },
{ ...common, scene, task_type: "policy_ack", title: "确认制度与入职须知", status: "pending" },
];
}
if (scene === "transfer") {
return [{ ...common, scene, task_type: "handover_note", title: "确认调岗交接说明", status: "pending" }];
}
return [
{ ...common, scene, task_type: "asset_return", title: "确认资产归还/转交", status: "pending" },
{ ...common, scene, task_type: "access_revoke", title: "确认账号和权限回收", status: "pending" },
{ ...common, scene, task_type: "expense_clearance", title: "确认费用与审批待办已清理", status: "pending" },
{ ...common, scene, task_type: "handover_note", title: "确认离职交接说明", status: "pending" },
];
}
async function applyEmployeeTransfer(tx, options) {
await tx.employee.update({
where: { id: options.employeeId },
data: {
...(options.departmentId !== undefined ? { department_id: options.departmentId } : {}),
...(options.directManagerId !== undefined ? { direct_manager_id: options.directManagerId } : {}),
...(options.title !== undefined ? { title: options.title } : {}),
updated_by: options.actorId,
},
});
await tx.employeeLifecycleEvent.create({
data: {
employee_id: options.employeeId,
event_type: "transfer",
effective_date: resolveEffectiveDate(options.effectiveDate),
payload_json: {
departmentId: options.departmentId ?? null,
directManagerId: options.directManagerId ?? null,
title: options.title ?? null,
notes: options.notes ?? null,
},
created_by: options.actorId,
},
});
await tx.employeeChecklistTask.createMany({
data: buildDefaultChecklist("transfer", options.employeeId),
});
}
module.exports = {
applyEmployeeTransfer,
buildDefaultChecklist,
resolveEffectiveDate,
};
+96
查看文件
@@ -0,0 +1,96 @@
const { currentDateString, parseDateOnlyString } = require("../utils/date");
function addDays(date, days) {
const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
async function buildEmployeeStats(prisma, options = {}) {
const timeZone = options.timeZone ?? "Asia/Shanghai";
const todayString = currentDateString(timeZone);
const today = parseDateOnlyString(todayString);
const last30DateFrom = addDays(today, -29);
const next30DateTo = addDays(today, 30);
const activeEmployeeWhere = { status: "active" };
const [employmentStatusRows, departmentRows, departments, recentHires, recentDepartures, expiringContracts] = await Promise.all([
prisma.employee.groupBy({
by: ["employment_status"],
where: activeEmployeeWhere,
_count: { _all: true },
}),
prisma.employee.groupBy({
by: ["department_id"],
where: activeEmployeeWhere,
_count: { _all: true },
}),
prisma.department.findMany({
where: { archived_at: null },
select: { id: true, name: true },
}),
prisma.employee.count({
where: {
hire_date: {
gte: last30DateFrom,
lte: new Date(`${todayString}T23:59:59.999Z`),
},
},
}),
prisma.employee.count({
where: {
last_working_date: {
gte: last30DateFrom,
lte: new Date(`${todayString}T23:59:59.999Z`),
},
},
}),
prisma.employeeContract.count({
where: {
terminated_date: null,
end_date: {
gte: today,
lte: new Date(`${next30DateTo.toISOString().slice(0, 10)}T23:59:59.999Z`),
},
employee: activeEmployeeWhere,
},
}),
]);
const departmentMap = new Map(departments.map((item) => [item.id, item.name]));
return {
asOfDate: todayString,
employmentStatusSummary: {
total: employmentStatusRows.reduce((sum, item) => sum + item._count._all, 0),
items: employmentStatusRows
.map((item) => ({
status: item.employment_status,
count: item._count._all,
}))
.sort((left, right) => right.count - left.count || left.status.localeCompare(right.status)),
},
departmentHeadcount: departmentRows
.map((item) => ({
departmentId: item.department_id,
departmentName: departmentMap.get(item.department_id) ?? "未知部门",
count: item._count._all,
}))
.sort((left, right) => right.count - left.count || left.departmentName.localeCompare(right.departmentName)),
recentMovements: {
dateFrom: last30DateFrom.toISOString().slice(0, 10),
dateTo: todayString,
hires: recentHires,
departures: recentDepartures,
},
expiringContracts: {
dateFrom: todayString,
dateTo: next30DateTo.toISOString().slice(0, 10),
count: expiringContracts,
},
};
}
module.exports = {
buildEmployeeStats,
};
@@ -0,0 +1,115 @@
const Fastify = require("fastify");
const employeesRoutes = require("../../../src/routes/employees");
const { AppError } = require("../../../src/utils/AppError");
async function buildApp({ permissionCodes = ["system.users"], stats } = {}) {
const app = Fastify();
const employeeGroupBy = vi
.fn()
.mockResolvedValueOnce([
{ employment_status: "active", _count: { _all: 8 } },
{ employment_status: "probation", _count: { _all: 2 } },
])
.mockResolvedValueOnce([
{ department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", _count: { _all: 6 } },
{ department_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", _count: { _all: 4 } },
]);
const employeeCount = vi.fn().mockResolvedValueOnce(3).mockResolvedValueOnce(1);
const departmentFindMany = vi.fn().mockResolvedValue([
{ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", name: "行政部" },
{ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "研发部" },
]);
const contractCount = vi.fn().mockResolvedValue(2);
app.decorate("prisma", {
employee: {
groupBy: stats?.employeeGroupBy ?? employeeGroupBy,
count: stats?.employeeCount ?? employeeCount,
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(null),
},
department: {
findMany: stats?.departmentFindMany ?? departmentFindMany,
},
employeeContract: {
count: stats?.employeeContractCount ?? contractCount,
findMany: vi.fn().mockResolvedValue([]),
},
});
app.decorate("authenticate", async (request) => {
request.employee = {
id: "actor-1",
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
permissionCodes,
roleCodes: [],
};
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = {
id: "actor-1",
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
permissionCodes,
roleCodes: [],
};
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("hasPermission", (employee, permissionCode) => {
const codes = employee.permissionCodes ?? [];
return codes.includes("*") || codes.includes(permissionCode);
});
app.decorate("config", { appTimezone: "Asia/Shanghai" });
app.setErrorHandler((error, request, reply) => {
reply
.status(error.statusCode || 500)
.send({ code: error.code || "ERR", message: error.message, details: error.details ?? null });
});
await app.register(employeesRoutes, { prefix: "/api" });
return { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount };
}
describe("employees stats route", () => {
it("returns aggregated employee stats for read-only viewers", async () => {
const { app, employeeGroupBy, employeeCount, departmentFindMany, contractCount } = await buildApp({
permissionCodes: ["report.dashboard.view"],
});
const response = await app.inject({
method: "GET",
url: "/api/employees/stats",
});
expect(response.statusCode).toBe(200);
const payload = response.json();
expect(payload.data.employmentStatusSummary.total).toBe(10);
expect(payload.data.departmentHeadcount).toEqual([
{ departmentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", departmentName: "行政部", count: 6 },
{ departmentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", departmentName: "研发部", count: 4 },
]);
expect(payload.data.recentMovements.hires).toBe(3);
expect(payload.data.recentMovements.departures).toBe(1);
expect(payload.data.expiringContracts.count).toBe(2);
expect(employeeGroupBy).toHaveBeenCalledTimes(2);
expect(employeeCount).toHaveBeenCalledTimes(2);
expect(departmentFindMany).toHaveBeenCalledTimes(1);
expect(contractCount).toHaveBeenCalledTimes(1);
await app.close();
});
it("rejects requests without people directory permission", async () => {
const { app } = await buildApp({ permissionCodes: ["employee"] });
const response = await app.inject({
method: "GET",
url: "/api/employees/stats",
});
expect(response.statusCode).toBe(403);
await app.close();
});
});
@@ -62,7 +62,7 @@ describe("employee exports route", () => {
const response = await app.inject({
method: "GET",
url: `/api/exports/employees?format=csv&status=active&departmentId=${DEPARTMENT_ID}&q=alice`,
url: `/api/exports/employees?format=csv&status=active&departmentId=${DEPARTMENT_ID}&q=alice&title=%E7%BB%8F%E7%90%86&managerId=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb&hireDateFrom=2026-01-01&hireDateTo=2026-01-31&employmentStatus=active`,
});
expect(response.statusCode).toBe(200);
@@ -73,9 +73,22 @@ describe("employee exports route", () => {
where: expect.objectContaining({
status: "active",
department_id: DEPARTMENT_ID,
OR: expect.arrayContaining([
expect.objectContaining({ name: expect.any(Object) }),
expect.objectContaining({ employee_no: expect.any(Object) }),
direct_manager_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
employment_status: "active",
hire_date: expect.objectContaining({
gte: new Date("2026-01-01T00:00:00.000Z"),
lte: new Date("2026-01-31T23:59:59.999Z"),
}),
AND: expect.arrayContaining([
expect.objectContaining({
OR: expect.arrayContaining([
expect.objectContaining({ name: expect.any(Object) }),
expect.objectContaining({ employee_no: expect.any(Object) }),
]),
}),
expect.objectContaining({
title: expect.objectContaining({ contains: "经理" }),
}),
]),
}),
}),
@@ -84,12 +97,20 @@ describe("employee exports route", () => {
where: expect.objectContaining({
status: "active",
department_id: DEPARTMENT_ID,
direct_manager_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
}),
});
expect(auditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "EXPORT_EMPLOYEES",
extra: expect.objectContaining({
title: "经理",
managerId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
hireDateFrom: "2026-01-01",
hireDateTo: "2026-01-31",
employmentStatus: "active",
}),
}),
}),
);
+87 -20
查看文件
@@ -1,6 +1,7 @@
const Fastify = require("fastify");
const usersRoutes = require("../../../src/routes/users");
const { AppError } = require("../../../src/utils/AppError");
function buildEmployeeRow(overrides = {}) {
const now = new Date("2026-03-28T10:00:00.000Z");
@@ -20,10 +21,17 @@ function buildEmployeeRow(overrides = {}) {
};
}
async function buildApp(routeOptions = {}) {
async function buildApp({ routeOptions = {}, employee } = {}) {
const app = Fastify();
const findMany = vi.fn().mockResolvedValue([buildEmployeeRow()]);
const count = vi.fn().mockResolvedValue(1);
const actor =
employee ??
{
id: "actor-1",
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
permissionCodes: ["system.users"],
};
app.decorate("prisma", {
employee: {
@@ -31,21 +39,20 @@ async function buildApp(routeOptions = {}) {
count,
},
});
app.decorate("requirePermission", () => async (request) => {
request.employee = {
id: "actor-1",
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
permissionCodes: ["system.users"],
};
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("authenticate", async (request) => {
request.employee = {
id: "actor-1",
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
permissionCodes: ["system.users"],
};
request.employee = actor;
});
app.decorate("hasPermission", (requestEmployee, permissionCode) => {
const permissionCodes = requestEmployee.permissionCodes ?? [];
return permissionCodes.includes("*") || permissionCodes.includes(permissionCode);
});
app.decorate("hasPermission", () => true);
app.decorate("config", {});
await app.register(usersRoutes, { prefix: "/api", ...routeOptions });
@@ -56,8 +63,10 @@ describe("usersRoutes list query", () => {
it("supports departmentId filter for /employees when enabled", async () => {
const departmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const { app, findMany, count } = await buildApp({
basePath: "/employees",
allowDepartmentFilter: true,
routeOptions: {
basePath: "/employees",
allowDepartmentFilter: true,
},
});
const response = await app.inject({
@@ -85,8 +94,10 @@ describe("usersRoutes list query", () => {
it("combines q + status + departmentId for /employees", async () => {
const departmentId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const { app, findMany } = await buildApp({
basePath: "/employees",
allowDepartmentFilter: true,
routeOptions: {
basePath: "/employees",
allowDepartmentFilter: true,
},
});
const response = await app.inject({
@@ -100,9 +111,13 @@ describe("usersRoutes list query", () => {
where: expect.objectContaining({
status: "active",
department_id: departmentId,
OR: expect.arrayContaining([
expect.objectContaining({ name: expect.any(Object) }),
expect.objectContaining({ employee_no: expect.any(Object) }),
AND: expect.arrayContaining([
expect.objectContaining({
OR: expect.arrayContaining([
expect.objectContaining({ name: expect.any(Object) }),
expect.objectContaining({ employee_no: expect.any(Object) }),
]),
}),
]),
}),
}),
@@ -111,6 +126,58 @@ describe("usersRoutes list query", () => {
await app.close();
});
it("supports advanced filters and report.dashboard.view for /employees", async () => {
const departmentId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
const managerId = "ffffffff-ffff-4fff-8fff-ffffffffffff";
const { app, findMany, count } = await buildApp({
routeOptions: {
basePath: "/employees",
allowDepartmentFilter: true,
listPermissionCodes: ["system.users", "report.dashboard.view"],
},
employee: {
id: "viewer-1",
department_id: departmentId,
permissionCodes: ["report.dashboard.view"],
},
});
const response = await app.inject({
method: "GET",
url: `/api/employees?page=1&pageSize=20&departmentId=${departmentId}&title=%E7%BB%8F%E7%90%86&managerId=${managerId}&hireDateFrom=2026-01-01&hireDateTo=2026-01-31&employmentStatus=probation`,
});
expect(response.statusCode).toBe(200);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
department_id: departmentId,
direct_manager_id: managerId,
employment_status: "probation",
hire_date: expect.objectContaining({
gte: new Date("2026-01-01T00:00:00.000Z"),
lte: new Date("2026-01-31T23:59:59.999Z"),
}),
AND: expect.arrayContaining([
expect.objectContaining({
title: expect.objectContaining({
contains: "经理",
}),
}),
]),
}),
}),
);
expect(count).toHaveBeenCalledWith({
where: expect.objectContaining({
department_id: departmentId,
direct_manager_id: managerId,
}),
});
await app.close();
});
it("does not apply departmentId filter for /users", async () => {
const departmentId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
const { app, findMany } = await buildApp();