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

274 lines
12 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.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<AuditAnnualPlan>> 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<AuditAnnualPlan> get(@PathVariable Long id) {
return ApiResp.ok(load(id));
}
/**
* 资源冲突检测:给定审计人员姓名,返回该人员在同一计划期间存在日期重叠的计划列表。
* leadAuditor=all 时返回全员冲突摘要(每个审计人日期重叠的计划对)。
*/
@GetMapping("/conflict-check")
public ApiResp<List<AuditAnnualPlan>> conflictCheck(
@RequestParam(required = false) String leadAuditor) {
List<AuditAnnualPlan> candidates;
if (leadAuditor != null && !leadAuditor.isBlank()) {
candidates = planRepo.findByLeadAuditor(leadAuditor);
} else {
candidates = planRepo.findByOrderByYearDescIdDesc();
}
// 返回状态为已批准/实施中且日期有效的计划(日期冲突由前端可视化展示)。
List<AuditAnnualPlan> 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<AuditAnnualPlan> 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<AuditAnnualPlan> 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<AuditAnnualPlan> 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<AuditAnnualPlan> 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<AuditAnnualPlan> 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<AuditAnnualPlan> 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<AuditAnnualPlan> 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<AuditAnnualPlan> 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<Void> 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);
}
}