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

304 lines
14 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.NotFoundException;
import com.kaidi.oa.domain.DesignClash;
import com.kaidi.oa.domain.DesignMetric;
import com.kaidi.oa.repository.DesignClashRepository;
import com.kaidi.oa.repository.DesignMetricRepository;
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.ArrayList;
import java.util.List;
/**
* 设计研究中心·规划设计部 §4 设计协同与接口管理(补深 PARTIAL-high)。
*
* 两块缺口补深:
* A) BIM 自动碰撞检查 + 碰撞报告 + 协调会议闭环:
* /api/oa/design-clashes CRUD + 状态机 待分配→待修改(assign)→修改中(start)→已解决(resolve)
* →已关闭(close),可 重开(reopen)/report 输出按专业/类型/状态聚合的碰撞报告;
* meetingNote 落协调会议结论 = 问题闭环跟踪。
* B) 混凝土量/窗地比超限预警(不再仅含钢量):以通用 DesignMetric 表承载任意指标限值/实际值,
* /metrics CRUD + /metrics/alerts 按 direction(upper/lower) 产出"指标超限"预警。
*
* 写口默认受 default-deny(ADMIN/APPROVER) 保护。注:本控制器不含金额,按既有口径仅需登记
* 进 SENSITIVE_READ_PREFIXES 与否由共享方决定(见 sharedFileSnippets,已建议加入财务前缀外的读门槛集)。
*/
@RestController
@RequestMapping("/api/oa/design-clashes")
public class DesignClashController {
private final DesignClashRepository repo;
private final DesignMetricRepository metricRepo;
public DesignClashController(DesignClashRepository repo, DesignMetricRepository metricRepo) {
this.repo = repo;
this.metricRepo = metricRepo;
}
// ============ 碰撞 CRUD ============
@GetMapping
public ApiResp<List<DesignClash>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) Long projectId,
@RequestParam(required = false) String assignedDiscipline) {
if (projectId != null) return ApiResp.ok(repo.findByProjectId(projectId));
if (assignedDiscipline != null && !assignedDiscipline.isBlank()) {
return ApiResp.ok(repo.findByAssignedDiscipline(assignedDiscipline));
}
if (status != null && !status.isBlank()) return ApiResp.ok(repo.findByStatus(status));
return ApiResp.ok(repo.findAll());
}
@GetMapping("/{id}")
public ApiResp<DesignClash> get(@PathVariable Long id) {
return ApiResp.ok(find(id));
}
public record ClashRequest(
Long projectId, String projectName, String modelArea, String disciplineA,
String disciplineB, String clashType, String severity, String description) {
}
@PostMapping
public ApiResp<DesignClash> create(@RequestBody ClashRequest req) {
if (req.modelArea() == null || req.modelArea().isBlank()) throw new ApiException(400, "碰撞区域/模型不能为空");
DesignClash c = new DesignClash();
c.setCode("PZ-" + (repo.count() + 1));
c.setProjectId(req.projectId());
c.setProjectName(req.projectName());
c.setModelArea(req.modelArea());
c.setDisciplineA(req.disciplineA());
c.setDisciplineB(req.disciplineB());
c.setClashType(req.clashType() == null || req.clashType().isBlank() ? "硬碰撞" : req.clashType());
c.setSeverity(req.severity() == null || req.severity().isBlank() ? "一般" : req.severity());
c.setDescription(req.description());
c.setStatus("待分配");
c.setDetectedDate(LocalDate.now().toString());
c.setCreatedAt(Instant.now());
return ApiResp.ok(repo.save(c));
}
@PatchMapping("/{id}")
public ApiResp<DesignClash> update(@PathVariable Long id, @RequestBody ClashRequest req) {
DesignClash c = find(id);
if ("已关闭".equals(c.getStatus())) throw new ApiException(409, "已关闭的碰撞不可编辑");
if (req.projectName() != null) c.setProjectName(req.projectName());
if (req.modelArea() != null) c.setModelArea(req.modelArea());
if (req.disciplineA() != null) c.setDisciplineA(req.disciplineA());
if (req.disciplineB() != null) c.setDisciplineB(req.disciplineB());
if (req.clashType() != null) c.setClashType(req.clashType());
if (req.severity() != null) c.setSeverity(req.severity());
if (req.description() != null) c.setDescription(req.description());
return ApiResp.ok(repo.save(c));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
DesignClash c = find(id);
if ("已关闭".equals(c.getStatus())) throw new ApiException(409, "已关闭的碰撞进台账,不可删除");
repo.deleteById(id);
return ApiResp.ok(null);
}
// ============ 碰撞状态机(问题闭环) ============
public record AssignRequest(String assignedDiscipline, String assignee) {
}
/** 待分配 → 待修改:分配至相关专业修改。 */
@PostMapping("/{id}/assign")
public ApiResp<DesignClash> assign(@PathVariable Long id, @RequestBody AssignRequest req) {
DesignClash c = find(id);
if (!"待分配".equals(c.getStatus()) && !"已解决".equals(c.getStatus())) {
throw new ApiException(409, "仅待分配/已解决可(重新)分配(当前:" + c.getStatus() + "");
}
if (req.assignedDiscipline() == null || req.assignedDiscipline().isBlank()) {
throw new ApiException(400, "请指定分配专业");
}
c.setAssignedDiscipline(req.assignedDiscipline());
c.setAssignee(req.assignee());
c.setStatus("待修改");
return ApiResp.ok(repo.save(c));
}
/** 待修改 → 修改中。 */
@PostMapping("/{id}/start")
public ApiResp<DesignClash> start(@PathVariable Long id) {
DesignClash c = find(id);
if (!"待修改".equals(c.getStatus())) throw new ApiException(409, "仅待修改可开始(当前:" + c.getStatus() + "");
c.setStatus("修改中");
return ApiResp.ok(repo.save(c));
}
public record ResolveRequest(String resolution) {
}
/** 修改中 → 已解决。 */
@PostMapping("/{id}/resolve")
public ApiResp<DesignClash> resolve(@PathVariable Long id, @RequestBody(required = false) ResolveRequest req) {
DesignClash c = find(id);
if (!"修改中".equals(c.getStatus())) throw new ApiException(409, "仅修改中可标记已解决(当前:" + c.getStatus() + "");
c.setStatus("已解决");
c.setResolution(req == null ? null : req.resolution());
c.setResolvedDate(LocalDate.now().toString());
return ApiResp.ok(repo.save(c));
}
public record MeetingRequest(String meetingNote) {
}
/** 已解决 → 已关闭:协调会议确认闭环,落会议结论。 */
@PostMapping("/{id}/close")
public ApiResp<DesignClash> close(@PathVariable Long id, @RequestBody(required = false) MeetingRequest req) {
DesignClash c = find(id);
if (!"已解决".equals(c.getStatus())) throw new ApiException(409, "仅已解决可经协调会议关闭(当前:" + c.getStatus() + "");
c.setStatus("已关闭");
if (req != null && req.meetingNote() != null) c.setMeetingNote(req.meetingNote());
return ApiResp.ok(repo.save(c));
}
/** 重开:任一已解决/已关闭重新打开为待分配。 */
@PostMapping("/{id}/reopen")
public ApiResp<DesignClash> reopen(@PathVariable Long id) {
DesignClash c = find(id);
if (!"已解决".equals(c.getStatus()) && !"已关闭".equals(c.getStatus())) {
throw new ApiException(409, "仅已解决/已关闭可重开(当前:" + c.getStatus() + "");
}
c.setStatus("待分配");
c.setResolvedDate(null);
return ApiResp.ok(repo.save(c));
}
// ============ 碰撞报告(按专业/类型/状态聚合) ============
public record ReportRow(String key, int count) {
}
public record ClashReport(int total, int open, int closed,
List<ReportRow> byAssignedDiscipline, List<ReportRow> byType,
List<ReportRow> bySeverity, List<ReportRow> byStatus) {
}
@GetMapping("/report")
public ApiResp<ClashReport> report(@RequestParam(required = false) Long projectId) {
List<DesignClash> all = projectId != null ? repo.findByProjectId(projectId) : repo.findAll();
java.util.Map<String, Integer> disc = new java.util.LinkedHashMap<>();
java.util.Map<String, Integer> type = new java.util.LinkedHashMap<>();
java.util.Map<String, Integer> sev = new java.util.LinkedHashMap<>();
java.util.Map<String, Integer> st = new java.util.LinkedHashMap<>();
int open = 0, closed = 0;
for (DesignClash c : all) {
inc(disc, c.getAssignedDiscipline() == null ? "未分配" : c.getAssignedDiscipline());
inc(type, c.getClashType() == null ? "未分类" : c.getClashType());
inc(sev, c.getSeverity() == null ? "未分级" : c.getSeverity());
inc(st, c.getStatus());
if ("已关闭".equals(c.getStatus())) closed++;
else open++;
}
return ApiResp.ok(new ClashReport(all.size(), open, closed,
rows(disc), rows(type), rows(sev), rows(st)));
}
// ============ 设计指标限额 + 超限预警(混凝土量/窗地比/含钢量…) ============
@GetMapping("/metrics")
public ApiResp<List<DesignMetric>> listMetrics(@RequestParam(required = false) Long projectId) {
return ApiResp.ok(projectId != null ? metricRepo.findByProjectId(projectId) : metricRepo.findAll());
}
public record MetricRequest(Long projectId, String projectName, String metricName,
String unit, Double limitValue, Double actualValue, String direction, String remark) {
}
@PostMapping("/metrics")
public ApiResp<DesignMetric> createMetric(@RequestBody MetricRequest req) {
if (req.metricName() == null || req.metricName().isBlank()) throw new ApiException(400, "指标名不能为空");
DesignMetric m = new DesignMetric();
m.setProjectId(req.projectId());
m.setProjectName(req.projectName());
m.setMetricName(req.metricName());
m.setUnit(req.unit());
m.setLimitValue(req.limitValue());
m.setActualValue(req.actualValue());
m.setDirection("lower".equalsIgnoreCase(req.direction()) ? "lower" : "upper");
m.setRemark(req.remark());
m.setCreatedAt(Instant.now());
return ApiResp.ok(metricRepo.save(m));
}
@PatchMapping("/metrics/{mid}")
public ApiResp<DesignMetric> updateMetric(@PathVariable Long mid, @RequestBody MetricRequest req) {
DesignMetric m = metricRepo.findById(mid).orElseThrow(() -> new NotFoundException("指标不存在:" + mid));
if (req.metricName() != null && !req.metricName().isBlank()) m.setMetricName(req.metricName());
if (req.projectName() != null) m.setProjectName(req.projectName());
if (req.unit() != null) m.setUnit(req.unit());
if (req.limitValue() != null) m.setLimitValue(req.limitValue());
if (req.actualValue() != null) m.setActualValue(req.actualValue());
if (req.direction() != null) m.setDirection("lower".equalsIgnoreCase(req.direction()) ? "lower" : "upper");
if (req.remark() != null) m.setRemark(req.remark());
return ApiResp.ok(metricRepo.save(m));
}
@DeleteMapping("/metrics/{mid}")
public ApiResp<Void> deleteMetric(@PathVariable Long mid) {
if (!metricRepo.existsById(mid)) throw new NotFoundException("指标不存在:" + mid);
metricRepo.deleteById(mid);
return ApiResp.ok(null);
}
public record MetricAlert(Long projectId, String projectName, String metricName, String unit,
double limitValue, double actualValue, String direction, double overBy, String level) {
}
/** 设计指标超限预警:upper 实际>限值 / lower 实际<限值 即报警,超幅越大级别越高。 */
@GetMapping("/metrics/alerts")
public ApiResp<List<MetricAlert>> metricAlerts(@RequestParam(required = false) Long projectId) {
List<DesignMetric> all = projectId != null ? metricRepo.findByProjectId(projectId) : metricRepo.findAll();
List<MetricAlert> out = new ArrayList<>();
for (DesignMetric m : all) {
if (m.getLimitValue() == null || m.getActualValue() == null) continue;
double lim = m.getLimitValue();
double act = m.getActualValue();
boolean upper = !"lower".equals(m.getDirection());
boolean exceed = upper ? act > lim : act < lim;
if (!exceed) continue;
double overBy = upper ? act - lim : lim - act;
double pct = lim == 0 ? 100 : Math.abs(overBy / lim) * 100;
String level = pct >= 10 ? "高" : pct >= 3 ? "中" : "低";
out.add(new MetricAlert(m.getProjectId(), m.getProjectName(), m.getMetricName(),
m.getUnit(), lim, act, m.getDirection(),
Math.round(overBy * 1000.0) / 1000.0, level));
}
return ApiResp.ok(out);
}
// ============ helpers ============
private DesignClash find(Long id) {
return repo.findById(id).orElseThrow(() -> new NotFoundException("碰撞记录不存在:" + id));
}
private void inc(java.util.Map<String, Integer> map, String key) {
map.merge(key == null ? "未知" : key, 1, Integer::sum);
}
private List<ReportRow> rows(java.util.Map<String, Integer> map) {
List<ReportRow> rows = new ArrayList<>();
map.forEach((k, v) -> rows.add(new ReportRow(k, v)));
return rows;
}
}