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;
}
}