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>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user