feat(aihr): support signed position mappings

This commit is contained in:
2026-07-15 16:32:06 +08:00
parent 07a08cecde
commit c866ef2701
8 changed files with 118 additions and 18 deletions
@@ -46,6 +46,7 @@ public class AihrOrgSyncService {
private static final int MAX_PAGE_SIZE = 500;
private static final int DEFAULT_MAX_PAGES = 200;
private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30);
private static final Set<String> POSITION_LEVELS = Set.of("一线", "主管", "项目经理");
private static final String[] PHONE_FIELDS = {"phone", "phone_number", "phoneNumber", "mobile", "mobile_phone",
"mobilePhone", "mobile_tel", "mobileTel", "cellphone", "cellPhone", "contact_phone",
"contactPhone", "contact_mobile", "contactMobile", "phonenumber", "telephone", "tel", "手机号", "手机", "联系电话"};
@@ -72,11 +73,15 @@ public class AihrOrgSyncService {
@Value("${aihr.org-sync.store-display-fields:${AIHR_ORG_SYNC_STORE_DISPLAY_FIELDS:false}}")
private boolean storeDisplayFields;
@Value("${aihr.org-sync.position-map-json:${AIHR_ORG_POSITION_MAP_JSON:}}")
private String configuredPositionMapJson;
public SyncResponse sync(SyncRequest request) {
SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request;
if (req.dryRun() == null) {
throw new IllegalArgumentException("组织同步必须明确传 dryRun=true 预检或 dryRun=false 写入");
}
Map<String, PositionMapping> positionMappings = positionMappings();
String baseUrl = normalizeBaseUrl(configuredBaseUrl);
if (baseUrl.isBlank()) {
throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀");
@@ -103,7 +108,7 @@ public class AihrOrgSyncService {
int skipped = 0;
int maskedPhone = 0;
for (JsonNode employee : employeeItems) {
OrgRow row = orgRow(employee, companies, departments);
OrgRow row = orgRow(employee, companies, departments, positionMappings);
if (row == null) {
skipped++;
} else {
@@ -116,6 +121,13 @@ public class AihrOrgSyncService {
int phoneLinked = (int) rows.stream().filter(row -> !row.personPhone().isBlank()).count();
int suspectText = (int) rows.stream().filter(OrgRow::suspectText).count();
int duplicatePartyIds = countDuplicatePartyIds(rows.stream().map(OrgRow::extPartyId).toList());
if (!positionMappings.isEmpty()) {
int mappedPositions = (int) rows.stream().filter(OrgRow::positionMapped).count();
int supervisors = (int) rows.stream()
.filter(row -> "主管".equals(row.positionLevel()) || "项目经理".equals(row.positionLevel()))
.count();
warnings.add("HR 岗位映射命中 " + mappedPositions + " 人,解析主管/项目经理 " + supervisors + " 人,请核对后再写入");
}
if (!rows.isEmpty() && phoneLinked < rows.size()) {
warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员");
}
@@ -375,7 +387,8 @@ public class AihrOrgSyncService {
});
}
private OrgRow orgRow(JsonNode employee, Map<String, CompanyInfo> companies, Map<String, DepartmentInfo> departments) {
private OrgRow orgRow(JsonNode employee, Map<String, CompanyInfo> companies, Map<String, DepartmentInfo> departments,
Map<String, PositionMapping> positionMappings) {
String extPartyId = firstNonBlank(text(employee, "employee_number", "employeeNo", "employee_id", "employeeId", "id", "user_id"));
if (extPartyId.isBlank()) {
return null;
@@ -399,9 +412,16 @@ public class AihrOrgSyncService {
"组织架构"
);
String personName = firstNonBlank(text(employee, "name", "person_name", "personName", "employee_name", "employeeName"), "-");
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 externalPositionName = text(employee, "position_name", "positionName", "job_title", "jobTitle",
"post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title");
String positionId = firstNonBlank(text(employee, "position_id", "positionId", "job_id", "jobId", "post_id", "postId"));
PositionMapping mappedPosition = positionMappings.get(positionId);
boolean positionMapped = externalPositionName.isBlank() && mappedPosition != null;
String positionName = firstNonBlank(externalPositionName, mappedPosition == null ? "" : mappedPosition.name(), "员工");
String externalPositionLevel = text(employee, "position_level", "positionLevel", "job_level", "jobLevel");
String resolvedPositionLevel = positionMapped
? mappedPosition.level()
: positionLevel(firstNonBlank(externalPositionLevel, level(positionName)), positionName);
LocalDate hireDate = parseDate(firstNonBlank(
text(employee, "hire_date", "hireDate", "entry_date", "entryDate", "employment_date", "employmentDate")));
boolean suspectText = hasQuestionMark(projectName)
@@ -416,7 +436,8 @@ public class AihrOrgSyncService {
phone(employee),
storeDisplayFields ? personName : "",
positionName,
positionLevel(positionLevel, positionName),
resolvedPositionLevel,
positionMapped,
status(text(employee, "status", "employment_status", "employmentStatus")),
hireDate,
LocalDate.now(),
@@ -602,6 +623,36 @@ public class AihrOrgSyncService {
return level;
}
private Map<String, PositionMapping> positionMappings() {
String json = clean(configuredPositionMapJson);
if (json.isBlank()) {
return Map.of();
}
try {
JsonNode root = objectMapper.readTree(json);
if (!root.isObject()) {
throw new IllegalArgumentException("岗位映射必须是 JSON 对象");
}
Map<String, PositionMapping> mappings = new TreeMap<>();
var fields = root.fields();
while (fields.hasNext()) {
var entry = fields.next();
String positionId = clean(entry.getKey());
String name = text(entry.getValue(), "name", "positionName", "position_name");
String level = text(entry.getValue(), "level", "positionLevel", "position_level");
if (positionId.isBlank() || name.isBlank() || name.length() > 100 || !POSITION_LEVELS.contains(level)) {
throw new IllegalArgumentException("岗位映射必须包含 position_id、岗位名称及合法层级(一线/主管/项目经理)");
}
mappings.put(positionId, new PositionMapping(name, level));
}
return Map.copyOf(mappings);
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("岗位映射 JSON 配置无效", e);
}
}
private static String phone(JsonNode employee) {
for (String field : PHONE_FIELDS) {
String normalized = normalizeMobilePhone(text(employee, field));
@@ -740,6 +791,9 @@ public class AihrOrgSyncService {
private record DepartmentInfo(String code, String name, String companyId) {
}
private record PositionMapping(String name, String level) {
}
private record OrgRow(
String projectCode,
String projectName,
@@ -749,6 +803,7 @@ public class AihrOrgSyncService {
String personName,
String positionName,
String positionLevel,
boolean positionMapped,
String employmentStatus,
LocalDate hireDate,
LocalDate snapshotDate,
@@ -79,13 +79,13 @@ public class AihrOrgSyncServiceTest {
""");
ReflectionTestUtils.setField(service, "storeDisplayFields", false);
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of());
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of());
assertEquals("", ReflectionTestUtils.getField(masked, "personName"));
assertEquals("", ReflectionTestUtils.getField(masked, "deptName"));
assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(masked, "hireDate"));
ReflectionTestUtils.setField(service, "storeDisplayFields", true);
Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of());
Object demo = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of());
assertEquals("测试员工", ReflectionTestUtils.getField(demo, "personName"));
assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName"));
assertEquals(LocalDate.of(2026, 7, 1), ReflectionTestUtils.getField(demo, "hireDate"));
@@ -100,13 +100,51 @@ public class AihrOrgSyncServiceTest {
""");
ReflectionTestUtils.setField(service, "storeDisplayFields", false);
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of());
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of(), Map.of());
assertEquals("", ReflectionTestUtils.getField(masked, "personName"));
assertEquals("", ReflectionTestUtils.getField(masked, "deptName"));
assertEquals(true, ReflectionTestUtils.getField(masked, "suspectText"));
}
@Test
public void signedPositionMapOnlyFillsMissingUpstreamJobFields() throws Exception {
ObjectMapper mapper = new ObjectMapper();
AihrOrgSyncService service = new AihrOrgSyncService(mapper, mock(JdbcTemplate.class), mock(TransactionTemplate.class));
ReflectionTestUtils.setField(service, "configuredPositionMapJson",
"{\"1683\":{\"name\":\"项目经理\",\"level\":\"项目经理\"},"
+ "\"316\":{\"name\":\"客服主管\",\"level\":\"一线\"}}");
Map<?, ?> mappings = ReflectionTestUtils.invokeMethod(service, "positionMappings");
Object mapped = ReflectionTestUtils.invokeMethod(service, "orgRow",
mapper.readTree("{\"id\":\"EMP-1\",\"position_id\":\"1683\"}"), Map.of(), Map.of(), mappings);
assertEquals("项目经理", ReflectionTestUtils.getField(mapped, "positionName"));
assertEquals("项目经理", ReflectionTestUtils.getField(mapped, "positionLevel"));
assertEquals(true, ReflectionTestUtils.getField(mapped, "positionMapped"));
Object upstream = ReflectionTestUtils.invokeMethod(service, "orgRow",
mapper.readTree("{\"id\":\"EMP-2\",\"position_id\":\"1683\",\"position_name\":\"客服专员\",\"position_level\":\"一线\"}"),
Map.of(), Map.of(), mappings);
assertEquals("客服专员", ReflectionTestUtils.getField(upstream, "positionName"));
assertEquals("一线", ReflectionTestUtils.getField(upstream, "positionLevel"));
assertEquals(false, ReflectionTestUtils.getField(upstream, "positionMapped"));
Object noImplicitPromotion = ReflectionTestUtils.invokeMethod(service, "orgRow",
mapper.readTree("{\"id\":\"EMP-3\",\"position_id\":\"316\"}"), Map.of(), Map.of(), mappings);
assertEquals("客服主管", ReflectionTestUtils.getField(noImplicitPromotion, "positionName"));
assertEquals("一线", ReflectionTestUtils.getField(noImplicitPromotion, "positionLevel"));
}
@Test
public void positionMapRejectsPrivilegeLevelsOutsideTheContract() {
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class));
ReflectionTestUtils.setField(service, "configuredPositionMapJson",
"{\"1683\":{\"name\":\"项目经理\",\"level\":\"超级管理员\"}}");
assertThrows(IllegalArgumentException.class,
() -> ReflectionTestUtils.invokeMethod(service, "positionMappings"));
}
@Test
public void hiddenDisplayFieldsStillAllowIdentitySearchByPhone() {
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class));
@@ -158,7 +196,7 @@ public class AihrOrgSyncServiceTest {
server.createContext("/api/open/v1/sync/snapshot", exchange -> {
boolean employee = exchange.getRequestURI().getRawQuery().contains("resource_type=employee");
String items = employee
? "[{\"id\":\"employee-1\",\"name\":\"测试员工\",\"phone\":\"13900001111\"}]"
? "[{\"id\":\"employee-1\",\"name\":\"测试员工\",\"phone\":\"13900001111\",\"position_id\":\"1683\"}]"
: "[]";
byte[] body = ("{\"data\":{\"items\":" + items + ",\"total\":" + (employee ? 1 : 0)
+ ",\"has_more\":false}}").getBytes(StandardCharsets.UTF_8);
@@ -179,6 +217,8 @@ public class AihrOrgSyncServiceTest {
ReflectionTestUtils.setField(service, "configuredClientId", "");
ReflectionTestUtils.setField(service, "configuredClientSecret", "");
ReflectionTestUtils.setField(service, "configuredSigningSecret", "");
ReflectionTestUtils.setField(service, "configuredPositionMapJson",
"{\"1683\":{\"name\":\"项目经理\",\"level\":\"项目经理\"}}");
var response = service.sync(new SyncRequest(true, true, 1, 1, null, null, null, null));
@@ -186,6 +226,7 @@ public class AihrOrgSyncServiceTest {
assertEquals(1, response.employeeCount());
assertEquals(0, response.syncedCount());
assertEquals(1, response.phoneLinked());
assertTrue(response.warnings().stream().anyMatch(warning -> warning.contains("命中 1 人,解析主管/项目经理 1 人")));
verifyNoInteractions(jdbcTemplate, transactionTemplate);
} finally {
server.stop(0);