R39: 完成员工档案汇报链查询
这个提交包含在:
@@ -8,6 +8,7 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const REPORTING_CHAIN_MAX_DEPTH = 12;
|
||||
const PROFILE_PATCH_SCHEMA = z
|
||||
.object({
|
||||
departmentId: UUID_SCHEMA.optional(),
|
||||
@@ -121,6 +122,17 @@ function mapLifecycleEvent(item) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapOrganizationNode(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
employeeNo: item.employee_no,
|
||||
name: item.name,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
department: item.department ? { id: item.department.id, name: item.department.name } : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getLifecycleBlockers(prisma, employeeId) {
|
||||
const [openAssetCount, pendingWorkflowTaskCount] = await Promise.all([
|
||||
prisma.assetCard.count({ where: { assignee_employee_id: employeeId } }),
|
||||
@@ -199,6 +211,59 @@ async function maybeAdvanceLifecycle(tx, employeeId) {
|
||||
return { autoChanged: false, completedOffboarding: false, blockers: null };
|
||||
}
|
||||
|
||||
async function buildEmployeeOrganization(prisma, employee) {
|
||||
const reportingChain = [];
|
||||
const seenEmployeeIds = new Set([employee.id]);
|
||||
let currentManagerId = employee.direct_manager_id ?? null;
|
||||
let depth = 0;
|
||||
|
||||
while (currentManagerId && depth < REPORTING_CHAIN_MAX_DEPTH) {
|
||||
if (seenEmployeeIds.has(currentManagerId)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const manager = await prisma.employee.findUnique({
|
||||
where: { id: currentManagerId },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
direct_manager_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!manager) {
|
||||
break;
|
||||
}
|
||||
|
||||
reportingChain.push(mapOrganizationNode(manager));
|
||||
seenEmployeeIds.add(manager.id);
|
||||
currentManagerId = manager.direct_manager_id ?? null;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
const directReports = await prisma.employee.findMany({
|
||||
where: { direct_manager_id: employee.id },
|
||||
orderBy: [{ status: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reportingChain: [...reportingChain.reverse(), mapOrganizationNode(employee)],
|
||||
directReports: directReports.map(mapOrganizationNode),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildEmployeeProfile(prisma, employeeId) {
|
||||
const [employee, checklistTasks, lifecycleEvents, assignedAssets, blockers] = await Promise.all([
|
||||
prisma.employee.findUnique({
|
||||
@@ -241,6 +306,8 @@ async function buildEmployeeProfile(prisma, employeeId) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
const organization = await buildEmployeeOrganization(prisma, employee);
|
||||
|
||||
return {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
@@ -270,6 +337,7 @@ async function buildEmployeeProfile(prisma, employeeId) {
|
||||
checklistTasks: checklistTasks.map(mapChecklistTask),
|
||||
blockers,
|
||||
},
|
||||
organization,
|
||||
assignedAssets: assignedAssets.map((item) => ({
|
||||
id: item.id,
|
||||
assetNo: item.asset_no,
|
||||
@@ -521,3 +589,9 @@ async function employeesRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = employeesRoutes;
|
||||
|
||||
module.exports.__testables = {
|
||||
buildEmployeeOrganization,
|
||||
buildEmployeeProfile,
|
||||
mapOrganizationNode,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
const employeesRoutes = require("../../../src/routes/employees");
|
||||
|
||||
const { buildEmployeeOrganization, buildEmployeeProfile } = employeesRoutes.__testables;
|
||||
|
||||
function buildEmployeeRow(overrides = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
employee_no: "E001",
|
||||
name: "Alice",
|
||||
title: "员工",
|
||||
status: "active",
|
||||
direct_manager_id: null,
|
||||
department: {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
name: "行政部",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("employee profile organization", () => {
|
||||
it("builds reporting chain from top to current employee and keeps direct reports scoped to one hop", async () => {
|
||||
const currentEmployee = buildEmployeeRow({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
employee_no: "E003",
|
||||
name: "Carol",
|
||||
title: "专员",
|
||||
direct_manager_id: "22222222-2222-4222-8222-222222222222",
|
||||
department: { id: "dept-3", name: "采购部" },
|
||||
});
|
||||
const manager = buildEmployeeRow({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
employee_no: "E002",
|
||||
name: "Bob",
|
||||
title: "经理",
|
||||
direct_manager_id: "11111111-1111-4111-8111-111111111111",
|
||||
department: { id: "dept-2", name: "财务部" },
|
||||
});
|
||||
const director = buildEmployeeRow({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
employee_no: "E001",
|
||||
name: "Alice",
|
||||
title: "总监",
|
||||
department: { id: "dept-1", name: "管理层" },
|
||||
});
|
||||
|
||||
const prisma = {
|
||||
employee: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
if (where.id === manager.id) return manager;
|
||||
if (where.id === director.id) return director;
|
||||
return null;
|
||||
}),
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
buildEmployeeRow({
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
employee_no: "E004",
|
||||
name: "David",
|
||||
title: "专员",
|
||||
status: "active",
|
||||
department: { id: "dept-3", name: "采购部" },
|
||||
}),
|
||||
buildEmployeeRow({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
employee_no: "E005",
|
||||
name: "Eve",
|
||||
title: "专员",
|
||||
status: "archived",
|
||||
department: { id: "dept-3", name: "采购部" },
|
||||
}),
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const organization = await buildEmployeeOrganization(prisma, currentEmployee);
|
||||
|
||||
expect(organization.reportingChain.map((item) => item.id)).toEqual([
|
||||
director.id,
|
||||
manager.id,
|
||||
currentEmployee.id,
|
||||
]);
|
||||
expect(organization.reportingChain.at(-1)).toMatchObject({
|
||||
id: currentEmployee.id,
|
||||
department: { id: "dept-3", name: "采购部" },
|
||||
});
|
||||
expect(organization.directReports.map((item) => item.id)).toEqual([
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"55555555-5555-4555-8555-555555555555",
|
||||
]);
|
||||
expect(prisma.employee.findMany).toHaveBeenCalledWith({
|
||||
where: { direct_manager_id: currentEmployee.id },
|
||||
orderBy: [{ status: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stops on manager cycle and returns the partial reporting chain", async () => {
|
||||
const currentEmployee = buildEmployeeRow({
|
||||
id: "66666666-6666-4666-8666-666666666666",
|
||||
employee_no: "E006",
|
||||
name: "Frank",
|
||||
title: "经理",
|
||||
direct_manager_id: "77777777-7777-4777-8777-777777777777",
|
||||
});
|
||||
const cycleManager = buildEmployeeRow({
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
employee_no: "E007",
|
||||
name: "Grace",
|
||||
title: "总监",
|
||||
direct_manager_id: currentEmployee.id,
|
||||
});
|
||||
|
||||
const prisma = {
|
||||
employee: {
|
||||
findUnique: vi.fn(async ({ where }) => (where.id === cycleManager.id ? cycleManager : null)),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const organization = await buildEmployeeOrganization(prisma, currentEmployee);
|
||||
|
||||
expect(organization.reportingChain.map((item) => item.id)).toEqual([cycleManager.id, currentEmployee.id]);
|
||||
expect(prisma.employee.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("embeds stable organization arrays into employee profile responses", async () => {
|
||||
const currentEmployee = {
|
||||
...buildEmployeeRow({
|
||||
id: "88888888-8888-4888-8888-888888888888",
|
||||
employee_no: "E008",
|
||||
name: "Helen",
|
||||
title: "主管",
|
||||
}),
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
employment_status: "active",
|
||||
department_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
hire_date: new Date("2026-01-10T00:00:00.000Z"),
|
||||
probation_end_date: null,
|
||||
last_working_date: null,
|
||||
work_location: "上海",
|
||||
emergency_contact_name: "联系人",
|
||||
emergency_contact_phone: "13900000000",
|
||||
notes: "none",
|
||||
archived_at: null,
|
||||
roles: [{ role_code: "employee" }],
|
||||
direct_manager: null,
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
employee: {
|
||||
findUnique: vi.fn(async ({ where }) => (where.id === currentEmployee.id ? currentEmployee : null)),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
employeeChecklistTask: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
employeeLifecycleEvent: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
assetCard: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
workflowTask: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
};
|
||||
|
||||
const profile = await buildEmployeeProfile(prisma, currentEmployee.id);
|
||||
|
||||
expect(profile.organization.reportingChain).toEqual([
|
||||
{
|
||||
id: currentEmployee.id,
|
||||
employeeNo: "E008",
|
||||
name: "Helen",
|
||||
title: "主管",
|
||||
status: "active",
|
||||
department: {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
name: "行政部",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(profile.organization.directReports).toEqual([]);
|
||||
expect(profile.assignedAssets).toEqual([]);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户