feat(aihr): link candidates to employee principals
This commit is contained in:
+21
@@ -1,15 +1,20 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||
import org.dromara.aihr.service.AihrInterviewService;
|
||||
import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
@@ -32,6 +37,8 @@ import java.util.List;
|
||||
@SaCheckLogin
|
||||
public class AihrInterviewController {
|
||||
|
||||
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
||||
|
||||
private final AihrInterviewService interviewService;
|
||||
|
||||
@PostMapping("/start")
|
||||
@@ -56,6 +63,20 @@ public class AihrInterviewController {
|
||||
return R.ok(interviewService.records(limit, appUsername.isBlank() ? candidateId : appUsername));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@GetMapping("/candidate-links")
|
||||
public R<CandidateLinkResponse> candidateLink(@RequestParam String candidateId) {
|
||||
return R.ok(interviewService.candidateLink(candidateId));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PostMapping("/candidate-links")
|
||||
public R<CandidateLinkResponse> linkCandidate(@RequestBody CandidateLinkRequest request) {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
String operator = loginUser == null ? "unknown" : loginUser.getUsername();
|
||||
return R.ok(interviewService.linkCandidate(request, operator));
|
||||
}
|
||||
|
||||
private static StartRequest bindAppCandidate(StartRequest request) {
|
||||
String appUsername = currentAppUsername();
|
||||
if (appUsername.isBlank()) {
|
||||
|
||||
+6
@@ -26,6 +26,12 @@ public final class AihrInterviewDto {
|
||||
public record FinishRequest(String sessionId, Map<String, String> answers) {
|
||||
}
|
||||
|
||||
public record CandidateLinkRequest(String candidateId, String extPartyId) {
|
||||
}
|
||||
|
||||
public record CandidateLinkResponse(String candidateId, String extPartyId, String status, String linkedTime) {
|
||||
}
|
||||
|
||||
public record FinishResponse(
|
||||
Integer totalScore,
|
||||
String suggestion,
|
||||
|
||||
+84
@@ -7,6 +7,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
|
||||
@@ -44,6 +46,7 @@ public class AihrInterviewService {
|
||||
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, CandidateProfile> candidates = buildCandidates();
|
||||
private volatile boolean tableReady;
|
||||
private volatile boolean candidateLinkTableReady;
|
||||
|
||||
public StartResponse start(StartRequest request) {
|
||||
CandidateProfile candidate = resolveCandidate(
|
||||
@@ -129,6 +132,59 @@ public class AihrInterviewService {
|
||||
return jdbcTemplate.query(sql.toString(), this::mapRecord, args.toArray());
|
||||
}
|
||||
|
||||
public CandidateLinkResponse candidateLink(String candidateId) {
|
||||
ensureCandidateLinkTable();
|
||||
String normalizedCandidateId = clean(candidateId);
|
||||
if (normalizedCandidateId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return jdbcTemplate.query("""
|
||||
SELECT candidate_id, ext_party_id, status, linked_time
|
||||
FROM aihr_candidate_employee_link
|
||||
WHERE tenant_id = '000000' AND candidate_id = ?
|
||||
LIMIT 1
|
||||
""", (rs, rowNum) -> new CandidateLinkResponse(
|
||||
rs.getString("candidate_id"),
|
||||
rs.getString("ext_party_id"),
|
||||
rs.getString("status"),
|
||||
rs.getTimestamp("linked_time") == null ? "" : rs.getTimestamp("linked_time").toLocalDateTime().format(TIME_FORMAT)
|
||||
), normalizedCandidateId).stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
public CandidateLinkResponse linkCandidate(CandidateLinkRequest request, String operator) {
|
||||
ensureCandidateLinkTable();
|
||||
String candidateId = clean(request == null ? null : request.candidateId());
|
||||
String extPartyId = clean(request == null ? null : request.extPartyId());
|
||||
if (candidateId.isBlank() || candidateId.length() > 80) {
|
||||
throw new IllegalArgumentException("候选人ID不能为空且不能超过80个字符");
|
||||
}
|
||||
if (extPartyId.isBlank() || extPartyId.length() > 100) {
|
||||
throw new IllegalArgumentException("员工主体ID不能为空且不能超过100个字符");
|
||||
}
|
||||
Integer activeEmployee = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM aihr_org_snapshot
|
||||
WHERE tenant_id = '000000'
|
||||
AND employment_status = 'active'
|
||||
AND ext_party_id = ?
|
||||
""", Integer.class, extPartyId);
|
||||
if (activeEmployee == null || activeEmployee == 0) {
|
||||
throw new IllegalArgumentException("员工主体不存在或已离职,请先同步在职组织快照");
|
||||
}
|
||||
CandidateLinkResponse current = candidateLink(candidateId);
|
||||
if (current != null && !extPartyId.equals(current.extPartyId())) {
|
||||
throw new IllegalArgumentException("候选人已关联其他员工主体,请先由HR核对后处理");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_candidate_employee_link
|
||||
(tenant_id, candidate_id, ext_party_id, status, linked_by, linked_time, create_time, update_time)
|
||||
VALUES ('000000', ?, ?, '已关联', ?, now(), now(), now())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = '已关联', linked_by = VALUES(linked_by), linked_time = now(), update_time = now()
|
||||
""", candidateId, extPartyId, firstNonBlank(operator, "unknown"));
|
||||
return candidateLink(candidateId);
|
||||
}
|
||||
|
||||
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
|
||||
var finishedTime = rs.getTimestamp("finished_time");
|
||||
return new RecordResponse(
|
||||
@@ -226,6 +282,34 @@ public class AihrInterviewService {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureCandidateLinkTable() {
|
||||
if (candidateLinkTableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (candidateLinkTableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`candidate_id` varchar(80) NOT NULL COMMENT '本地候选人ID',
|
||||
`ext_party_id` varchar(100) NOT NULL COMMENT '外部员工主体ID',
|
||||
`status` varchar(20) DEFAULT '已关联' COMMENT '关联状态',
|
||||
`linked_by` varchar(100) DEFAULT NULL COMMENT '关联操作人',
|
||||
`linked_time` datetime DEFAULT NULL COMMENT '关联时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_candidate_employee_link_candidate` (`tenant_id`, `candidate_id`),
|
||||
KEY `idx_aihr_candidate_employee_link_party` (`tenant_id`, `ext_party_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人与员工主体关联';
|
||||
""");
|
||||
candidateLinkTableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private List<QuestionRecordResponse> questionRecords(InterviewSession session) {
|
||||
return session.questions().stream().map(question -> {
|
||||
String answer = clean(session.answers().get(question.questionId()));
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.CandidateLinkResponse;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AihrInterviewServiceTest {
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void linkCandidateRejectsMissingOrInactiveEmployee() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
AihrInterviewService service = new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate);
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-inactive"))).thenReturn(0);
|
||||
|
||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.linkCandidate(
|
||||
new CandidateLinkRequest("candidate-1", "employee-inactive"), "hr"
|
||||
));
|
||||
|
||||
assertEquals("员工主体不存在或已离职,请先同步在职组织快照", error.getMessage());
|
||||
verify(jdbcTemplate, never()).update(anyString(), eq("candidate-1"), eq("employee-inactive"), eq("hr"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void linkCandidateRejectsChangingAnExistingEmployee() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
AihrInterviewService service = spy(new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate));
|
||||
CandidateLinkResponse current = new CandidateLinkResponse("candidate-2", "employee-old", "已关联", "07-14 06:00");
|
||||
doReturn(current).when(service).candidateLink("candidate-2");
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-new"))).thenReturn(1);
|
||||
|
||||
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.linkCandidate(
|
||||
new CandidateLinkRequest("candidate-2", "employee-new"), "hr"
|
||||
));
|
||||
|
||||
assertEquals("候选人已关联其他员工主体,请先由HR核对后处理", error.getMessage());
|
||||
verify(jdbcTemplate, never()).update(anyString(), eq("candidate-2"), eq("employee-new"), eq("hr"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void linkCandidateIsIdempotentForTheSameEmployee() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
AihrInterviewService service = spy(new AihrInterviewService(new ObjectMapper(), null, jdbcTemplate));
|
||||
CandidateLinkResponse linked = new CandidateLinkResponse("candidate-3", "employee-3", "已关联", "07-14 06:10");
|
||||
doReturn(linked).when(service).candidateLink("candidate-3");
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), eq("employee-3"))).thenReturn(1);
|
||||
|
||||
CandidateLinkResponse result = service.linkCandidate(
|
||||
new CandidateLinkRequest("candidate-3", "employee-3"), "hr"
|
||||
);
|
||||
|
||||
assertSame(linked, result);
|
||||
verify(jdbcTemplate).update(anyString(), eq("candidate-3"), eq("employee-3"), eq("hr"));
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,18 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
|
||||
KEY `idx_aihr_interview_candidate` (`tenant_id`, `candidate_id`, `finished_time`),
|
||||
KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_candidate_employee_link` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`candidate_id` varchar(80) NOT NULL COMMENT '本地候选人ID',
|
||||
`ext_party_id` varchar(100) NOT NULL COMMENT '外部员工主体ID',
|
||||
`status` varchar(20) DEFAULT '已关联' COMMENT '关联状态',
|
||||
`linked_by` varchar(100) DEFAULT NULL COMMENT '关联操作人',
|
||||
`linked_time` datetime DEFAULT NULL COMMENT '关联时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_candidate_employee_link_candidate` (`tenant_id`, `candidate_id`),
|
||||
KEY `idx_aihr_candidate_employee_link_party` (`tenant_id`, `ext_party_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人与员工主体关联';
|
||||
|
||||
Reference in New Issue
Block a user