恢复点(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>
256 lines
11 KiB
Java
256 lines
11 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.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));
|
||
}
|
||
}
|