R44 A2-A8 完成员工高级筛选批量操作与统计页
这个提交包含在:
+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, {
|
||||
|
||||
在新工单中引用
屏蔽一个用户