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

376 lines
18 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.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<String, String> 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<DesignHandoff>> 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<DesignHandoff> 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<DesignHandoff> 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<DesignHandoff> 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<DesignHandoff> 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<DesignHandoff> 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<DesignHandoff> 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<Void> 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<DeptRow> byDept) {
}
@GetMapping("/board")
public ApiResp<HandoffBoard> board(@RequestParam(required = false) Long projectId) {
List<DesignHandoff> all = projectId != null ? repo.findByProjectId(projectId) : repo.findAll();
Map<String, int[]> 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<DeptRow> 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<DesignHandoff> 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============
/**
* 设计模块创新点一键推送至知识产权部(显式移交路由),补完"创新点→专利申请无显式路由"缺口。
*
* <p>调用方传入设计项目创新点摘要(innovationTitle/innovationDesc/inventors/drawingRef),
* 本接口自动构建 sourceType=「创新点」的移交单,路由到「知识产权部」,并标记 notified=true。
* 创建后知识产权部可通过 /patent-disclosures 接单并启动专利交底书评估流程。
*
* <p>幂等:同一 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<DesignHandoff> 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<DesignHandoff> 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============
/**
* 材料检测需求移交单的结构化实验室报告回流。
*
* <p>补完"LabReport未与设计图纸参数验证形成数据闭环"缺口:
* 接收方(实验室)在 feedback 时,除传统 feedbackRef 文本外,
* 额外关联 LabReportArchive.id(结构化检测报告归档记录),
* 系统查询并校验报告存在性,最终在移交单上落结构化 labReportId 字段
* (通过 feedbackRef 格式「LAB-{id}」存储,前端可据此解析跳转到实验室报告详情)。
*
* <p>状态:移交单须处于「已接收」状态才能进行结构化回流验证。
*/
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<LabFeedbackResult> 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));
}
}