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 管理。 * *

覆盖:立项台账 CRUD;立项状态机(草稿→评审中→已立项→研发中→结题/已驳回); * 提交评审 / 评审通过(立项) / 评审驳回 / 启动研发 / 结题 等业务动作; * 立项通过时自动生成标准 WBS 任务树(设计→评审→打样→测试→试产→量产); * WBS 任务进度填报后自动上卷计算 project 级 progress。 * *

写口默认受 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(@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 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 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 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 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 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 任务树 * (已有任务时不重复生成,幂等)。 * *

硬绑定技术委员会投票节点(修补审计"立项 approve 与 DesignReview 投票节点未硬绑定"): * 若本立项已挂技术评审节点({@link DesignReview}),则必须存在至少一个已结论为 * 「已通过」或「有条件通过」的评审,立项才能通过 —— 投票闭环成为立项必经。 * 未挂任何评审节点时(轻量立项)允许直接通过,保持向后兼容。 */ @PostMapping("/{id}/approve") @Transactional public ApiResp approve(@PathVariable Long id, @RequestBody(required = false) ApproveRequest req) { DevProject p = find(id); if (!"评审中".equals(p.getStage())) { throw new ApiException(409, "仅评审中的立项可通过,当前为「" + p.getStage() + "」"); } List 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 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 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 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> wbs(@PathVariable Long id) { return ApiResp.ok(wbsRepo.findByDevProjectIdOrderBySeqAsc(id)); } /** 手动补建标准 WBS(已有任务时报 409,避免重复展开)。 */ @PostMapping("/{id}/wbs/generate") @Transactional public ApiResp> 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 addWbs(@PathVariable Long id, @RequestBody WbsTaskRequest req) { find(id); if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "任务名称(name) 不能为空"); } List 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 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 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<100)且 * 计划完成日 planEnd 已过的 WBS 任务,按逾期天数降序。可选 devProjectId 收敛到单个立项。 */ @GetMapping("/wbs/alerts/overdue") public ApiResp> overdueWbs(@RequestParam(required = false) Long devProjectId) { LocalDate today = LocalDate.now(); List tasks = devProjectId != null ? wbsRepo.findByDevProjectIdOrderBySeqAsc(devProjectId) : wbsRepo.findAll(); List 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 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 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; } } }