package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.domain.DevProject; import com.kaidi.oa.domain.DevProjectBudget; import com.kaidi.oa.repository.DevProjectBudgetRepository; import com.kaidi.oa.repository.DevProjectRepository; import org.springframework.web.bind.annotation.GetMapping; 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.math.BigDecimal; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 创新研发中心 / 产品开发部 · 产品上市统计与研发投入三维分析(需求模块 10 报表与决策支持)。 * *

补审计缺口"缺产品上市数量/上市周期/成功率端点;研发投入缺按部门维度统计": * (1) /dev-launch-stats/launch-metrics — 新产品上市数量、上市周期(立项→发布天数)均值、成功率; * (2) /dev-launch-stats/by-dept — 研发投入按部门维度汇总(预算/实际); * (3) /dev-launch-stats/by-product-line — 按产品线维度汇总(已有,本端点补 dept 维度); * (4) /dev-launch-stats/cycle-detail — 每个已上市项目的立项→发布周期明细(可用于甘特分析)。 * *

读口含成本汇总,已登记进 SENSITIVE_READ_PREFIXES(见 sharedFileSnippets)。 */ @RestController @RequestMapping("/api/oa/dev-launch-stats") public class DevLaunchStatController { private final DevProjectRepository projectRepo; private final DevProjectBudgetRepository budgetRepo; public DevLaunchStatController(DevProjectRepository projectRepo, DevProjectBudgetRepository budgetRepo) { this.projectRepo = projectRepo; this.budgetRepo = budgetRepo; } // ====================================================================== // 1. 产品上市核心指标 // ====================================================================== public record LaunchMetrics( int totalProjects, int launchedCount, int closedNotLaunched, double successRate, double avgCycleDays, double minCycleDays, double maxCycleDays, int noCycleDateCount) { } /** * 新产品上市数量、上市周期(立项 createdAt → launchDate,天数)、成功率。 * successRate = 已上市 / 结题 × 100%(结题但未标记上市视为失败)。 */ @GetMapping("/launch-metrics") public ApiResp launchMetrics( @RequestParam(required = false) String productLine, @RequestParam(required = false) String dept) { List projects = projectRepo.findAll(); int totalClosed = 0; int launched = 0; List cycleDaysList = new ArrayList<>(); int noCycleDate = 0; for (DevProject p : projects) { if (productLine != null && !productLine.isBlank() && !productLine.equals(p.getProductLine())) continue; if (dept != null && !dept.isBlank() && !dept.equals(p.getDept()) && !dept.equals(p.getDepartment())) continue; boolean isClosed = "结题".equals(p.getStage()); if (isClosed) totalClosed++; if (p.isProductLaunched()) { launched++; // 计算周期:createdAt → launchDate。 if (p.getCreatedAt() != null && p.getLaunchDate() != null && !p.getLaunchDate().isBlank()) { try { LocalDate start = p.getCreatedAt().atZone(java.time.ZoneId.systemDefault()) .toLocalDate(); LocalDate end = LocalDate.parse(p.getLaunchDate()); long days = ChronoUnit.DAYS.between(start, end); if (days >= 0) cycleDaysList.add(days); } catch (Exception ignored) { noCycleDate++; } } else { noCycleDate++; } } } double successRate = totalClosed == 0 ? 0 : round2((double) launched / totalClosed * 100); double avgCycle = cycleDaysList.isEmpty() ? 0 : round2(cycleDaysList.stream().mapToLong(Long::longValue).average().orElse(0)); double minCycle = cycleDaysList.isEmpty() ? 0 : cycleDaysList.stream().mapToLong(Long::longValue).min().orElse(0); double maxCycle = cycleDaysList.isEmpty() ? 0 : cycleDaysList.stream().mapToLong(Long::longValue).max().orElse(0); return ApiResp.ok(new LaunchMetrics( (int) projectRepo.count(), launched, totalClosed - launched, successRate, avgCycle, minCycle, maxCycle, noCycleDate)); } // ====================================================================== // 2. 按部门维度统计研发投入(补"三维:项目/产品线/部门"中的部门维度) // ====================================================================== public record DeptStat(String dept, int projectCount, int launchedCount, double budget, double actual, double execRate) { } @GetMapping("/by-dept") public ApiResp> byDept() { List projects = projectRepo.findAll(); // 预算/实际按项目 id 聚合。 Map budgetByProject = new LinkedHashMap<>(); Map actualByProject = new LinkedHashMap<>(); for (DevProjectBudget b : budgetRepo.findAll()) { budgetByProject.merge(b.getDevProjectId(), Money.nz(b.getBudgetAmount()), Money::add); actualByProject.merge(b.getDevProjectId(), Money.nz(b.getActualAmount()), Money::add); } // 按部门分组。 Map countByDept = new LinkedHashMap<>(); // [0]=total [1]=launched Map budgetByDept = new LinkedHashMap<>(); Map actualByDept = new LinkedHashMap<>(); for (DevProject p : projects) { String d = notBlank(p.getDept(), notBlank(p.getDepartment(), "未分类")); int[] cnt = countByDept.computeIfAbsent(d, k -> new int[2]); cnt[0]++; if (p.isProductLaunched()) cnt[1]++; BigDecimal bud = budgetByProject.getOrDefault(p.getId(), Money.nz(p.getBudget())); BigDecimal act = actualByProject.getOrDefault(p.getId(), Money.ZERO); budgetByDept.merge(d, bud, Money::add); actualByDept.merge(d, act, Money::add); } List stats = new ArrayList<>(); for (Map.Entry e : countByDept.entrySet()) { BigDecimal bud = budgetByDept.getOrDefault(e.getKey(), Money.ZERO); BigDecimal act = actualByDept.getOrDefault(e.getKey(), Money.ZERO); double rate = bud.signum() == 0 ? 0 : round2(act.doubleValue() / bud.doubleValue() * 100); stats.add(new DeptStat(e.getKey(), e.getValue()[0], e.getValue()[1], bud.doubleValue(), act.doubleValue(), rate)); } return ApiResp.ok(stats); } // ====================================================================== // 3. 上市周期明细(每个已上市项目) // ====================================================================== public record CycleDetail(Long id, String code, String name, String productLine, String dept, String createdDate, String launchDate, long cycleDays, String projectType) { } @GetMapping("/cycle-detail") public ApiResp> cycleDetail() { List projects = projectRepo.findAll(); List details = new ArrayList<>(); for (DevProject p : projects) { if (!p.isProductLaunched()) continue; String createdDate = p.getCreatedAt() != null ? p.getCreatedAt().atZone(java.time.ZoneId.systemDefault()) .toLocalDate().toString() : null; long days = 0; if (createdDate != null && p.getLaunchDate() != null && !p.getLaunchDate().isBlank()) { try { days = ChronoUnit.DAYS.between(LocalDate.parse(createdDate), LocalDate.parse(p.getLaunchDate())); } catch (Exception ignored) { days = -1; } } details.add(new CycleDetail(p.getId(), p.getCode(), p.getName(), p.getProductLine(), notBlank(p.getDept(), p.getDepartment()), createdDate, p.getLaunchDate(), days, p.getProjectType())); } return ApiResp.ok(details); } // ====================================================================== // 4. 标记项目已上市(写口,触发 productLaunched=true + launchDate 回填) // ====================================================================== public record MarkLaunchRequest(String launchDate, String dept) { } /** * 标记研发项目为「已上市」(量产发布):回填 launchDate + productLaunched=true。 * 须项目处于结题或研发中阶段。写口受 ADMIN/APPROVER 保护。 */ @org.springframework.web.bind.annotation.PostMapping("/{id}/mark-launched") @org.springframework.transaction.annotation.Transactional public ApiResp markLaunched( @PathVariable Long id, @RequestBody(required = false) MarkLaunchRequest req) { DevProject p = projectRepo.findById(id) .orElseThrow(() -> new com.kaidi.oa.common.NotFoundException("研发立项不存在:" + id)); if (p.isProductLaunched()) { throw new com.kaidi.oa.common.ApiException(409, "该项目已标记上市(上市日期:" + p.getLaunchDate() + ")"); } if (!"结题".equals(p.getStage()) && !"研发中".equals(p.getStage())) { throw new com.kaidi.oa.common.ApiException(400, "仅结题或研发中阶段的项目可标记上市,当前阶段:" + p.getStage()); } p.setProductLaunched(true); String ld = (req != null && req.launchDate() != null && !req.launchDate().isBlank()) ? req.launchDate() : LocalDate.now().toString(); p.setLaunchDate(ld); if (req != null && req.dept() != null && !req.dept().isBlank()) { p.setDept(req.dept()); } return ApiResp.ok(projectRepo.save(p)); } // ---------- helpers ---------- private static double round2(double v) { return Math.round(v * 100d) / 100d; } private static String notBlank(String v, String dft) { return v == null || v.isBlank() ? dft : v; } }