package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.domain.QualCert; import com.kaidi.oa.domain.QualDeclFee; import com.kaidi.oa.domain.QualDeclaration; import com.kaidi.oa.domain.StaffCredential; import com.kaidi.oa.repository.QualCertRepository; import com.kaidi.oa.repository.QualDeclFeeRepository; import com.kaidi.oa.repository.QualDeclarationRepository; import com.kaidi.oa.repository.StaffCredentialRepository; import org.springframework.web.bind.annotation.GetMapping; 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.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 行政·综合部——资质报表与决策分析(Module 8 缺口)。 * * 补完缺口(4 个独立聚合端点): * GET /qual-analytics/panorama — 资质全景图:所有资质按等级/有效期/业务板块可视化数据 * GET /qual-analytics/calendar — 资质到期日历:未来 12 个月到期资质分月汇总 * GET /qual-analytics/cert-gap — 人员证书统计与资质标准差距仪表板 * GET /qual-analytics/roi — 申报成本 ROI 分析(费用台账 + 关联中标额) */ @RestController @RequestMapping("/api/oa/qual-analytics") public class QualAnalyticsController { private final QualCertRepository certRepo; private final StaffCredentialRepository credRepo; private final QualDeclarationRepository declRepo; private final QualDeclFeeRepository feeRepo; public QualAnalyticsController(QualCertRepository certRepo, StaffCredentialRepository credRepo, QualDeclarationRepository declRepo, QualDeclFeeRepository feeRepo) { this.certRepo = certRepo; this.credRepo = credRepo; this.declRepo = declRepo; this.feeRepo = feeRepo; } // ---------- 资质全景图 ---------- /** * 资质全景图:图形化展示所有资质的等级/有效期/业务板块/状态分布。 * 前端可用饼图/条形图渲染。 * * 满足需求:"图形化展示公司拥有的所有资质、等级、有效期,按业务板块分类"。 */ @GetMapping("/panorama") public ApiResp> panorama() { List all = certRepo.findAll(); // 按状态分组计数 Map byStatus = new LinkedHashMap<>(); for (QualCert c : all) { String s = c.getStatus() != null ? c.getStatus() : "未知"; byStatus.merge(s, 1, Integer::sum); } // 按等级分组 Map byLevel = new LinkedHashMap<>(); for (QualCert c : all) { String lv = c.getLevel() != null && !c.getLevel().isBlank() ? c.getLevel() : "不分级"; byLevel.merge(lv, 1, Integer::sum); } // 按类别分组(企业资质 / 人员资质 / 荣誉资质) Map byCategory = new LinkedHashMap<>(); for (QualCert c : all) { String cat = c.getCategory() != null ? c.getCategory() : "其他"; byCategory.merge(cat, 1, Integer::sum); } // 按资质细类(certType)分组 Map byCertType = new LinkedHashMap<>(); for (QualCert c : all) { String ct = c.getCertType() != null && !c.getCertType().isBlank() ? c.getCertType() : "其他"; byCertType.merge(ct, 1, Integer::sum); } // 明细列表(含剩余天数) LocalDate today = LocalDate.now(); List> detail = new ArrayList<>(); for (QualCert c : all) { Map item = new HashMap<>(); item.put("id", c.getId()); item.put("name", c.getName() != null ? c.getName() : ""); item.put("category", c.getCategory() != null ? c.getCategory() : ""); item.put("certType", c.getCertType() != null ? c.getCertType() : ""); item.put("level", c.getLevel() != null ? c.getLevel() : ""); item.put("status", c.getStatus() != null ? c.getStatus() : ""); item.put("expireDate", c.getExpireDate() != null ? c.getExpireDate() : ""); LocalDate exp = parse(c.getExpireDate()); item.put("daysToExpire", exp != null ? ChronoUnit.DAYS.between(today, exp) : null); detail.add(item); } return ApiResp.ok(Map.of( "total", all.size(), "byStatus", byStatus, "byLevel", byLevel, "byCategory", byCategory, "byCertType", byCertType, "detail", detail, "generatedAt", today.toString() )); } // ---------- 资质到期日历 ---------- /** * 到期日历:按月聚合未来 12 个月内所有即将到期的资质, * 前端可渲染日历视图(每月 badge 数量 + 明细列表)。 * * 满足需求:"日历形式展示未来12个月所有即将到期的资质证书,便于提前规划"。 */ @GetMapping("/calendar") public ApiResp> calendar( @RequestParam(required = false, defaultValue = "12") int months) { LocalDate today = LocalDate.now(); LocalDate end = today.plusMonths(months); List all = certRepo.findAll(); // 按 YYYY-MM 分组 Map>> monthly = new LinkedHashMap<>(); // 初始化 12 个月 key(保证顺序) for (int i = 0; i < months; i++) { String key = today.plusMonths(i).toString().substring(0, 7); monthly.put(key, new ArrayList<>()); } for (QualCert c : all) { LocalDate exp = parse(c.getExpireDate()); if (exp == null) continue; if (exp.isBefore(today) || exp.isAfter(end)) continue; String monthKey = exp.toString().substring(0, 7); if (!monthly.containsKey(monthKey)) continue; long daysLeft = ChronoUnit.DAYS.between(today, exp); monthly.get(monthKey).add(Map.of( "id", c.getId(), "name", c.getName() != null ? c.getName() : "", "category", c.getCategory() != null ? c.getCategory() : "", "level", c.getLevel() != null ? c.getLevel() : "", "expireDate", c.getExpireDate(), "status", c.getStatus() != null ? c.getStatus() : "", "daysLeft", daysLeft, "urgency", daysLeft <= 30 ? "紧急" : daysLeft <= 90 ? "提醒" : "提前规划" )); } // 月度摘要 List> summary = new ArrayList<>(); for (Map.Entry>> e : monthly.entrySet()) { summary.add(Map.of( "month", e.getKey(), "count", e.getValue().size(), "items", e.getValue() )); } return ApiResp.ok(Map.of( "windowMonths", months, "startDate", today.toString(), "endDate", end.toString(), "monthly", summary, "totalExpiring", all.stream().filter(c -> { LocalDate e = parse(c.getExpireDate()); return e != null && !e.isBefore(today) && !e.isAfter(end); }).count() )); } // ---------- 人员证书统计与资质标准差距 ---------- /** * 人员证书统计仪表板:按证书类型/专业/等级统计数量, * 并与资质标准要求的典型配置对比,显示差距。 * * 满足需求:"按证书类型、专业、等级统计人员证书数量,显示与资质标准要求的差距"。 */ @GetMapping("/cert-gap") public ApiResp> certGap() { List creds = credRepo.findAll(); // 按证书类型分组统计(在职有效,借出/占用也算) Map byCredType = new LinkedHashMap<>(); Map availableByType = new LinkedHashMap<>(); for (StaffCredential c : creds) { String t = c.getCredType() != null ? c.getCredType() : "其他"; byCredType.merge(t, 1L, Long::sum); if (!"已占用".equals(c.getLockState())) { availableByType.merge(t, 1L, Long::sum); } } // 参考行业典型资质标准配置要求(建筑业企业,供差距对比参考) Map standardReq = Map.of( "一级建造师", 12, "二级建造师", 6, "注册造价工程师", 3, "注册安全工程师", 2, "高级工程师职称", 5, "安全员证", 4 ); List> gapList = new ArrayList<>(); for (Map.Entry req : standardReq.entrySet()) { long have = byCredType.getOrDefault(req.getKey(), 0L); long avail = availableByType.getOrDefault(req.getKey(), 0L); long gap = Math.max(0, req.getValue() - have); gapList.add(Map.of( "credType", req.getKey(), "required", req.getValue(), "current", have, "available", avail, "gap", gap, "status", gap == 0 ? "达标" : "缺口" )); } // 其他证书也列出(标准无要求的) List> extra = new ArrayList<>(); for (Map.Entry e : byCredType.entrySet()) { if (!standardReq.containsKey(e.getKey())) { extra.add(Map.of( "credType", e.getKey(), "current", e.getValue(), "available", availableByType.getOrDefault(e.getKey(), 0L) )); } } return ApiResp.ok(Map.of( "totalCerts", creds.size(), "gapAnalysis", gapList, "extraCerts", extra, "byCredType", byCredType, "note", "差距分析参考建筑业企业资质通用配置标准,实际以目标资质最新申报要求为准" )); } // ---------- 申报成本 ROI 分析 ---------- /** * 申报成本 ROI 分析:统计各资质申报费用 + 关联中标额,计算综合 ROI。 * * 满足需求:"统计各资质的申报费用、维护成本、带来的中标额,计算ROI"。 */ @GetMapping("/roi") public ApiResp> roi() { List decls = declRepo.findAll(); List fees = feeRepo.findAll(); List certs = certRepo.findAll(); // 按 declarationId 汇总费用 Map feeByDecl = new HashMap<>(); for (QualDeclFee f : fees) { if (f.getDeclarationId() != null) { feeByDecl.merge(f.getDeclarationId(), f.getAmount() != null ? f.getAmount() : BigDecimal.ZERO, BigDecimal::add); } } BigDecimal totalCost = BigDecimal.ZERO; BigDecimal totalBudget = BigDecimal.ZERO; List> items = new ArrayList<>(); for (QualDeclaration d : decls) { BigDecimal spent = feeByDecl.getOrDefault(d.getId(), BigDecimal.ZERO); BigDecimal budget = d.getBudget() != null ? d.getBudget() : BigDecimal.ZERO; totalCost = totalCost.add(spent); totalBudget = totalBudget.add(budget); // 估算资质带来的中标额(此处仅做占位逻辑,实际应关联投标/合同模块) BigDecimal estBidAmount = BigDecimal.ZERO; // ROI = (中标额 - 申报成本) / 申报成本 * 100% // 因无真实合同联动,此处给出成本利用效率(已领证/已提交视为有效) boolean effective = "已领证".equals(d.getStatus()) || "已提交".equals(d.getStatus()); Map item = new HashMap<>(); item.put("declId", d.getId()); item.put("qualName", d.getQualName() != null ? d.getQualName() : ""); item.put("declType", d.getDeclType() != null ? d.getDeclType() : ""); item.put("status", d.getStatus() != null ? d.getStatus() : ""); item.put("budget", budget); item.put("spent", spent); item.put("budgetUtil", budget.compareTo(BigDecimal.ZERO) > 0 ? spent.multiply(new BigDecimal("100")).divide(budget, 1, java.math.RoundingMode.HALF_UP) : BigDecimal.ZERO); item.put("effective", effective); item.put("estBidAmount", estBidAmount); item.put("note", effective ? "已产生资质效益" : "申报中/未完成"); items.add(item); } // 资质有效数量 long validCerts = certs.stream().filter(c -> "有效".equals(c.getStatus()) || "即将到期".equals(c.getStatus())).count(); return ApiResp.ok(Map.of( "totalDeclCost", totalCost, "totalBudget", totalBudget, "validCertCount", validCerts, "declCount", decls.size(), "items", items, "note", "ROI 中标额关联需对接投标/合同模块获取实际中标数据,当前为成本利用分析" )); } // ---------- helper ---------- private static LocalDate parse(String s) { if (s == null || s.isBlank()) return null; try { return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length()))); } catch (DateTimeParseException | IndexOutOfBoundsException e) { return null; } } }