feat(aihr): map supervisor phone to org scope

This commit is contained in:
2026-07-09 12:54:33 +08:00
parent 9bd567a72f
commit 5f92847c00
5 changed files with 89 additions and 9 deletions
@@ -280,13 +280,14 @@ public class AihrOrgSyncService {
} }
jdbcTemplate.batchUpdate(""" jdbcTemplate.batchUpdate("""
insert into aihr_org_snapshot 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) position_name, position_level, employment_status, snapshot_date, create_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
on duplicate key update on duplicate key update
project_code = values(project_code), project_code = values(project_code),
project_name = values(project_name), project_name = values(project_name),
dept_name = values(dept_name), dept_name = values(dept_name),
person_phone = values(person_phone),
person_name = values(person_name), person_name = values(person_name),
position_name = values(position_name), position_name = values(position_name),
position_level = values(position_level), position_level = values(position_level),
@@ -320,16 +321,18 @@ public class AihrOrgSyncService {
deptName, 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)); String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName));
return new OrgRow( return new OrgRow(
projectCode, projectCode,
projectName, projectName,
deptName, deptName,
extPartyId, extPartyId,
phone(employee),
firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-"), firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-"),
positionName, positionName,
positionLevel, positionLevel(positionLevel, positionName),
status(text(employee, "status", "employment_status", "employmentStatus")), status(text(employee, "status", "employment_status", "employmentStatus")),
LocalDate.now() LocalDate.now()
); );
@@ -437,11 +440,35 @@ public class AihrOrgSyncService {
if (count == null || count == 0) { if (count == null || count == 0) {
throw new IllegalArgumentException("请先执行 aihr_org_snapshot_mysql8.sql 初始化组织人员快照表"); 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) { } catch (DataAccessException e) {
throw new IllegalStateException("组织人员快照表检查失败: " + e.getMessage(), 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) { private static String status(String value) {
String text = clean(value).toLowerCase(); String text = clean(value).toLowerCase();
if (text.isBlank() || text.equals("1") || text.equals("active") || text.equals("enabled") if (text.isBlank() || text.equals("1") || text.equals("active") || text.equals("enabled")
@@ -466,6 +493,20 @@ public class AihrOrgSyncService {
return "一线"; 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) { private static String text(JsonNode node, String... fields) {
for (String field : fields) { for (String field : fields) {
JsonNode child = node.path(field); JsonNode child = node.path(field);
@@ -564,6 +605,7 @@ public class AihrOrgSyncService {
String projectName, String projectName,
String deptName, String deptName,
String extPartyId, String extPartyId,
String personPhone,
String personName, String personName,
String positionName, String positionName,
String positionLevel, String positionLevel,
@@ -572,7 +614,7 @@ public class AihrOrgSyncService {
) { ) {
Object[] args() { Object[] args() {
return new Object[] { return new Object[] {
TENANT_ID, projectCode, projectName, deptName, extPartyId, personName, TENANT_ID, projectCode, projectName, deptName, extPartyId, personPhone, personName,
positionName, positionLevel, employmentStatus, snapshotDate positionName, positionLevel, employmentStatus, snapshotDate
}; };
} }
@@ -1366,12 +1366,22 @@ public class AihrPracticeSeedService {
if (owner.isEmpty() || !orgSnapshotTableExists()) { if (owner.isEmpty() || !orgSnapshotTableExists()) {
return TeamScope.unscoped(); return TeamScope.unscoped();
} }
boolean hasPhone = orgSnapshotColumnExists("person_phone");
List<Object> 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<OrgScopeRow> owners = jdbcTemplate.query(""" List<OrgScopeRow> owners = jdbcTemplate.query("""
SELECT project_code, position_level SELECT project_code, position_level
FROM aihr_org_snapshot 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 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()) { if (owners.isEmpty()) {
return TeamScope.unscoped(); return TeamScope.unscoped();
} }
@@ -1397,6 +1407,15 @@ public class AihrPracticeSeedService {
return count != null && count > 0; 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) { private static boolean canSeeProject(String positionLevel) {
return "主管".equals(positionLevel) || "项目经理".equals(positionLevel); return "主管".equals(positionLevel) || "项目经理".equals(positionLevel);
} }
@@ -93,6 +93,19 @@ public class AihrPracticeSeedServiceTest {
assertEquals(List.of("000000", "SUP-1", 5), jdbcTemplate.reviewArgs); 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"}) @SuppressWarnings({"unchecked", "rawtypes"})
private static <T> List<T> mapOne(InvocationOnMock invocation, Map<String, String> values) throws SQLException { private static <T> List<T> mapOne(InvocationOnMock invocation, Map<String, String> values) throws SQLException {
RowMapper mapper = invocation.getArgument(1); RowMapper mapper = invocation.getArgument(1);
@@ -116,6 +129,8 @@ public class AihrPracticeSeedServiceTest {
private static final class PracticeReviewJdbcTemplate extends JdbcTemplate { private static final class PracticeReviewJdbcTemplate extends JdbcTemplate {
private final String ownerLevel; private final String ownerLevel;
private final List<String> scopeIds; private final List<String> scopeIds;
private String ownerSql = "";
private List<Object> ownerArgs = List.of();
private String reviewSql = ""; private String reviewSql = "";
private List<Object> reviewArgs = List.of(); private List<Object> reviewArgs = List.of();
@@ -148,6 +163,8 @@ public class AihrPracticeSeedServiceTest {
return List.of(); return List.of();
} }
if (sql.contains("SELECT project_code") && sql.contains("LIMIT 1")) { if (sql.contains("SELECT project_code") && sql.contains("LIMIT 1")) {
ownerSql = sql;
ownerArgs = List.of(args);
return mapRows(rowMapper, List.of(Map.of( return mapRows(rowMapper, List.of(Map.of(
"project_code", "P1", "project_code", "P1",
"position_level", ownerLevel "position_level", ownerLevel
@@ -11,6 +11,7 @@ CREATE TABLE `aihr_org_snapshot` (
`project_name` varchar(100) NOT NULL COMMENT '项目名称', `project_name` varchar(100) NOT NULL COMMENT '项目名称',
`dept_name` varchar(100) DEFAULT NULL COMMENT '部门/条线', `dept_name` varchar(100) DEFAULT NULL COMMENT '部门/条线',
`ext_party_id` varchar(100) NOT NULL COMMENT '外部主体ID(只存ID,不回写外部系统)', `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)', `person_name` varchar(100) DEFAULT NULL COMMENT '姓名(Demo 快照允许,生产走脱敏闸门G3)',
`position_name` varchar(100) DEFAULT NULL COMMENT '岗位', `position_name` varchar(100) DEFAULT NULL COMMENT '岗位',
`position_level` varchar(30) 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 '创建时间', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `ext_party_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`) KEY `idx_aihr_org_project` (`tenant_id`, `project_code`, `position_level`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='组织人员静态快照(Demo)'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='组织人员静态快照(Demo)';
+2 -2
View File
@@ -12,10 +12,10 @@
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 | | 案例沉淀 `/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 | | 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 | | 资料处理 `/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 代码级强制失效) | | 移动端手机号登录 | `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 保演示 | | 用户侧三端首页 `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 面试页标记 `已通过/已驳回`,候选人端列表同步状态 | | 移动端候选人闭环 | 面试复用 `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 面试页标记 `已通过/已驳回`,候选人端列表同步状态 |
## 后端落点 ## 后端落点