feat: 完成 R13/R14/R15/R16 小团队管理核心能力

这个提交包含在:
2026-03-21 20:31:34 +08:00
父节点 0826693a8f
当前提交 1e52f1c225
共修改 47 个文件,包含 4298 行新增和 306 行删除
+516 -1
查看文件
@@ -1,8 +1,523 @@
const { z } = require("zod");
const usersRoutes = require("./users");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const UUID_SCHEMA = z.string().uuid();
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const PROFILE_PATCH_SCHEMA = z
.object({
departmentId: UUID_SCHEMA.optional(),
directManagerId: UUID_SCHEMA.nullable().optional(),
title: z.string().trim().min(1).max(100).optional(),
probationEndDate: DATE_SCHEMA.nullable().optional(),
lastWorkingDate: DATE_SCHEMA.nullable().optional(),
workLocation: z.string().trim().max(100).nullable().optional(),
emergencyContactName: z.string().trim().max(100).nullable().optional(),
emergencyContactPhone: z.string().trim().max(50).nullable().optional(),
notes: z.string().trim().max(1000).nullable().optional(),
})
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const ONBOARDING_SCHEMA = z.object({
effectiveDate: DATE_SCHEMA.optional(),
probationEndDate: DATE_SCHEMA.nullable().optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
const REGULARIZE_SCHEMA = z.object({
effectiveDate: DATE_SCHEMA.optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
const TRANSFER_SCHEMA = z.object({
effectiveDate: DATE_SCHEMA.optional(),
departmentId: UUID_SCHEMA.optional(),
directManagerId: UUID_SCHEMA.nullable().optional(),
title: z.string().trim().min(1).max(100).optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
const OFFBOARDING_SCHEMA = z.object({
effectiveDate: DATE_SCHEMA.optional(),
lastWorkingDate: DATE_SCHEMA.nullable().optional(),
notes: z.string().trim().max(500).nullable().optional(),
}).strict();
function buildValidationError(details, statusCode = 400) {
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
}
function parseId(value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
async function ensureFeatureEnabled(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);
}
}
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");
}
function canManageLifecycle(fastify, request) {
return fastify.hasPermission(request.employee, "system.users") || fastify.hasPermission(request.employee, "system.settings") || fastify.hasPermission(request.employee, "*");
}
function mapChecklistTask(item) {
return {
id: item.id,
employeeId: item.employee_id,
scene: item.scene,
taskType: item.task_type,
title: item.title,
status: item.status,
dueDate: item.due_date?.toISOString?.()?.slice(0, 10) ?? null,
completedAt: item.completed_at?.toISOString?.() ?? null,
completedBy: item.completed_by ?? null,
payloadJson: item.payload_json ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
function mapLifecycleEvent(item) {
return {
id: item.id,
employeeId: item.employee_id,
eventType: item.event_type,
effectiveDate: item.effective_date?.toISOString?.()?.slice(0, 10) ?? null,
payloadJson: item.payload_json ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
createdBy: item.created_by ?? null,
};
}
async function getLifecycleBlockers(prisma, employeeId) {
const [openAssetCount, pendingWorkflowTaskCount] = await Promise.all([
prisma.assetCard.count({ where: { assignee_employee_id: employeeId } }),
prisma.workflowTask.count({ where: { assignee_id: employeeId, status: "pending" } }),
]);
return {
openAssetCount,
pendingWorkflowTaskCount,
canCompleteOffboarding: openAssetCount === 0 && pendingWorkflowTaskCount === 0,
};
}
async function ensureEmployeeExists(prisma, employeeId) {
const employee = await prisma.employee.findUnique({ where: { id: employeeId } });
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return employee;
}
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) {
return { autoChanged: false, completedOffboarding: false, blockers: null };
}
const pendingOnboarding = await tx.employeeChecklistTask.count({ where: { employee_id: employeeId, scene: "onboarding", status: "pending" } });
if (employee.employment_status === "onboarding" && pendingOnboarding === 0) {
await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "probation" } });
return { autoChanged: true, completedOffboarding: false, blockers: null };
}
const pendingOffboarding = await tx.employeeChecklistTask.count({ where: { employee_id: employeeId, scene: "offboarding", status: "pending" } });
if (employee.employment_status === "offboarding" && pendingOffboarding === 0) {
const [openAssetCount, pendingWorkflowTaskCount] = await Promise.all([
tx.assetCard.count({ where: { assignee_employee_id: employeeId } }),
tx.workflowTask.count({ where: { assignee_id: employeeId, status: "pending" } }),
]);
if (openAssetCount === 0 && pendingWorkflowTaskCount === 0) {
await tx.employee.update({
where: { id: employeeId },
data: {
employment_status: "inactive",
status: "archived",
archived_at: new Date(),
token_version: { increment: 1 },
},
});
return { autoChanged: true, completedOffboarding: true, blockers: { openAssetCount, pendingWorkflowTaskCount } };
}
return { autoChanged: false, completedOffboarding: false, blockers: { openAssetCount, pendingWorkflowTaskCount } };
}
return { autoChanged: false, completedOffboarding: false, blockers: null };
}
async function buildEmployeeProfile(prisma, employeeId) {
const [employee, checklistTasks, lifecycleEvents, assignedAssets, blockers] = await Promise.all([
prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
employee_no: true,
name: true,
phone: true,
email: true,
status: true,
employment_status: true,
department_id: true,
title: true,
direct_manager_id: true,
hire_date: true,
probation_end_date: true,
last_working_date: true,
work_location: true,
emergency_contact_name: true,
emergency_contact_phone: true,
notes: true,
archived_at: true,
roles: { select: { role_code: true } },
department: { select: { id: true, name: true } },
direct_manager: { select: { id: true, employee_no: true, name: true } },
},
}),
prisma.employeeChecklistTask.findMany({ where: { employee_id: employeeId }, orderBy: [{ created_at: "desc" }] }),
prisma.employeeLifecycleEvent.findMany({ where: { employee_id: employeeId }, orderBy: [{ effective_date: "desc" }, { created_at: "desc" }] }),
prisma.assetCard.findMany({
where: { assignee_employee_id: employeeId },
orderBy: { asset_no: "asc" },
select: { id: true, asset_no: true, name: true, status: true, location: true },
}),
getLifecycleBlockers(prisma, employeeId),
]);
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
return {
employee: {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
phone: employee.phone,
email: employee.email,
status: employee.status,
employmentStatus: employee.employment_status,
departmentId: employee.department_id,
title: employee.title,
directManagerId: employee.direct_manager_id ?? null,
hireDate: employee.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
probationEndDate: employee.probation_end_date?.toISOString?.()?.slice(0, 10) ?? null,
lastWorkingDate: employee.last_working_date?.toISOString?.()?.slice(0, 10) ?? null,
workLocation: employee.work_location ?? null,
emergencyContactName: employee.emergency_contact_name ?? null,
emergencyContactPhone: employee.emergency_contact_phone ?? null,
notes: employee.notes ?? null,
archivedAt: employee.archived_at?.toISOString?.() ?? null,
roleCodes: employee.roles.map((item) => item.role_code),
department: employee.department ? { id: employee.department.id, name: employee.department.name } : null,
directManager: employee.direct_manager ? { id: employee.direct_manager.id, employeeNo: employee.direct_manager.employee_no, name: employee.direct_manager.name } : null,
},
lifecycle: {
events: lifecycleEvents.map(mapLifecycleEvent),
checklistTasks: checklistTasks.map(mapChecklistTask),
blockers,
},
assignedAssets: assignedAssets.map((item) => ({
id: item.id,
assetNo: item.asset_no,
name: item.name,
status: item.status,
location: item.location ?? null,
})),
};
}
async function employeesRoutes(fastify) {
await usersRoutes(fastify, { basePath: "/employees" });
fastify.get("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
if (!canManageProfile(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.view" } });
}
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.patch("/employees/:id/profile", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
if (!canManageProfile(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.profile.write" } });
}
const body = parseBody(PROFILE_PATCH_SCHEMA, request.body);
const managerMode = canManageLifecycle(fastify, request);
const selfEditableKeys = ["workLocation", "emergencyContactName", "emergencyContactPhone", "notes"];
if (!managerMode && request.employee.id === employeeId) {
const invalidKeys = Object.keys(body).filter((key) => !selfEditableKeys.includes(key));
if (invalidKeys.length) {
throw buildValidationError({ body: `self_profile_fields_forbidden:${invalidKeys.join(",")}` }, 403);
}
}
await ensureEmployeeExists(fastify.prisma, employeeId);
await fastify.prisma.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 } : {}),
...(body.probationEndDate !== undefined ? { probation_end_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null } : {}),
...(body.lastWorkingDate !== undefined ? { last_working_date: body.lastWorkingDate ? new Date(`${body.lastWorkingDate}T00:00:00.000Z`) : null } : {}),
...(body.workLocation !== undefined ? { work_location: body.workLocation ?? null } : {}),
...(body.emergencyContactName !== undefined ? { emergency_contact_name: body.emergencyContactName ?? null } : {}),
...(body.emergencyContactPhone !== undefined ? { emergency_contact_phone: body.emergencyContactPhone ?? null } : {}),
...(body.notes !== undefined ? { notes: body.notes ?? null } : {}),
updated_by: request.employee.id,
},
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.post("/employees/:id/onboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(ONBOARDING_SCHEMA, request.body);
const effectiveDate = resolveEffectiveDate(body.effectiveDate);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
await tx.employee.update({
where: { id: employeeId },
data: {
employment_status: "onboarding",
probation_end_date: body.probationEndDate ? new Date(`${body.probationEndDate}T00:00:00.000Z`) : null,
notes: body.notes !== undefined ? body.notes ?? null : undefined,
updated_by: request.employee.id,
},
});
await tx.employeeLifecycleEvent.create({
data: {
employee_id: employeeId,
event_type: "onboard",
effective_date: effectiveDate,
payload_json: { notes: body.notes ?? null },
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 }));
await tx.employeeChecklistTask.createMany({ data: checklist, skipDuplicates: false });
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.post("/employees/:id/regularize", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(REGULARIZE_SCHEMA, request.body);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
await tx.employee.update({ where: { id: employeeId }, data: { employment_status: "active", updated_by: request.employee.id } });
await tx.employeeLifecycleEvent.create({
data: {
employee_id: employeeId,
event_type: "regularize",
effective_date: resolveEffectiveDate(body.effectiveDate),
payload_json: { notes: body.notes ?? null },
created_by: request.employee.id,
},
});
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.post("/employees/:id/transfer", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
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),
});
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.post("/employees/:id/offboarding/start", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManageLifecycle(fastify, request)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "system.users" } });
}
const employeeId = parseId(request.params?.id);
const body = parseBody(OFFBOARDING_SCHEMA, request.body);
await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
await tx.employee.update({
where: { id: employeeId },
data: {
employment_status: "offboarding",
...(body.lastWorkingDate !== undefined ? { last_working_date: body.lastWorkingDate ? new Date(`${body.lastWorkingDate}T00:00:00.000Z`) : null } : {}),
updated_by: request.employee.id,
},
});
await tx.employeeLifecycleEvent.create({
data: {
employee_id: employeeId,
event_type: "offboard",
effective_date: resolveEffectiveDate(body.effectiveDate),
payload_json: { notes: body.notes ?? null, lastWorkingDate: body.lastWorkingDate ?? null },
created_by: request.employee.id,
},
});
await tx.employeeChecklistTask.createMany({ data: buildDefaultChecklist("offboarding", employeeId, request.employee.id) });
});
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile);
});
fastify.get("/employees/:id/lifecycle", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
if (!canManageProfile(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.lifecycle.view" } });
}
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, profile.lifecycle);
});
async function updateChecklistTaskStatus(request, reply, nextStatus) {
await ensureFeatureEnabled(fastify, request);
const employeeId = parseId(request.params?.id);
const taskId = parseId(request.params?.taskId);
if (!canManageProfile(fastify, request, employeeId)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "employee.lifecycle.write" } });
}
const result = await fastify.prisma.$transaction(async (tx) => {
await ensureEmployeeExists(tx, employeeId);
const task = await tx.employeeChecklistTask.findFirst({ where: { id: taskId, employee_id: employeeId } });
if (!task) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
await tx.employeeChecklistTask.update({
where: { id: taskId },
data: {
status: nextStatus,
completed_at: nextStatus === "completed" ? new Date() : task.completed_at,
completed_by: nextStatus === "completed" ? request.employee.id : task.completed_by,
},
});
return maybeAdvanceLifecycle(tx, employeeId);
});
if (result.completedOffboarding) {
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "EMPLOYEE_OFFBOARDING_COMPLETED",
targetType: "employee",
targetId: employeeId,
extra: result.blockers ?? {},
}),
});
}
const profile = await buildEmployeeProfile(fastify.prisma, employeeId);
return sendData(reply, request, { ...profile.lifecycle, autoAdvanced: result.autoChanged });
}
fastify.post("/employees/:id/checklist-tasks/:taskId/complete", { preHandler: fastify.authenticate }, async (request, reply) => {
return updateChecklistTaskStatus(request, reply, "completed");
});
fastify.post("/employees/:id/checklist-tasks/:taskId/skip", { preHandler: fastify.authenticate }, async (request, reply) => {
return updateChecklistTaskStatus(request, reply, "skipped");
});
}
module.exports = employeesRoutes;