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(@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 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 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 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 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 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> 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 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 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 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 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 stat(@PathVariable Long id) { SafetyTraining t = trainRepo.findById(id) .orElseThrow(() -> new NotFoundException("training not found: " + id)); List 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)); } }