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(@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 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 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 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 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 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 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 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 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 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 byAssignedDiscipline, List byType, List bySeverity, List byStatus) { } @GetMapping("/report") public ApiResp report(@RequestParam(required = false) Long projectId) { List all = projectId != null ? repo.findByProjectId(projectId) : repo.findAll(); java.util.Map disc = new java.util.LinkedHashMap<>(); java.util.Map type = new java.util.LinkedHashMap<>(); java.util.Map sev = new java.util.LinkedHashMap<>(); java.util.Map 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> 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 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 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 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> metricAlerts(@RequestParam(required = false) Long projectId) { List all = projectId != null ? metricRepo.findByProjectId(projectId) : metricRepo.findAll(); List 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 map, String key) { map.merge(key == null ? "未知" : key, 1, Integer::sum); } private List rows(java.util.Map map) { List rows = new ArrayList<>(); map.forEach((k, v) -> rows.add(new ReportRow(k, v))); return rows; } }