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

252 lines
11 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.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 报表与决策支持)。
*
* <p>补审计缺口"缺产品上市数量/上市周期/成功率端点;研发投入缺按部门维度统计":
* (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 — 每个已上市项目的立项→发布周期明细(可用于甘特分析)。
*
* <p>读口含成本汇总,已登记进 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> launchMetrics(
@RequestParam(required = false) String productLine,
@RequestParam(required = false) String dept) {
List<DevProject> projects = projectRepo.findAll();
int totalClosed = 0;
int launched = 0;
List<Long> 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<List<DeptStat>> byDept() {
List<DevProject> projects = projectRepo.findAll();
// 预算/实际按项目 id 聚合。
Map<Long, BigDecimal> budgetByProject = new LinkedHashMap<>();
Map<Long, BigDecimal> 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<String, int[]> countByDept = new LinkedHashMap<>(); // [0]=total [1]=launched
Map<String, BigDecimal> budgetByDept = new LinkedHashMap<>();
Map<String, BigDecimal> 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<DeptStat> stats = new ArrayList<>();
for (Map.Entry<String, int[]> 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<List<CycleDetail>> cycleDetail() {
List<DevProject> projects = projectRepo.findAll();
List<CycleDetail> 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<DevProject> 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;
}
}