RecruitmentController.java 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. package com.example.hrlab.recruitment;
  2. import com.example.hrlab.audit.AuditService;
  3. import com.example.hrlab.common.ApiResponse;
  4. import com.example.hrlab.common.BusinessException;
  5. import com.example.hrlab.common.ErrorCodes;
  6. import com.example.hrlab.common.JsonUtils;
  7. import com.example.hrlab.common.PageResponse;
  8. import com.example.hrlab.common.Rows;
  9. import jakarta.validation.Valid;
  10. import java.time.OffsetDateTime;
  11. import java.util.List;
  12. import java.util.Map;
  13. import org.springframework.http.HttpStatus;
  14. import org.springframework.jdbc.core.JdbcTemplate;
  15. import org.springframework.security.access.prepost.PreAuthorize;
  16. import org.springframework.web.bind.annotation.GetMapping;
  17. import org.springframework.web.bind.annotation.PathVariable;
  18. import org.springframework.web.bind.annotation.PostMapping;
  19. import org.springframework.web.bind.annotation.PutMapping;
  20. import org.springframework.web.bind.annotation.DeleteMapping;
  21. import org.springframework.web.bind.annotation.RequestBody;
  22. import org.springframework.web.bind.annotation.RequestMapping;
  23. import org.springframework.web.bind.annotation.RequestParam;
  24. import org.springframework.web.bind.annotation.RestController;
  25. @RestController
  26. @RequestMapping("/api/v1")
  27. public class RecruitmentController {
  28. private final JdbcTemplate jdbcTemplate;
  29. private final AuditService auditService;
  30. public RecruitmentController(JdbcTemplate jdbcTemplate, AuditService auditService) {
  31. this.jdbcTemplate = jdbcTemplate;
  32. this.auditService = auditService;
  33. }
  34. @GetMapping("/job-requisitions")
  35. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')")
  36. public ApiResponse<PageResponse<Map<String, Object>>> jobRequisitions(@RequestParam(defaultValue = "1") int page,
  37. @RequestParam(defaultValue = "20") int size) {
  38. return ApiResponse.ok(page("job_requisitions", "id DESC", page, size));
  39. }
  40. @PostMapping("/job-requisitions")
  41. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  42. public ApiResponse<Map<String, Object>> createJobRequisition(@Valid @RequestBody JobRequisitionRequest request) {
  43. Long id = jdbcTemplate.queryForObject("""
  44. INSERT INTO job_requisitions(req_no, department_id, position_id, planned_headcount, status, opened_at, closed_at)
  45. VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id
  46. """, Long.class, request.reqNo(), request.departmentId(), request.positionId(), request.plannedHeadcount(),
  47. defaultString(request.status(), "OPEN"), request.openedAt(), request.closedAt());
  48. auditService.record("JOB_REQUISITION_CREATE", "job_requisitions", id, null, request);
  49. return ApiResponse.ok(find("job_requisitions", id, "招聘需求不存在"));
  50. }
  51. @PutMapping("/job-requisitions/{id}")
  52. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  53. public ApiResponse<Map<String, Object>> updateJobRequisition(@PathVariable Long id,
  54. @Valid @RequestBody JobRequisitionRequest request) {
  55. Map<String, Object> before = find("job_requisitions", id, "招聘需求不存在");
  56. jdbcTemplate.update("""
  57. UPDATE job_requisitions SET req_no = ?, department_id = ?, position_id = ?, planned_headcount = ?,
  58. status = ?, opened_at = ?, closed_at = ?, version = version + 1, updated_at = now()
  59. WHERE id = ?
  60. """, request.reqNo(), request.departmentId(), request.positionId(), request.plannedHeadcount(),
  61. defaultString(request.status(), "OPEN"), request.openedAt(), request.closedAt(), id);
  62. auditService.record("JOB_REQUISITION_UPDATE", "job_requisitions", id, before, request);
  63. return ApiResponse.ok(find("job_requisitions", id, "招聘需求不存在"));
  64. }
  65. @DeleteMapping("/job-requisitions/{id}")
  66. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  67. public ApiResponse<Map<String, Object>> closeJobRequisition(@PathVariable Long id) {
  68. Map<String, Object> before = find("job_requisitions", id, "招聘需求不存在");
  69. jdbcTemplate.update("UPDATE job_requisitions SET status = 'CLOSED', closed_at = now(), version = version + 1, updated_at = now() WHERE id = ?", id);
  70. auditService.record("JOB_REQUISITION_CLOSE", "job_requisitions", id, before, Map.of("status", "CLOSED"));
  71. return ApiResponse.ok(find("job_requisitions", id, "招聘需求不存在"));
  72. }
  73. @GetMapping("/candidates")
  74. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')")
  75. public ApiResponse<PageResponse<Map<String, Object>>> candidates(@RequestParam(defaultValue = "1") int page,
  76. @RequestParam(defaultValue = "20") int size) {
  77. return ApiResponse.ok(page("candidates", "id DESC", page, size));
  78. }
  79. @PostMapping("/candidates")
  80. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  81. public ApiResponse<Map<String, Object>> createCandidate(@Valid @RequestBody CandidateRequest request) {
  82. Map<String, Object> requisition = find("job_requisitions", request.requisitionId(), "招聘需求不存在");
  83. if (!"OPEN".equals(requisition.get("status"))) {
  84. throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "已关闭岗位不能新增候选人");
  85. }
  86. long duplicates = jdbcTemplate.queryForObject("""
  87. SELECT count(*) FROM candidates WHERE requisition_id = ? AND (email = ? OR phone = ?)
  88. """, Long.class, request.requisitionId(), request.email(), request.phone());
  89. if (duplicates > 0) {
  90. throw new BusinessException(ErrorCodes.CONFLICT, "候选人联系方式已存在", HttpStatus.CONFLICT);
  91. }
  92. Long id = jdbcTemplate.queryForObject("""
  93. INSERT INTO candidates(requisition_id, candidate_code, display_name, email, phone, source, status, ext_jsonb)
  94. VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb) RETURNING id
  95. """, Long.class, request.requisitionId(), request.candidateCode(), request.displayName(), request.email(),
  96. request.phone(), defaultString(request.source(), "LOCAL"), defaultString(request.status(), "NEW"),
  97. JsonUtils.toJson(request.ext() == null ? Map.of() : request.ext()));
  98. auditService.record("CANDIDATE_CREATE", "candidates", id, null, request);
  99. return ApiResponse.ok(find("candidates", id, "候选人不存在"));
  100. }
  101. @PutMapping("/candidates/{id}")
  102. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  103. public ApiResponse<Map<String, Object>> updateCandidate(@PathVariable Long id, @Valid @RequestBody CandidateRequest request) {
  104. Map<String, Object> before = find("candidates", id, "候选人不存在");
  105. jdbcTemplate.update("""
  106. UPDATE candidates SET requisition_id = ?, candidate_code = ?, display_name = ?, email = ?, phone = ?,
  107. source = ?, status = ?, ext_jsonb = ?::jsonb, version = version + 1, updated_at = now()
  108. WHERE id = ?
  109. """, request.requisitionId(), request.candidateCode(), request.displayName(), request.email(), request.phone(),
  110. defaultString(request.source(), "LOCAL"), defaultString(request.status(), "NEW"),
  111. JsonUtils.toJson(request.ext() == null ? Map.of() : request.ext()), id);
  112. auditService.record("CANDIDATE_UPDATE", "candidates", id, before, request);
  113. return ApiResponse.ok(find("candidates", id, "候选人不存在"));
  114. }
  115. @DeleteMapping("/candidates/{id}")
  116. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  117. public ApiResponse<Map<String, Object>> archiveCandidate(@PathVariable Long id) {
  118. Map<String, Object> before = find("candidates", id, "候选人不存在");
  119. jdbcTemplate.update("UPDATE candidates SET status = 'ARCHIVED', version = version + 1, updated_at = now() WHERE id = ?", id);
  120. auditService.record("CANDIDATE_ARCHIVE", "candidates", id, before, Map.of("status", "ARCHIVED"));
  121. return ApiResponse.ok(find("candidates", id, "候选人不存在"));
  122. }
  123. @PostMapping("/candidates/{id}/schedule-interview")
  124. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  125. public ApiResponse<Map<String, Object>> scheduleInterview(@PathVariable Long id, @Valid @RequestBody InterviewRequest request) {
  126. Map<String, Object> candidate = find("candidates", id, "候选人不存在");
  127. requireCandidateNotTerminal(candidate);
  128. if (!request.startAt().isBefore(request.endAt())) {
  129. throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "面试开始时间必须早于结束时间");
  130. }
  131. long conflicts = jdbcTemplate.queryForObject("""
  132. SELECT count(*) FROM interviews
  133. WHERE candidate_id = ? AND status <> 'CANCELLED' AND start_at < ? AND end_at > ?
  134. """, Long.class, id, request.endAt(), request.startAt());
  135. if (conflicts > 0) {
  136. throw new BusinessException(ErrorCodes.CONFLICT, "面试时间冲突", HttpStatus.CONFLICT);
  137. }
  138. Long interviewId = jdbcTemplate.queryForObject("""
  139. INSERT INTO interviews(candidate_id, interviewer_employee_id, start_at, end_at, status, feedback, score)
  140. VALUES (?, ?, ?, ?, 'SCHEDULED', ?, ?) RETURNING id
  141. """, Long.class, id, request.interviewerEmployeeId(), request.startAt(), request.endAt(),
  142. request.feedback(), request.score());
  143. jdbcTemplate.update("UPDATE candidates SET status = 'INTERVIEWING', version = version + 1, updated_at = now() WHERE id = ?", id);
  144. auditService.record("INTERVIEW_SCHEDULE", "interviews", interviewId, null, request);
  145. return ApiResponse.ok(find("interviews", interviewId, "面试不存在"));
  146. }
  147. @GetMapping("/candidates/{id}/interviews")
  148. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')")
  149. public ApiResponse<Object> candidateInterviews(@PathVariable Long id) {
  150. find("candidates", id, "候选人不存在");
  151. return ApiResponse.ok(jdbcTemplate.queryForList("""
  152. SELECT * FROM interviews WHERE candidate_id = ? ORDER BY start_at DESC, id DESC
  153. """, id));
  154. }
  155. @PostMapping("/interviews/{id}/result")
  156. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  157. public ApiResponse<Map<String, Object>> interviewResult(@PathVariable Long id,
  158. @Valid @RequestBody InterviewResultRequest request) {
  159. Map<String, Object> before = find("interviews", id, "面试不存在");
  160. String normalized = request.result().trim().toUpperCase();
  161. String interviewStatus = switch (normalized) {
  162. case "PASSED", "INTERVIEW_PASSED" -> "PASSED";
  163. case "FAILED", "INTERVIEW_FAILED" -> "FAILED";
  164. case "NO_SHOW", "INTERVIEW_NO_SHOW" -> "NO_SHOW";
  165. default -> throw new BusinessException(ErrorCodes.VALIDATION_FAILED, "面试结果不合法");
  166. };
  167. String candidateStatus = switch (interviewStatus) {
  168. case "PASSED" -> "INTERVIEW_PASSED";
  169. case "FAILED" -> "INTERVIEW_FAILED";
  170. default -> "INTERVIEW_NO_SHOW";
  171. };
  172. Long candidateId = ((Number) before.get("candidate_id")).longValue();
  173. jdbcTemplate.update("""
  174. UPDATE interviews SET status = ?, feedback = ?, score = ?, updated_at = now() WHERE id = ?
  175. """, interviewStatus, request.feedback(), request.score(), id);
  176. jdbcTemplate.update("UPDATE candidates SET status = ?, version = version + 1, updated_at = now() WHERE id = ?",
  177. candidateStatus, candidateId);
  178. auditService.record("INTERVIEW_RESULT", "interviews", id, before,
  179. Map.of("candidateId", candidateId, "status", candidateStatus));
  180. return ApiResponse.ok(find("interviews", id, "面试不存在"));
  181. }
  182. @PostMapping("/candidates/{id}/issue-offer")
  183. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  184. public ApiResponse<Map<String, Object>> issueOffer(@PathVariable Long id) {
  185. Map<String, Object> before = find("candidates", id, "候选人不存在");
  186. if (!"INTERVIEW_PASSED".equals(before.get("status"))) {
  187. throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "只有面试通过的候选人才能发放 Offer");
  188. }
  189. jdbcTemplate.update("UPDATE candidates SET status = 'OFFERED', version = version + 1, updated_at = now() WHERE id = ?", id);
  190. jdbcTemplate.update("""
  191. INSERT INTO local_notifications(channel, recipient, title, content, status)
  192. VALUES ('LOCAL', ?, '录用通知', '候选人录用流程已生成', 'RECORDED')
  193. """, before.get("display_name"));
  194. auditService.record("OFFER_ISSUE", "candidates", id, before, Map.of("status", "OFFERED"));
  195. return ApiResponse.ok(find("candidates", id, "候选人不存在"));
  196. }
  197. @PostMapping("/candidates/{id}/offer-decision")
  198. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  199. public ApiResponse<Map<String, Object>> offerDecision(@PathVariable Long id,
  200. @Valid @RequestBody OfferDecisionRequest request) {
  201. Map<String, Object> before = find("candidates", id, "候选人不存在");
  202. if (!"OFFERED".equals(before.get("status"))) {
  203. throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "只有已发放 Offer 的候选人才能反馈 Offer 决策");
  204. }
  205. String decision = request.decision().trim().toUpperCase();
  206. String status = switch (decision) {
  207. case "ACCEPT", "ACCEPTED", "OFFER_ACCEPTED" -> "OFFER_ACCEPTED";
  208. case "REJECT", "REJECTED", "OFFER_REJECTED" -> "OFFER_REJECTED";
  209. default -> throw new BusinessException(ErrorCodes.VALIDATION_FAILED, "Offer 决策不合法");
  210. };
  211. jdbcTemplate.update("UPDATE candidates SET status = ?, version = version + 1, updated_at = now() WHERE id = ?",
  212. status, id);
  213. auditService.record("OFFER_DECISION", "candidates", id, before,
  214. Map.of("status", status, "reason", request.reason() == null ? "" : request.reason()));
  215. return ApiResponse.ok(find("candidates", id, "候选人不存在"));
  216. }
  217. @PostMapping("/candidates/{id}/convert-to-onboarding")
  218. @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
  219. public ApiResponse<Map<String, Object>> convertToOnboarding(@PathVariable Long id) {
  220. Map<String, Object> candidate = find("candidates", id, "候选人不存在");
  221. if (candidate.get("converted_employee_id") != null) {
  222. throw new BusinessException(ErrorCodes.CONFLICT, "候选人已转入职", HttpStatus.CONFLICT);
  223. }
  224. if (!"OFFER_ACCEPTED".equals(candidate.get("status"))) {
  225. throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "只有接受 Offer 的候选人才能转入职");
  226. }
  227. Map<String, Object> requisition = find("job_requisitions", ((Number) candidate.get("requisition_id")).longValue(), "招聘需求不存在");
  228. Long employeeId = jdbcTemplate.queryForObject("""
  229. INSERT INTO employees(employee_no, department_id, position_id, display_name, employment_status, hire_date,
  230. emergency_contact_jsonb, tags, ext_jsonb)
  231. VALUES (?, ?, ?, ?, 'PENDING_ONBOARD', CURRENT_DATE, '{}'::jsonb, '[]'::jsonb, '{}'::jsonb)
  232. RETURNING id
  233. """, Long.class, "E-" + candidate.get("candidate_code"), requisition.get("department_id"),
  234. requisition.get("position_id"), candidate.get("display_name"));
  235. Long caseId = jdbcTemplate.queryForObject("""
  236. INSERT INTO onboarding_cases(candidate_id, target_employee_id, status, checklist_jsonb, expected_join_date)
  237. VALUES (?, ?, 'OPEN', ?::jsonb, CURRENT_DATE) RETURNING id
  238. """, Long.class, id, employeeId, JsonUtils.toJson(List.of(
  239. Map.of("name", "资料确认", "required", true, "done", false),
  240. Map.of("name", "账号准备", "required", true, "done", false),
  241. Map.of("name", "岗位确认", "required", true, "done", false))));
  242. jdbcTemplate.update("UPDATE candidates SET converted_employee_id = ?, status = 'ONBOARDING', version = version + 1, updated_at = now() WHERE id = ?",
  243. employeeId, id);
  244. auditService.record("CANDIDATE_CONVERT_ONBOARDING", "onboarding_cases", caseId, candidate, Map.of("employeeId", employeeId));
  245. return ApiResponse.ok(find("onboarding_cases", caseId, "入职单不存在"));
  246. }
  247. private PageResponse<Map<String, Object>> page(String table, String order, int page, int size) {
  248. int safePage = Math.max(page, 1);
  249. int safeSize = Math.min(Math.max(size, 1), 100);
  250. long total = jdbcTemplate.queryForObject("SELECT count(*) FROM " + table, Long.class);
  251. var rows = jdbcTemplate.query("SELECT * FROM " + table + " ORDER BY " + order + " LIMIT ? OFFSET ?",
  252. (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
  253. return new PageResponse<>(rows, safePage, safeSize, total);
  254. }
  255. private Map<String, Object> find(String table, Long id, String message) {
  256. return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> {
  257. if (!rs.next()) {
  258. throw new BusinessException(ErrorCodes.NOT_FOUND, message, HttpStatus.NOT_FOUND);
  259. }
  260. return Rows.map(rs);
  261. }, id);
  262. }
  263. private String defaultString(String value, String defaultValue) {
  264. return value == null || value.isBlank() ? defaultValue : value;
  265. }
  266. private void requireCandidateNotTerminal(Map<String, Object> candidate) {
  267. String status = String.valueOf(candidate.get("status"));
  268. if (List.of("INTERVIEW_FAILED", "INTERVIEW_NO_SHOW", "OFFER_REJECTED", "ONBOARDING", "NO_SHOW", "ARCHIVED").contains(status)) {
  269. throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "候选人当前状态不能继续安排面试");
  270. }
  271. }
  272. }