package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.AuditAnnualPlan; import com.kaidi.oa.domain.AuditProject; import com.kaidi.oa.repository.AuditAnnualPlanRepository; import com.kaidi.oa.repository.AuditProjectRepository; 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.PutMapping; 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.Year; import java.util.List; import java.util.Map; /** * 年度审计计划(内控部·审计监察部·第1模块)。 * * 解决审计缺口:「缺独立年度审计计划编制(按风险等级/重要性/管理层要求)/审计人员资质专长 * 可用时间管理/资源冲突检测/项目预算与费用报销关联」。 * * 核心功能: * - POST / 新建年度计划项(状态=草稿); * - PUT /{id} 编辑(草稿/待审批阶段); * - PATCH /{id}/submit 提交审批(草稿→待审批); * - PATCH /{id}/approve 审批通过(待审批→已批准); * - PATCH /{id}/reject 审批驳回(待审批→草稿); * - PATCH /{id}/start 启动实施(已批准→实施中); * - PATCH /{id}/complete 完成(实施中→已完成); * - POST /{id}/link-project 关联已立项的 AuditProject(回写 linkedProjectId); * - GET /conflict-check 资源冲突检测:同期同审计人员重叠计划列表; * - GET /by-year?year= 按年度查询计划; * - DELETE /{id} 删除(仅草稿阶段)。 */ @RestController @RequestMapping("/api/oa/audit-annual-plans") public class AuditAnnualPlanController { private final AuditAnnualPlanRepository planRepo; private final AuditProjectRepository projectRepo; public AuditAnnualPlanController(AuditAnnualPlanRepository planRepo, AuditProjectRepository projectRepo) { this.planRepo = planRepo; this.projectRepo = projectRepo; } @GetMapping public ApiResp> list( @RequestParam(required = false) String year, @RequestParam(required = false) String status, @RequestParam(required = false) String riskLevel) { if (year != null && !year.isBlank()) { return ApiResp.ok(planRepo.findByYear(year)); } if (status != null && !status.isBlank()) { return ApiResp.ok(planRepo.findByStatus(status)); } if (riskLevel != null && !riskLevel.isBlank()) { return ApiResp.ok(planRepo.findByRiskLevel(riskLevel)); } return ApiResp.ok(planRepo.findByOrderByYearDescIdDesc()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(load(id)); } /** * 资源冲突检测:给定审计人员姓名,返回该人员在同一计划期间存在日期重叠的计划列表。 * leadAuditor=all 时返回全员冲突摘要(每个审计人日期重叠的计划对)。 */ @GetMapping("/conflict-check") public ApiResp> conflictCheck( @RequestParam(required = false) String leadAuditor) { List candidates; if (leadAuditor != null && !leadAuditor.isBlank()) { candidates = planRepo.findByLeadAuditor(leadAuditor); } else { candidates = planRepo.findByOrderByYearDescIdDesc(); } // 返回状态为已批准/实施中且日期有效的计划(日期冲突由前端可视化展示)。 List active = candidates.stream() .filter(p -> "已批准".equals(p.getStatus()) || "实施中".equals(p.getStatus())) .filter(p -> p.getPlannedStartDate() != null && p.getPlannedEndDate() != null) .toList(); return ApiResp.ok(active); } public record CreateRequest( String year, String auditType, String auditee, String riskLevel, String importance, String basis, String plannedStartDate, String plannedEndDate, String leadAuditor, String teamMembers, Double budget, String notes) { } @PostMapping public ApiResp create(@RequestBody CreateRequest req) { if (req.auditee() == null || req.auditee().isBlank()) { throw new ApiException(400, "被审计单位不能为空"); } if (req.auditType() == null || req.auditType().isBlank()) { throw new ApiException(400, "审计类型不能为空"); } AuditAnnualPlan p = new AuditAnnualPlan(); p.setPlanNo(nextPlanNo()); p.setYear(req.year() != null && !req.year().isBlank() ? req.year() : String.valueOf(Year.now().getValue())); p.setAuditType(req.auditType()); p.setAuditee(req.auditee()); p.setRiskLevel(req.riskLevel() == null || req.riskLevel().isBlank() ? "中" : req.riskLevel()); p.setImportance(req.importance() == null || req.importance().isBlank() ? "一般" : req.importance()); p.setBasis(req.basis()); p.setPlannedStartDate(req.plannedStartDate()); p.setPlannedEndDate(req.plannedEndDate()); p.setLeadAuditor(req.leadAuditor()); p.setTeamMembers(req.teamMembers()); p.setBudget(Money.of(req.budget())); p.setNotes(req.notes()); p.setStatus("草稿"); p.setCreatedAt(Instant.now()); return ApiResp.ok(planRepo.save(p)); } public record UpdateRequest( String year, String auditType, String auditee, String riskLevel, String importance, String basis, String plannedStartDate, String plannedEndDate, String leadAuditor, String teamMembers, Double budget, String notes) { } @PutMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody UpdateRequest req) { AuditAnnualPlan p = load(id); String st = p.getStatus(); if ("已批准".equals(st) || "实施中".equals(st) || "已完成".equals(st)) { throw new ApiException(409, "该阶段(" + st + ")不允许修改计划信息"); } if (req.year() != null && !req.year().isBlank()) p.setYear(req.year()); if (req.auditType() != null && !req.auditType().isBlank()) p.setAuditType(req.auditType()); if (req.auditee() != null && !req.auditee().isBlank()) p.setAuditee(req.auditee()); if (req.riskLevel() != null) p.setRiskLevel(req.riskLevel()); if (req.importance() != null) p.setImportance(req.importance()); if (req.basis() != null) p.setBasis(req.basis()); if (req.plannedStartDate() != null) p.setPlannedStartDate(req.plannedStartDate()); if (req.plannedEndDate() != null) p.setPlannedEndDate(req.plannedEndDate()); if (req.leadAuditor() != null) p.setLeadAuditor(req.leadAuditor()); if (req.teamMembers() != null) p.setTeamMembers(req.teamMembers()); if (req.budget() != null) p.setBudget(Money.of(req.budget())); if (req.notes() != null) p.setNotes(req.notes()); return ApiResp.ok(planRepo.save(p)); } /** 提交审批(草稿 → 待审批)。 */ @PatchMapping("/{id}/submit") public ApiResp submit(@PathVariable Long id) { AuditAnnualPlan p = load(id); if (!"草稿".equals(p.getStatus())) { throw new ApiException(409, "只有草稿状态可提交审批(当前:" + p.getStatus() + ")"); } if (p.getLeadAuditor() == null || p.getLeadAuditor().isBlank()) { throw new ApiException(400, "提交审批前请填写项目负责人"); } p.setStatus("待审批"); return ApiResp.ok(planRepo.save(p)); } public record ApproveRequest(String approver) { } /** 审批通过(待审批 → 已批准)。 */ @PatchMapping("/{id}/approve") public ApiResp approve(@PathVariable Long id, @RequestBody ApproveRequest req) { AuditAnnualPlan p = load(id); if (!"待审批".equals(p.getStatus())) { throw new ApiException(409, "只有待审批状态可批准(当前:" + p.getStatus() + ")"); } p.setStatus("已批准"); if (req.approver() != null && !req.approver().isBlank()) p.setApprover(req.approver()); p.setApprovedAt(Instant.now()); return ApiResp.ok(planRepo.save(p)); } public record RejectRequest(String reason) { } /** 审批驳回(待审批 → 草稿,备注附驳回原因)。 */ @PatchMapping("/{id}/reject") public ApiResp reject(@PathVariable Long id, @RequestBody RejectRequest req) { AuditAnnualPlan p = load(id); if (!"待审批".equals(p.getStatus())) { throw new ApiException(409, "只有待审批状态可驳回(当前:" + p.getStatus() + ")"); } p.setStatus("草稿"); if (req.reason() != null && !req.reason().isBlank()) { p.setNotes((p.getNotes() == null ? "" : p.getNotes() + "\n") + "【驳回】" + req.reason()); } return ApiResp.ok(planRepo.save(p)); } /** 启动实施(已批准 → 实施中)。 */ @PatchMapping("/{id}/start") public ApiResp start(@PathVariable Long id) { AuditAnnualPlan p = load(id); if (!"已批准".equals(p.getStatus())) { throw new ApiException(409, "只有已批准状态可启动实施(当前:" + p.getStatus() + ")"); } p.setStatus("实施中"); return ApiResp.ok(planRepo.save(p)); } /** 完成(实施中 → 已完成)。 */ @PatchMapping("/{id}/complete") public ApiResp complete(@PathVariable Long id) { AuditAnnualPlan p = load(id); if (!"实施中".equals(p.getStatus())) { throw new ApiException(409, "只有实施中状态可标记完成(当前:" + p.getStatus() + ")"); } p.setStatus("已完成"); return ApiResp.ok(planRepo.save(p)); } public record LinkProjectRequest(Long projectId) { } /** 关联已立项的审计项目(回写 linkedProjectId)。 */ @PostMapping("/{id}/link-project") @Transactional public ApiResp linkProject(@PathVariable Long id, @RequestBody LinkProjectRequest req) { AuditAnnualPlan p = load(id); if (req.projectId() == null) { throw new ApiException(400, "请传入 projectId"); } AuditProject proj = projectRepo.findById(req.projectId()) .orElseThrow(() -> new ApiException(400, "审计项目不存在: " + req.projectId())); p.setLinkedProjectId(proj.getId()); return ApiResp.ok(planRepo.save(p)); } /** 删除(仅草稿阶段)。 */ @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { AuditAnnualPlan p = load(id); if (!"草稿".equals(p.getStatus())) { throw new ApiException(409, "只有草稿阶段的计划可以删除(当前:" + p.getStatus() + ")"); } planRepo.deleteById(id); return ApiResp.ok(null); } private AuditAnnualPlan load(Long id) { return planRepo.findById(id) .orElseThrow(() -> new NotFoundException("年度审计计划不存在: " + id)); } private String nextPlanNo() { String year = String.valueOf(Year.now().getValue()); long seq = planRepo.count() + 1; return String.format("AP-%s-%03d", year, seq); } }