feat(aihr): map supervisor phone to org scope
This commit is contained in:
+47
-5
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
+21
-2
@@ -1366,12 +1366,22 @@ public class AihrPracticeSeedService {
|
||||
if (owner.isEmpty() || !orgSnapshotTableExists()) {
|
||||
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("""
|
||||
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);
|
||||
}
|
||||
|
||||
+17
@@ -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 <T> List<T> mapOne(InvocationOnMock invocation, Map<String, String> 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<String> scopeIds;
|
||||
private String ownerSql = "";
|
||||
private List<Object> ownerArgs = List.of();
|
||||
private String reviewSql = "";
|
||||
private List<Object> 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
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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 面试页标记 `已通过/已驳回`,候选人端列表同步状态 |
|
||||
|
||||
## 后端落点
|
||||
|
||||
Reference in New Issue
Block a user