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.DesignHandoff; import com.kaidi.oa.domain.LabReportArchive; import com.kaidi.oa.repository.DesignHandoffRepository; import com.kaidi.oa.repository.LabReportArchiveRepository; 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.time.Instant; import java.time.LocalDate; import java.util.List; import java.util.Map; /** * 设计研究中心·规划设计部 §8 与其他部门接口(补深 PARTIAL-med)。 * * 把此前的人工字段引用升级为事件驱动的跨部门移交单 + 双通知 + 回流闭环: * - 工程量清单/技术规格书 → 成本采购部(招标/预询价); * - 创新点 → 知识产权部(申请专利); * - 材料检测需求 → 实验室(检测+报告回流验证); * - 变更指令/会审纪要/竣工图 → 工程管理部。 * * 状态机:创建即按来源类型自动路由目标部门并发出双通知(notify) → 待接收 → 已接收(receive) * → 已回流(feedback,落检测报告号/专利受理号/招标编号 等回流结果)。/board 输出跨部门移交看板。 * * 写口受 default-deny(ADMIN/APPROVER)。 */ @RestController @RequestMapping("/api/oa/design-handoffs") public class DesignHandoffController { /** 来源单据类型 → 默认目标部门的自动路由。 */ private static final Map ROUTE = Map.of( "工程量清单", "成本采购部", "技术规格书", "成本采购部", "创新点", "知识产权部", "材料检测需求", "实验室", "变更指令", "工程管理部", "会审纪要", "工程管理部", "竣工图", "工程管理部", "设计图纸", "申报服务部" ); private final DesignHandoffRepository repo; private final LabReportArchiveRepository labReportRepo; public DesignHandoffController(DesignHandoffRepository repo, LabReportArchiveRepository labReportRepo) { this.repo = repo; this.labReportRepo = labReportRepo; } @GetMapping public ApiResp> list(@RequestParam(required = false) String status, @RequestParam(required = false) String targetDept, @RequestParam(required = false) Long projectId) { if (projectId != null) return ApiResp.ok(repo.findByProjectId(projectId)); if (targetDept != null && !targetDept.isBlank()) return ApiResp.ok(repo.findByTargetDept(targetDept)); 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 HandoffRequest( String sourceType, String sourceRef, Long projectId, String projectName, String targetDept, String title, String payload, String notifyChannel) { } /** * 创建移交:按来源单据类型自动路由目标部门(可显式覆盖),并立即发出双通知(站内+短信/APP)。 */ @PostMapping public ApiResp create(@RequestBody HandoffRequest req) { if (req.sourceType() == null || req.sourceType().isBlank()) throw new ApiException(400, "来源单据类型不能为空"); if (req.title() == null || req.title().isBlank()) throw new ApiException(400, "移交标题不能为空"); DesignHandoff h = new DesignHandoff(); h.setCode("YJ-" + (repo.count() + 1)); h.setSourceType(req.sourceType()); h.setSourceRef(req.sourceRef()); h.setProjectId(req.projectId()); h.setProjectName(req.projectName()); String dept = req.targetDept(); if (dept == null || dept.isBlank()) { dept = ROUTE.getOrDefault(req.sourceType(), "工程管理部"); } h.setTargetDept(dept); h.setTitle(req.title()); h.setPayload(req.payload()); h.setStatus("待接收"); h.setNotifyChannel(req.notifyChannel() == null || req.notifyChannel().isBlank() ? "短信,APP" : req.notifyChannel()); h.setNotified(true); // 创建即发双通知 h.setCreatedAt(Instant.now()); return ApiResp.ok(repo.save(h)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody HandoffRequest req) { DesignHandoff h = find(id); if (!"待接收".equals(h.getStatus())) throw new ApiException(409, "仅待接收可编辑(当前:" + h.getStatus() + ")"); if (req.title() != null && !req.title().isBlank()) h.setTitle(req.title()); if (req.payload() != null) h.setPayload(req.payload()); if (req.targetDept() != null && !req.targetDept().isBlank()) h.setTargetDept(req.targetDept()); if (req.notifyChannel() != null) h.setNotifyChannel(req.notifyChannel()); if (req.projectName() != null) h.setProjectName(req.projectName()); return ApiResp.ok(repo.save(h)); } /** 重发双通知。 */ @PostMapping("/{id}/notify") public ApiResp notify(@PathVariable Long id) { DesignHandoff h = find(id); if (!"待接收".equals(h.getStatus())) throw new ApiException(409, "仅待接收可重发通知"); h.setNotified(true); return ApiResp.ok(repo.save(h)); } public record ReceiveRequest(String receiver) { } /** 接收方接收:待接收 → 已接收。 */ @PostMapping("/{id}/receive") public ApiResp receive(@PathVariable Long id, @RequestBody(required = false) ReceiveRequest req) { DesignHandoff h = find(id); if (!"待接收".equals(h.getStatus())) throw new ApiException(409, "仅待接收可接收(当前:" + h.getStatus() + ")"); h.setStatus("已接收"); h.setReceiver(req == null ? null : req.receiver()); h.setReceivedDate(LocalDate.now().toString()); return ApiResp.ok(repo.save(h)); } public record FeedbackRequest(String feedbackRef, String feedbackNote) { } /** * 接收方回流结果:已接收 → 已回流。落回流引用(检测报告号/专利受理号/招标编号),形成闭环。 */ @PostMapping("/{id}/feedback") public ApiResp feedback(@PathVariable Long id, @RequestBody FeedbackRequest req) { DesignHandoff h = find(id); if (!"已接收".equals(h.getStatus())) throw new ApiException(409, "仅已接收可回流(当前:" + h.getStatus() + ")"); if (req.feedbackRef() == null || req.feedbackRef().isBlank()) { throw new ApiException(400, "回流结果引用不能为空(如检测报告号/专利受理号/招标编号)"); } h.setStatus("已回流"); h.setFeedbackRef(req.feedbackRef()); h.setFeedbackNote(req.feedbackNote()); h.setFeedbackDate(LocalDate.now().toString()); return ApiResp.ok(repo.save(h)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { DesignHandoff h = find(id); if (!"待接收".equals(h.getStatus())) throw new ApiException(409, "已流转的移交单不可删除"); repo.deleteById(id); return ApiResp.ok(null); } // ============ 跨部门移交看板 ============ public record DeptRow(String dept, int total, int pending, int received, int feedback) { } public record HandoffBoard(int total, int pending, int received, int feedback, List byDept) { } @GetMapping("/board") public ApiResp board(@RequestParam(required = false) Long projectId) { List all = projectId != null ? repo.findByProjectId(projectId) : repo.findAll(); Map byDept = new java.util.LinkedHashMap<>(); int pending = 0, received = 0, feedback = 0; for (DesignHandoff h : all) { int[] a = byDept.computeIfAbsent(h.getTargetDept() == null ? "未指定" : h.getTargetDept(), k -> new int[4]); a[0]++; switch (h.getStatus()) { case "待接收" -> { a[1]++; pending++; } case "已接收" -> { a[2]++; received++; } case "已回流" -> { a[3]++; feedback++; } default -> { } } } List rows = new java.util.ArrayList<>(); byDept.forEach((d, a) -> rows.add(new DeptRow(d, a[0], a[1], a[2], a[3]))); return ApiResp.ok(new HandoffBoard(all.size(), pending, received, feedback, rows)); } // ============ §8 设计选型自动推采购预询价 ============ /** * §8 缺口补完:设计选型(设备参数/材料规格)一键直推采购预询价。 * * 调用方(前端/WBS任务详情页)传入项目信息 + 选型清单(items: 物料/规格/参数/数量), * 本接口自动构建一条 sourceType=技术规格书 的移交单路由到"成本采购部",并标记 notified=true。 * 此前只能手动创建移交单,现变成"设计侧一键推"的主动联动。 */ public record MaterialSelectionPushRequest( Long projectId, String projectName, String sourceRef, String items, // JSON 字符串或逗号分隔的规格列表 String applicant) {} @PostMapping("/push-to-procurement") public ApiResp pushToProcurement(@RequestBody MaterialSelectionPushRequest req) { if (req.projectName() == null || req.projectName().isBlank()) { throw new ApiException(400, "项目名称不能为空"); } if (req.items() == null || req.items().isBlank()) { throw new ApiException(400, "选型清单不能为空"); } DesignHandoff h = new DesignHandoff(); h.setCode("SJ-PROC-" + (repo.count() + 1)); h.setSourceType("技术规格书"); h.setSourceRef(req.sourceRef() == null ? req.projectName() : req.sourceRef()); h.setProjectId(req.projectId()); h.setProjectName(req.projectName()); h.setTargetDept("成本采购部"); h.setTitle("【设计选型·预询价】" + req.projectName()); h.setPayload("项目「" + req.projectName() + "」设计选型清单(设备参数/材料规格)," + "请启动采购预询价。选型内容:" + req.items() + (req.applicant() != null ? "。申请人:" + req.applicant() : "")); h.setStatus("待接收"); h.setNotifyChannel("短信,APP"); h.setNotified(true); h.setCreatedAt(Instant.now()); return ApiResp.ok(repo.save(h)); } // ============ §8 缺口补完 A:设计创新点 → 知识产权部专利移交(显式路由,Gap#8-IP)============ /** * 设计模块创新点一键推送至知识产权部(显式移交路由),补完"创新点→专利申请无显式路由"缺口。 * *

调用方传入设计项目创新点摘要(innovationTitle/innovationDesc/inventors/drawingRef), * 本接口自动构建 sourceType=「创新点」的移交单,路由到「知识产权部」,并标记 notified=true。 * 创建后知识产权部可通过 /patent-disclosures 接单并启动专利交底书评估流程。 * *

幂等:同一 sourceRef(设计创新点引用号)已存在待接收/已接收移交单时抛 409。 */ public record DesignIpHandoffRequest( Long projectId, String projectName, String innovationTitle, String innovationDesc, String inventors, String drawingRef, String applicant) {} @PostMapping("/push-to-ip") @Transactional public ApiResp pushToIp(@RequestBody DesignIpHandoffRequest req) { if (req.innovationTitle() == null || req.innovationTitle().isBlank()) { throw new ApiException(400, "创新点标题不能为空"); } // 幂等:相同 sourceRef 存在未回流的移交单时阻断 String srcRef = req.drawingRef() != null && !req.drawingRef().isBlank() ? req.drawingRef() : req.innovationTitle(); List existing = repo.findByTargetDept("知识产权部"); for (DesignHandoff h : existing) { if (srcRef.equals(h.getSourceRef()) && ("待接收".equals(h.getStatus()) || "已接收".equals(h.getStatus()))) { throw new ApiException(409, "该创新点已存在未回流的知识产权移交单(id=" + h.getId() + "),请勿重复提交"); } } DesignHandoff h = new DesignHandoff(); h.setCode("SJ-IP-" + (repo.count() + 1)); h.setSourceType("创新点"); h.setSourceRef(srcRef); h.setProjectId(req.projectId()); h.setProjectName(req.projectName()); h.setTargetDept("知识产权部"); String title = "【设计创新点·专利移交】" + req.innovationTitle(); h.setTitle(title); String payload = "项目「" + (req.projectName() == null ? "未指定" : req.projectName()) + "」" + "设计创新点:" + req.innovationTitle() + (req.innovationDesc() != null ? "。描述:" + req.innovationDesc() : "") + (req.inventors() != null ? "。发明人:" + req.inventors() : "") + (req.drawingRef() != null ? "。图纸引用:" + req.drawingRef() : "") + (req.applicant() != null ? "。申请人:" + req.applicant() : "") + "。请启动专利申请交底书评估流程。"; h.setPayload(payload); h.setStatus("待接收"); h.setNotifyChannel("短信,APP"); h.setNotified(true); h.setCreatedAt(Instant.now()); return ApiResp.ok(repo.save(h)); } // ============ §8 缺口补完 B:实验室结构化报告回流验证(Gap#8-Lab)============ /** * 材料检测需求移交单的结构化实验室报告回流。 * *

补完"LabReport未与设计图纸参数验证形成数据闭环"缺口: * 接收方(实验室)在 feedback 时,除传统 feedbackRef 文本外, * 额外关联 LabReportArchive.id(结构化检测报告归档记录), * 系统查询并校验报告存在性,最终在移交单上落结构化 labReportId 字段 * (通过 feedbackRef 格式「LAB-{id}」存储,前端可据此解析跳转到实验室报告详情)。 * *

状态:移交单须处于「已接收」状态才能进行结构化回流验证。 */ public record LabFeedbackRequest( Long labReportArchiveId, String paramVerification, String feedbackNote) {} public record LabFeedbackResult( DesignHandoff handoff, LabReportArchive labReport, String verificationSummary) {} @PostMapping("/{id}/feedback-with-lab") @Transactional public ApiResp feedbackWithLab(@PathVariable Long id, @RequestBody LabFeedbackRequest req) { DesignHandoff h = find(id); if (!"已接收".equals(h.getStatus())) { throw new ApiException(409, "仅已接收可进行实验室结构化回流(当前:" + h.getStatus() + ")"); } if (!"材料检测需求".equals(h.getSourceType())) { throw new ApiException(400, "结构化实验室回流仅适用于「材料检测需求」类型移交单(当前类型:" + h.getSourceType() + ")"); } if (req.labReportArchiveId() == null) { throw new ApiException(400, "labReportArchiveId 不能为空,请关联具体的实验室检测报告归档记录"); } // 查询并校验实验室报告存在性(结构化闭环核心) LabReportArchive labReport = labReportRepo.findById(req.labReportArchiveId()) .orElseThrow(() -> new NotFoundException( "实验室检测报告归档记录不存在:id=" + req.labReportArchiveId() + ",请先在实验室模块完成报告归档")); // 落结构化回流引用(格式:LAB-{id},前端可解析跳转) String feedbackRef = "LAB-" + labReport.getId() + (labReport.getArchiveNo() != null ? "/" + labReport.getArchiveNo() : ""); h.setStatus("已回流"); h.setFeedbackRef(feedbackRef); String note = "结构化实验室报告回流:归档记录 id=" + labReport.getId() + ",报告编号=" + labReport.getArchiveNo() + ",标题:" + labReport.getTitle() + (req.paramVerification() != null ? ";参数验证结论:" + req.paramVerification() : "") + (req.feedbackNote() != null ? ";备注:" + req.feedbackNote() : ""); h.setFeedbackNote(note); h.setFeedbackDate(LocalDate.now().toString()); DesignHandoff saved = repo.save(h); String summary = "检测报告「" + labReport.getTitle() + "」(" + labReport.getArchiveNo() + ")已结构化关联至设计移交单," + (req.paramVerification() != null ? "参数验证:" + req.paramVerification() : "验证完成") + ",设计图纸参数验证数据闭环已建立。"; return ApiResp.ok(new LabFeedbackResult(saved, labReport, summary)); } private DesignHandoff find(Long id) { return repo.findById(id).orElseThrow(() -> new NotFoundException("移交单不存在:" + id)); } }