Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/SafetyTrainingController.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

256 lines
11 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.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));
}
}