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

459 lines
20 KiB
Java

package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.DesignReview;
import com.kaidi.oa.domain.DevProject;
import com.kaidi.oa.domain.DevWbsTask;
import com.kaidi.oa.repository.DesignReviewRepository;
import com.kaidi.oa.repository.DevProjectRepository;
import com.kaidi.oa.repository.DevWbsTaskRepository;
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.PutMapping;
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.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* 创新研发中心 / 产品开发部 · 产品开发立项与 WBS 管理。
*
* <p>覆盖:立项台账 CRUD;立项状态机(草稿→评审中→已立项→研发中→结题/已驳回);
* 提交评审 / 评审通过(立项) / 评审驳回 / 启动研发 / 结题 等业务动作;
* 立项通过时自动生成标准 WBS 任务树(设计→评审→打样→测试→试产→量产);
* WBS 任务进度填报后自动上卷计算 project 级 progress。
*
* <p>写口默认受 AuthInterceptor 的 default-deny(ADMIN/APPROVER) 保护;含金额、
* 跨表读,已登记进 SENSITIVE_READ_PREFIXES(见 sharedFileSnippets)。
*/
@RestController
@RequestMapping("/api/oa/dev-projects")
public class DevProjectController {
/** 标准研发阶段 WBS 模板(立项通过后自动展开)。第 2 步评审、最后一步量产为里程碑。 */
private static final String[] WBS_STAGES = {
"方案设计", "技术评审", "样机打样", "测试验证", "小批试产", "量产导入"
};
private final DevProjectRepository projectRepo;
private final DevWbsTaskRepository wbsRepo;
private final DesignReviewRepository reviewRepo;
public DevProjectController(DevProjectRepository projectRepo, DevWbsTaskRepository wbsRepo,
DesignReviewRepository reviewRepo) {
this.projectRepo = projectRepo;
this.wbsRepo = wbsRepo;
this.reviewRepo = reviewRepo;
}
// ---------- 立项台账 CRUD ----------
@GetMapping
public ApiResp<List<DevProject>> list(@RequestParam(required = false) String stage,
@RequestParam(required = false) String projectType) {
if (stage != null && !stage.isBlank()) {
return ApiResp.ok(projectRepo.findByStage(stage));
}
if (projectType != null && !projectType.isBlank()) {
return ApiResp.ok(projectRepo.findByProjectType(projectType));
}
return ApiResp.ok(projectRepo.findAll());
}
@GetMapping("/{id}")
public ApiResp<DevProject> get(@PathVariable Long id) {
return ApiResp.ok(find(id));
}
public record DevProjectRequest(
String code, String name, String projectType, String productLine, String leader,
String department, String planStart, String planEnd, Double budget, Double targetCost,
Double expectedRevenue, String objective, String techRoute) {
}
@PostMapping
public ApiResp<DevProject> create(@RequestBody DevProjectRequest req) {
if (req.name() == null || req.name().isBlank()) {
throw new ApiException(400, "项目名称(name) 不能为空");
}
DevProject p = new DevProject();
p.setCode(req.code() == null || req.code().isBlank()
? "DEV-" + (projectRepo.count() + 1) : req.code());
p.setName(req.name());
p.setProjectType(blankTo(req.projectType(), "新品"));
p.setProductLine(req.productLine());
p.setLeader(req.leader());
p.setDepartment(blankTo(req.department(), "产品开发部"));
p.setPlanStart(req.planStart());
p.setPlanEnd(req.planEnd());
p.setBudget(Money.of(req.budget()));
p.setTargetCost(Money.of(req.targetCost()));
p.setExpectedRevenue(Money.of(req.expectedRevenue()));
p.setObjective(req.objective());
p.setTechRoute(req.techRoute());
p.setStage("草稿");
p.setProgress(0);
p.setCreatedAt(Instant.now());
return ApiResp.ok(projectRepo.save(p));
}
@PutMapping("/{id}")
public ApiResp<DevProject> update(@PathVariable Long id, @RequestBody DevProjectRequest req) {
DevProject p = find(id);
if (req.name() != null) {
if (req.name().isBlank()) {
throw new ApiException(400, "项目名称(name) 不能为空");
}
p.setName(req.name());
}
if (req.projectType() != null && !req.projectType().isBlank()) p.setProjectType(req.projectType());
if (req.productLine() != null) p.setProductLine(req.productLine());
if (req.leader() != null) p.setLeader(req.leader());
if (req.department() != null) p.setDepartment(req.department());
if (req.planStart() != null) p.setPlanStart(req.planStart());
if (req.planEnd() != null) p.setPlanEnd(req.planEnd());
if (req.budget() != null) p.setBudget(Money.of(req.budget()));
if (req.targetCost() != null) p.setTargetCost(Money.of(req.targetCost()));
if (req.expectedRevenue() != null) p.setExpectedRevenue(Money.of(req.expectedRevenue()));
if (req.objective() != null) p.setObjective(req.objective());
if (req.techRoute() != null) p.setTechRoute(req.techRoute());
return ApiResp.ok(projectRepo.save(p));
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
if (!projectRepo.existsById(id)) {
throw new NotFoundException("devProject not found: " + id);
}
// 级联清理本立项的 WBS 任务,杜绝悬挂任务行。
wbsRepo.deleteByDevProjectId(id);
projectRepo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 立项状态机 ----------
/** 提交立项评审:草稿 → 评审中。 */
@PostMapping("/{id}/submit")
public ApiResp<DevProject> submit(@PathVariable Long id) {
DevProject p = find(id);
if (!"草稿".equals(p.getStage()) && !"已驳回".equals(p.getStage())) {
throw new ApiException(409, "仅草稿/已驳回的立项可提交评审,当前为「" + p.getStage() + "」");
}
p.setStage("评审中");
return ApiResp.ok(projectRepo.save(p));
}
public record ApproveRequest(String opinion) {
}
/**
* 技术委员会评审通过 → 立项:评审中 → 已立项,并自动展开标准 WBS 任务树
* (已有任务时不重复生成,幂等)。
*
* <p>硬绑定技术委员会投票节点(修补审计"立项 approve 与 DesignReview 投票节点未硬绑定"):
* 若本立项已挂技术评审节点({@link DesignReview}),则必须存在至少一个已结论为
* 「已通过」或「有条件通过」的评审,立项才能通过 —— 投票闭环成为立项必经。
* 未挂任何评审节点时(轻量立项)允许直接通过,保持向后兼容。
*/
@PostMapping("/{id}/approve")
@Transactional
public ApiResp<DevProject> approve(@PathVariable Long id, @RequestBody(required = false) ApproveRequest req) {
DevProject p = find(id);
if (!"评审中".equals(p.getStage())) {
throw new ApiException(409, "仅评审中的立项可通过,当前为「" + p.getStage() + "」");
}
List<DesignReview> reviews = reviewRepo.findByDevProjectId(id);
if (!reviews.isEmpty()) {
boolean anyPassed = reviews.stream()
.anyMatch(r -> "已通过".equals(r.getStatus()) || "有条件通过".equals(r.getStatus()));
if (!anyPassed) {
throw new ApiException(409, "立项已挂技术评审节点,须先有评审表决「通过/有条件通过」后才能立项");
}
}
p.setStage("已立项");
p.setReviewOpinion(req == null ? null : req.opinion());
projectRepo.save(p);
generateWbsIfAbsent(p);
return ApiResp.ok(p);
}
/** 技术委员会驳回:评审中 → 已驳回。 */
@PostMapping("/{id}/reject")
public ApiResp<DevProject> reject(@PathVariable Long id, @RequestBody(required = false) ApproveRequest req) {
DevProject p = find(id);
if (!"评审中".equals(p.getStage())) {
throw new ApiException(409, "仅评审中的立项可驳回,当前为「" + p.getStage() + "」");
}
p.setStage("已驳回");
p.setReviewOpinion(req == null ? null : req.opinion());
return ApiResp.ok(projectRepo.save(p));
}
/** 启动研发:已立项 → 研发中。 */
@PostMapping("/{id}/start")
public ApiResp<DevProject> start(@PathVariable Long id) {
DevProject p = find(id);
if (!"已立项".equals(p.getStage())) {
throw new ApiException(409, "仅已立项的项目可启动研发,当前为「" + p.getStage() + "」");
}
p.setStage("研发中");
return ApiResp.ok(projectRepo.save(p));
}
/** 结题:研发中 → 结题(要求进度达 100%)。 */
@PostMapping("/{id}/close")
public ApiResp<DevProject> close(@PathVariable Long id) {
DevProject p = find(id);
if (!"研发中".equals(p.getStage())) {
throw new ApiException(409, "仅研发中的项目可结题,当前为「" + p.getStage() + "」");
}
if (p.getProgress() < 100) {
throw new ApiException(409, "项目进度未达 100%(当前 " + p.getProgress() + "%),不能结题");
}
p.setStage("结题");
return ApiResp.ok(projectRepo.save(p));
}
// ---------- WBS 任务树 ----------
@GetMapping("/{id}/wbs")
public ApiResp<List<DevWbsTask>> wbs(@PathVariable Long id) {
return ApiResp.ok(wbsRepo.findByDevProjectIdOrderBySeqAsc(id));
}
/** 手动补建标准 WBS(已有任务时报 409,避免重复展开)。 */
@PostMapping("/{id}/wbs/generate")
@Transactional
public ApiResp<List<DevWbsTask>> generateWbs(@PathVariable Long id) {
DevProject p = find(id);
if (wbsRepo.countByDevProjectId(id) > 0) {
throw new ApiException(409, "该项目已有 WBS 任务,不能重复生成");
}
generateWbsIfAbsent(p);
return ApiResp.ok(wbsRepo.findByDevProjectIdOrderBySeqAsc(id));
}
public record WbsTaskRequest(String name, String assignee, String planStart, String planEnd,
Boolean milestone, String deliverable, Integer dependsOnSeq) {
}
/** 自定义新增一条 WBS 任务(seq 自动接到末尾,可指定前置任务序号 dependsOnSeq)。 */
@PostMapping("/{id}/wbs")
public ApiResp<DevWbsTask> addWbs(@PathVariable Long id, @RequestBody WbsTaskRequest req) {
find(id);
if (req.name() == null || req.name().isBlank()) {
throw new ApiException(400, "任务名称(name) 不能为空");
}
List<DevWbsTask> existing = wbsRepo.findByDevProjectIdOrderBySeqAsc(id);
int nextSeq = existing.isEmpty() ? 1 : existing.get(existing.size() - 1).getSeq() + 1;
if (req.dependsOnSeq() != null) {
boolean exists = existing.stream().anyMatch(x -> x.getSeq() == req.dependsOnSeq());
if (!exists || req.dependsOnSeq() >= nextSeq) {
throw new ApiException(400, "前置任务序号(dependsOnSeq) 必须是本项目已存在且在本任务之前的任务");
}
}
DevWbsTask t = new DevWbsTask();
t.setDevProjectId(id);
t.setSeq(nextSeq);
t.setDependsOnSeq(req.dependsOnSeq());
t.setName(req.name());
t.setAssignee(req.assignee());
t.setStatus("待办");
t.setProgress(0);
t.setPlanStart(req.planStart());
t.setPlanEnd(req.planEnd());
t.setMilestone(Boolean.TRUE.equals(req.milestone()));
t.setDeliverable(req.deliverable());
t.setCreatedAt(Instant.now());
return ApiResp.ok(wbsRepo.save(t));
}
public record WbsProgressRequest(Integer progress, String deliverable, String assignee) {
}
/**
* 填报 WBS 任务进度:进度=100→状态置「已完成」;0<进度<100→「进行中」;0→「待办」。
* 填报后联动上卷 project 级 progress;全部任务完成时若项目仍「已立项」则推进到「研发中」。
*/
@PatchMapping("/wbs/{taskId}/progress")
@Transactional
public ApiResp<DevWbsTask> reportProgress(@PathVariable Long taskId, @RequestBody WbsProgressRequest req) {
DevWbsTask t = wbsRepo.findById(taskId)
.orElseThrow(() -> new NotFoundException("wbsTask not found: " + taskId));
if (req.progress() != null) {
int pr = clampPercent(req.progress());
// 任务依赖门禁(FS):前置任务未完成(progress<100)时不允许推进本任务进度。
if (pr > 0 && t.getDependsOnSeq() != null) {
DevWbsTask pre = wbsRepo.findByDevProjectIdOrderBySeqAsc(t.getDevProjectId()).stream()
.filter(x -> x.getSeq() == t.getDependsOnSeq())
.findFirst().orElse(null);
if (pre != null && pre.getProgress() < 100) {
throw new ApiException(409, "前置任务「" + pre.getName() + "」未完成,本任务不能开始填报进度");
}
}
t.setProgress(pr);
t.setStatus(pr >= 100 ? "已完成" : pr > 0 ? "进行中" : "待办");
}
if (req.deliverable() != null) t.setDeliverable(req.deliverable());
if (req.assignee() != null) t.setAssignee(req.assignee());
wbsRepo.save(t);
rollUpProgress(t.getDevProjectId());
return ApiResp.ok(t);
}
@DeleteMapping("/wbs/{taskId}")
@Transactional
public ApiResp<Void> deleteWbs(@PathVariable Long taskId) {
DevWbsTask t = wbsRepo.findById(taskId)
.orElseThrow(() -> new NotFoundException("wbsTask not found: " + taskId));
Long projectId = t.getDevProjectId();
wbsRepo.deleteById(taskId);
rollUpProgress(projectId);
return ApiResp.ok(null);
}
// ---------- 超期任务预警 ----------
public record OverdueWbs(Long taskId, Long devProjectId, String devProjectName, int seq,
String name, String assignee, int progress, String planEnd,
long daysOverdue, boolean milestone) {
}
/**
* 超期任务预警(修补审计"WBS 超期任务预警端点缺失"):未完成(progress&lt;100)且
* 计划完成日 planEnd 已过的 WBS 任务,按逾期天数降序。可选 devProjectId 收敛到单个立项。
*/
@GetMapping("/wbs/alerts/overdue")
public ApiResp<List<OverdueWbs>> overdueWbs(@RequestParam(required = false) Long devProjectId) {
LocalDate today = LocalDate.now();
List<DevWbsTask> tasks = devProjectId != null
? wbsRepo.findByDevProjectIdOrderBySeqAsc(devProjectId)
: wbsRepo.findAll();
List<OverdueWbs> out = new ArrayList<>();
for (DevWbsTask t : tasks) {
if (t.getProgress() >= 100) {
continue;
}
LocalDate end = parseDateOrNull(t.getPlanEnd());
if (end == null || !end.isBefore(today)) {
continue;
}
long days = java.time.temporal.ChronoUnit.DAYS.between(end, today);
DevProject p = projectRepo.findById(t.getDevProjectId()).orElse(null);
out.add(new OverdueWbs(t.getId(), t.getDevProjectId(), p == null ? "" : p.getName(),
t.getSeq(), t.getName(), t.getAssignee(), t.getProgress(), t.getPlanEnd(),
days, t.isMilestone()));
}
out.sort((a, b) -> Long.compare(b.daysOverdue(), a.daysOverdue()));
return ApiResp.ok(out);
}
// ---------- helpers ----------
private DevProject find(Long id) {
return projectRepo.findById(id)
.orElseThrow(() -> new NotFoundException("devProject not found: " + id));
}
private void generateWbsIfAbsent(DevProject p) {
if (wbsRepo.countByDevProjectId(p.getId()) > 0) {
return;
}
LocalDate start = parseDateOrNull(p.getPlanStart());
List<DevWbsTask> tasks = new ArrayList<>();
Instant now = Instant.now();
// 把计划周期按阶段数均摊到每阶段(缺计划日期时只给名称,不排期)。
Long days = null;
LocalDate end = parseDateOrNull(p.getPlanEnd());
if (start != null && end != null && !end.isBefore(start)) {
days = java.time.temporal.ChronoUnit.DAYS.between(start, end);
}
for (int i = 0; i < WBS_STAGES.length; i++) {
DevWbsTask t = new DevWbsTask();
t.setDevProjectId(p.getId());
t.setSeq(i + 1);
// 标准阶段为串行 FS 依赖:每阶段以前一阶段为前置(首阶段无前置)。
t.setDependsOnSeq(i == 0 ? null : i);
t.setName(WBS_STAGES[i]);
t.setAssignee(p.getLeader());
t.setStatus("待办");
t.setProgress(0);
// 第 2 步技术评审、最后一步量产导入为里程碑。
t.setMilestone(i == 1 || i == WBS_STAGES.length - 1);
if (start != null && days != null) {
long segStart = days * i / WBS_STAGES.length;
long segEnd = days * (i + 1) / WBS_STAGES.length;
t.setPlanStart(start.plusDays(segStart).toString());
t.setPlanEnd(start.plusDays(segEnd).toString());
}
t.setCreatedAt(now);
tasks.add(t);
}
wbsRepo.saveAll(tasks);
}
/** 按各 WBS 任务进度均摊上卷 project 级进度;全部完成且项目「已立项」则自动进「研发中」。 */
private void rollUpProgress(Long projectId) {
if (projectId == null) {
return;
}
DevProject p = projectRepo.findById(projectId).orElse(null);
if (p == null) {
return;
}
List<DevWbsTask> tasks = wbsRepo.findByDevProjectIdOrderBySeqAsc(projectId);
if (tasks.isEmpty()) {
p.setProgress(0);
projectRepo.save(p);
return;
}
int sum = 0;
for (DevWbsTask t : tasks) {
sum += clampPercent(t.getProgress());
}
int avg = sum / tasks.size();
p.setProgress(avg);
if (avg > 0 && "已立项".equals(p.getStage())) {
p.setStage("研发中");
}
projectRepo.save(p);
}
private static int clampPercent(Integer v) {
if (v == null) {
return 0;
}
return Math.max(0, Math.min(100, v));
}
private static String blankTo(String v, String dft) {
return v == null || v.isBlank() ? dft : v;
}
private static LocalDate parseDateOrNull(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length())));
} catch (DateTimeParseException | IndexOutOfBoundsException e) {
return null;
}
}
}