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(@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 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 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 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 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 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> 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 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 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 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 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> 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 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 rows) { } @GetMapping("/{id}/completion") public ApiResp completion(@PathVariable Long id) { BizPlan p = repo.findById(id) .orElseThrow(() -> new NotFoundException("biz plan not found: " + id)); List 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)); } }