package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.HrRecruitCandidate; import com.kaidi.oa.domain.HrRecruitJob; import com.kaidi.oa.repository.HrRecruitCandidateRepository; import com.kaidi.oa.repository.HrRecruitJobRepository; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * 行政·综合部——招聘与人才管理(Module 11 缺口)。 * * 替换原 recruit.vue 中纯 settingListStore 假数据,接入真实后端: * 岗位招聘单 CRUD + 状态机推进; * 候选人管理 CRUD + 面试状态流转(待初筛→初筛通过→面试安排→面试通过→offer发出→已入职/已淘汰); * AI 初筛接口:提供结构化候选人摘要,前端可调用 AiController 写回 aiScore/aiReport; * /hr-recruit/jobs/{id}/ai-screen — 触发 AI 批量初筛(返回模拟评分+建议,可对接 AiController); * /hr-recruit/stats — 漏斗统计。 */ @RestController @RequestMapping("/api/oa/hr-recruit") public class HrRecruitController { /** 候选人状态机。 */ private static final Map> NEXT = Map.of( "待初筛", List.of("初筛通过", "已淘汰"), "初筛通过", List.of("面试安排", "已淘汰"), "面试安排", List.of("面试通过", "已淘汰"), "面试通过", List.of("offer发出", "已淘汰"), "offer发出", List.of("已入职", "已淘汰") ); private final HrRecruitJobRepository jobRepo; private final HrRecruitCandidateRepository candidateRepo; public HrRecruitController(HrRecruitJobRepository jobRepo, HrRecruitCandidateRepository candidateRepo) { this.jobRepo = jobRepo; this.candidateRepo = candidateRepo; } // ============ 岗位招聘单 ============ @GetMapping("/jobs") public ApiResp> listJobs( @RequestParam(required = false) String status, @RequestParam(required = false) String dept) { if (status != null && !status.isBlank()) { return ApiResp.ok(jobRepo.findByStatus(status)); } if (dept != null && !dept.isBlank()) { return ApiResp.ok(jobRepo.findByDept(dept)); } return ApiResp.ok(jobRepo.findAll()); } @GetMapping("/jobs/{id}") public ApiResp getJob(@PathVariable Long id) { return ApiResp.ok(findJob(id)); } public record JobRequest(String position, String dept, Integer headcount, String channel, String requirement, String salaryRange, String targetDate, String hrOwner, String remark) { } @PostMapping("/jobs") @Transactional public ApiResp createJob(@RequestBody JobRequest req) { if (req.position() == null || req.position().isBlank()) { throw new ApiException(400, "岗位名称(position) 不能为空"); } HrRecruitJob job = new HrRecruitJob(); job.setPosition(req.position()); job.setDept(req.dept()); job.setHeadcount(req.headcount() != null ? req.headcount() : 1); job.setChannel(req.channel()); job.setRequirement(req.requirement()); job.setSalaryRange(req.salaryRange()); job.setTargetDate(req.targetDate()); job.setStatus("招聘中"); job.setHrOwner(req.hrOwner()); job.setRemark(req.remark()); job.setCreatedAt(Instant.now()); job.setUpdatedAt(Instant.now()); return ApiResp.ok(jobRepo.save(job)); } @PatchMapping("/jobs/{id}") @Transactional public ApiResp updateJob(@PathVariable Long id, @RequestBody JobRequest req) { HrRecruitJob job = findJob(id); if (req.position() != null && !req.position().isBlank()) job.setPosition(req.position()); if (req.dept() != null) job.setDept(req.dept()); if (req.headcount() != null) job.setHeadcount(req.headcount()); if (req.channel() != null) job.setChannel(req.channel()); if (req.requirement() != null) job.setRequirement(req.requirement()); if (req.salaryRange() != null) job.setSalaryRange(req.salaryRange()); if (req.targetDate() != null) job.setTargetDate(req.targetDate()); if (req.hrOwner() != null) job.setHrOwner(req.hrOwner()); if (req.remark() != null) job.setRemark(req.remark()); job.setUpdatedAt(Instant.now()); return ApiResp.ok(jobRepo.save(job)); } public record JobStatusReq(String status) { } @PostMapping("/jobs/{id}/close") @Transactional public ApiResp closeJob(@PathVariable Long id) { HrRecruitJob job = findJob(id); job.setStatus("已关闭"); job.setUpdatedAt(Instant.now()); return ApiResp.ok(jobRepo.save(job)); } @DeleteMapping("/jobs/{id}") @Transactional public ApiResp deleteJob(@PathVariable Long id) { if (!jobRepo.existsById(id)) throw new NotFoundException("招聘岗位不存在: " + id); candidateRepo.findByJobId(id).forEach(c -> candidateRepo.deleteById(c.getId())); jobRepo.deleteById(id); return ApiResp.ok(null); } // ============ 候选人 ============ @GetMapping("/jobs/{jobId}/candidates") public ApiResp> listCandidates(@PathVariable Long jobId) { return ApiResp.ok(candidateRepo.findByJobId(jobId)); } public record CandidateRequest(String name, String phone, String education, Integer workYears, String resumeSummary, String expectedSalary, String remark) { } @PostMapping("/jobs/{jobId}/candidates") @Transactional public ApiResp addCandidate(@PathVariable Long jobId, @RequestBody CandidateRequest req) { findJob(jobId); // 校验岗位存在 if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "候选人姓名(name) 不能为空"); } HrRecruitCandidate c = new HrRecruitCandidate(); c.setJobId(jobId); c.setName(req.name()); c.setPhone(req.phone()); c.setEducation(req.education()); c.setWorkYears(req.workYears()); c.setResumeSummary(req.resumeSummary()); c.setExpectedSalary(req.expectedSalary()); c.setStatus("待初筛"); c.setCreatedAt(Instant.now()); c.setUpdatedAt(Instant.now()); return ApiResp.ok(candidateRepo.save(c)); } @PatchMapping("/candidates/{id}") @Transactional public ApiResp updateCandidate(@PathVariable Long id, @RequestBody CandidateRequest req) { HrRecruitCandidate c = findCandidate(id); if (req.name() != null && !req.name().isBlank()) c.setName(req.name()); if (req.phone() != null) c.setPhone(req.phone()); if (req.education() != null) c.setEducation(req.education()); if (req.workYears() != null) c.setWorkYears(req.workYears()); if (req.resumeSummary() != null) c.setResumeSummary(req.resumeSummary()); if (req.expectedSalary() != null) c.setExpectedSalary(req.expectedSalary()); c.setUpdatedAt(Instant.now()); return ApiResp.ok(candidateRepo.save(c)); } @DeleteMapping("/candidates/{id}") @Transactional public ApiResp deleteCandidate(@PathVariable Long id) { if (!candidateRepo.existsById(id)) throw new NotFoundException("候选人不存在: " + id); candidateRepo.deleteById(id); return ApiResp.ok(null); } // ---------- 候选人状态推进 ---------- public record AdvanceReq(String toStatus, String interviewTime, String interviewNote) { } @PostMapping("/candidates/{id}/advance") @Transactional public ApiResp advance(@PathVariable Long id, @RequestBody AdvanceReq req) { HrRecruitCandidate c = findCandidate(id); List allowed = NEXT.getOrDefault(c.getStatus(), List.of()); if (!allowed.contains(req.toStatus())) { throw new ApiException(400, "状态不允许从「" + c.getStatus() + "」推进到「" + req.toStatus() + "」"); } c.setStatus(req.toStatus()); if (req.interviewTime() != null) c.setInterviewTime(req.interviewTime()); if (req.interviewNote() != null) c.setInterviewNote(req.interviewNote()); c.setUpdatedAt(Instant.now()); // 若已入职,同步更新岗位状态为面试中(可扩展到"已录用") if ("已入职".equals(req.toStatus())) { jobRepo.findById(c.getJobId()).ifPresent(job -> { job.setStatus("已录用"); job.setUpdatedAt(Instant.now()); jobRepo.save(job); }); } return ApiResp.ok(candidateRepo.save(c)); } // ---------- AI 批量初筛(集成桩)---------- /** * AI 批量初筛端点:根据岗位要求和候选人简历,生成评分建议。 * 实际 AI 调用通过 /api/oa/ai 完成;此端点为简历摘要结构化 + 评分填充。 * 满足需求:"用 AI 对海量简历进行关键词/岗位匹配度筛选,自动生成初筛报告"。 */ @PostMapping("/jobs/{jobId}/ai-screen") @Transactional public ApiResp> aiScreen(@PathVariable Long jobId) { HrRecruitJob job = findJob(jobId); List pending = candidateRepo.findByJobIdAndStatus(jobId, "待初筛"); List> results = new ArrayList<>(); int screened = 0; for (HrRecruitCandidate c : pending) { // 简历关键词匹配打分(模拟逻辑,真实请对接 AiController) int score = simulateAiScore(job, c); String report = buildAiReport(job, c, score); c.setAiScore(score); c.setAiReport(report); // 评分 >= 60 自动推进到初筛通过 if (score >= 60) { c.setStatus("初筛通过"); } c.setUpdatedAt(Instant.now()); candidateRepo.save(c); screened++; results.add(Map.of( "candidateId", c.getId(), "name", c.getName(), "score", score, "status", c.getStatus(), "report", report )); } return ApiResp.ok(Map.of( "jobId", jobId, "position", job.getPosition(), "screened", screened, "results", results, "note", "AI 初筛基于关键词匹配,建议结合具体业务要求人工复核" )); } // ---------- 漏斗统计 ---------- @GetMapping("/stats") public ApiResp> stats() { List jobs = jobRepo.findAll(); List candidates = candidateRepo.findAll(); Map jobByStatus = new HashMap<>(); for (HrRecruitJob j : jobs) { String s = j.getStatus() != null ? j.getStatus() : "未知"; jobByStatus.merge(s, 1L, Long::sum); } Map candidateByStatus = new HashMap<>(); for (HrRecruitCandidate c : candidates) { String s = c.getStatus() != null ? c.getStatus() : "未知"; candidateByStatus.merge(s, 1L, Long::sum); } return ApiResp.ok(Map.of( "jobCount", jobs.size(), "candidateCount", candidates.size(), "jobByStatus", jobByStatus, "candidateFunnel", candidateByStatus )); } // ---------- helpers ---------- private HrRecruitJob findJob(Long id) { return jobRepo.findById(id).orElseThrow(() -> new NotFoundException("招聘岗位不存在: " + id)); } private HrRecruitCandidate findCandidate(Long id) { return candidateRepo.findById(id).orElseThrow(() -> new NotFoundException("候选人不存在: " + id)); } /** 模拟 AI 评分(根据学历/工作年限/简历摘要关键词与岗位要求匹配),真实场景请对接 LLM。 */ private static int simulateAiScore(HrRecruitJob job, HrRecruitCandidate c) { int score = 50; // 基础分 // 学历加分 String edu = c.getEducation() != null ? c.getEducation() : ""; if (edu.contains("博士")) score += 25; else if (edu.contains("硕士")) score += 20; else if (edu.contains("本科")) score += 15; else if (edu.contains("大专")) score += 5; // 工作年限加分 Integer years = c.getWorkYears(); if (years != null) { if (years >= 10) score += 15; else if (years >= 5) score += 10; else if (years >= 3) score += 5; } // 岗位要求关键词匹配 if (job.getRequirement() != null && c.getResumeSummary() != null) { String req = job.getRequirement().toLowerCase(); String summary = c.getResumeSummary().toLowerCase(); String[] keywords = req.split("[,,、\\s]+"); for (String kw : keywords) { if (!kw.isBlank() && summary.contains(kw)) score += 3; } } return Math.min(score, 100); } private static String buildAiReport(HrRecruitJob job, HrRecruitCandidate c, int score) { String level = score >= 80 ? "强烈推荐" : score >= 60 ? "推荐" : "不推荐"; return level + "(评分:" + score + ")。" + "学历:" + (c.getEducation() != null ? c.getEducation() : "未知") + ";" + "工作年限:" + (c.getWorkYears() != null ? c.getWorkYears() + "年" : "未知") + "。" + "简历摘要与岗位「" + job.getPosition() + "」要求匹配度" + (score >= 60 ? "较高,建议安排面试。" : "偏低,可与HR沟通是否补充考量。"); } }