Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/HrRecruitController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

352 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, List<String>> 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<List<HrRecruitJob>> 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<HrRecruitJob> 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<HrRecruitJob> 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<HrRecruitJob> 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<HrRecruitJob> 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<Void> 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<List<HrRecruitCandidate>> 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<HrRecruitCandidate> 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<HrRecruitCandidate> 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<Void> 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<HrRecruitCandidate> advance(@PathVariable Long id, @RequestBody AdvanceReq req) {
HrRecruitCandidate c = findCandidate(id);
List<String> 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<Map<String, Object>> aiScreen(@PathVariable Long jobId) {
HrRecruitJob job = findJob(jobId);
List<HrRecruitCandidate> pending = candidateRepo.findByJobIdAndStatus(jobId, "待初筛");
List<Map<String, Object>> 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<Map<String, Object>> stats() {
List<HrRecruitJob> jobs = jobRepo.findAll();
List<HrRecruitCandidate> candidates = candidateRepo.findAll();
Map<String, Long> jobByStatus = new HashMap<>();
for (HrRecruitJob j : jobs) {
String s = j.getStatus() != null ? j.getStatus() : "未知";
jobByStatus.merge(s, 1L, Long::sum);
}
Map<String, Long> 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沟通是否补充考量。");
}
}