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

380 lines
17 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.DesignChangeOrder;
import com.kaidi.oa.domain.DesignProject;
import com.kaidi.oa.repository.DesignChangeOrderRepository;
import com.kaidi.oa.repository.DesignProjectRepository;
import com.kaidi.oa.service.NotificationService;
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.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 设计研究中心·规划设计部 §3 设计变更管理(变更单工作流)。覆盖变更申请→评估→按等级生成
* 审批链→会签审批→执行(版本更新+自动提醒受影响专业)→变更台账统计。
*
* 状态机:草稿 → 评估中(submit) → 审批中 → 已批准(approve) → 已实施(implement);任一审批环节
* 可 已驳回(reject)。审批链按变更等级自动生成:
* 重大 → 专业负责人 / 项目总工 / 成本部 / 甲方代表(多方会签);
* 一般 → 专业负责人 / 项目总工;
* 轻微 → 专业负责人。
*
* 写口含成本影响额,默认受 AuthInterceptor 的 default-deny(ADMIN/APPROVER) 保护,
* 读口含金额应登记进 SENSITIVE_READ_PREFIXES(见 sharedFileSnippets)。
*/
@RestController
@RequestMapping("/api/oa/design-change-orders")
public class DesignChangeOrderController {
private final DesignChangeOrderRepository repo;
private final DesignProjectRepository projectRepo;
private final NotificationService notificationService;
public DesignChangeOrderController(DesignChangeOrderRepository repo,
DesignProjectRepository projectRepo,
NotificationService notificationService) {
this.repo = repo;
this.projectRepo = projectRepo;
this.notificationService = notificationService;
}
@GetMapping
public ApiResp<List<DesignChangeOrder>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) Long projectId) {
if (projectId != null) {
return ApiResp.ok(repo.findByProjectId(projectId));
}
if (status != null && !status.isBlank()) {
return ApiResp.ok(repo.findByStatus(status));
}
return ApiResp.ok(repo.findAll());
}
@GetMapping("/{id}")
public ApiResp<DesignChangeOrder> get(@PathVariable Long id) {
return ApiResp.ok(repo.findById(id)
.orElseThrow(() -> new NotFoundException("design change order not found: " + id)));
}
public record ChangeRequest(
Long projectId, String projectName, String docName, String title, String source,
String level, String reason, String impactScope, String affectedDisciplines,
Double costImpact, Integer scheduleImpactDays, String fromVersion, String applicant) {
}
@PostMapping
public ApiResp<DesignChangeOrder> create(@RequestBody ChangeRequest req) {
if (req.title() == null || req.title().isBlank()) {
throw new ApiException(400, "变更标题不能为空");
}
DesignChangeOrder c = new DesignChangeOrder();
c.setCode("BG-" + (repo.count() + 1));
if (req.projectId() != null) {
DesignProject p = projectRepo.findById(req.projectId()).orElse(null);
if (p != null) {
c.setProjectId(p.getId());
c.setProjectCode(p.getCode());
c.setProjectName(p.getName());
}
}
if (c.getProjectName() == null) {
c.setProjectName(req.projectName());
}
c.setDocName(req.docName());
c.setTitle(req.title());
c.setSource(req.source());
c.setLevel(normLevel(req.level()));
c.setReason(req.reason());
c.setImpactScope(req.impactScope());
c.setAffectedDisciplines(req.affectedDisciplines());
c.setCostImpact(Money.of(req.costImpact()));
c.setScheduleImpactDays(req.scheduleImpactDays());
c.setFromVersion(req.fromVersion());
c.setApplicant(req.applicant());
c.setStatus("草稿");
c.setCreatedAt(Instant.now());
return ApiResp.ok(repo.save(c));
}
@PatchMapping("/{id}")
public ApiResp<DesignChangeOrder> update(@PathVariable Long id, @RequestBody ChangeRequest req) {
DesignChangeOrder c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("design change order not found: " + id));
if (!"草稿".equals(c.getStatus())) {
throw new ApiException(409, "仅草稿状态可编辑(当前:" + c.getStatus() + "");
}
if (req.projectName() != null) c.setProjectName(req.projectName());
if (req.docName() != null) c.setDocName(req.docName());
if (req.title() != null && !req.title().isBlank()) c.setTitle(req.title());
if (req.source() != null) c.setSource(req.source());
if (req.level() != null) c.setLevel(normLevel(req.level()));
if (req.reason() != null) c.setReason(req.reason());
if (req.impactScope() != null) c.setImpactScope(req.impactScope());
if (req.affectedDisciplines() != null) c.setAffectedDisciplines(req.affectedDisciplines());
if (req.costImpact() != null) c.setCostImpact(Money.of(req.costImpact()));
if (req.scheduleImpactDays() != null) c.setScheduleImpactDays(req.scheduleImpactDays());
if (req.fromVersion() != null) c.setFromVersion(req.fromVersion());
if (req.applicant() != null) c.setApplicant(req.applicant());
return ApiResp.ok(repo.save(c));
}
/**
* 提交评估并进入审批:草稿 → 评估中 → 审批中(按变更等级自动生成会签审批链)。
* 同时自动计算审批链——这一步把"变更等级→不同审批链"落到工作流上。
*/
@PostMapping("/{id}/submit")
public ApiResp<DesignChangeOrder> submit(@PathVariable Long id) {
DesignChangeOrder c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("design change order not found: " + id));
if (!"草稿".equals(c.getStatus()) && !"已驳回".equals(c.getStatus())) {
throw new ApiException(409, "仅草稿/已驳回可提交审批(当前:" + c.getStatus() + "");
}
c.setApprovalChain(chainFor(c.getLevel()));
c.setStatus("审批中");
c.setDecisionNote(null);
return ApiResp.ok(repo.save(c));
}
public record DecisionRequest(String note) {
}
/** 审批通过:审批中 → 已批准。 */
@PostMapping("/{id}/approve")
public ApiResp<DesignChangeOrder> approve(@PathVariable Long id, @RequestBody(required = false) DecisionRequest req) {
DesignChangeOrder c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("design change order not found: " + id));
if (!"审批中".equals(c.getStatus())) {
throw new ApiException(409, "仅审批中可审批通过(当前:" + c.getStatus() + "");
}
c.setStatus("已批准");
if (req != null && req.note() != null) {
c.setDecisionNote(req.note());
}
return ApiResp.ok(repo.save(c));
}
/** 审批驳回:审批中 → 已驳回(可重新提交)。 */
@PostMapping("/{id}/reject")
public ApiResp<DesignChangeOrder> reject(@PathVariable Long id, @RequestBody(required = false) DecisionRequest req) {
DesignChangeOrder c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("design change order not found: " + id));
if (!"审批中".equals(c.getStatus())) {
throw new ApiException(409, "仅审批中可驳回(当前:" + c.getStatus() + "");
}
c.setStatus("已驳回");
c.setDecisionNote(req == null ? null : req.note());
return ApiResp.ok(repo.save(c));
}
public record ImplementRequest(String toVersion) {
}
/**
* 执行变更:已批准 → 已实施。强制更新图纸版本(fromVersion→toVersion,未给则自动 +0.1),
* 落实施日期。返回体里带"受影响专业自动提醒"清单(前端据此提示通知其他专业)。
*/
@PostMapping("/{id}/implement")
@Transactional
public ApiResp<DesignChangeOrder> implement(@PathVariable Long id, @RequestBody(required = false) ImplementRequest req) {
DesignChangeOrder c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("design change order not found: " + id));
if (!"已批准".equals(c.getStatus())) {
throw new ApiException(409, "仅已批准的变更可执行(当前:" + c.getStatus() + "");
}
String to = req == null ? null : req.toVersion();
if (to == null || to.isBlank()) {
to = bumpVersion(c.getFromVersion());
}
c.setToVersion(to);
c.setStatus("已实施");
c.setImplementedDate(LocalDate.now().toString());
DesignChangeOrder saved = repo.save(c);
// §3 缺口补完:实施后对受影响专业逐一发送真实站内通知(Message 持久化),
// 而非仅在响应体 affectedDisciplines 字段提示,解决"无真实推送"缺口。
String disciplines = c.getAffectedDisciplines();
if (disciplines != null && !disciplines.isBlank()) {
String title = "【设计变更通知】" + safe(c.getTitle());
String body = "变更单「" + safe(c.getCode()) + "」已实施,图纸版本已从 "
+ safe(c.getFromVersion()) + " 更新至 " + to
+ "。受影响专业:" + disciplines + ",请相关专业负责人核实本专业图纸并做对应调整。";
for (String disc : disciplines.split("[,]+")) {
String recipient = disc.trim();
if (!recipient.isBlank()) {
notificationService.notify(recipient, "设计变更", title, body,
"DesignChangeOrder", c.getId());
}
}
}
return ApiResp.ok(saved);
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
DesignChangeOrder c = repo.findById(id)
.orElseThrow(() -> new NotFoundException("design change order not found: " + id));
if ("已实施".equals(c.getStatus())) {
throw new ApiException(409, "已实施的变更已进台账,不可删除");
}
repo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 变更台账统计 ----------
public record LedgerRow(String key, int count, double costImpact, int scheduleImpactDays) {
}
public record Ledger(List<LedgerRow> bySource, List<LedgerRow> byLevel,
int total, int implemented, double totalCostImpact, int totalScheduleDays) {
}
/**
* 变更台账:按来源 / 按等级汇总变更数、成本影响合计、工期影响合计。这是 §3"生成变更台账,
* 支持按项目/时间/变更原因统计分析"的只读聚合。projectId 给定时只统计该项目。
*/
@GetMapping("/ledger")
public ApiResp<Ledger> ledger(@RequestParam(required = false) Long projectId) {
List<DesignChangeOrder> all = projectId != null ? repo.findByProjectId(projectId) : repo.findAll();
Map<String, BigDecimal[]> bySource = new LinkedHashMap<>();
Map<String, BigDecimal[]> byLevel = new LinkedHashMap<>();
BigDecimal totalCost = BigDecimal.ZERO;
int totalDays = 0;
int implemented = 0;
for (DesignChangeOrder c : all) {
BigDecimal cost = Money.nz(c.getCostImpact());
int days = c.getScheduleImpactDays() == null ? 0 : c.getScheduleImpactDays();
accumulate(bySource, c.getSource() == null ? "未分类" : c.getSource(), cost, days);
accumulate(byLevel, c.getLevel() == null ? "未分级" : c.getLevel(), cost, days);
totalCost = Money.add(totalCost, cost);
totalDays += days;
if ("已实施".equals(c.getStatus())) {
implemented++;
}
}
return ApiResp.ok(new Ledger(toRows(bySource), toRows(byLevel),
all.size(), implemented, totalCost.doubleValue(), totalDays));
}
private void accumulate(Map<String, BigDecimal[]> map, String key, BigDecimal cost, int days) {
BigDecimal[] acc = map.computeIfAbsent(key, k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
acc[0] = acc[0].add(BigDecimal.ONE);
acc[1] = Money.add(acc[1], cost);
acc[2] = acc[2].add(BigDecimal.valueOf(days));
}
private List<LedgerRow> toRows(Map<String, BigDecimal[]> map) {
List<LedgerRow> rows = new ArrayList<>();
for (Map.Entry<String, BigDecimal[]> e : map.entrySet()) {
BigDecimal[] v = e.getValue();
rows.add(new LedgerRow(e.getKey(), v[0].intValue(), v[1].doubleValue(), v[2].intValue()));
}
return rows;
}
// ---------- helpers ----------
private static String normLevel(String level) {
if (level == null || level.isBlank()) {
return "一般";
}
return level;
}
/** 按变更等级生成会签审批链。 */
private static String chainFor(String level) {
if ("重大".equals(level)) {
return "专业负责人,项目总工,成本部,甲方代表";
}
if ("轻微".equals(level)) {
return "专业负责人";
}
return "专业负责人,项目总工";
}
// ---------- §3 成本影响自动估算 ----------
/**
* §3 缺口补完:变更成本影响自动估算(从成本数据库/项目预算联动,不再只靠人工填写)。
*
* 逻辑:取项目 budget(设计预算)×变更等级系数 → 估算 costImpact 建议值(元)。
* 重大:预算×5%;一般:预算×1%;轻微:预算×0.3%。
* 真实场景下此处应接成本数据库/BIM工程量清单API;当前接设计项目预算做近似估算。
* 前端调此接口后将建议值回填 costImpact 字段,用户仍可手动覆盖。
*/
@GetMapping("/estimate-cost-impact")
public ApiResp<Map<String, Object>> estimateCostImpact(
@RequestParam(required = false) Long projectId,
@RequestParam(required = false) String level) {
BigDecimal budget = BigDecimal.ZERO;
String projectName = "";
if (projectId != null) {
DesignProject p = projectRepo.findById(projectId).orElse(null);
if (p != null) {
budget = Money.nz(p.getBudget());
projectName = p.getName();
}
}
// 系数
double ratio;
String normalizedLevel = level == null ? "一般" : level;
switch (normalizedLevel) {
case "重大" -> ratio = 0.05;
case "轻微" -> ratio = 0.003;
default -> ratio = 0.01;
}
BigDecimal estimate = budget.multiply(BigDecimal.valueOf(ratio)).setScale(2, java.math.RoundingMode.HALF_UP);
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
result.put("projectId", projectId);
result.put("projectName", projectName);
result.put("projectBudget", budget.doubleValue());
result.put("level", normalizedLevel);
result.put("ratio", ratio);
result.put("estimatedCostImpact", estimate.doubleValue());
result.put("note", "估算值 = 设计预算 × 等级系数(重大5%/一般1%/轻微0.3%);用户可手动覆盖");
return ApiResp.ok(result);
}
private static String safe(String s) {
return s == null ? "" : s;
}
/** 版本号 +0.1(如 v1.0 → v1.1;无前导 v 也兼容;不可解析则回退 v1.1)。 */
private static String bumpVersion(String from) {
if (from == null || from.isBlank()) {
return "v1.1";
}
String s = from.trim();
boolean hasV = s.startsWith("v") || s.startsWith("V");
String num = hasV ? s.substring(1) : s;
try {
double d = Double.parseDouble(num);
double next = Math.round((d + 0.1) * 10.0) / 10.0;
String body = next == Math.rint(next) ? String.valueOf((int) next) + ".0" : String.valueOf(next);
return (hasV ? "v" : "") + body;
} catch (NumberFormatException e) {
return "v1.1";
}
}
}