feat: 完成 R13/R14/R15/R16 小团队管理核心能力
这个提交包含在:
+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;
|
||||
|
||||
|
||||
+302
-40
@@ -1,6 +1,8 @@
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { getWorkspaceSetting } = require("../services/workspaceSettings");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
|
||||
async function buildTaskVisibilityFilters(tx, request) {
|
||||
const roleFilters = (request.employee.roleCodes ?? []).length
|
||||
@@ -27,78 +29,310 @@ async function ensureFeatureEnabled(fastify, request) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuickLinks(request) {
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
async function evaluateOptionalFlag(fastify, request, flagKey) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
return gate.enabled;
|
||||
}
|
||||
|
||||
function resolveWeekStart(rawDateString) {
|
||||
const date = parseDateOnlyString(rawDateString);
|
||||
const day = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() - (day - 1));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatPresenceStatus(item) {
|
||||
if (!item) return "未更新";
|
||||
if (item.status === "office") return "在岗";
|
||||
if (item.status === "remote") return "居家";
|
||||
if (item.status === "out") return "外出";
|
||||
if (item.status === "travel") return "出差";
|
||||
if (item.status === "leave") return "请假";
|
||||
return item.status;
|
||||
}
|
||||
|
||||
async function resolvePresenceForEmployee(prisma, employeeId, dateString) {
|
||||
const date = parseDateOnlyString(dateString);
|
||||
const leave = await prisma.leaveRequest.findFirst({
|
||||
where: {
|
||||
owner_id: employeeId,
|
||||
status: "approved",
|
||||
start_date: { lte: date },
|
||||
end_date: { gte: date },
|
||||
},
|
||||
select: { id: true, reason: true },
|
||||
});
|
||||
if (leave) {
|
||||
return { status: "leave", note: leave.reason ?? null, source: "leave_sync" };
|
||||
}
|
||||
|
||||
const travel = await prisma.travelRequest.findFirst({
|
||||
where: {
|
||||
applicant_employee_id: employeeId,
|
||||
status: "approved",
|
||||
start_date: { lte: date },
|
||||
end_date: { gte: date },
|
||||
},
|
||||
select: { id: true, purpose: true },
|
||||
});
|
||||
if (travel) {
|
||||
return { status: "travel", note: travel.purpose ?? null, source: "leave_sync" };
|
||||
}
|
||||
|
||||
const manual = await prisma.employeePresence.findUnique({
|
||||
where: {
|
||||
employee_id_date: {
|
||||
employee_id: employeeId,
|
||||
date,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!manual) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
status: manual.status,
|
||||
note: manual.note ?? null,
|
||||
source: manual.source,
|
||||
timeRange: manual.time_range,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildTeamPresenceSummary(prisma, dateString) {
|
||||
const employees = await prisma.employee.findMany({ where: { status: "active" }, select: { id: true } });
|
||||
const summary = { office: 0, remote: 0, out: 0, travel: 0, leave: 0, unspecified: 0, total: employees.length };
|
||||
for (const employee of employees) {
|
||||
const item = await resolvePresenceForEmployee(prisma, employee.id, dateString);
|
||||
const status = item?.status;
|
||||
if (!status || !(status in summary)) {
|
||||
summary.unspecified += 1;
|
||||
continue;
|
||||
}
|
||||
summary[status] += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildQuickLinks({ request, featureEnabled }) {
|
||||
const items = [];
|
||||
items.push({ key: "team", title: "团队状态", href: "/team" });
|
||||
if (request.employee.permissionCodes.includes("leave.view_own") || request.employee.permissionCodes.includes("leave.view_all")) {
|
||||
items.push({ key: "leave", title: "请假", href: "/leave-requests" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("expense.view_own") || request.employee.permissionCodes.includes("expense.view_all")) {
|
||||
items.push({ key: "expense", title: "我的报销", href: "/reimbursements" });
|
||||
items.push({ key: "expense", title: "报销", href: "/reimbursements" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("procurement.view_own") || request.employee.permissionCodes.includes("procurement.view_all")) {
|
||||
items.push({ key: "procurement", title: "采购申请", href: "/purchase-requests" });
|
||||
items.push({ key: "assets", title: "资产", href: "/assets" });
|
||||
if (featureEnabled.weeklyReport) {
|
||||
items.push({ key: "weekly", title: "周报", href: "/weekly-reports" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("travel.view_own") || request.employee.permissionCodes.includes("travel.view_all")) {
|
||||
items.push({ key: "travel", title: "差旅申请", href: "/travel-requests" });
|
||||
if (featureEnabled.todoBoard) {
|
||||
items.push({ key: "todo", title: "待办", href: "/todos" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("budget.view") || request.employee.permissionCodes.includes("budget.manage")) {
|
||||
items.push({ key: "budget", title: "预算管理", href: "/budgets" });
|
||||
if (request.employee.permissionCodes.includes("system.users") || request.employee.permissionCodes.includes("system.settings")) {
|
||||
items.push({ key: "people", title: "人员管理", href: "/people" });
|
||||
items.push({ key: "workspace", title: "工作区设置", href: "/system/workspace" });
|
||||
}
|
||||
if (request.employee.permissionCodes.includes("payment.batch.manage")) {
|
||||
items.push({ key: "payment-batches", title: "支付批次", href: "/payment-batches" });
|
||||
}
|
||||
return items;
|
||||
return items.slice(0, 8);
|
||||
}
|
||||
|
||||
async function portalRoutes(fastify) {
|
||||
fastify.get("/portal/home", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
|
||||
const workspace = await getWorkspaceSetting(fastify.prisma);
|
||||
const today = currentDateString(fastify.config.appTimezone);
|
||||
const currentWeekStart = resolveWeekStart(today);
|
||||
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
||||
const now = new Date();
|
||||
const isManagerView =
|
||||
fastify.hasPermission(request.employee, "system.users") ||
|
||||
fastify.hasPermission(request.employee, "report.dashboard.view") ||
|
||||
fastify.hasPermission(request.employee, "*");
|
||||
|
||||
const [pendingTaskCount, reimbursementsCount, leaveCount, purchaseCount, travelCount, riskCount, announcementRows, paymentBatchCount] = await Promise.all([
|
||||
const featureEnabled = {
|
||||
announcements: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.announcements),
|
||||
presenceBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.presenceBoard),
|
||||
weeklyReport: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.weeklyReport),
|
||||
employeeLifecycle: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.employeeLifecycle),
|
||||
todoBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.todoBoard),
|
||||
paymentBatch: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.paymentBatch),
|
||||
};
|
||||
|
||||
const [
|
||||
pendingTaskCount,
|
||||
reimbursementsCount,
|
||||
leaveCount,
|
||||
assetCount,
|
||||
riskCount,
|
||||
announcementRows,
|
||||
todoRows,
|
||||
myWeeklyReport,
|
||||
onboardingCount,
|
||||
offboardingCount,
|
||||
assetReturnPendingCount,
|
||||
] = await Promise.all([
|
||||
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }),
|
||||
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
||||
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
|
||||
fastify.prisma.purchaseRequest.count({ where: { applicant_id: request.employee.id } }),
|
||||
fastify.prisma.travelRequest.count({ where: { OR: [{ applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
||||
(fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage"))
|
||||
fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
|
||||
fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage")
|
||||
? fastify.prisma.entityRiskFlag.count({ where: { status: "open" } })
|
||||
: Promise.resolve(0),
|
||||
fastify.prisma.portalAnnouncement.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
OR: [{ starts_at: null }, { starts_at: { lte: now } }],
|
||||
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: now } }] }],
|
||||
},
|
||||
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
|
||||
take: 6,
|
||||
}),
|
||||
fastify.hasPermission(request.employee, "payment.batch.manage")
|
||||
? fastify.prisma.paymentBatch.count({ where: { status: { in: ["draft", "reviewed", "failed"] } } })
|
||||
featureEnabled.announcements
|
||||
? fastify.prisma.portalAnnouncement.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
OR: [{ starts_at: null }, { starts_at: { lte: new Date() } }],
|
||||
AND: [{ OR: [{ ends_at: null }, { ends_at: { gte: new Date() } }] }],
|
||||
},
|
||||
orderBy: [{ priority: "asc" }, { created_at: "desc" }],
|
||||
take: 6,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
featureEnabled.todoBoard
|
||||
? fastify.prisma.todoItem.findMany({
|
||||
where: { assignee_employee_id: request.employee.id, status: { in: ["todo", "doing"] } },
|
||||
orderBy: [{ priority: "desc" }, { due_date: "asc" }, { updated_at: "desc" }],
|
||||
take: 10,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
featureEnabled.weeklyReport
|
||||
? fastify.prisma.weeklyReport.findUnique({
|
||||
where: {
|
||||
employee_id_week_start: {
|
||||
employee_id: request.employee.id,
|
||||
week_start: parseDateOnlyString(currentWeekStart),
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
featureEnabled.employeeLifecycle && isManagerView
|
||||
? fastify.prisma.employee.count({ where: { employment_status: "onboarding", status: "active" } })
|
||||
: Promise.resolve(0),
|
||||
featureEnabled.employeeLifecycle && isManagerView
|
||||
? fastify.prisma.employee.count({ where: { employment_status: "offboarding" } })
|
||||
: Promise.resolve(0),
|
||||
featureEnabled.employeeLifecycle && isManagerView
|
||||
? fastify.prisma.assetCard.count({ where: { assignee: { is: { employment_status: "offboarding" } } } })
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
const cards = [
|
||||
{ key: "pending_tasks", title: "我的待办", value: pendingTaskCount, href: "/workflow/tasks", severity: pendingTaskCount > 0 ? "warning" : "normal" },
|
||||
{ key: "my_reimbursements", title: "报销单", value: reimbursementsCount, href: "/reimbursements", severity: "normal" },
|
||||
{ key: "my_purchases", title: "采购单", value: purchaseCount, href: "/purchase-requests", severity: "normal" },
|
||||
{ key: "my_travel", title: "差旅单", value: travelCount, href: "/travel-requests", severity: "normal" },
|
||||
];
|
||||
if (paymentBatchCount > 0) {
|
||||
cards.push({ key: "payment_batches", title: "待处理支付批次", value: paymentBatchCount, href: "/payment-batches", severity: "warning" });
|
||||
const todayPresence = featureEnabled.presenceBoard
|
||||
? await resolvePresenceForEmployee(fastify.prisma, request.employee.id, today)
|
||||
: null;
|
||||
const teamPresenceSummary = featureEnabled.presenceBoard && isManagerView
|
||||
? await buildTeamPresenceSummary(fastify.prisma, today)
|
||||
: null;
|
||||
|
||||
const teamWeeklySummary = featureEnabled.weeklyReport && isManagerView
|
||||
? await (async () => {
|
||||
const [employeeCount, submittedCount, needsHelpCount] = await Promise.all([
|
||||
fastify.prisma.employee.count({ where: { status: "active" } }),
|
||||
fastify.prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(currentWeekStart), status: "submitted" } }),
|
||||
fastify.prisma.weeklyReport.count({ where: { week_start: parseDateOnlyString(currentWeekStart), needs_help: true } }),
|
||||
]);
|
||||
return {
|
||||
weekStart: currentWeekStart,
|
||||
submittedCount,
|
||||
missingCount: Math.max(0, employeeCount - submittedCount),
|
||||
needsHelpCount,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
const cards = [];
|
||||
if (isManagerView) {
|
||||
cards.push({
|
||||
key: "team_presence",
|
||||
title: "今日团队状态",
|
||||
value: teamPresenceSummary ? `${teamPresenceSummary.office}/${teamPresenceSummary.total} 在岗` : "-",
|
||||
href: "/team",
|
||||
severity: teamPresenceSummary && (teamPresenceSummary.leave > 0 || teamPresenceSummary.unspecified > 0) ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "weekly_missing",
|
||||
title: "未提交周报",
|
||||
value: teamWeeklySummary?.missingCount ?? 0,
|
||||
href: "/weekly-reports",
|
||||
severity: (teamWeeklySummary?.missingCount ?? 0) > 0 ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "weekly_help",
|
||||
title: "需协助员工",
|
||||
value: teamWeeklySummary?.needsHelpCount ?? 0,
|
||||
href: "/weekly-reports",
|
||||
severity: (teamWeeklySummary?.needsHelpCount ?? 0) > 0 ? "high" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "people_lifecycle",
|
||||
title: "入/离职待办",
|
||||
value: (onboardingCount ?? 0) + (offboardingCount ?? 0),
|
||||
href: "/people",
|
||||
severity: (offboardingCount ?? 0) > 0 ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "asset_return_pending",
|
||||
title: "资产待归还",
|
||||
value: assetReturnPendingCount ?? 0,
|
||||
href: "/people",
|
||||
severity: (assetReturnPendingCount ?? 0) > 0 ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "pending_approvals",
|
||||
title: "审批待办",
|
||||
value: pendingTaskCount,
|
||||
href: "/workflow/tasks",
|
||||
severity: pendingTaskCount > 0 ? "warning" : "normal",
|
||||
});
|
||||
} else {
|
||||
cards.push({
|
||||
key: "my_todos",
|
||||
title: "我的待办",
|
||||
value: featureEnabled.todoBoard ? todoRows.length : 0,
|
||||
href: "/todos",
|
||||
severity: todoRows.some((item) => item.priority === "high") ? "warning" : "normal",
|
||||
});
|
||||
cards.push({
|
||||
key: "today_presence",
|
||||
title: "今日状态",
|
||||
value: featureEnabled.presenceBoard ? formatPresenceStatus(todayPresence) : "未启用",
|
||||
href: "/team",
|
||||
severity: todayPresence?.status === "leave" ? "warning" : "normal",
|
||||
});
|
||||
cards.push({ key: "my_leave", title: "我的请假", value: leaveCount, href: "/leave-requests", severity: "normal" });
|
||||
cards.push({ key: "my_reimbursements", title: "我的报销", value: reimbursementsCount, href: "/reimbursements", severity: "normal" });
|
||||
cards.push({ key: "my_assets", title: "我的资产", value: assetCount, href: "/assets", severity: "normal" });
|
||||
cards.push({
|
||||
key: "my_weekly_report",
|
||||
title: "本周周报",
|
||||
value: featureEnabled.weeklyReport ? (myWeeklyReport?.status === "submitted" ? "已提交" : "待填写") : "未启用",
|
||||
href: "/weekly-reports",
|
||||
severity: myWeeklyReport?.status === "submitted" ? "normal" : "warning",
|
||||
});
|
||||
}
|
||||
if (riskCount > 0) {
|
||||
|
||||
if (featureEnabled.paymentBatch && riskCount > 0) {
|
||||
cards.push({ key: "risk_flags", title: "待复核风险", value: riskCount, href: "/ap-operations", severity: "high" });
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
workspace,
|
||||
greeting: { name: request.employee.name ?? request.employee.id },
|
||||
cards,
|
||||
pendingTasks: { count: pendingTaskCount, href: "/workflow/tasks" },
|
||||
myApplications: [
|
||||
{ domain: "reimbursement", count: reimbursementsCount, href: "/reimbursements" },
|
||||
{ domain: "leave", count: leaveCount, href: "/leave-requests" },
|
||||
{ domain: "purchase_request", count: purchaseCount, href: "/purchase-requests" },
|
||||
{ domain: "travel_request", count: travelCount, href: "/travel-requests" },
|
||||
],
|
||||
riskSummary: riskCount > 0 ? { openCount: riskCount, href: "/ap-operations" } : null,
|
||||
announcements: announcementRows.map((item) => ({
|
||||
@@ -107,7 +341,35 @@ async function portalRoutes(fastify) {
|
||||
summary: item.summary,
|
||||
href: item.link_url ?? null,
|
||||
})),
|
||||
quickLinks: buildQuickLinks(request),
|
||||
quickLinks: buildQuickLinks({ request, featureEnabled }),
|
||||
todayPresence: featureEnabled.presenceBoard
|
||||
? { date: today, status: todayPresence?.status ?? null, label: formatPresenceStatus(todayPresence), note: todayPresence?.note ?? null, href: "/team" }
|
||||
: null,
|
||||
teamPresence: teamPresenceSummary ? { date: today, href: "/team", ...teamPresenceSummary } : null,
|
||||
weeklyReport: featureEnabled.weeklyReport
|
||||
? {
|
||||
weekStart: currentWeekStart,
|
||||
status: myWeeklyReport?.status ?? "draft",
|
||||
needsHelp: myWeeklyReport?.needs_help ?? false,
|
||||
href: "/weekly-reports",
|
||||
}
|
||||
: null,
|
||||
weeklySummary: teamWeeklySummary ? { ...teamWeeklySummary, href: "/weekly-reports" } : null,
|
||||
todoSummary: featureEnabled.todoBoard
|
||||
? {
|
||||
openCount: todoRows.length,
|
||||
highPriorityCount: todoRows.filter((item) => item.priority === "high").length,
|
||||
href: "/todos",
|
||||
}
|
||||
: null,
|
||||
peopleLifecycle: featureEnabled.employeeLifecycle && isManagerView
|
||||
? {
|
||||
onboardingCount,
|
||||
offboardingCount,
|
||||
assetReturnPendingCount,
|
||||
href: "/people",
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { parseDateOnlyString, currentDateString } = require("../utils/date");
|
||||
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const QUERY_SCHEMA = z.object({ date: DATE_SCHEMA.optional() });
|
||||
const WRITE_SCHEMA = z
|
||||
.object({
|
||||
date: DATE_SCHEMA.optional(),
|
||||
status: z.enum(["office", "remote", "out", "travel"]),
|
||||
timeRange: z.enum(["full_day", "morning", "afternoon"]).optional(),
|
||||
note: z.string().trim().max(300).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function formatDateOnly(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function resolveDateInput(rawDate, timeZone) {
|
||||
return rawDate ? rawDate : currentDateString(timeZone ?? "Asia/Shanghai");
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.presenceBoard,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError({ featureFlag: "presence_board_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function computeLeaveTimeRange(leave, dateString) {
|
||||
const startDate = formatDateOnly(leave.start_date);
|
||||
const endDate = formatDateOnly(leave.end_date);
|
||||
if (startDate === dateString && endDate === dateString) {
|
||||
if (leave.start_session === "pm" && leave.end_session === "pm") return "afternoon";
|
||||
if (leave.start_session === "am" && leave.end_session === "am") return "morning";
|
||||
return "full_day";
|
||||
}
|
||||
if (startDate === dateString && leave.start_session === "pm") {
|
||||
return "afternoon";
|
||||
}
|
||||
if (endDate === dateString && leave.end_session === "am") {
|
||||
return "morning";
|
||||
}
|
||||
return "full_day";
|
||||
}
|
||||
|
||||
function mapPresenceRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employee_id,
|
||||
date: formatDateOnly(row.date),
|
||||
status: row.status,
|
||||
timeRange: row.time_range,
|
||||
note: row.note ?? null,
|
||||
source: row.source,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function findLeaveOverlay(prisma, employeeIds, dateString) {
|
||||
if (!employeeIds.length) {
|
||||
return new Map();
|
||||
}
|
||||
const date = parseDateOnlyString(dateString);
|
||||
const rows = await prisma.leaveRequest.findMany({
|
||||
where: {
|
||||
owner_id: { in: employeeIds },
|
||||
status: "approved",
|
||||
start_date: { lte: date },
|
||||
end_date: { gte: date },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
start_session: true,
|
||||
end_session: true,
|
||||
reason: true,
|
||||
},
|
||||
});
|
||||
const map = new Map();
|
||||
for (const row of rows) {
|
||||
if (!map.has(row.owner_id)) {
|
||||
map.set(row.owner_id, {
|
||||
id: `leave-${row.id}`,
|
||||
employeeId: row.owner_id,
|
||||
date: dateString,
|
||||
status: "leave",
|
||||
timeRange: computeLeaveTimeRange(row, dateString),
|
||||
note: row.reason ?? null,
|
||||
source: "leave_sync",
|
||||
updatedAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function findTravelOverlay(prisma, employeeIds, dateString) {
|
||||
if (!employeeIds.length) {
|
||||
return new Map();
|
||||
}
|
||||
const date = parseDateOnlyString(dateString);
|
||||
const rows = await prisma.travelRequest.findMany({
|
||||
where: {
|
||||
applicant_employee_id: { in: employeeIds },
|
||||
status: "approved",
|
||||
start_date: { lte: date },
|
||||
end_date: { gte: date },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
applicant_employee_id: true,
|
||||
purpose: true,
|
||||
},
|
||||
});
|
||||
const map = new Map();
|
||||
for (const row of rows) {
|
||||
if (!map.has(row.applicant_employee_id)) {
|
||||
map.set(row.applicant_employee_id, {
|
||||
id: `travel-${row.id}`,
|
||||
employeeId: row.applicant_employee_id,
|
||||
date: dateString,
|
||||
status: "travel",
|
||||
timeRange: "full_day",
|
||||
note: row.purpose ?? null,
|
||||
source: "leave_sync",
|
||||
updatedAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function buildPresenceState(prisma, employeeIds, dateString) {
|
||||
const [manualRows, leaveOverlay, travelOverlay] = await Promise.all([
|
||||
prisma.employeePresence.findMany({
|
||||
where: { employee_id: { in: employeeIds }, date: parseDateOnlyString(dateString) },
|
||||
}),
|
||||
findLeaveOverlay(prisma, employeeIds, dateString),
|
||||
findTravelOverlay(prisma, employeeIds, dateString),
|
||||
]);
|
||||
|
||||
const manualMap = new Map(manualRows.map((row) => [row.employee_id, mapPresenceRow(row)]));
|
||||
const result = new Map();
|
||||
for (const employeeId of employeeIds) {
|
||||
if (leaveOverlay.has(employeeId)) {
|
||||
result.set(employeeId, leaveOverlay.get(employeeId));
|
||||
continue;
|
||||
}
|
||||
if (travelOverlay.has(employeeId)) {
|
||||
result.set(employeeId, travelOverlay.get(employeeId));
|
||||
continue;
|
||||
}
|
||||
if (manualMap.has(employeeId)) {
|
||||
result.set(employeeId, manualMap.get(employeeId));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildSummary(items) {
|
||||
const summary = {
|
||||
office: 0,
|
||||
remote: 0,
|
||||
out: 0,
|
||||
travel: 0,
|
||||
leave: 0,
|
||||
unspecified: 0,
|
||||
total: items.length,
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const status = item.presence?.status;
|
||||
if (!status || !(status in summary)) {
|
||||
summary.unspecified += 1;
|
||||
continue;
|
||||
}
|
||||
summary[status] += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function presenceRoutes(fastify) {
|
||||
fastify.get("/presence/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const dateString = resolveDateInput(parsed.data.date, fastify.config.appTimezone);
|
||||
const map = await buildPresenceState(fastify.prisma, [request.employee.id], dateString);
|
||||
return sendData(reply, request, { item: map.get(request.employee.id) ?? null, date: dateString });
|
||||
});
|
||||
|
||||
fastify.put("/presence/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseBody(WRITE_SCHEMA, request.body);
|
||||
const dateString = resolveDateInput(body.date, fastify.config.appTimezone);
|
||||
const leaveOverlay = await findLeaveOverlay(fastify.prisma, [request.employee.id], dateString);
|
||||
if (leaveOverlay.has(request.employee.id)) {
|
||||
throw buildValidationError({ date: "covered_by_approved_leave" }, 409);
|
||||
}
|
||||
const record = await fastify.prisma.employeePresence.upsert({
|
||||
where: {
|
||||
employee_id_date: {
|
||||
employee_id: request.employee.id,
|
||||
date: parseDateOnlyString(dateString),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
status: body.status,
|
||||
time_range: body.timeRange ?? "full_day",
|
||||
note: body.note ?? null,
|
||||
source: "manual",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
create: {
|
||||
employee_id: request.employee.id,
|
||||
date: parseDateOnlyString(dateString),
|
||||
status: body.status,
|
||||
time_range: body.timeRange ?? "full_day",
|
||||
note: body.note ?? null,
|
||||
source: "manual",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapPresenceRow(record) });
|
||||
});
|
||||
|
||||
fastify.get("/presence/team", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const dateString = resolveDateInput(parsed.data.date, fastify.config.appTimezone);
|
||||
const employees = await fastify.prisma.employee.findMany({
|
||||
where: { status: "active" },
|
||||
orderBy: [{ department_id: "asc" }, { employee_no: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
employment_status: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
const map = await buildPresenceState(fastify.prisma, employees.map((item) => item.id), dateString);
|
||||
const items = employees.map((item) => ({
|
||||
employee: {
|
||||
id: item.id,
|
||||
employeeNo: item.employee_no,
|
||||
name: item.name,
|
||||
title: item.title,
|
||||
employmentStatus: item.employment_status,
|
||||
department: item.department ? { id: item.department.id, name: item.department.name } : null,
|
||||
},
|
||||
presence: map.get(item.id) ?? null,
|
||||
}));
|
||||
return sendData(reply, request, { date: dateString, summary: buildSummary(items), items });
|
||||
});
|
||||
|
||||
fastify.get("/presence/summary", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const dateString = resolveDateInput(parsed.data.date, fastify.config.appTimezone);
|
||||
const employeeIds = (
|
||||
await fastify.prisma.employee.findMany({ where: { status: "active" }, select: { id: true } })
|
||||
).map((item) => item.id);
|
||||
const map = await buildPresenceState(fastify.prisma, employeeIds, dateString);
|
||||
const items = employeeIds.map((employeeId) => ({ employeeId, presence: map.get(employeeId) ?? null }));
|
||||
return sendData(reply, request, { date: dateString, summary: buildSummary(items) });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = presenceRoutes;
|
||||
+198
-51
@@ -9,6 +9,18 @@ const QUERY_SCHEMA = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(20).optional(),
|
||||
});
|
||||
|
||||
const ENTITY_PRIORITY = {
|
||||
employee: 0,
|
||||
announcement: 1,
|
||||
asset: 2,
|
||||
leave: 3,
|
||||
reimbursement: 4,
|
||||
weekly_report: 5,
|
||||
todo: 6,
|
||||
purchase_request: 7,
|
||||
travel_request: 8,
|
||||
};
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
@@ -39,6 +51,17 @@ function compareMatchedBy(left, right) {
|
||||
return score[left] - score[right];
|
||||
}
|
||||
|
||||
async function isFlagEnabled(fastify, request, flagKey) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
return gate.enabled;
|
||||
}
|
||||
|
||||
async function searchRoutes(fastify) {
|
||||
fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
@@ -47,8 +70,131 @@ async function searchRoutes(fastify) {
|
||||
const q = parsed.data.q.trim();
|
||||
const limit = parsed.data.limit ?? 10;
|
||||
|
||||
const tasks = [];
|
||||
const isAdmin = fastify.hasPermission(request.employee, "*");
|
||||
const featureEnabled = {
|
||||
announcements: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.announcements),
|
||||
weeklyReport: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.weeklyReport),
|
||||
todoBoard: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.todoBoard),
|
||||
};
|
||||
|
||||
const tasks = [];
|
||||
|
||||
tasks.push(
|
||||
fastify.prisma.employee.findMany({
|
||||
where: {
|
||||
status: "active",
|
||||
OR: [
|
||||
{ employee_no: { contains: q, mode: "insensitive" } },
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ email: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, employee_no: true, name: true, email: true, title: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "employee",
|
||||
entityId: item.id,
|
||||
title: item.name,
|
||||
subtitle: `${item.employee_no} · ${item.title ?? item.email ?? "成员"}`,
|
||||
status: "active",
|
||||
href: `/people/${item.id}`,
|
||||
matchedBy: matchByValue(q, item.employee_no, item.name, item.email),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
|
||||
if (featureEnabled.announcements) {
|
||||
tasks.push(
|
||||
fastify.prisma.portalAnnouncement.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ summary: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, title: true, summary: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "announcement",
|
||||
entityId: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.summary,
|
||||
status: null,
|
||||
href: "/announcements",
|
||||
matchedBy: matchByValue(q, item.title, item.summary),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
fastify.hasPermission(request.employee, "asset.view_own") ||
|
||||
fastify.hasPermission(request.employee, "asset.view_all") ||
|
||||
fastify.hasPermission(request.employee, "asset.manage") ||
|
||||
fastify.hasPermission(request.employee, "asset.transfer") ||
|
||||
fastify.hasPermission(request.employee, "asset.inventory")
|
||||
) {
|
||||
tasks.push(
|
||||
fastify.prisma.assetCard.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin
|
||||
? {}
|
||||
: { assignee_employee_id: request.employee.id },
|
||||
{
|
||||
OR: [
|
||||
{ asset_no: { contains: q, mode: "insensitive" } },
|
||||
{ serial_no: { contains: q, mode: "insensitive" } },
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "asset",
|
||||
entityId: item.id,
|
||||
title: item.asset_no,
|
||||
subtitle: item.name ?? item.serial_no ?? null,
|
||||
status: item.status,
|
||||
href: "/assets",
|
||||
matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) {
|
||||
tasks.push(
|
||||
fastify.prisma.leaveRequest.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin
|
||||
? {}
|
||||
: { owner_id: request.employee.id },
|
||||
{ OR: [{ reason: { contains: q, mode: "insensitive" } }] },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "leave",
|
||||
entityId: item.id,
|
||||
title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`,
|
||||
subtitle: item.reason ?? null,
|
||||
status: item.status,
|
||||
href: "/leave-requests",
|
||||
matchedBy: matchByValue(q, item.reason),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) {
|
||||
tasks.push(
|
||||
@@ -82,28 +228,66 @@ async function searchRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) {
|
||||
if (featureEnabled.weeklyReport) {
|
||||
tasks.push(
|
||||
fastify.prisma.leaveRequest.findMany({
|
||||
fastify.prisma.weeklyReport.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin
|
||||
fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin
|
||||
? {}
|
||||
: { owner_id: request.employee.id },
|
||||
{ OR: [{ reason: { contains: q, mode: "insensitive" } }] },
|
||||
: { employee_id: request.employee.id },
|
||||
{
|
||||
OR: [
|
||||
{ highlights_text: { contains: q, mode: "insensitive" } },
|
||||
{ blockers_text: { contains: q, mode: "insensitive" } },
|
||||
{ next_week_text: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true },
|
||||
include: { employee: { select: { name: true } } },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "leave",
|
||||
entityType: "weekly_report",
|
||||
entityId: item.id,
|
||||
title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`,
|
||||
subtitle: item.reason ?? null,
|
||||
title: `${item.employee?.name ?? "成员"} 周报 ${item.week_start?.toISOString?.()?.slice(0, 10) ?? ""}`,
|
||||
subtitle: item.highlights_text || item.next_week_text || item.blockers_text || null,
|
||||
status: item.status,
|
||||
href: "/leave-requests",
|
||||
matchedBy: matchByValue(q, item.reason),
|
||||
href: "/weekly-reports",
|
||||
matchedBy: matchByValue(q, item.highlights_text, item.blockers_text, item.next_week_text),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (featureEnabled.todoBoard) {
|
||||
tasks.push(
|
||||
fastify.prisma.todoItem.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin
|
||||
? {}
|
||||
: { OR: [{ assignee_employee_id: request.employee.id }, { created_by: request.employee.id }] },
|
||||
{
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ description: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, title: true, description: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "todo",
|
||||
entityId: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.description ?? null,
|
||||
status: item.status,
|
||||
href: "/todos",
|
||||
matchedBy: matchByValue(q, item.title, item.description),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
@@ -173,49 +357,12 @@ async function searchRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
fastify.hasPermission(request.employee, "asset.view_own") ||
|
||||
fastify.hasPermission(request.employee, "asset.view_all") ||
|
||||
fastify.hasPermission(request.employee, "asset.manage") ||
|
||||
fastify.hasPermission(request.employee, "asset.transfer") ||
|
||||
fastify.hasPermission(request.employee, "asset.inventory")
|
||||
) {
|
||||
tasks.push(
|
||||
fastify.prisma.assetCard.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin
|
||||
? {}
|
||||
: { assignee_employee_id: request.employee.id },
|
||||
{
|
||||
OR: [
|
||||
{ asset_no: { contains: q, mode: "insensitive" } },
|
||||
{ serial_no: { contains: q, mode: "insensitive" } },
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "asset",
|
||||
entityId: item.id,
|
||||
title: item.asset_no,
|
||||
subtitle: item.name ?? item.serial_no ?? null,
|
||||
status: item.status,
|
||||
href: "/assets",
|
||||
matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
const items = (await Promise.all(tasks)).flat();
|
||||
items.sort((left, right) => {
|
||||
const matchedDiff = compareMatchedBy(left.matchedBy, right.matchedBy);
|
||||
if (matchedDiff !== 0) return matchedDiff;
|
||||
const entityDiff = (ENTITY_PRIORITY[left.entityType] ?? 999) - (ENTITY_PRIORITY[right.entityType] ?? 999);
|
||||
if (entityDiff !== 0) return entityDiff;
|
||||
return String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const {
|
||||
DEFAULT_MODULE_VISIBILITY,
|
||||
getWorkspaceSetting,
|
||||
getSystemNavigation,
|
||||
normalizeModuleVisibilityJson,
|
||||
upsertWorkspaceSetting,
|
||||
} = require("../services/workspaceSettings");
|
||||
|
||||
const MODULE_VISIBILITY_SCHEMA = z.record(z.string(), z.enum(["main", "admin", "hidden"])).optional();
|
||||
const PATCH_SCHEMA = z
|
||||
.object({
|
||||
moduleVisibility: MODULE_VISIBILITY_SCHEMA,
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function writeWorkspaceAuditLog(fastify, request, workspace) {
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "WORKSPACE_SETTING_UPDATED",
|
||||
targetType: "workspace_setting",
|
||||
targetId: workspace.id,
|
||||
extra: { moduleVisibility: workspace.moduleVisibility },
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error({ err: error, workspaceId: workspace.id }, "workspace setting audit failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function systemWorkspaceRoutes(fastify) {
|
||||
fastify.get("/system/workspace", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
const workspace = await getWorkspaceSetting(fastify.prisma);
|
||||
return sendData(reply, request, {
|
||||
...workspace,
|
||||
defaults: DEFAULT_MODULE_VISIBILITY,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.patch("/system/workspace", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
const body = parseBody(PATCH_SCHEMA, request.body);
|
||||
const workspace = await upsertWorkspaceSetting(fastify.prisma, request.employee.id, {
|
||||
moduleVisibility: normalizeModuleVisibilityJson(body.moduleVisibility ?? null),
|
||||
});
|
||||
|
||||
await writeWorkspaceAuditLog(fastify, request, workspace);
|
||||
return sendData(reply, request, { item: workspace });
|
||||
});
|
||||
|
||||
fastify.get("/system/navigation", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
const data = await getSystemNavigation({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
employee: request.employee,
|
||||
hasPermission: fastify.hasPermission,
|
||||
});
|
||||
return sendData(reply, request, data);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = systemWorkspaceRoutes;
|
||||
@@ -0,0 +1,238 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { createNotifications } = require("../services/notifications");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const QUERY_SCHEMA = z.object({
|
||||
status: z.enum(["todo", "doing", "done", "cancelled"]).optional(),
|
||||
scope: z.enum(["mine", "team"]).optional(),
|
||||
assigneeId: UUID_SCHEMA.optional(),
|
||||
q: z.string().trim().max(100).optional(),
|
||||
});
|
||||
const CREATE_SCHEMA = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(2000).nullable().optional(),
|
||||
assigneeEmployeeId: UUID_SCHEMA.optional(),
|
||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
priority: z.enum(["low", "normal", "high"]).optional(),
|
||||
source: z.enum(["manual", "weekly_report"]).optional(),
|
||||
}).strict();
|
||||
const PATCH_SCHEMA = z.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
description: z.string().trim().max(2000).nullable().optional(),
|
||||
assigneeEmployeeId: UUID_SCHEMA.optional(),
|
||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
priority: z.enum(["low", "normal", "high"]).optional(),
|
||||
status: z.enum(["todo", "doing", "done", "cancelled"]).optional(),
|
||||
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseUuid(value, fieldName = "id") {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ [fieldName]: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function mapTodo(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description ?? null,
|
||||
assigneeEmployeeId: row.assignee_employee_id,
|
||||
createdBy: row.created_by,
|
||||
dueDate: row.due_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||
completedAt: row.completed_at?.toISOString?.() ?? null,
|
||||
assignee: row.assignee ? { id: row.assignee.id, employeeNo: row.assignee.employee_no, name: row.assignee.name } : null,
|
||||
creator: row.creator ? { id: row.creator.id, employeeNo: row.creator.employee_no, name: row.creator.name } : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.todoBoard,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError({ featureFlag: "todo_board_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function canManageTeamTodos(fastify, employee) {
|
||||
return fastify.hasPermission(employee, "report.dashboard.view") || fastify.hasPermission(employee, "system.users");
|
||||
}
|
||||
|
||||
async function todosRoutes(fastify) {
|
||||
fastify.get("/todos", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const query = parsed.data;
|
||||
const canManage = canManageTeamTodos(fastify, request.employee);
|
||||
const scope = query.scope ?? "mine";
|
||||
const where = {
|
||||
AND: [
|
||||
query.status ? { status: query.status } : {},
|
||||
query.q
|
||||
? {
|
||||
OR: [
|
||||
{ title: { contains: query.q, mode: "insensitive" } },
|
||||
{ description: { contains: query.q, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
scope === "team" && canManage
|
||||
? query.assigneeId
|
||||
? { assignee_employee_id: query.assigneeId }
|
||||
: {}
|
||||
: {
|
||||
OR: [{ assignee_employee_id: request.employee.id }, { created_by: request.employee.id }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rows = await fastify.prisma.todoItem.findMany({
|
||||
where,
|
||||
orderBy: [{ status: "asc" }, { priority: "desc" }, { due_date: "asc" }, { updated_at: "desc" }],
|
||||
include: {
|
||||
assignee: { select: { id: true, employee_no: true, name: true } },
|
||||
creator: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { items: rows.map(mapTodo) });
|
||||
});
|
||||
|
||||
fastify.post("/todos", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseBody(CREATE_SCHEMA, request.body);
|
||||
const assigneeEmployeeId = body.assigneeEmployeeId ?? request.employee.id;
|
||||
if (assigneeEmployeeId !== request.employee.id && !canManageTeamTodos(fastify, request.employee)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
|
||||
}
|
||||
const created = await fastify.prisma.todoItem.create({
|
||||
data: {
|
||||
title: body.title,
|
||||
description: body.description ?? null,
|
||||
assignee_employee_id: assigneeEmployeeId,
|
||||
created_by: request.employee.id,
|
||||
due_date: body.dueDate ? new Date(`${body.dueDate}T00:00:00.000Z`) : null,
|
||||
priority: body.priority ?? "normal",
|
||||
status: "todo",
|
||||
source: body.source ?? "manual",
|
||||
},
|
||||
include: {
|
||||
assignee: { select: { id: true, employee_no: true, name: true } },
|
||||
creator: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (assigneeEmployeeId !== request.employee.id) {
|
||||
await createNotifications(fastify.prisma, {
|
||||
recipientEmployeeIds: [assigneeEmployeeId],
|
||||
type: "todo.assigned",
|
||||
title: `新的待办:${created.title}`,
|
||||
body: created.description ?? "请到待办页查看详情。",
|
||||
targetType: "todo_item",
|
||||
targetId: created.id,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapTodo(created) } });
|
||||
});
|
||||
|
||||
fastify.patch("/todos/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const todoId = parseUuid(request.params?.id);
|
||||
const body = parseBody(PATCH_SCHEMA, request.body);
|
||||
const current = await fastify.prisma.todoItem.findUnique({ where: { id: todoId } });
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
const canManage = canManageTeamTodos(fastify, request.employee);
|
||||
if (!(canManage || current.assignee_employee_id === request.employee.id || current.created_by === request.employee.id)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "todo.write" } });
|
||||
}
|
||||
if (body.assigneeEmployeeId && body.assigneeEmployeeId !== request.employee.id && !canManage) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
|
||||
}
|
||||
const updated = await fastify.prisma.todoItem.update({
|
||||
where: { id: todoId },
|
||||
data: {
|
||||
...(body.title !== undefined ? { title: body.title } : {}),
|
||||
...(body.description !== undefined ? { description: body.description ?? null } : {}),
|
||||
...(body.assigneeEmployeeId !== undefined ? { assignee_employee_id: body.assigneeEmployeeId } : {}),
|
||||
...(body.dueDate !== undefined ? { due_date: body.dueDate ? new Date(`${body.dueDate}T00:00:00.000Z`) : null } : {}),
|
||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||
...(body.status !== undefined ? { status: body.status, completed_at: body.status === "done" ? new Date() : body.status === "cancelled" ? current.completed_at : null } : {}),
|
||||
},
|
||||
include: {
|
||||
assignee: { select: { id: true, employee_no: true, name: true } },
|
||||
creator: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapTodo(updated) });
|
||||
});
|
||||
|
||||
fastify.post("/todos/:id/complete", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const todoId = parseUuid(request.params?.id);
|
||||
const current = await fastify.prisma.todoItem.findUnique({ where: { id: todoId } });
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!(canManageTeamTodos(fastify, request.employee) || current.assignee_employee_id === request.employee.id || current.created_by === request.employee.id)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "todo.complete" } });
|
||||
}
|
||||
const updated = await fastify.prisma.todoItem.update({
|
||||
where: { id: todoId },
|
||||
data: { status: "done", completed_at: new Date() },
|
||||
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return sendData(reply, request, { item: mapTodo(updated) });
|
||||
});
|
||||
|
||||
fastify.post("/todos/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const todoId = parseUuid(request.params?.id);
|
||||
const current = await fastify.prisma.todoItem.findUnique({ where: { id: todoId } });
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!(canManageTeamTodos(fastify, request.employee) || current.assignee_employee_id === request.employee.id || current.created_by === request.employee.id)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "todo.cancel" } });
|
||||
}
|
||||
const updated = await fastify.prisma.todoItem.update({
|
||||
where: { id: todoId },
|
||||
data: { status: "cancelled", completed_at: null },
|
||||
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return sendData(reply, request, { item: mapTodo(updated) });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = todosRoutes;
|
||||
@@ -0,0 +1,213 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const QUERY_SCHEMA = z.object({ weekStart: DATE_SCHEMA.optional() });
|
||||
const WRITE_SCHEMA = z
|
||||
.object({
|
||||
weekStart: DATE_SCHEMA.optional(),
|
||||
highlightsText: z.string().max(5000).optional(),
|
||||
blockersText: z.string().max(5000).optional(),
|
||||
nextWeekText: z.string().max(5000).optional(),
|
||||
needsHelp: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function normalizeDateString(value) {
|
||||
return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function resolveWeekStart(rawValue, timeZone) {
|
||||
const base = rawValue ? normalizeDateString(rawValue) : currentDateString(timeZone ?? "Asia/Shanghai");
|
||||
const date = parseDateOnlyString(base);
|
||||
const day = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() - (day - 1));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.weeklyReport,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError({ featureFlag: "weekly_report_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function mapReport(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
employeeId: row.employee_id,
|
||||
weekStart: row.week_start?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
status: row.status,
|
||||
highlightsText: row.highlights_text,
|
||||
blockersText: row.blockers_text,
|
||||
nextWeekText: row.next_week_text,
|
||||
needsHelp: row.needs_help,
|
||||
submittedAt: row.submitted_at?.toISOString?.() ?? null,
|
||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureReportDraft(prisma, employeeId, weekStart) {
|
||||
const existing = await prisma.weeklyReport.findUnique({
|
||||
where: {
|
||||
employee_id_week_start: {
|
||||
employee_id: employeeId,
|
||||
week_start: parseDateOnlyString(weekStart),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return prisma.weeklyReport.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
week_start: parseDateOnlyString(weekStart),
|
||||
status: "draft",
|
||||
highlights_text: "",
|
||||
blockers_text: "",
|
||||
next_week_text: "",
|
||||
needs_help: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function weeklyReportsRoutes(fastify) {
|
||||
fastify.get("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
|
||||
const report = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
||||
return sendData(reply, request, { item: mapReport(report) });
|
||||
});
|
||||
|
||||
fastify.put("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseBody(WRITE_SCHEMA, request.body);
|
||||
const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone);
|
||||
const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
||||
if (current.status === "submitted") {
|
||||
throw buildValidationError({ status: "submitted_readonly" }, 409);
|
||||
}
|
||||
const updated = await fastify.prisma.weeklyReport.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
...(body.highlightsText !== undefined ? { highlights_text: body.highlightsText } : {}),
|
||||
...(body.blockersText !== undefined ? { blockers_text: body.blockersText } : {}),
|
||||
...(body.nextWeekText !== undefined ? { next_week_text: body.nextWeekText } : {}),
|
||||
...(body.needsHelp !== undefined ? { needs_help: body.needsHelp } : {}),
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapReport(updated) });
|
||||
});
|
||||
|
||||
fastify.post("/weekly-reports/me/submit", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const body = parseBody(QUERY_SCHEMA, request.body);
|
||||
const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone);
|
||||
const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
||||
const updated = await fastify.prisma.weeklyReport.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
status: "submitted",
|
||||
submitted_at: new Date(),
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: mapReport(updated) });
|
||||
});
|
||||
|
||||
fastify.get("/weekly-reports/team", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!(fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users"))) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
|
||||
}
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
|
||||
const [employees, reports] = await Promise.all([
|
||||
fastify.prisma.employee.findMany({
|
||||
where: { status: "active" },
|
||||
orderBy: { employee_no: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
fastify.prisma.weeklyReport.findMany({
|
||||
where: { week_start: parseDateOnlyString(weekStart) },
|
||||
select: {
|
||||
id: true,
|
||||
employee_id: true,
|
||||
week_start: true,
|
||||
status: true,
|
||||
highlights_text: true,
|
||||
blockers_text: true,
|
||||
next_week_text: true,
|
||||
needs_help: true,
|
||||
submitted_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const reportMap = new Map(reports.map((item) => [item.employee_id, item]));
|
||||
const items = employees.map((employee) => {
|
||||
const report = reportMap.get(employee.id) ?? null;
|
||||
return {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
title: employee.title,
|
||||
department: employee.department ? { id: employee.department.id, name: employee.department.name } : null,
|
||||
},
|
||||
report: report ? mapReport(report) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const summary = {
|
||||
totalEmployees: employees.length,
|
||||
submittedCount: items.filter((item) => item.report?.status === "submitted").length,
|
||||
draftCount: items.filter((item) => item.report?.status === "draft").length,
|
||||
missingCount: items.filter((item) => !item.report).length,
|
||||
needsHelpCount: items.filter((item) => item.report?.needsHelp).length,
|
||||
};
|
||||
|
||||
return sendData(reply, request, { weekStart, summary, items });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = weeklyReportsRoutes;
|
||||
在新工单中引用
屏蔽一个用户