恢复点(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>
352 lines
15 KiB
Java
352 lines
15 KiB
Java
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沟通是否补充考量。");
|
||
}
|
||
}
|