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

这个提交包含在:
2026-03-29 12:27:44 +08:00
父节点 c3873999c2
当前提交 d11f5593d1
共修改 17 个文件,包含 1640 行新增和 337 行删除
@@ -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,
};