From 5f92847c00169f2a718af0554d20f4bdbabfa367 Mon Sep 17 00:00:00 2001 From: let5sne Date: Thu, 9 Jul 2026 12:54:33 +0800 Subject: [PATCH] feat(aihr): map supervisor phone to org scope --- .../aihr/service/AihrOrgSyncService.java | 52 +++++++++++++++++-- .../aihr/service/AihrPracticeSeedService.java | 23 +++++++- .../service/AihrPracticeSeedServiceTest.java | 17 ++++++ .../script/sql/aihr_org_snapshot_mysql8.sql | 2 + docs/API_INTEGRATION.md | 4 +- 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java index a823b2fa..1c1ddf91 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java @@ -280,13 +280,14 @@ public class AihrOrgSyncService { } jdbcTemplate.batchUpdate(""" insert into aihr_org_snapshot - (tenant_id, project_code, project_name, dept_name, ext_party_id, person_name, + (tenant_id, project_code, project_name, dept_name, ext_party_id, person_phone, person_name, position_name, position_level, employment_status, snapshot_date, create_time) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) on duplicate key update project_code = values(project_code), project_name = values(project_name), dept_name = values(dept_name), + person_phone = values(person_phone), person_name = values(person_name), position_name = values(position_name), position_level = values(position_level), @@ -320,16 +321,18 @@ public class AihrOrgSyncService { deptName, "组织架构" ); - String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle", "title"), "员工"); + String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle", + "post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工"); String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName)); return new OrgRow( projectCode, projectName, deptName, extPartyId, + phone(employee), firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-"), positionName, - positionLevel, + positionLevel(positionLevel, positionName), status(text(employee, "status", "employment_status", "employmentStatus")), LocalDate.now() ); @@ -437,11 +440,35 @@ public class AihrOrgSyncService { if (count == null || count == 0) { throw new IllegalArgumentException("请先执行 aihr_org_snapshot_mysql8.sql 初始化组织人员快照表"); } + ensureSnapshotColumn("person_phone", "ALTER TABLE aihr_org_snapshot ADD COLUMN `person_phone` varchar(30) DEFAULT NULL COMMENT '手机号(仅用于登录身份映射)' AFTER `ext_party_id`"); + ensureSnapshotIndex("idx_aihr_org_phone", "ALTER TABLE aihr_org_snapshot ADD KEY `idx_aihr_org_phone` (`tenant_id`, `person_phone`)"); } catch (DataAccessException e) { throw new IllegalStateException("组织人员快照表检查失败: " + e.getMessage(), e); } } + private void ensureSnapshotColumn(String column, String ddl) { + Integer exists = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_org_snapshot' AND COLUMN_NAME = ? + """, Integer.class, column); + if (exists == null || exists == 0) { + jdbcTemplate.execute(ddl); + } + } + + private void ensureSnapshotIndex(String index, String ddl) { + Integer exists = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_org_snapshot' AND INDEX_NAME = ? + """, Integer.class, index); + if (exists == null || exists == 0) { + jdbcTemplate.execute(ddl); + } + } + private static String status(String value) { String text = clean(value).toLowerCase(); if (text.isBlank() || text.equals("1") || text.equals("active") || text.equals("enabled") @@ -466,6 +493,20 @@ public class AihrOrgSyncService { return "一线"; } + private static String positionLevel(String rawLevel, String positionName) { + String inferred = level(positionName); + String level = clean(rawLevel); + if (level.isBlank() || (("一线".equals(level) || "员工".equals(level)) && !"一线".equals(inferred))) { + return inferred; + } + return level; + } + + private static String phone(JsonNode employee) { + return text(employee, "phone", "mobile", "mobile_phone", "mobilePhone", "contact_phone", + "contactPhone", "phonenumber", "telephone", "tel"); + } + private static String text(JsonNode node, String... fields) { for (String field : fields) { JsonNode child = node.path(field); @@ -564,6 +605,7 @@ public class AihrOrgSyncService { String projectName, String deptName, String extPartyId, + String personPhone, String personName, String positionName, String positionLevel, @@ -572,7 +614,7 @@ public class AihrOrgSyncService { ) { Object[] args() { return new Object[] { - TENANT_ID, projectCode, projectName, deptName, extPartyId, personName, + TENANT_ID, projectCode, projectName, deptName, extPartyId, personPhone, personName, positionName, positionLevel, employmentStatus, snapshotDate }; } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 863e6592..18ab34c2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -1366,12 +1366,22 @@ public class AihrPracticeSeedService { if (owner.isEmpty() || !orgSnapshotTableExists()) { return TeamScope.unscoped(); } + boolean hasPhone = orgSnapshotColumnExists("person_phone"); + List ownerArgs = new ArrayList<>(); + ownerArgs.add(TENANT_ID); + ownerArgs.add(owner); + String ownerWhere = "ext_party_id = ?"; + if (hasPhone) { + ownerWhere = "(ext_party_id = ? OR person_phone = ?)"; + ownerArgs.add(owner); + } List owners = jdbcTemplate.query(""" SELECT project_code, position_level FROM aihr_org_snapshot - WHERE tenant_id = ? AND ext_party_id = ? AND employment_status = 'active' + WHERE tenant_id = ? AND """ + ownerWhere + """ + AND employment_status = 'active' LIMIT 1 - """, (rs, rowNum) -> new OrgScopeRow(rs.getString("project_code"), rs.getString("position_level")), TENANT_ID, owner); + """, (rs, rowNum) -> new OrgScopeRow(rs.getString("project_code"), rs.getString("position_level")), ownerArgs.toArray()); if (owners.isEmpty()) { return TeamScope.unscoped(); } @@ -1397,6 +1407,15 @@ public class AihrPracticeSeedService { return count != null && count > 0; } + private boolean orgSnapshotColumnExists(String column) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_org_snapshot' AND COLUMN_NAME = ? + """, Integer.class, column); + return count != null && count > 0; + } + private static boolean canSeeProject(String positionLevel) { return "主管".equals(positionLevel) || "项目经理".equals(positionLevel); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 9032feaf..0a48abcc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -93,6 +93,19 @@ public class AihrPracticeSeedServiceTest { assertEquals(List.of("000000", "SUP-1", 5), jdbcTemplate.reviewArgs); } + @Test + public void pendingReviewRecordsResolvesSupervisorPhone() { + PracticeReviewJdbcTemplate jdbcTemplate = new PracticeReviewJdbcTemplate("主管", List.of("SUP-1", "EMP-1")); + AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); + + service.pendingReviewRecords("13800000001", "", "", 5); + + assertTrue(jdbcTemplate.ownerSql.contains("person_phone = ?")); + assertEquals(List.of("000000", "13800000001", "13800000001"), jdbcTemplate.ownerArgs); + assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id IN (?,?)")); + assertEquals(List.of("000000", "SUP-1", "EMP-1", 5), jdbcTemplate.reviewArgs); + } + @SuppressWarnings({"unchecked", "rawtypes"}) private static List mapOne(InvocationOnMock invocation, Map values) throws SQLException { RowMapper mapper = invocation.getArgument(1); @@ -116,6 +129,8 @@ public class AihrPracticeSeedServiceTest { private static final class PracticeReviewJdbcTemplate extends JdbcTemplate { private final String ownerLevel; private final List scopeIds; + private String ownerSql = ""; + private List ownerArgs = List.of(); private String reviewSql = ""; private List reviewArgs = List.of(); @@ -148,6 +163,8 @@ public class AihrPracticeSeedServiceTest { return List.of(); } if (sql.contains("SELECT project_code") && sql.contains("LIMIT 1")) { + ownerSql = sql; + ownerArgs = List.of(args); return mapRows(rowMapper, List.of(Map.of( "project_code", "P1", "position_level", ownerLevel diff --git a/backend/script/sql/aihr_org_snapshot_mysql8.sql b/backend/script/sql/aihr_org_snapshot_mysql8.sql index 21295a5b..31147608 100644 --- a/backend/script/sql/aihr_org_snapshot_mysql8.sql +++ b/backend/script/sql/aihr_org_snapshot_mysql8.sql @@ -11,6 +11,7 @@ CREATE TABLE `aihr_org_snapshot` ( `project_name` varchar(100) NOT NULL COMMENT '项目名称', `dept_name` varchar(100) DEFAULT NULL COMMENT '部门/条线', `ext_party_id` varchar(100) NOT NULL COMMENT '外部主体ID(只存ID,不回写外部系统)', + `person_phone` varchar(30) DEFAULT NULL COMMENT '手机号(仅用于登录身份映射)', `person_name` varchar(100) DEFAULT NULL COMMENT '姓名(Demo 快照允许,生产走脱敏闸门G3)', `position_name` varchar(100) DEFAULT NULL COMMENT '岗位', `position_level` varchar(30) DEFAULT NULL COMMENT '岗位层级(一线/主管/项目经理)', @@ -19,6 +20,7 @@ CREATE TABLE `aihr_org_snapshot` ( `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `ext_party_id`), + KEY `idx_aihr_org_phone` (`tenant_id`, `person_phone`), KEY `idx_aihr_org_project` (`tenant_id`, `project_code`, `position_level`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='组织人员静态快照(Demo)'; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index b27d3328..5283217a 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -12,10 +12,10 @@ | 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | -| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页大小参数按线上接口使用 `limit`,映射并刷新 `aihr_org_snapshot`;默认 `replaceExisting=true` 覆盖本租户快照,先用 `{"dryRun":true}` 预检;外部接口未配置时保留 SQL seed | +| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页大小参数按线上接口使用 `limit`,映射并刷新 `aihr_org_snapshot`;员工手机号只落 `person_phone` 用于移动端登录身份映射,不在组织快照列表响应暴露;岗位优先识别 `position/job_title/post/job_name/role/title` 等字段,避免正式数据把主管/项目经理误落为一线;默认 `replaceExisting=true` 覆盖本租户快照,先用 `{"dryRun":true}` 预检;外部接口未配置时保留 SQL seed | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 | -| 移动端员工训练闭环 | 复用 `POST /api/train/practice/start`、`/turn`、`/finish`;查询 `GET /api/aihr/mobile/practice/history`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化;复盘标记可带 `incentivePoint` 写入 `incentive_point`,用于贡献度和试点 CSV | +| 移动端员工训练闭环 | 复用 `POST /api/train/practice/start`、`/turn`、`/finish`;查询 `GET /api/aihr/mobile/practice/history`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化;主管端复盘默认按登录手机号映射组织快照后做项目/主管范围过滤,组织快照缺失时保持演示降级不阻断;复盘标记可带 `incentivePoint` 写入 `incentive_point`,用于贡献度和试点 CSV | | 移动端候选人闭环 | 面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;资料 `POST /api/aihr/mobile/candidate/materials`、`GET /api/aihr/mobile/candidate/materials`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;APP 候选人资料、面试启动和面试记录以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试拉题/评分走真实模型优先;资料上传写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 在 AI 面试页标记 `已通过/已驳回`,候选人端列表同步状态 | ## 后端落点