恢复点(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>
376 lines
18 KiB
Java
376 lines
18 KiB
Java
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));
|
||
}
|
||
}
|