|
|
@@ -0,0 +1,811 @@
|
|
|
+package cn.iocoder.yudao.module.system.service.biz;
|
|
|
+
|
|
|
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.riskboard.NineRiskBoardReqVO;
|
|
|
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.riskboard.NineRiskBoardRespVO;
|
|
|
+import cn.iocoder.yudao.module.system.controller.admin.biz.vo.riskboard.NineRiskBoardRespVO.*;
|
|
|
+import cn.iocoder.yudao.module.system.dal.mysql.biz.bo.NineRiskAssessFlatBO;
|
|
|
+import cn.iocoder.yudao.module.system.dal.mysql.biz.NineRiskBoardMapper;
|
|
|
+import cn.iocoder.yudao.module.system.enums.biz.NineRiskItemEnum;
|
|
|
+import cn.iocoder.yudao.module.system.enums.biz.RiskLevelEnum;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.YearMonth;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
+import java.util.*;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 九防安全风险综合看板 Service 实现。
|
|
|
+ *
|
|
|
+ * <p><b>设计要点</b>:整个看板仅访问数据库 2 次 —— 一次取九防评估扁平化明细,
|
|
|
+ * 一次取知情书统计。其余 12 个模块的指标全部由这份明细在内存中聚合派生,
|
|
|
+ * 避免了「一个指标一条 SQL」带来的重复扫表。</p>
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class NineRiskBoardServiceImpl implements NineRiskBoardService {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 月度趋势默认统计月份数
|
|
|
+ */
|
|
|
+ private static final int DEFAULT_TREND_MONTHS = 6;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 多项高风险默认阈值:2 项及以上
|
|
|
+ */
|
|
|
+ private static final int DEFAULT_MULTI_HIGH_RISK_THRESHOLD = 2;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 得分分布的分段边界,形成 0-20、20-40、40-60、60-80、80-100 五档
|
|
|
+ */
|
|
|
+ private static final int[] SCORE_BUCKET_BOUNDS = {0, 20, 40, 60, 80, 100};
|
|
|
+
|
|
|
+ private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
|
|
|
+
|
|
|
+ private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private NineRiskBoardMapper nineRiskBoardMapper;
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public NineRiskBoardRespVO getRiskBoard(NineRiskBoardReqVO reqVO) {
|
|
|
+ if (reqVO == null) {
|
|
|
+ reqVO = new NineRiskBoardReqVO();
|
|
|
+ }
|
|
|
+ int trendMonths = reqVO.getTrendMonths() == null || reqVO.getTrendMonths() <= 0
|
|
|
+ ? DEFAULT_TREND_MONTHS : reqVO.getTrendMonths();
|
|
|
+ int multiThreshold = reqVO.getMultiHighRiskThreshold() == null || reqVO.getMultiHighRiskThreshold() <= 1
|
|
|
+ ? DEFAULT_MULTI_HIGH_RISK_THRESHOLD : reqVO.getMultiHighRiskThreshold();
|
|
|
+
|
|
|
+ // ============ 数据库访问 1/2:九防评估扁平化明细(看板通用数据源)============
|
|
|
+ // 查询全量历史明细,不下推日期条件:月度趋势需要历史轨迹,其余指标在内存中取最新一条
|
|
|
+ List<NineRiskAssessFlatBO> historyList = nineRiskBoardMapper.selectAssessFlatList(reqVO, null);
|
|
|
+ historyList = historyList == null ? Collections.emptyList() : historyList;
|
|
|
+
|
|
|
+ // ============ 数据库访问 2/2:知情书统计 ============
|
|
|
+ Map<String, Object> disclosureStat = nineRiskBoardMapper.selectDisclosureStat(reqVO);
|
|
|
+
|
|
|
+ // 同一长者在同一张九防表可能存在多条评估记录,「现状类」指标一律只认最新的一条
|
|
|
+ List<NineRiskAssessFlatBO> latestList = filterLatestPerElderItem(historyList);
|
|
|
+
|
|
|
+ // ---------- 基于最新明细预计算若干「公共中间结果」,供多个模块复用 ----------
|
|
|
+ Context ctx = buildContext(latestList);
|
|
|
+
|
|
|
+ NineRiskBoardRespVO resp = new NineRiskBoardRespVO();
|
|
|
+ DisclosureSignRate signRate = buildDisclosureSignRate(disclosureStat);
|
|
|
+
|
|
|
+ resp.setDisclosureSignRate(signRate); // 7
|
|
|
+ resp.setOverview(buildOverview(ctx, signRate)); // 1
|
|
|
+ resp.setItemRiskDistributions(buildItemRiskDistributions(ctx)); // 2-1
|
|
|
+ resp.setRiskLevelRatios(buildRiskLevelRatios(ctx)); // 2-2
|
|
|
+ // 月度趋势反映的是「每月评估情况的变化」,必须基于全量历史明细,不能去重
|
|
|
+ resp.setMonthlyTrends(buildMonthlyTrends(historyList, trendMonths)); // 3
|
|
|
+ resp.setHighRiskHeatMap(buildHeatMap(ctx)); // 4
|
|
|
+ resp.setHighRiskElders(buildHighRiskElders(ctx)); // 5
|
|
|
+ resp.setItemScoreDistributions(buildItemScoreDistributions(ctx)); // 6
|
|
|
+ resp.setNurseLevelHighRiskElderCounts(buildNurseLevelElderCounts(ctx)); // 8
|
|
|
+ resp.setNurseLevelHighRiskRecordCounts(buildNurseLevelRecordCounts(ctx)); // 9
|
|
|
+ resp.setAssessorStats(buildAssessorStats(ctx)); // 10
|
|
|
+ resp.setHighRiskRatio(buildHighRiskRatio(ctx)); // 11
|
|
|
+ resp.setMultiHighRiskElders(buildMultiHighRiskElders(ctx, multiThreshold)); // 12
|
|
|
+ return resp;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 公共中间结果
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 看板计算上下文:把明细「一次遍历」拆解成若干可复用的中间结构,
|
|
|
+ * 后续 12 个模块只读这些结构,不再重复遍历原始明细。
|
|
|
+ */
|
|
|
+ private static class Context {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 九防评估明细,每个「长者 + 九防项」只保留最新一条
|
|
|
+ */
|
|
|
+ List<NineRiskAssessFlatBO> all;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 高风险明细(risk_level 归一化为「高」),同样只含最新一条
|
|
|
+ */
|
|
|
+ List<NineRiskAssessFlatBO> highList;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 按九防项分组的明细(最新一条口径)
|
|
|
+ */
|
|
|
+ Map<String, List<NineRiskAssessFlatBO>> byItem;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 全局风险等级计数
|
|
|
+ */
|
|
|
+ Map<RiskLevelEnum, Integer> riskLevelCount;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 被评估长者(去重):elderId -> 该长者任一档案行,用于取姓名等冗余信息
|
|
|
+ */
|
|
|
+ Map<Long, NineRiskAssessFlatBO> elderProfileMap;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 长者 -> 已评估的九防项集合,用于计算综合覆盖率
|
|
|
+ */
|
|
|
+ Map<Long, Set<String>> elderAssessedItems;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 长者 -> 高风险的九防项集合(同一长者同一项多次评估只算 1 项)
|
|
|
+ */
|
|
|
+ Map<Long, Set<String>> elderHighRiskItems;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 长者 -> 高风险项中的最新评估日期
|
|
|
+ */
|
|
|
+ Map<Long, LocalDate> elderLatestHighRiskDate;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 长者 -> 高风险项的评估人集合
|
|
|
+ */
|
|
|
+ Map<Long, Set<String>> elderHighRiskAssessors;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 按「长者 + 九防项」保留最新的一条评估记录。
|
|
|
+ *
|
|
|
+ * <p>同一名长者在同一张九防表中可能有多条历史评估记录,看板的「现状类」指标
|
|
|
+ * (风险分布、高危人数、得分、覆盖率、热力图、明细列表等)只应体现最近一次评估结果,
|
|
|
+ * 否则历史记录会被重复计入,导致人次虚高、且同一长者可能同时被算成高危与低危。</p>
|
|
|
+ *
|
|
|
+ * <p>排序规则:评估日期较晚者优先;日期相同(或为空)时取主键 id 较大者,
|
|
|
+ * 保证同日多次录入时仍能稳定拿到最后录入的一条。</p>
|
|
|
+ *
|
|
|
+ * @param historyList 全量历史明细
|
|
|
+ * @return 每个「长者 + 九防项」仅保留一条的最新明细
|
|
|
+ */
|
|
|
+ private List<NineRiskAssessFlatBO> filterLatestPerElderItem(List<NineRiskAssessFlatBO> historyList) {
|
|
|
+ Map<String, NineRiskAssessFlatBO> latestMap = new LinkedHashMap<>();
|
|
|
+ for (NineRiskAssessFlatBO row : historyList) {
|
|
|
+ if (row.getElderId() == null || row.getItemCode() == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String key = row.getElderId() + "#" + row.getItemCode();
|
|
|
+ NineRiskAssessFlatBO exists = latestMap.get(key);
|
|
|
+ if (exists == null || isNewer(row, exists)) {
|
|
|
+ latestMap.put(key, row);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return new ArrayList<>(latestMap.values());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断 candidate 是否比 current 更新:先比评估日期,日期无法区分时比主键 id。
|
|
|
+ */
|
|
|
+ private static boolean isNewer(NineRiskAssessFlatBO candidate, NineRiskAssessFlatBO current) {
|
|
|
+ LocalDate candidateDate = candidate.getAssessDate();
|
|
|
+ LocalDate currentDate = current.getAssessDate();
|
|
|
+ if (candidateDate != null && currentDate != null && !candidateDate.isEqual(currentDate)) {
|
|
|
+ return candidateDate.isAfter(currentDate);
|
|
|
+ }
|
|
|
+ // 有日期的优先于没日期的
|
|
|
+ if (candidateDate != null && currentDate == null) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (candidateDate == null && currentDate != null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // 日期相同或均为空,比较主键
|
|
|
+ Long candidateId = candidate.getAssessId();
|
|
|
+ Long currentId = current.getAssessId();
|
|
|
+ if (candidateId == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return currentId == null || candidateId > currentId;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 单次遍历明细,构建全部公共中间结果。
|
|
|
+ */
|
|
|
+ private Context buildContext(List<NineRiskAssessFlatBO> flatList) {
|
|
|
+ Context ctx = new Context();
|
|
|
+ ctx.all = flatList;
|
|
|
+ ctx.highList = new ArrayList<>();
|
|
|
+ ctx.byItem = new LinkedHashMap<>();
|
|
|
+ ctx.riskLevelCount = new EnumMap<>(RiskLevelEnum.class);
|
|
|
+ ctx.elderProfileMap = new LinkedHashMap<>();
|
|
|
+ ctx.elderAssessedItems = new LinkedHashMap<>();
|
|
|
+ ctx.elderHighRiskItems = new LinkedHashMap<>();
|
|
|
+ ctx.elderLatestHighRiskDate = new HashMap<>();
|
|
|
+ ctx.elderHighRiskAssessors = new HashMap<>();
|
|
|
+
|
|
|
+ // 预置九防项,保证没有数据的项也会出现在图表中
|
|
|
+ for (NineRiskItemEnum item : NineRiskItemEnum.values()) {
|
|
|
+ ctx.byItem.put(item.getCode(), new ArrayList<>());
|
|
|
+ }
|
|
|
+ for (RiskLevelEnum level : RiskLevelEnum.values()) {
|
|
|
+ ctx.riskLevelCount.put(level, 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (NineRiskAssessFlatBO row : flatList) {
|
|
|
+ String itemCode = row.getItemCode();
|
|
|
+ Long elderId = row.getElderId();
|
|
|
+ RiskLevelEnum level = RiskLevelEnum.parse(row.getRiskLevel());
|
|
|
+
|
|
|
+ ctx.byItem.computeIfAbsent(itemCode, k -> new ArrayList<>()).add(row);
|
|
|
+ ctx.riskLevelCount.merge(level, 1, Integer::sum);
|
|
|
+
|
|
|
+ if (elderId != null) {
|
|
|
+ ctx.elderProfileMap.putIfAbsent(elderId, row);
|
|
|
+ ctx.elderAssessedItems.computeIfAbsent(elderId, k -> new HashSet<>()).add(itemCode);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (level == RiskLevelEnum.HIGH) {
|
|
|
+ ctx.highList.add(row);
|
|
|
+ if (elderId != null) {
|
|
|
+ ctx.elderHighRiskItems.computeIfAbsent(elderId, k -> new LinkedHashSet<>()).add(itemCode);
|
|
|
+ // 记录高风险项中的最新评估日期
|
|
|
+ LocalDate date = row.getAssessDate();
|
|
|
+ if (date != null) {
|
|
|
+ ctx.elderLatestHighRiskDate.merge(elderId, date,
|
|
|
+ (oldVal, newVal) -> newVal.isAfter(oldVal) ? newVal : oldVal);
|
|
|
+ }
|
|
|
+ if (isNotBlank(row.getAssessor())) {
|
|
|
+ ctx.elderHighRiskAssessors.computeIfAbsent(elderId, k -> new LinkedHashSet<>())
|
|
|
+ .add(row.getAssessor().trim());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return ctx;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 1、顶部核心指标
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private Overview buildOverview(Context ctx, DisclosureSignRate signRate) {
|
|
|
+ Overview vo = new Overview();
|
|
|
+ int recordTotal = ctx.all.size();
|
|
|
+ int highTimes = ctx.highList.size();
|
|
|
+ int elderCount = ctx.elderProfileMap.size();
|
|
|
+
|
|
|
+ vo.setAssessRecordTotal(recordTotal);
|
|
|
+ vo.setAssessedElderCount(elderCount);
|
|
|
+ vo.setHighRiskTimes(highTimes);
|
|
|
+ vo.setHighRiskTimesRatio(ratio(highTimes, recordTotal));
|
|
|
+
|
|
|
+ // 知情书相关
|
|
|
+ vo.setDisclosureTotal(signRate.getTotal());
|
|
|
+ vo.setDisclosureUnsignedCount(signRate.getUnsignedCount());
|
|
|
+ vo.setDisclosureUnsignedRatio(ratio(signRate.getUnsignedCount(), signRate.getTotal()));
|
|
|
+
|
|
|
+ // 评估完成率(9 项防险综合覆盖率)= 实际评估到的项数 / (被评估长者数 × 9)
|
|
|
+ int assessedItemCount = ctx.elderAssessedItems.values().stream().mapToInt(Set::size).sum();
|
|
|
+ vo.setAssessCompleteRate(ratio(assessedItemCount, elderCount * NineRiskItemEnum.TOTAL_ITEM_COUNT));
|
|
|
+
|
|
|
+ // 评估人员数
|
|
|
+ vo.setAssessorCount((int) ctx.all.stream()
|
|
|
+ .map(NineRiskAssessFlatBO::getAssessor)
|
|
|
+ .filter(NineRiskBoardServiceImpl::isNotBlank)
|
|
|
+ .map(String::trim)
|
|
|
+ .distinct()
|
|
|
+ .count());
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 2、风险等级分布与整体占比
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 九防各项的风险等级分布,按高危数量降序排列。
|
|
|
+ */
|
|
|
+ private List<ItemRiskDistribution> buildItemRiskDistributions(Context ctx) {
|
|
|
+ List<ItemRiskDistribution> list = new ArrayList<>();
|
|
|
+ for (Map.Entry<String, List<NineRiskAssessFlatBO>> entry : ctx.byItem.entrySet()) {
|
|
|
+ List<NineRiskAssessFlatBO> rows = entry.getValue();
|
|
|
+ ItemRiskDistribution vo = new ItemRiskDistribution();
|
|
|
+ vo.setItemCode(entry.getKey());
|
|
|
+ vo.setItemName(NineRiskItemEnum.nameOf(entry.getKey()));
|
|
|
+
|
|
|
+ int high = 0, middle = 0, low = 0, unknown = 0;
|
|
|
+ for (NineRiskAssessFlatBO row : rows) {
|
|
|
+ switch (RiskLevelEnum.parse(row.getRiskLevel())) {
|
|
|
+ case HIGH:
|
|
|
+ high++;
|
|
|
+ break;
|
|
|
+ case MIDDLE:
|
|
|
+ middle++;
|
|
|
+ break;
|
|
|
+ case LOW:
|
|
|
+ low++;
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ unknown++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ vo.setHighCount(high);
|
|
|
+ vo.setMiddleCount(middle);
|
|
|
+ vo.setLowCount(low);
|
|
|
+ vo.setUnknownCount(unknown);
|
|
|
+ vo.setTotalCount(rows.size());
|
|
|
+ vo.setHighRatio(ratio(high, rows.size()));
|
|
|
+ list.add(vo);
|
|
|
+ }
|
|
|
+ // 按高危人次降序,高危相同时按总量降序
|
|
|
+ list.sort(Comparator.comparing(ItemRiskDistribution::getHighCount).reversed()
|
|
|
+ .thenComparing(Comparator.comparing(ItemRiskDistribution::getTotalCount).reversed()));
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<RiskLevelRatio> buildRiskLevelRatios(Context ctx) {
|
|
|
+ int total = ctx.all.size();
|
|
|
+ List<RiskLevelRatio> list = new ArrayList<>();
|
|
|
+ for (RiskLevelEnum level : RiskLevelEnum.values()) {
|
|
|
+ int count = ctx.riskLevelCount.getOrDefault(level, 0);
|
|
|
+ RiskLevelRatio vo = new RiskLevelRatio();
|
|
|
+ vo.setRiskLevel(level.getCode());
|
|
|
+ vo.setRiskLevelName(level.getName());
|
|
|
+ vo.setCount(count);
|
|
|
+ vo.setRatio(ratio(count, total));
|
|
|
+ list.add(vo);
|
|
|
+ }
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 3、风险等级月度趋势(近 N 个月)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private List<MonthlyTrend> buildMonthlyTrends(List<NineRiskAssessFlatBO> flatList, int trendMonths) {
|
|
|
+ // 先按月份初始化,保证没有数据的月份也返回 0,前端折线图不断点
|
|
|
+ YearMonth current = YearMonth.now();
|
|
|
+ Map<String, MonthlyTrend> monthMap = new LinkedHashMap<>();
|
|
|
+ for (int i = trendMonths - 1; i >= 0; i--) {
|
|
|
+ YearMonth ym = current.minusMonths(i);
|
|
|
+ MonthlyTrend vo = new MonthlyTrend();
|
|
|
+ vo.setMonth(ym.format(MONTH_FORMATTER));
|
|
|
+ vo.setHighCount(0);
|
|
|
+ vo.setMiddleCount(0);
|
|
|
+ vo.setLowCount(0);
|
|
|
+ vo.setUnknownCount(0);
|
|
|
+ vo.setTotalCount(0);
|
|
|
+ monthMap.put(vo.getMonth(), vo);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (NineRiskAssessFlatBO row : flatList) {
|
|
|
+ LocalDate date = row.getAssessDate();
|
|
|
+ if (date == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ MonthlyTrend vo = monthMap.get(YearMonth.from(date).format(MONTH_FORMATTER));
|
|
|
+ if (vo == null) {
|
|
|
+ // 落在统计窗口之外
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ switch (RiskLevelEnum.parse(row.getRiskLevel())) {
|
|
|
+ case HIGH:
|
|
|
+ vo.setHighCount(vo.getHighCount() + 1);
|
|
|
+ break;
|
|
|
+ case MIDDLE:
|
|
|
+ vo.setMiddleCount(vo.getMiddleCount() + 1);
|
|
|
+ break;
|
|
|
+ case LOW:
|
|
|
+ vo.setLowCount(vo.getLowCount() + 1);
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ vo.setUnknownCount(vo.getUnknownCount() + 1);
|
|
|
+ }
|
|
|
+ vo.setTotalCount(vo.getTotalCount() + 1);
|
|
|
+ }
|
|
|
+ monthMap.values().forEach(vo -> vo.setHighRatio(ratio(vo.getHighCount(), vo.getTotalCount())));
|
|
|
+ return new ArrayList<>(monthMap.values());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 4、九防 × 楼栋 高危人数热力图
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private HeatMap buildHeatMap(Context ctx) {
|
|
|
+ // key = buildId + "#" + itemCode,value = 该格子内去重后的长者集合
|
|
|
+ Map<String, Set<Long>> cellElderMap = new LinkedHashMap<>();
|
|
|
+ Map<Long, String> buildNameMap = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ for (NineRiskAssessFlatBO row : ctx.highList) {
|
|
|
+ Long buildId = row.getBuildId();
|
|
|
+ if (buildId == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ buildNameMap.putIfAbsent(buildId, row.getBuildName());
|
|
|
+ cellElderMap.computeIfAbsent(buildId + "#" + row.getItemCode(), k -> new HashSet<>())
|
|
|
+ .add(row.getElderId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<HeatMapAxis> itemAxis = Arrays.stream(NineRiskItemEnum.values())
|
|
|
+ .map(item -> {
|
|
|
+ HeatMapAxis axis = new HeatMapAxis();
|
|
|
+ axis.setCode(item.getCode());
|
|
|
+ axis.setName(item.getName());
|
|
|
+ return axis;
|
|
|
+ }).collect(Collectors.toList());
|
|
|
+
|
|
|
+ List<HeatMapAxis> buildAxis = buildNameMap.entrySet().stream()
|
|
|
+ .map(entry -> {
|
|
|
+ HeatMapAxis axis = new HeatMapAxis();
|
|
|
+ axis.setCode(String.valueOf(entry.getKey()));
|
|
|
+ axis.setName(entry.getValue());
|
|
|
+ return axis;
|
|
|
+ }).collect(Collectors.toList());
|
|
|
+
|
|
|
+ // 生成完整矩阵,缺失格子补 0
|
|
|
+ List<HeatMapCell> cells = new ArrayList<>();
|
|
|
+ int maxValue = 0;
|
|
|
+ for (Map.Entry<Long, String> build : buildNameMap.entrySet()) {
|
|
|
+ for (NineRiskItemEnum item : NineRiskItemEnum.values()) {
|
|
|
+ Set<Long> elders = cellElderMap.get(build.getKey() + "#" + item.getCode());
|
|
|
+ int count = elders == null ? 0 : elders.size();
|
|
|
+ maxValue = Math.max(maxValue, count);
|
|
|
+
|
|
|
+ HeatMapCell cell = new HeatMapCell();
|
|
|
+ cell.setBuildId(build.getKey());
|
|
|
+ cell.setBuildName(build.getValue());
|
|
|
+ cell.setItemCode(item.getCode());
|
|
|
+ cell.setItemName(item.getName());
|
|
|
+ cell.setHighRiskElderCount(count);
|
|
|
+ cells.add(cell);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ HeatMap heatMap = new HeatMap();
|
|
|
+ heatMap.setItems(itemAxis);
|
|
|
+ heatMap.setBuilds(buildAxis);
|
|
|
+ heatMap.setCells(cells);
|
|
|
+ heatMap.setMaxValue(maxValue);
|
|
|
+ return heatMap;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 5、存在高风险的长者列表
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private List<HighRiskElder> buildHighRiskElders(Context ctx) {
|
|
|
+ return ctx.elderHighRiskItems.entrySet().stream()
|
|
|
+ .map(entry -> {
|
|
|
+ NineRiskAssessFlatBO profile = ctx.elderProfileMap.get(entry.getKey());
|
|
|
+ HighRiskElder vo = new HighRiskElder();
|
|
|
+ vo.setElderId(entry.getKey());
|
|
|
+ if (profile != null) {
|
|
|
+ vo.setElderName(profile.getElderName());
|
|
|
+ vo.setElderSex(profile.getElderSex());
|
|
|
+ vo.setElderAge(profile.getElderAge());
|
|
|
+ vo.setNurseLevelId(profile.getNurseLevelId());
|
|
|
+ vo.setNurseLevelName(profile.getNurseLevelName());
|
|
|
+ }
|
|
|
+ vo.setHighRiskCount(entry.getValue().size());
|
|
|
+ return vo;
|
|
|
+ })
|
|
|
+ // 高风险项个数降序
|
|
|
+ .sorted(Comparator.comparing(HighRiskElder::getHighRiskCount).reversed())
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 6、九防评估得分分布
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private List<ItemScoreDistribution> buildItemScoreDistributions(Context ctx) {
|
|
|
+ List<ItemScoreDistribution> list = new ArrayList<>();
|
|
|
+ for (Map.Entry<String, List<NineRiskAssessFlatBO>> entry : ctx.byItem.entrySet()) {
|
|
|
+ ItemScoreDistribution vo = new ItemScoreDistribution();
|
|
|
+ vo.setItemCode(entry.getKey());
|
|
|
+ vo.setItemName(NineRiskItemEnum.nameOf(entry.getKey()));
|
|
|
+
|
|
|
+ List<BigDecimal> scores = entry.getValue().stream()
|
|
|
+ .map(NineRiskAssessFlatBO::getAssessScore)
|
|
|
+ .filter(Objects::nonNull)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ vo.setCount(scores.size());
|
|
|
+ vo.setBuckets(buildScoreBuckets(scores));
|
|
|
+ if (scores.isEmpty()) {
|
|
|
+ vo.setAvgScore(BigDecimal.ZERO);
|
|
|
+ vo.setMinScore(BigDecimal.ZERO);
|
|
|
+ vo.setMaxScore(BigDecimal.ZERO);
|
|
|
+ } else {
|
|
|
+ BigDecimal sum = scores.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setAvgScore(sum.divide(BigDecimal.valueOf(scores.size()), 2, RoundingMode.HALF_UP));
|
|
|
+ vo.setMinScore(scores.stream().min(BigDecimal::compareTo).orElse(BigDecimal.ZERO));
|
|
|
+ vo.setMaxScore(scores.stream().max(BigDecimal::compareTo).orElse(BigDecimal.ZERO));
|
|
|
+ }
|
|
|
+ list.add(vo);
|
|
|
+ }
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<ScoreBucket> buildScoreBuckets(List<BigDecimal> scores) {
|
|
|
+ List<ScoreBucket> buckets = new ArrayList<>();
|
|
|
+ for (int i = 0; i < SCORE_BUCKET_BOUNDS.length - 1; i++) {
|
|
|
+ ScoreBucket bucket = new ScoreBucket();
|
|
|
+ int min = SCORE_BUCKET_BOUNDS[i];
|
|
|
+ int max = SCORE_BUCKET_BOUNDS[i + 1];
|
|
|
+ bucket.setMin(min);
|
|
|
+ bucket.setMax(max);
|
|
|
+ bucket.setRange(min + "-" + max);
|
|
|
+ bucket.setCount(0);
|
|
|
+ buckets.add(bucket);
|
|
|
+ }
|
|
|
+ int lastIndex = buckets.size() - 1;
|
|
|
+ for (BigDecimal score : scores) {
|
|
|
+ double value = score.doubleValue();
|
|
|
+ for (int i = 0; i < buckets.size(); i++) {
|
|
|
+ ScoreBucket bucket = buckets.get(i);
|
|
|
+ // 最后一段闭区间,其余左闭右开;超出上界的统一归入最后一段
|
|
|
+ boolean matched = i == lastIndex
|
|
|
+ ? value >= bucket.getMin()
|
|
|
+ : value >= bucket.getMin() && value < bucket.getMax();
|
|
|
+ if (matched) {
|
|
|
+ bucket.setCount(bucket.getCount() + 1);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return buckets;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 7、知情书签署率
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private DisclosureSignRate buildDisclosureSignRate(Map<String, Object> stat) {
|
|
|
+ int total = toInt(stat == null ? null : stat.get("total"));
|
|
|
+ int signed = toInt(stat == null ? null : stat.get("signedCount"));
|
|
|
+ DisclosureSignRate vo = new DisclosureSignRate();
|
|
|
+ vo.setTotal(total);
|
|
|
+ vo.setSignedCount(signed);
|
|
|
+ vo.setUnsignedCount(Math.max(total - signed, 0));
|
|
|
+ vo.setSignRate(ratio(signed, total));
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 8、各护理等级「高风险长者人数」(基于第 5 点的长者维度数据分组)
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private List<NurseLevelHighRiskCount> buildNurseLevelElderCounts(Context ctx) {
|
|
|
+ Map<String, NurseLevelHighRiskCount> map = new LinkedHashMap<>();
|
|
|
+ int total = 0;
|
|
|
+ for (Long elderId : ctx.elderHighRiskItems.keySet()) {
|
|
|
+ NineRiskAssessFlatBO profile = ctx.elderProfileMap.get(elderId);
|
|
|
+ NurseLevelHighRiskCount vo = obtainNurseLevelBucket(map, profile);
|
|
|
+ vo.setCount(vo.getCount() + 1);
|
|
|
+ total++;
|
|
|
+ }
|
|
|
+ return finishNurseLevelCounts(map, total);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 9、各护理等级下、各九防项「风险程度为高的记录数」
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 按「护理等级 × 九防项」二维交叉统计高风险记录数。
|
|
|
+ *
|
|
|
+ * <p>每个护理等级下都会输出全部九防项,没有高风险记录的项补 0,
|
|
|
+ * 便于前端直接渲染成矩阵或堆叠柱状图而无需再做补位。</p>
|
|
|
+ */
|
|
|
+ private List<NurseLevelItemHighRiskCount> buildNurseLevelRecordCounts(Context ctx) {
|
|
|
+ // 护理等级 -> (九防项编码 -> 高风险记录数)
|
|
|
+ Map<String, Map<String, Integer>> levelItemCountMap = new LinkedHashMap<>();
|
|
|
+ // 护理等级 -> 等级基本信息(借用任意一条明细承载 id 与名称)
|
|
|
+ Map<String, NineRiskAssessFlatBO> levelProfileMap = new LinkedHashMap<>();
|
|
|
+ for (NineRiskAssessFlatBO row : ctx.highList) {
|
|
|
+ String levelKey = buildNurseLevelKey(row);
|
|
|
+ levelProfileMap.putIfAbsent(levelKey, row);
|
|
|
+ levelItemCountMap.computeIfAbsent(levelKey, k -> new LinkedHashMap<>())
|
|
|
+ .merge(row.getItemCode(), 1, Integer::sum);
|
|
|
+ }
|
|
|
+
|
|
|
+ int grandTotal = ctx.highList.size();
|
|
|
+ List<NurseLevelItemHighRiskCount> result = new ArrayList<>(levelItemCountMap.size());
|
|
|
+ for (Map.Entry<String, Map<String, Integer>> entry : levelItemCountMap.entrySet()) {
|
|
|
+ NineRiskAssessFlatBO profile = levelProfileMap.get(entry.getKey());
|
|
|
+ Map<String, Integer> itemCountMap = entry.getValue();
|
|
|
+
|
|
|
+ NurseLevelItemHighRiskCount levelVO = new NurseLevelItemHighRiskCount();
|
|
|
+ levelVO.setNurseLevelId(profile == null ? null : profile.getNurseLevelId());
|
|
|
+ levelVO.setNurseLevelName(resolveNurseLevelName(profile));
|
|
|
+
|
|
|
+ // 遍历全部九防项,缺失的补 0,保证各等级返回的项一致
|
|
|
+ List<ItemHighRiskCount> items = new ArrayList<>(NineRiskItemEnum.values().length);
|
|
|
+ int levelTotal = 0;
|
|
|
+ for (NineRiskItemEnum item : NineRiskItemEnum.values()) {
|
|
|
+ int count = itemCountMap.getOrDefault(item.getCode(), 0);
|
|
|
+ levelTotal += count;
|
|
|
+
|
|
|
+ ItemHighRiskCount itemVO = new ItemHighRiskCount();
|
|
|
+ itemVO.setItemCode(item.getCode());
|
|
|
+ itemVO.setItemName(item.getName());
|
|
|
+ itemVO.setCount(count);
|
|
|
+ items.add(itemVO);
|
|
|
+ }
|
|
|
+ // 项占比的分母是所在护理等级的合计数
|
|
|
+ for (ItemHighRiskCount itemVO : items) {
|
|
|
+ itemVO.setRatio(ratio(itemVO.getCount(), levelTotal));
|
|
|
+ }
|
|
|
+ items.sort(Comparator.comparing(ItemHighRiskCount::getCount).reversed());
|
|
|
+
|
|
|
+ levelVO.setItems(items);
|
|
|
+ levelVO.setTotalCount(levelTotal);
|
|
|
+ levelVO.setRatio(ratio(levelTotal, grandTotal));
|
|
|
+ result.add(levelVO);
|
|
|
+ }
|
|
|
+ result.sort(Comparator.comparing(NurseLevelItemHighRiskCount::getTotalCount).reversed());
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成护理等级的分组键:优先用 id,id 为空时退化为名称,避免不同的空等级被合并。
|
|
|
+ */
|
|
|
+ private static String buildNurseLevelKey(NineRiskAssessFlatBO profile) {
|
|
|
+ Long levelId = profile == null ? null : profile.getNurseLevelId();
|
|
|
+ return levelId == null ? "null#" + resolveNurseLevelName(profile) : String.valueOf(levelId);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String resolveNurseLevelName(NineRiskAssessFlatBO profile) {
|
|
|
+ return profile == null || !isNotBlank(profile.getNurseLevelName())
|
|
|
+ ? "未设置" : profile.getNurseLevelName();
|
|
|
+ }
|
|
|
+
|
|
|
+ private NurseLevelHighRiskCount obtainNurseLevelBucket(Map<String, NurseLevelHighRiskCount> map,
|
|
|
+ NineRiskAssessFlatBO profile) {
|
|
|
+ Long levelId = profile == null ? null : profile.getNurseLevelId();
|
|
|
+ String levelName = resolveNurseLevelName(profile);
|
|
|
+ return map.computeIfAbsent(buildNurseLevelKey(profile), k -> {
|
|
|
+ NurseLevelHighRiskCount vo = new NurseLevelHighRiskCount();
|
|
|
+ vo.setNurseLevelId(levelId);
|
|
|
+ vo.setNurseLevelName(levelName);
|
|
|
+ vo.setCount(0);
|
|
|
+ return vo;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<NurseLevelHighRiskCount> finishNurseLevelCounts(Map<String, NurseLevelHighRiskCount> map,
|
|
|
+ int total) {
|
|
|
+ List<NurseLevelHighRiskCount> list = new ArrayList<>(map.values());
|
|
|
+ list.forEach(vo -> vo.setRatio(ratio(vo.getCount(), total)));
|
|
|
+ list.sort(Comparator.comparing(NurseLevelHighRiskCount::getCount).reversed());
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 10、评估人统计
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private List<AssessorStat> buildAssessorStats(Context ctx) {
|
|
|
+ // assessor -> 评估过的长者集合 / 高风险长者集合 / 记录数
|
|
|
+ Map<String, Set<Long>> assessedMap = new LinkedHashMap<>();
|
|
|
+ Map<String, Set<Long>> highRiskMap = new LinkedHashMap<>();
|
|
|
+ Map<String, Integer> recordCountMap = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ for (NineRiskAssessFlatBO row : ctx.all) {
|
|
|
+ if (!isNotBlank(row.getAssessor())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String assessor = row.getAssessor().trim();
|
|
|
+ recordCountMap.merge(assessor, 1, Integer::sum);
|
|
|
+ if (row.getElderId() != null) {
|
|
|
+ assessedMap.computeIfAbsent(assessor, k -> new HashSet<>()).add(row.getElderId());
|
|
|
+ // 同一长者被同一评估人在多张九防表判为高风险时,只计 1 个
|
|
|
+ if (RiskLevelEnum.isHigh(row.getRiskLevel())) {
|
|
|
+ highRiskMap.computeIfAbsent(assessor, k -> new HashSet<>()).add(row.getElderId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return recordCountMap.entrySet().stream().map(entry -> {
|
|
|
+ String assessor = entry.getKey();
|
|
|
+ AssessorStat vo = new AssessorStat();
|
|
|
+ vo.setAssessor(assessor);
|
|
|
+ vo.setAssessRecordCount(entry.getValue());
|
|
|
+ vo.setAssessedElderCount(assessedMap.getOrDefault(assessor, Collections.emptySet()).size());
|
|
|
+ vo.setHighRiskElderCount(highRiskMap.getOrDefault(assessor, Collections.emptySet()).size());
|
|
|
+ return vo;
|
|
|
+ }).sorted(Comparator.comparing(AssessorStat::getAssessedElderCount).reversed())
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 11、九防高危占比
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private HighRiskRatio buildHighRiskRatio(Context ctx) {
|
|
|
+ HighRiskRatio vo = new HighRiskRatio();
|
|
|
+ int elderCount = ctx.elderProfileMap.size();
|
|
|
+ int highElderCount = ctx.elderHighRiskItems.size();
|
|
|
+ int recordTotal = ctx.all.size();
|
|
|
+ int highRecordCount = ctx.highList.size();
|
|
|
+
|
|
|
+ vo.setAssessedElderCount(elderCount);
|
|
|
+ vo.setHighRiskElderCount(highElderCount);
|
|
|
+ vo.setHighRiskElderRatio(ratio(highElderCount, elderCount));
|
|
|
+ vo.setAssessRecordTotal(recordTotal);
|
|
|
+ vo.setHighRiskRecordCount(highRecordCount);
|
|
|
+ vo.setHighRiskRecordRatio(ratio(highRecordCount, recordTotal));
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 12、2 项及以上高风险列表
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ private List<MultiHighRiskElder> buildMultiHighRiskElders(Context ctx, int threshold) {
|
|
|
+ List<MultiHighRiskElder> list = new ArrayList<>();
|
|
|
+ for (Map.Entry<Long, Set<String>> entry : ctx.elderHighRiskItems.entrySet()) {
|
|
|
+ Set<String> items = entry.getValue();
|
|
|
+ if (items.size() < threshold) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Long elderId = entry.getKey();
|
|
|
+ NineRiskAssessFlatBO profile = ctx.elderProfileMap.get(elderId);
|
|
|
+
|
|
|
+ MultiHighRiskElder vo = new MultiHighRiskElder();
|
|
|
+ vo.setElderId(elderId);
|
|
|
+ if (profile != null) {
|
|
|
+ vo.setContractNumber(profile.getContractNumber());
|
|
|
+ vo.setElderName(profile.getElderName());
|
|
|
+ vo.setElderSex(profile.getElderSex());
|
|
|
+ vo.setElderAge(profile.getElderAge());
|
|
|
+ vo.setBuildName(profile.getBuildName());
|
|
|
+ vo.setFloorName(profile.getFloorName());
|
|
|
+ vo.setBedName(profile.getBedName());
|
|
|
+ vo.setNurseLevelName(profile.getNurseLevelName());
|
|
|
+ }
|
|
|
+ vo.setHighRiskCount(items.size());
|
|
|
+ vo.setHighRiskItems(items.stream().map(NineRiskItemEnum::nameOf)
|
|
|
+ .collect(Collectors.joining("、")));
|
|
|
+ vo.setAssessor(String.join("、",
|
|
|
+ ctx.elderHighRiskAssessors.getOrDefault(elderId, Collections.emptySet())));
|
|
|
+ vo.setAssessDate(ctx.elderLatestHighRiskDate.get(elderId));
|
|
|
+ list.add(vo);
|
|
|
+ }
|
|
|
+ list.sort(Comparator.comparing(MultiHighRiskElder::getHighRiskCount).reversed());
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================================================================
|
|
|
+ // 工具方法
|
|
|
+ // ==================================================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算百分比,保留 2 位小数;分母为 0 时返回 0。
|
|
|
+ */
|
|
|
+ private static BigDecimal ratio(int part, int total) {
|
|
|
+ if (total <= 0) {
|
|
|
+ return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
|
|
+ }
|
|
|
+ return BigDecimal.valueOf(part)
|
|
|
+ .multiply(HUNDRED)
|
|
|
+ .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isNotBlank(String text) {
|
|
|
+ return text != null && !text.trim().isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int toInt(Object value) {
|
|
|
+ if (value == null) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ if (value instanceof Number) {
|
|
|
+ return ((Number) value).intValue();
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return Integer.parseInt(value.toString());
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ log.warn("[toInt] 无法解析数值: {}", value);
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|