From 1cac1447b1a11737b141539a5fea137336dc40fa Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 28 Mar 2026 17:41:03 +0800 Subject: [PATCH] =?UTF-8?q?R39:=20=E5=AE=8C=E6=88=90=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=E6=B1=87=E6=8A=A5=E9=93=BE=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/employees.ts | 13 ++ client/src/pages/EmployeeProfilePage.tsx | 67 +++++- ...28_12-36-28-r37-agent-modes-governance.csv | 2 +- ...8_15-35-48-r39-employee-reporting-line.csv | 5 + server/src/routes/employees.js | 74 +++++++ .../unit/routes/employees.profile.test.js | 195 ++++++++++++++++++ 6 files changed, 347 insertions(+), 9 deletions(-) create mode 100644 issues/2026-03-28_15-35-48-r39-employee-reporting-line.csv create mode 100644 server/tests/unit/routes/employees.profile.test.js diff --git a/client/src/api/employees.ts b/client/src/api/employees.ts index c1713566..1921eaa7 100644 --- a/client/src/api/employees.ts +++ b/client/src/api/employees.ts @@ -55,6 +55,15 @@ export type EmployeeChecklistTask = { createdAt: string | null; }; +export type EmployeeOrganizationNode = { + id: string; + employeeNo: string; + name: string; + title: string; + status: EmployeeStatus; + department: { id: string; name: string } | null; +}; + export type EmployeeProfile = { employee: { id: string; @@ -95,6 +104,10 @@ export type EmployeeProfile = { status: string; location: string | null; }>; + organization: { + reportingChain: EmployeeOrganizationNode[]; + directReports: EmployeeOrganizationNode[]; + }; }; export async function fetchEmployees(params: { diff --git a/client/src/pages/EmployeeProfilePage.tsx b/client/src/pages/EmployeeProfilePage.tsx index 9c9dad29..bd77a876 100644 --- a/client/src/pages/EmployeeProfilePage.tsx +++ b/client/src/pages/EmployeeProfilePage.tsx @@ -106,6 +106,20 @@ export function EmployeeProfilePage() { return
加载员工档案中...
; } + const reportingChain = profile.organization.reportingChain.length + ? profile.organization.reportingChain + : [ + { + id: profile.employee.id, + employeeNo: profile.employee.employeeNo, + name: profile.employee.name, + title: profile.employee.title, + status: profile.employee.status, + department: profile.employee.department, + }, + ]; + const directReports = profile.organization.directReports; + return (
-
-

离职阻塞项

-
- 资产未归还:{profile.lifecycle.blockers.openAssetCount} +
+
+

离职阻塞项

+
+ 资产未归还:{profile.lifecycle.blockers.openAssetCount} +
+
+ 待办审批任务:{profile.lifecycle.blockers.pendingWorkflowTaskCount} +
+
+ 可完成离职:{profile.lifecycle.blockers.canCompleteOffboarding ? "是" : "否"} +
-
- 待办审批任务:{profile.lifecycle.blockers.pendingWorkflowTaskCount} + +
+

汇报链

+
+ {reportingChain.map((item, index) => ( +
+
+
{item.name}
+ +
+
+ {item.employeeNo} · {item.title} · {item.department?.name ?? "-"} +
+ {index === reportingChain.length - 1 ?
当前员工
: null} +
+ ))} +
-
- 可完成离职:{profile.lifecycle.blockers.canCompleteOffboarding ? "是" : "否"} + +
+

直属下级

+
+ {directReports.length ? directReports.map((item) => ( +
+
+
{item.name}
+ +
+
+ {item.employeeNo} · {item.title} · {item.department?.name ?? "-"} +
+
+ )) :
暂无直属下级
} +
diff --git a/issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv b/issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv index 02724aeb..8d85bcef 100644 --- a/issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv +++ b/issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv @@ -2,4 +2,4 @@ ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regr R37-0,R37治理合同补建与范围冻结,为 AGENTS 多 agent 规则更新补建治理合同并锁定本轮仅做治理文档与提交准备,新 CSV 校验通过 且范围明确为 AGENTS agent modes 固化与提交留痕 不承接业务代码改动,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv,none,DONE,DONE,DONE,issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv,[],"本轮仅治理协作规则与提交流程;不修改 server/src 或 client/src 业务代码。" R37-1,Agent modes 治理规则固化与 review 修订落地,将 3 常驻加 2 按需模式固化到 AGENTS 并落实 qa 写权限 Test_Method 语义和 backend post-check 三处修订,AGENTS.md 包含 Agent modes 段落 且三处修订与现有 issues/README 和 docs/mcp-tools 边界一致,1) git diff --check 2) 核对 AGENTS.md 中 mode:qa 允许写 issues/*.csv 与 scripts/ 3) 核对回滚说明已移入 Notes 约束 4) 核对 backend post-check 已改为按变更类型执行,manual,DONE,DONE,DONE,AGENTS.md,"[""R37-0""]","若后续证明规则过严或过松,回滚方式为还原本次 Agent modes 段落并保留 CSV 审计记录。" R37-2,治理变更提交与审计留痕,提交 AGENTS 与治理 CSV 变更并使用中文 commit message 引用本 Issue ID,生成 1 个仅包含治理变更的 commit 且 git log 最新提交可见 Issue ID,"1) git diff --stat 确认仅含 AGENTS.md 与 issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv 2) git commit -m ""R37: 固化 Agent modes 治理规则"" 3) git log -1 --oneline",manual,DONE,DONE,DONE,AGENTS.md|issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv,"[""R37-1""]","commit 仅承载治理变更;若提交前发现额外文件改动则先停止并重新确认范围。" -R37-3,下个真实迭代按新模式试跑并复盘,在后续一个同时涉及 backend frontend qa 的真实迭代中按新 Agent modes 跑一次并记录阻塞点,至少完成 1 次真实试跑 并能说明 handoff QA 回填 reviewer 触发条件是否顺畅,"1) 选择一个同时涉及 server/ 与 client/ 的真实 issue 组 2) 按 Agent modes 执行 3) 在 Notes 或后续治理 issue 中记录问题与修订建议",manual,DONE,DONE,DONE,TBD,"[""R37-2""]","已用 R38 员工管理试跑完成一次真实迭代。结论: Files 派工整体清晰 backend 侧唯一摩擦点是 /employees 复用 usersRoutes 需要显式 allowDepartmentFilter;QA 回填顺畅 命令式验证和合同状态映射明确;reviewer 触发条件当前无需细化 本轮未触及权限模型 状态机 workflow 或迁移。" +R37-3,下个真实迭代按新模式试跑并复盘,在后续一个同时涉及 backend frontend qa 的真实迭代中按新 Agent modes 跑一次并记录阻塞点,至少完成 1 次真实试跑 并能说明 handoff QA 回填 reviewer 触发条件是否顺畅,"1) 选择一个同时涉及 server/ 与 client/ 的真实 issue 组 2) 按 Agent modes 执行 3) 在 Notes 或后续治理 issue 中记录问题与修订建议",manual,DONE,DONE,DONE,TBD,"[""R37-2""]","已用 R38 员工管理试跑完成一次真实迭代。结论: Files 派工整体清晰 backend 侧唯一摩擦点是 /employees 复用 usersRoutes 需要显式 allowDepartmentFilter;QA 回填顺畅 命令式验证和合同状态映射明确;reviewer 触发条件当前无需细化 本轮未触及权限模型 状态机 workflow 或迁移。R39 后续观测: 围绕单一 profile 响应做 backend/frontend handoff 比 R38 更清晰 扩现有接口比新建专用端点更顺;qa-runner 的新增经验是即使 shell 初始 PATH 缺少 Node,也可通过显式注入 NVM 路径完成验证,R39 最终已完成 node --check、后端单测、client typecheck 和 build。" diff --git a/issues/2026-03-28_15-35-48-r39-employee-reporting-line.csv b/issues/2026-03-28_15-35-48-r39-employee-reporting-line.csv new file mode 100644 index 00000000..ba6bdbed --- /dev/null +++ b/issues/2026-03-28_15-35-48-r39-employee-reporting-line.csv @@ -0,0 +1,5 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +R39-0,R39执行合同建档与范围冻结,为员工档案页汇报链查询创建独立合同并锁定本轮只做 profile 读模型扩展与档案页只读展示,CSV 校验通过 且明确本轮不承接独立组织页 PeoplePage 总览 汇报关系编辑与权限口径重构,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-28_15-35-48-r39-employee-reporting-line.csv,none,DONE,DONE,DONE,issues/2026-03-28_15-35-48-r39-employee-reporting-line.csv,[],本轮继续沿用现有 GET /employees/:id/profile 权限门与响应入口 若实现中发现负载过重仅记录到复盘 不当轮拆专用端点 +R39-1,员工档案汇报链读模型与接口扩展,扩展 GET /employees/:id/profile 返回 organization.reportingChain 与 organization.directReports 读模型,profile 响应稳定返回 organization.reportingChain 与 organization.directReports reportingChain 顺序为最高层到当前员工 directReports 仅含直属下级且坏数据循环不会导致接口失败,"业务流程: 管理员打开某普通员工档案接口返回完整上行汇报链 打开某经理档案接口返回直属下级列表 顶层负责人仅返回本人 archived 上级或下级仍带状态展示。验证命令: cd server && node --check src/routes/employees.js && npm test -- tests/unit/routes/employees.profile.test.js",manual,DONE,DONE,DONE,server/src/routes/employees.js|server/tests/unit/routes/employees.profile.test.js,"[""R39-0""]","后端仅扩现有 profile 响应 不新增 /reporting-line 专用端点。已实现 buildEmployeeOrganization 循环保护和最大深度保护 并通过 __testables 暴露给单测。已验证: node --check src/routes/employees.js 通过;npm test -- tests/unit/routes/employees.profile.test.js 3/3 通过。" +R39-2,员工档案页汇报链卡片,在员工档案页右侧新增汇报链与直属下级只读卡片并扩展前端类型接线,EmployeeProfilePage 右侧在离职阻塞项下方展示汇报链与直属下级 使用现有 StatusBadge 无上级或无下级时显示稳定空态 且不影响现有档案编辑与生命周期展示,"业务流程: 管理员进入员工档案页 在右侧看到汇报链卡片与直属下级卡片 当前员工作为汇报链最后一项 archived 节点带状态标识 无直属下级时显示暂无直属下级。验证命令: cd client && npm run typecheck && npm run build",manual,DONE,DONE,DONE,client/src/api/employees.ts|client/src/pages/EmployeeProfilePage.tsx,"[""R39-0"",""R39-1""]","前端已接入 EmployeeProfile.organization 并在右侧新增汇报链/直属下级卡片 不新增导航或组织树交互。已验证: cd client && npm run typecheck 通过;cd client && npm run build 通过。" +R39-3,QA回归与R37-3后续观测补充,按 agent modes 完成 R39 单项验证 最小回归 CSV 状态回填 并补充这轮 handoff 复盘,后端与前端验证通过 R39 各状态正确回填 且 Notes 记录扩 profile 响应与新建专用端点的取舍 以及 backend frontend qa handoff 观察,"业务流程: 验证普通员工档案的完整上行汇报链 经理档案的直属下级 顶层负责人仅显示本人 无下级员工显示空态 archived 节点带状态标识 并确认 PeoplePage 与 DepartmentsPage 未受影响。验证命令: cd server && node --check src/routes/employees.js && npm test -- tests/unit/routes/employees.profile.test.js && cd ../client && npm run typecheck && npm run build",manual,DONE,DONE,DONE,issues/2026-03-28_15-35-48-r39-employee-reporting-line.csv|issues/2026-03-28_12-36-28-r37-agent-modes-governance.csv|server/src/routes/employees.js|client/src/api/employees.ts|client/src/pages/EmployeeProfilePage.tsx,"[""R39-1"",""R39-2""]","试跑观察: 扩现有 profile 响应比新建专用端点更顺 因为权限门 查询入口和缓存 key 都可直接复用;backend/frontend handoff 比 R38 更清晰 这次契约直接围绕单一 profile 响应组织 边界更窄;reviewer 仍无需默认启用 但若后续扩到组织架构浏览或权限口径调整 再单独引入。已解决命令环境问题: 通过显式注入 NVM PATH 执行 node/npm。最小回归结果: server node --check 通过,server 单测 3/3 通过,client typecheck/build 通过。" diff --git a/server/src/routes/employees.js b/server/src/routes/employees.js index c7b07885..6da51c1e 100644 --- a/server/src/routes/employees.js +++ b/server/src/routes/employees.js @@ -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, +}; diff --git a/server/tests/unit/routes/employees.profile.test.js b/server/tests/unit/routes/employees.profile.test.js new file mode 100644 index 00000000..dbdd8f5c --- /dev/null +++ b/server/tests/unit/routes/employees.profile.test.js @@ -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: "helen@example.com", + 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([]); + }); +});