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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,351 @@
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沟通是否补充考量。");
}
}