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,255 @@
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.SafetyTraining;
import com.kaidi.oa.domain.TrainingEnrollment;
import com.kaidi.oa.repository.SafetyTrainingRepository;
import com.kaidi.oa.repository.TrainingEnrollmentRepository;
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.time.LocalDate;
import java.util.List;
/**
* 质安部·安全培训计划与执行(需求 §2 安全培训与教育 + §7 培训计划与执行)。补深审计 high/med 缺口:
* 三级教育/特种作业复训/培训计划 + 在线报名 + 签到 + 考核成绩自动判定 + 培训记录归档个人档案。
*
* 培训状态机:计划 → 报名中(开放报名)→ 进行中(开班)→ 已完成(结业,自动按及格分判定通过并可归档)。
* 学员明细 enrollment:报名 → 签到 → 录成绩(按培训 passScore 自动判 passed)→ 归档个人档案。
*
* 写口默认受 AuthInterceptor default-deny(ADMIN/APPROVER) 保护。
*/
@RestController
@RequestMapping("/api/oa/safety-trainings")
public class SafetyTrainingController {
private final SafetyTrainingRepository trainRepo;
private final TrainingEnrollmentRepository enrollRepo;
public SafetyTrainingController(SafetyTrainingRepository trainRepo,
TrainingEnrollmentRepository enrollRepo) {
this.trainRepo = trainRepo;
this.enrollRepo = enrollRepo;
}
// ---------- 培训计划 CRUD ----------
@GetMapping
public ApiResp<List<SafetyTraining>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String trainType) {
if (status != null && !status.isBlank()) {
return ApiResp.ok(trainRepo.findByStatus(status));
}
if (trainType != null && !trainType.isBlank()) {
return ApiResp.ok(trainRepo.findByTrainType(trainType));
}
return ApiResp.ok(trainRepo.findAll());
}
@GetMapping("/{id}")
public ApiResp<SafetyTraining> get(@PathVariable Long id) {
return ApiResp.ok(trainRepo.findById(id)
.orElseThrow(() -> new NotFoundException("training not found: " + id)));
}
public record TrainRequest(String code, String title, String trainType, String dept,
String lecturer, String planDate, Integer hours, Integer passScore,
String content) {
}
@PostMapping
public ApiResp<SafetyTraining> create(@RequestBody TrainRequest req) {
if (req.title() == null || req.title().isBlank()) {
throw new ApiException(400, "培训主题不能为空");
}
SafetyTraining t = new SafetyTraining();
t.setCode(req.code() == null || req.code().isBlank() ? "PX-" + (trainRepo.count() + 1) : req.code());
t.setTitle(req.title());
t.setTrainType(req.trainType() == null || req.trainType().isBlank() ? "日常培训" : req.trainType());
t.setDept(req.dept());
t.setLecturer(req.lecturer());
t.setPlanDate(req.planDate());
t.setHours(req.hours());
t.setPassScore(req.passScore() == null ? 60 : req.passScore());
t.setContent(req.content());
t.setStatus("计划");
t.setCreatedAt(Instant.now());
return ApiResp.ok(trainRepo.save(t));
}
@PatchMapping("/{id}")
public ApiResp<SafetyTraining> update(@PathVariable Long id, @RequestBody TrainRequest req) {
SafetyTraining t = trainRepo.findById(id)
.orElseThrow(() -> new NotFoundException("training not found: " + id));
if ("已完成".equals(t.getStatus())) {
throw new ApiException(409, "已完成培训不可编辑");
}
if (req.title() != null && !req.title().isBlank()) t.setTitle(req.title());
if (req.trainType() != null && !req.trainType().isBlank()) t.setTrainType(req.trainType());
if (req.dept() != null) t.setDept(req.dept());
if (req.lecturer() != null) t.setLecturer(req.lecturer());
if (req.planDate() != null) t.setPlanDate(req.planDate());
if (req.hours() != null) t.setHours(req.hours());
if (req.passScore() != null) t.setPassScore(req.passScore());
if (req.content() != null) t.setContent(req.content());
return ApiResp.ok(trainRepo.save(t));
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
SafetyTraining t = trainRepo.findById(id)
.orElseThrow(() -> new NotFoundException("training not found: " + id));
for (TrainingEnrollment e : enrollRepo.findByTrainingId(id)) {
enrollRepo.deleteById(e.getId());
}
trainRepo.deleteById(t.getId());
return ApiResp.ok(null);
}
/** 状态推进:计划→报名中→进行中→已完成。已完成时自动结业判定(按及格分判 passed)。 */
@PostMapping("/{id}/advance")
@Transactional
public ApiResp<SafetyTraining> advance(@PathVariable Long id) {
SafetyTraining t = trainRepo.findById(id)
.orElseThrow(() -> new NotFoundException("training not found: " + id));
String next = switch (t.getStatus() == null ? "" : t.getStatus()) {
case "计划" -> "报名中";
case "报名中" -> "进行中";
case "进行中" -> "已完成";
default -> null;
};
if (next == null) {
throw new ApiException(409, "当前状态不可推进(" + t.getStatus() + "");
}
if ("已完成".equals(next)) {
int pass = t.getPassScore() == null ? 60 : t.getPassScore();
for (TrainingEnrollment e : enrollRepo.findByTrainingId(id)) {
if (e.getScore() != null) {
e.setPassed(e.getScore() >= pass);
enrollRepo.save(e);
}
}
}
t.setStatus(next);
return ApiResp.ok(trainRepo.save(t));
}
// ---------- 学员 报名/签到/成绩/归档 ----------
@GetMapping("/{trainingId}/enrollments")
public ApiResp<List<TrainingEnrollment>> enrollments(@PathVariable Long trainingId) {
if (!trainRepo.existsById(trainingId)) {
throw new NotFoundException("training not found: " + trainingId);
}
return ApiResp.ok(enrollRepo.findByTrainingId(trainingId));
}
public record EnrollRequest(String traineeName, String dept) {
}
/** 报名:培训需未完成。同培训同名学员去重。 */
@PostMapping("/{trainingId}/enroll")
@Transactional
public ApiResp<TrainingEnrollment> enroll(@PathVariable Long trainingId, @RequestBody EnrollRequest req) {
SafetyTraining t = trainRepo.findById(trainingId)
.orElseThrow(() -> new NotFoundException("training not found: " + trainingId));
if ("已完成".equals(t.getStatus())) {
throw new ApiException(409, "培训已结束,不能报名");
}
if (req.traineeName() == null || req.traineeName().isBlank()) {
throw new ApiException(400, "学员姓名不能为空");
}
for (TrainingEnrollment e : enrollRepo.findByTrainingId(trainingId)) {
if (req.traineeName().equals(e.getTraineeName())) {
throw new ApiException(409, "该学员已报名");
}
}
TrainingEnrollment e = new TrainingEnrollment();
e.setTrainingId(trainingId);
e.setTraineeName(req.traineeName());
e.setDept(req.dept());
e.setSignedIn(false);
e.setPassed(false);
e.setArchived(false);
e.setCreatedAt(Instant.now());
return ApiResp.ok(enrollRepo.save(e));
}
/** 签到(二维码/人脸签到的服务端落点)。 */
@PostMapping("/enrollments/{eid}/sign-in")
public ApiResp<TrainingEnrollment> signIn(@PathVariable Long eid) {
TrainingEnrollment e = enrollRepo.findById(eid)
.orElseThrow(() -> new NotFoundException("enrollment not found: " + eid));
e.setSignedIn(true);
e.setSignInDate(LocalDate.now().toString());
return ApiResp.ok(enrollRepo.save(e));
}
public record ScoreRequest(Integer score) {
}
/** 录入考核成绩:按培训及格分自动判定是否通过。 */
@PostMapping("/enrollments/{eid}/score")
public ApiResp<TrainingEnrollment> score(@PathVariable Long eid, @RequestBody ScoreRequest req) {
TrainingEnrollment e = enrollRepo.findById(eid)
.orElseThrow(() -> new NotFoundException("enrollment not found: " + eid));
if (req.score() == null) {
throw new ApiException(400, "成绩不能为空");
}
SafetyTraining t = trainRepo.findById(e.getTrainingId())
.orElseThrow(() -> new NotFoundException("training not found: " + e.getTrainingId()));
int pass = t.getPassScore() == null ? 60 : t.getPassScore();
e.setScore(req.score());
e.setPassed(req.score() >= pass);
return ApiResp.ok(enrollRepo.save(e));
}
/** 归档至个人档案(与需求"培训记录自动归档至个人档案"一致)。 */
@PostMapping("/enrollments/{eid}/archive")
public ApiResp<TrainingEnrollment> archive(@PathVariable Long eid) {
TrainingEnrollment e = enrollRepo.findById(eid)
.orElseThrow(() -> new NotFoundException("enrollment not found: " + eid));
if (e.getScore() == null) {
throw new ApiException(409, "未录成绩,不能归档");
}
e.setArchived(true);
return ApiResp.ok(enrollRepo.save(e));
}
// ---------- 培训统计 ----------
public record TrainStat(Long trainingId, String code, String title, long enrolled, long signedIn,
long scored, long passed, double signInRate, double passRate) {
}
/** 单次培训统计:参训/签到/考核/通过 + 签到率/通过率。 */
@GetMapping("/{id}/stat")
public ApiResp<TrainStat> stat(@PathVariable Long id) {
SafetyTraining t = trainRepo.findById(id)
.orElseThrow(() -> new NotFoundException("training not found: " + id));
List<TrainingEnrollment> es = enrollRepo.findByTrainingId(id);
long enrolled = es.size(), signedIn = 0, scored = 0, passed = 0;
for (TrainingEnrollment e : es) {
if (e.isSignedIn()) signedIn++;
if (e.getScore() != null) scored++;
if (e.isPassed()) passed++;
}
double signRate = enrolled == 0 ? 0 : Math.round(signedIn * 1000.0 / enrolled) / 10.0;
double passRate = scored == 0 ? 0 : Math.round(passed * 1000.0 / scored) / 10.0;
return ApiResp.ok(new TrainStat(t.getId(), t.getCode(), t.getTitle(), enrolled, signedIn,
scored, passed, signRate, passRate));
}
}