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

261 lines
12 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.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.BizPlan;
import com.kaidi.oa.domain.BizPlanTarget;
import com.kaidi.oa.repository.BizPlanRepository;
import com.kaidi.oa.repository.BizPlanTargetRepository;
import com.kaidi.oa.service.MarketPlanService;
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.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.math.RoundingMode;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* 经营计划与预算·计划部分(需求 §7)。深水能力:
* ① 计划主表 + 指标行(新签合同额/营业收入/回款额/新客户开发数…);
* ② 下达——草稿→已下达;
* ③ 实际值自动取数——sync 把「新签合同额/回款额」从合同台账按周期自动汇总回填
* {@link MarketPlanService},跨模块取数,取代人工填实际);
* ④ 完成率看板 + 偏差预警——完成率<目标且偏差>10% 标记预警(需求"偏差超10%自动预警")。
*
* 写口默认受 default-deny(ADMIN/APPROVER) 保护;含金额、属机密读,已登记
* SENSITIVE_READ_PREFIXES + FINANCE_PREFIXES(见 sharedFileSnippets)。
*/
@RestController
@RequestMapping("/api/oa/biz-plans")
public class BizPlanController {
private static final BigDecimal DEVIATION_THRESHOLD = new BigDecimal("10");
private final BizPlanRepository repo;
private final BizPlanTargetRepository targetRepo;
private final MarketPlanService planService;
public BizPlanController(BizPlanRepository repo, BizPlanTargetRepository targetRepo,
MarketPlanService planService) {
this.repo = repo;
this.targetRepo = targetRepo;
this.planService = planService;
}
// ---------- 计划主表 CRUD ----------
@GetMapping
public ApiResp<List<BizPlan>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String period) {
if (period != null && !period.isBlank()) {
return ApiResp.ok(repo.findByPeriod(period));
}
if (status != null && !status.isBlank()) {
return ApiResp.ok(repo.findByStatus(status));
}
return ApiResp.ok(repo.findAll());
}
@GetMapping("/{id}")
public ApiResp<BizPlan> get(@PathVariable Long id) {
return ApiResp.ok(repo.findById(id)
.orElseThrow(() -> new NotFoundException("biz plan not found: " + id)));
}
public record PlanRequest(String code, String name, String period, String orgUnit,
String owner, String remark) {
}
@PostMapping
public ApiResp<BizPlan> create(@RequestBody PlanRequest req) {
if (req.period() == null || req.period().isBlank()) {
throw new ApiException(400, "计划周期(period)不能为空,如 2026 / 2026Q2");
}
BizPlan p = new BizPlan();
p.setCode(req.code() == null || req.code().isBlank() ? "JHJ-" + (repo.count() + 1) : req.code());
p.setName(req.name() == null || req.name().isBlank()
? (req.period() + " 经营计划") : req.name());
p.setPeriod(req.period());
p.setOrgUnit(req.orgUnit());
p.setStatus("草稿");
p.setOwner(req.owner());
p.setRemark(req.remark());
p.setCreatedAt(Instant.now());
return ApiResp.ok(repo.save(p));
}
@PatchMapping("/{id}")
public ApiResp<BizPlan> update(@PathVariable Long id, @RequestBody PlanRequest req) {
BizPlan p = repo.findById(id)
.orElseThrow(() -> new NotFoundException("biz plan not found: " + id));
if (req.name() != null && !req.name().isBlank()) p.setName(req.name());
if (req.period() != null && !req.period().isBlank()) p.setPeriod(req.period());
if (req.orgUnit() != null) p.setOrgUnit(req.orgUnit());
if (req.owner() != null) p.setOwner(req.owner());
if (req.remark() != null) p.setRemark(req.remark());
return ApiResp.ok(repo.save(p));
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
if (!repo.existsById(id)) {
throw new NotFoundException("biz plan not found: " + id);
}
targetRepo.deleteByPlanId(id);
repo.deleteById(id);
return ApiResp.ok(null);
}
/** 下达计划:草稿 → 已下达。 */
@PostMapping("/{id}/issue")
public ApiResp<BizPlan> issue(@PathVariable Long id) {
BizPlan p = repo.findById(id)
.orElseThrow(() -> new NotFoundException("biz plan not found: " + id));
if ("已下达".equals(p.getStatus())) {
throw new ApiException(409, "计划已下达");
}
if (targetRepo.findByPlanIdOrderBySeqAsc(id).isEmpty()) {
throw new ApiException(400, "请先添加至少一条指标再下达");
}
p.setStatus("已下达");
return ApiResp.ok(repo.save(p));
}
// ---------- 指标行 ----------
@GetMapping("/{id}/targets")
public ApiResp<List<BizPlanTarget>> targets(@PathVariable Long id) {
return ApiResp.ok(targetRepo.findByPlanIdOrderBySeqAsc(id));
}
public record TargetRequest(String metric, String unit, Double targetValue,
Double actualValue, String autoSource) {
}
@PostMapping("/{id}/targets")
public ApiResp<BizPlanTarget> addTarget(@PathVariable Long id, @RequestBody TargetRequest req) {
BizPlan p = repo.findById(id)
.orElseThrow(() -> new NotFoundException("biz plan not found: " + id));
if (req.metric() == null || req.metric().isBlank()) {
throw new ApiException(400, "指标名称不能为空");
}
List<BizPlanTarget> existing = targetRepo.findByPlanIdOrderBySeqAsc(id);
BizPlanTarget t = new BizPlanTarget();
t.setPlanId(p.getId());
t.setMetric(req.metric());
t.setUnit(req.unit() == null || req.unit().isBlank() ? "万元" : req.unit());
t.setTargetValue(Money.of(req.targetValue()));
t.setActualValue(Money.of(req.actualValue()));
t.setAutoSource(req.autoSource() == null || req.autoSource().isBlank() ? "手工" : req.autoSource());
t.setSeq(existing.size() + 1);
t.setCreatedAt(Instant.now());
return ApiResp.ok(targetRepo.save(t));
}
@PatchMapping("/targets/{targetId}")
public ApiResp<BizPlanTarget> updateTarget(@PathVariable Long targetId, @RequestBody TargetRequest req) {
BizPlanTarget t = targetRepo.findById(targetId)
.orElseThrow(() -> new NotFoundException("plan target not found: " + targetId));
if (req.metric() != null && !req.metric().isBlank()) t.setMetric(req.metric());
if (req.unit() != null && !req.unit().isBlank()) t.setUnit(req.unit());
if (req.targetValue() != null) t.setTargetValue(Money.of(req.targetValue()));
if (req.actualValue() != null) t.setActualValue(Money.of(req.actualValue()));
if (req.autoSource() != null && !req.autoSource().isBlank()) t.setAutoSource(req.autoSource());
return ApiResp.ok(targetRepo.save(t));
}
@DeleteMapping("/targets/{targetId}")
public ApiResp<Void> deleteTarget(@PathVariable Long targetId) {
if (!targetRepo.existsById(targetId)) {
throw new NotFoundException("plan target not found: " + targetId);
}
targetRepo.deleteById(targetId);
return ApiResp.ok(null);
}
// ---------- 实际值自动取数 ----------
/**
* 同步实际完成:把 autoSource=合同新签 的指标实际值刷为周期内新签合同额,
* autoSource=回款 的刷为周期内回款额(跨模块从合同台账自动取数)。
* autoSource=手工 的不动(保留人工填报)。
*/
@PostMapping("/{id}/sync")
@Transactional
public ApiResp<List<BizPlanTarget>> sync(@PathVariable Long id) {
BizPlan p = repo.findById(id)
.orElseThrow(() -> new NotFoundException("biz plan not found: " + id));
BigDecimal newContract = planService.newContractTotal(p.getPeriod());
BigDecimal collection = planService.collectionTotal(p.getPeriod());
List<BizPlanTarget> ts = targetRepo.findByPlanIdOrderBySeqAsc(id);
for (BizPlanTarget t : ts) {
if ("合同新签".equals(t.getAutoSource())) {
t.setActualValue(scaleToUnit(newContract, t.getUnit()));
} else if ("回款".equals(t.getAutoSource())) {
t.setActualValue(scaleToUnit(collection, t.getUnit()));
}
}
targetRepo.saveAll(ts);
return ApiResp.ok(targetRepo.findByPlanIdOrderBySeqAsc(id));
}
/** 单位为「万元」时把元口径金额换算成万元;否则原值。 */
private BigDecimal scaleToUnit(BigDecimal yuan, String unit) {
if ("万元".equals(unit)) {
return yuan.divide(new BigDecimal("10000"), Money.SCALE, RoundingMode.HALF_UP);
}
return Money.nz(yuan);
}
// ---------- 完成率看板 + 偏差预警 ----------
public record CompletionRow(Long targetId, String metric, String unit, double target,
double actual, double completionRate, double deviation,
boolean warn, String autoSource) {
}
public record Completion(Long planId, String period, String orgUnit, String status,
int warnCount, List<CompletionRow> rows) {
}
@GetMapping("/{id}/completion")
public ApiResp<Completion> completion(@PathVariable Long id) {
BizPlan p = repo.findById(id)
.orElseThrow(() -> new NotFoundException("biz plan not found: " + id));
List<CompletionRow> rows = new ArrayList<>();
int warnCount = 0;
for (BizPlanTarget t : targetRepo.findByPlanIdOrderBySeqAsc(id)) {
BigDecimal target = Money.nz(t.getTargetValue());
BigDecimal actual = Money.nz(t.getActualValue());
double rate = target.signum() == 0 ? 0
: actual.divide(target, 4, RoundingMode.HALF_UP).doubleValue() * 100.0;
// 偏差 = 目标完成度距 100% 的差(正=欠账)。
double deviation = 100.0 - rate;
boolean warn = target.signum() != 0
&& BigDecimal.valueOf(deviation).compareTo(DEVIATION_THRESHOLD) > 0;
if (warn) {
warnCount++;
}
rows.add(new CompletionRow(t.getId(), t.getMetric(), t.getUnit(),
target.doubleValue(), actual.doubleValue(),
Math.round(rate * 10) / 10.0, Math.round(deviation * 10) / 10.0,
warn, t.getAutoSource()));
}
return ApiResp.ok(new Completion(id, p.getPeriod(), p.getOrgUnit(), p.getStatus(),
warnCount, rows));
}
}