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