tuqin
6 天以前 c35bd84886fb0c4e4ee0ffeeb3819a8a8df532b5
feat(safety): 安全生产六期 - 风险可视化看板

把风险、隐患、作业票、培训、证书五块数据集中呈现,管理层一眼看全局。
不新增业务表,只做统计聚合。

后端:
- 新增 GET /mes/safety/dashboard/overview 聚合接口(权限 mes:safety-dashboard:query)
- 一次返回:风险总数与等级分布、风险按车间分布、隐患状态统计(含闭环率与超期数)、
隐患按车间分布、作业票状态统计(含作业超期数)、培训统计(总数/完成/进行中/
参训人次/平均分)、证书统计(有效/即将到期/已过期)、最近 5 条隐患与作业票
- 当前数据规模较小,采用全量查询 + 内存聚合,避免为每张统计写一套 SQL;
闭环率、超期、到期天数等派生指标统一在服务端算好,前端只管展示

前端:
- views/safety/dashboard:4 个核心指标卡 + 4 张图表 + 2 个概览卡 + 2 个动态表
- 图表复用项目已有的 @vben/plugins/echarts(EchartsUI + useEcharts)
- 风险等级沿用四色(重大红/较大橙/一般蓝/低绿),与字典 color_type 一致
- 超期数、已过期数用红色醒目提示

SQL(mes_safety_dashboard_init.sql,可重复执行):
- 菜单「安全看板」置于安全生产目录首位,1 个查询权限

注:本期只提交代码,未部署。
已添加5个文件
599 ■■■■■ 文件已修改
sql/mysql/mes_safety_dashboard_init.sql 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/MesSafetyDashboardController.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/dashboard/MesSafetyDashboardRespVO.java 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/MesSafetyDashboardService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/MesSafetyDashboardServiceImpl.java 317 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_safety_dashboard_init.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
-- =====================================================================
-- å®‰å…¨ç”Ÿäº§æ¨¡å— â€”— å…­æœŸï¼šé£Žé™©å¯è§†åŒ–看板
--
-- å®šä½ï¼šæŠŠé£Žé™©ã€é𐿂£ã€ä½œä¸šç¥¨ã€åŸ¹è®­ã€è¯ä¹¦äº”块数据集中呈现,供管理层一眼看全局。
--       ä¸æ–°å¢žä¸šåŠ¡è¡¨ï¼Œåªåšæ•°æ®èšåˆå±•ç¤ºã€‚
--
-- å†…容:菜单「安全看板」,置于安全生产目录首位
--
-- ç‰¹æ€§ï¼šå¯é‡å¤æ‰§è¡Œ
-- =====================================================================
DELETE FROM system_role_menu WHERE menu_id BETWEEN 1065961 AND 1065970;
DELETE FROM system_menu WHERE id BETWEEN 1065961 AND 1065970;
INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon,
                         component, component_name, status, visible, keep_alive, always_show,
                         creator, create_time, updater, update_time, deleted)
VALUES
(1065961, '安全看板', '', 2, 0, 1065910, 'dashboard', 'ep:data-analysis',
 'safety/dashboard/index', 'SafetyDashboard', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1065962, '看板查询', 'mes:safety-dashboard:query', 3, 1, 1065961, '', '', NULL, NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0');
INSERT INTO system_role_menu (role_id, menu_id, creator, create_time, updater, update_time, deleted)
SELECT 1, m.id, '1', NOW(), '1', NOW(), b'0'
FROM system_menu m
WHERE m.id BETWEEN 1065961 AND 1065970;
-- ---------------------------------------------------------------------
-- éªŒè¯ï¼šå®‰å…¨ç”Ÿäº§ç›®å½•下的全部菜单
-- ---------------------------------------------------------------------
SELECT id, parent_id, name, path, component, permission, type, sort
FROM system_menu WHERE id BETWEEN 1065910 AND 1065970 ORDER BY sort, id;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/MesSafetyDashboardController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.dashboard.MesSafetyDashboardRespVO;
import cn.iocoder.yudao.module.mes.service.safety.MesSafetyDashboardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * å®‰å…¨ç”Ÿäº§ - å¯è§†åŒ–看板 Controller
 *
 * @author è¶…级管理员
 */
@Tag(name = "管理后台 - å®‰å…¨ç”Ÿäº§-可视化看板")
@RestController
@RequestMapping("/mes/safety/dashboard")
@Validated
public class MesSafetyDashboardController {
    @Resource
    private MesSafetyDashboardService dashboardService;
    @GetMapping("/overview")
    @Operation(summary = "获得看板概览数据")
    @PreAuthorize("@ss.hasPermission('mes:safety-dashboard:query')")
    public CommonResult<MesSafetyDashboardRespVO> getOverview() {
        return success(dashboardService.getOverview());
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/dashboard/MesSafetyDashboardRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,192 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety.vo.dashboard;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
 * å®‰å…¨ç”Ÿäº§ - å¯è§†åŒ–看板 Response VO
 *
 * èšåˆé£Žé™©ã€é𐿂£ã€ä½œä¸šç¥¨ã€åŸ¹è®­ã€è¯ä¹¦çš„统计数据,供看板一次性渲染
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - å®‰å…¨ç”Ÿäº§çœ‹æ¿ Response VO")
@Data
public class MesSafetyDashboardRespVO {
    // ==================== é£Žé™© ====================
    @Schema(description = "风险总数", example = "25")
    private Long riskTotal;
    @Schema(description = "风险等级分布")
    private List<NameCount> riskLevelStats;
    @Schema(description = "风险按车间分布(Top 10)")
    private List<NameCount> riskByWorkshop;
    // ==================== é𐿂£ ====================
    @Schema(description = "隐患统计")
    private HazardStats hazardStats;
    @Schema(description = "隐患按车间分布(Top 10)")
    private List<NameCount> hazardByWorkshop;
    // ==================== ä½œä¸šç¥¨ ====================
    @Schema(description = "作业票统计")
    private TicketStats ticketStats;
    // ==================== åŸ¹è®­ ====================
    @Schema(description = "培训统计")
    private TrainingStats trainingStats;
    // ==================== è¯ä¹¦ ====================
    @Schema(description = "证书统计")
    private CertificateStats certificateStats;
    // ==================== æœ€è¿‘动态 ====================
    @Schema(description = "最近隐患(5 æ¡ï¼‰")
    private List<RecentHazard> recentHazards;
    @Schema(description = "最近作业票(5 æ¡ï¼‰")
    private List<RecentTicket> recentTickets;
    // ==================== å†…部类 ====================
    @Schema(description = "名称-数量")
    @Data
    public static class NameCount {
        @Schema(description = "名称", example = "重大风险")
        private String name;
        @Schema(description = "数量", example = "3")
        private Long count;
        @Schema(description = "附加项:等级编码,用于前端配色", example = "1")
        private Integer extra;
        public NameCount() {}
        public NameCount(String name, Long count) {
            this.name = name;
            this.count = count;
        }
        public NameCount(String name, Long count, Integer extra) {
            this.name = name;
            this.count = count;
            this.extra = extra;
        }
    }
    @Schema(description = "隐患统计")
    @Data
    public static class HazardStats {
        @Schema(description = "隐患总数")
        private Long total;
        @Schema(description = "待整改")
        private Long pending;
        @Schema(description = "整改中")
        private Long rectifying;
        @Schema(description = "待验收")
        private Long toCheck;
        @Schema(description = "已闭环")
        private Long closed;
        @Schema(description = "已超期(未闭环且超过整改期限)")
        private Long overdue;
        @Schema(description = "整改闭环率(%)")
        private BigDecimal closedRate;
    }
    @Schema(description = "作业票统计")
    @Data
    public static class TicketStats {
        @Schema(description = "作业票总数")
        private Long total;
        @Schema(description = "待审批")
        private Long pending;
        @Schema(description = "已批准")
        private Long approved;
        @Schema(description = "已完工")
        private Long finished;
        @Schema(description = "已归档")
        private Long archived;
        @Schema(description = "已驳回")
        private Long rejected;
        @Schema(description = "已批准但超计划结束时间")
        private Long overdue;
    }
    @Schema(description = "培训统计")
    @Data
    public static class TrainingStats {
        @Schema(description = "培训总数")
        private Long total;
        @Schema(description = "已完成")
        private Long finished;
        @Schema(description = "进行中")
        private Long ongoing;
        @Schema(description = "计划中")
        private Long planned;
        @Schema(description = "累计参训人次")
        private Long attendeeTotal;
        @Schema(description = "平均考试成绩")
        private BigDecimal avgScore;
    }
    @Schema(description = "证书统计")
    @Data
    public static class CertificateStats {
        @Schema(description = "证书总数")
        private Long total;
        @Schema(description = "有效")
        private Long valid;
        @Schema(description = "已过期")
        private Long expired;
        @Schema(description = "30 å¤©å†…到期")
        private Long expiringSoon;
    }
    @Schema(description = "最近隐患")
    @Data
    public static class RecentHazard {
        @Schema(description = "编号")
        private Long id;
        @Schema(description = "隐患编码")
        private String code;
        @Schema(description = "隐患名称")
        private String name;
        @Schema(description = "隐患等级")
        private Integer hazardLevel;
        @Schema(description = "状态")
        private Integer status;
        @Schema(description = "发现日期")
        private LocalDate foundDate;
    }
    @Schema(description = "最近作业票")
    @Data
    public static class RecentTicket {
        @Schema(description = "编号")
        private Long id;
        @Schema(description = "作业票号")
        private String code;
        @Schema(description = "作业类型")
        private Integer ticketType;
        @Schema(description = "作业地点")
        private String workLocation;
        @Schema(description = "状态")
        private Integer status;
        @Schema(description = "创建时间")
        private LocalDateTime createTime;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/MesSafetyDashboardService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.mes.service.safety;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.dashboard.MesSafetyDashboardRespVO;
/**
 * å®‰å…¨ç”Ÿäº§ - å¯è§†åŒ–看板 Service æŽ¥å£
 *
 * èšåˆé£Žé™©ã€é𐿂£ã€ä½œä¸šç¥¨ã€åŸ¹è®­ã€è¯ä¹¦çš„统计数据
 *
 * @author è¶…级管理员
 */
public interface MesSafetyDashboardService {
    /**
     * èŽ·å¾—çœ‹æ¿æ¦‚è§ˆæ•°æ®
     */
    MesSafetyDashboardRespVO getOverview();
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/MesSafetyDashboardServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,317 @@
package cn.iocoder.yudao.module.mes.service.safety;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.dashboard.MesSafetyDashboardRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.dashboard.MesSafetyDashboardRespVO.*;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkshopDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.*;
import cn.iocoder.yudao.module.mes.dal.mysql.md.workstation.MesMdWorkshopMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.safety.*;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
 * å®‰å…¨ç”Ÿäº§ - å¯è§†åŒ–看板 Service å®žçް
 *
 * æ•°æ®é‡åœ¨å½“前规模下较小,采用全量查询 + å†…存聚合,避免为每张统计写一套 SQL
 *
 * @author è¶…级管理员
 */
@Service
@Validated
public class MesSafetyDashboardServiceImpl implements MesSafetyDashboardService {
    @Resource
    private MesSafetyRiskMapper riskMapper;
    @Resource
    private MesSafetyRiskUnitMapper riskUnitMapper;
    @Resource
    private MesSafetyHazardMapper hazardMapper;
    @Resource
    private MesSafetyWorkTicketMapper ticketMapper;
    @Resource
    private MesSafetyTrainingMapper trainingMapper;
    @Resource
    private MesSafetyTrainingAttendeeMapper attendeeMapper;
    @Resource
    private MesSafetyCertificateMapper certificateMapper;
    @Resource
    private MesMdWorkshopMapper workshopMapper;
    /** Top N */
    private static final int TOP_N = 10;
    /** æœ€è¿‘动态条数 */
    private static final int RECENT_N = 5;
    /** è¯ä¹¦å³å°†åˆ°æœŸé˜ˆå€¼ */
    private static final long EXPIRING_SOON_DAYS = 30L;
    @Override
    public MesSafetyDashboardRespVO getOverview() {
        MesSafetyDashboardRespVO vo = new MesSafetyDashboardRespVO();
        LocalDate today = LocalDate.now();
        LocalDateTime now = LocalDateTime.now();
        // è½¦é—´åç§°æ˜ å°„(多个统计共用)
        Map<Long, String> workshopNameMap = loadWorkshopNames();
        // ========== é£Žé™© ==========
        List<MesSafetyRiskDO> risks = riskMapper.selectAllList();
        vo.setRiskTotal((long) risks.size());
        vo.setRiskLevelStats(buildRiskLevelStats(risks));
        vo.setRiskByWorkshop(buildRiskByWorkshop(risks, workshopNameMap));
        // ========== é𐿂£ ==========
        List<MesSafetyHazardDO> hazards = hazardMapper.selectAllList();
        vo.setHazardStats(buildHazardStats(hazards, today));
        vo.setHazardByWorkshop(buildHazardByWorkshop(hazards, workshopNameMap));
        // ========== ä½œä¸šç¥¨ ==========
        List<MesSafetyWorkTicketDO> tickets = ticketMapper.selectAllList();
        vo.setTicketStats(buildTicketStats(tickets, now));
        // ========== åŸ¹è®­ ==========
        List<MesSafetyTrainingDO> trainings = trainingMapper.selectAllList();
        vo.setTrainingStats(buildTrainingStats(trainings));
        // ========== è¯ä¹¦ ==========
        List<MesSafetyCertificateDO> certificates = certificateMapper.selectAllList();
        vo.setCertificateStats(buildCertificateStats(certificates, today));
        // ========== æœ€è¿‘动态 ==========
        vo.setRecentHazards(buildRecentHazards(hazards));
        vo.setRecentTickets(buildRecentTickets(tickets));
        return vo;
    }
    // ==================== é£Žé™© ====================
    private List<NameCount> buildRiskLevelStats(List<MesSafetyRiskDO> risks) {
        // 1重大 2较大 3一般 4低
        String[] levelNames = {"重大风险", "较大风险", "一般风险", "低风险"};
        Map<Integer, Long> countMap = risks.stream()
                .filter(r -> r.getRiskLevel() != null)
                .collect(Collectors.groupingBy(MesSafetyRiskDO::getRiskLevel, Collectors.counting()));
        List<NameCount> list = new ArrayList<>(4);
        for (int i = 1; i <= 4; i++) {
            list.add(new NameCount(levelNames[i - 1], countMap.getOrDefault(i, 0L), i));
        }
        return list;
    }
    private List<NameCount> buildRiskByWorkshop(List<MesSafetyRiskDO> risks, Map<Long, String> workshopNameMap) {
        // é£Žé™©æœ¬èº«æ²¡æœ‰è½¦é—´ï¼Œç»é£Žé™©å•元的 workshopId å½’属
        Set<Long> unitIds = risks.stream().map(MesSafetyRiskDO::getUnitId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        if (unitIds.isEmpty()) {
            return Collections.emptyList();
        }
        Map<Long, MesSafetyRiskUnitDO> unitMap = riskUnitMapper.selectByIds(unitIds).stream()
                .collect(Collectors.toMap(MesSafetyRiskUnitDO::getId, Function.identity(), (a, b) -> a));
        Map<Long, Long> countMap = new HashMap<>();
        for (MesSafetyRiskDO risk : risks) {
            MesSafetyRiskUnitDO unit = unitMap.get(risk.getUnitId());
            if (unit == null || unit.getWorkshopId() == null) {
                continue;
            }
            countMap.merge(unit.getWorkshopId(), 1L, Long::sum);
        }
        return toTopList(countMap, workshopNameMap);
    }
    // ==================== é𐿂£ ====================
    private HazardStats buildHazardStats(List<MesSafetyHazardDO> hazards, LocalDate today) {
        HazardStats stats = new HazardStats();
        long total = hazards.size();
        long pending = 0, rectifying = 0, toCheck = 0, closed = 0, overdue = 0;
        for (MesSafetyHazardDO h : hazards) {
            Integer s = h.getStatus();
            if (MesSafetyHazardDO.STATUS_PENDING.equals(s)) pending++;
            else if (MesSafetyHazardDO.STATUS_RECTIFYING.equals(s)) rectifying++;
            else if (MesSafetyHazardDO.STATUS_TO_CHECK.equals(s)) toCheck++;
            else if (MesSafetyHazardDO.STATUS_CLOSED.equals(s)) closed++;
            if (h.getDeadline() != null
                    && !MesSafetyHazardDO.STATUS_CLOSED.equals(s)
                    && h.getDeadline().isBefore(today)) {
                overdue++;
            }
        }
        stats.setTotal(total);
        stats.setPending(pending);
        stats.setRectifying(rectifying);
        stats.setToCheck(toCheck);
        stats.setClosed(closed);
        stats.setOverdue(overdue);
        stats.setClosedRate(total > 0
                ? BigDecimal.valueOf(closed * 100.0 / total).setScale(1, RoundingMode.HALF_UP)
                : BigDecimal.ZERO);
        return stats;
    }
    private List<NameCount> buildHazardByWorkshop(List<MesSafetyHazardDO> hazards, Map<Long, String> workshopNameMap) {
        Map<Long, Long> countMap = new HashMap<>();
        for (MesSafetyHazardDO h : hazards) {
            if (h.getWorkshopId() != null) {
                countMap.merge(h.getWorkshopId(), 1L, Long::sum);
            }
        }
        return toTopList(countMap, workshopNameMap);
    }
    // ==================== ä½œä¸šç¥¨ ====================
    private TicketStats buildTicketStats(List<MesSafetyWorkTicketDO> tickets, LocalDateTime now) {
        TicketStats stats = new TicketStats();
        long pending = 0, approved = 0, finished = 0, archived = 0, rejected = 0, overdue = 0;
        for (MesSafetyWorkTicketDO t : tickets) {
            Integer s = t.getStatus();
            if (MesSafetyWorkTicketDO.STATUS_PENDING.equals(s)) pending++;
            else if (MesSafetyWorkTicketDO.STATUS_APPROVED.equals(s)) approved++;
            else if (MesSafetyWorkTicketDO.STATUS_FINISHED.equals(s)) finished++;
            else if (MesSafetyWorkTicketDO.STATUS_ARCHIVED.equals(s)) archived++;
            else if (MesSafetyWorkTicketDO.STATUS_REJECTED.equals(s)) rejected++;
            if (MesSafetyWorkTicketDO.STATUS_APPROVED.equals(s)
                    && t.getPlanEndTime() != null
                    && t.getPlanEndTime().isBefore(now)) {
                overdue++;
            }
        }
        stats.setTotal((long) tickets.size());
        stats.setPending(pending);
        stats.setApproved(approved);
        stats.setFinished(finished);
        stats.setArchived(archived);
        stats.setRejected(rejected);
        stats.setOverdue(overdue);
        return stats;
    }
    // ==================== åŸ¹è®­ ====================
    private TrainingStats buildTrainingStats(List<MesSafetyTrainingDO> trainings) {
        TrainingStats stats = new TrainingStats();
        long finished = 0, ongoing = 0, planned = 0;
        long attendeeTotal = 0;
        BigDecimal scoreSum = BigDecimal.ZERO;
        int scoredCount = 0;
        for (MesSafetyTrainingDO t : trainings) {
            Integer s = t.getStatus();
            if (MesSafetyTrainingDO.STATUS_FINISHED.equals(s)) finished++;
            else if (MesSafetyTrainingDO.STATUS_ONGOING.equals(s)) ongoing++;
            else if (MesSafetyTrainingDO.STATUS_PLANNED.equals(s)) planned++;
            if (t.getAttendeeCount() != null) {
                attendeeTotal += t.getAttendeeCount();
            }
            if (t.getAvgScore() != null) {
                scoreSum = scoreSum.add(t.getAvgScore());
                scoredCount++;
            }
        }
        stats.setTotal((long) trainings.size());
        stats.setFinished(finished);
        stats.setOngoing(ongoing);
        stats.setPlanned(planned);
        stats.setAttendeeTotal(attendeeTotal);
        stats.setAvgScore(scoredCount > 0
                ? scoreSum.divide(BigDecimal.valueOf(scoredCount), 1, RoundingMode.HALF_UP)
                : null);
        return stats;
    }
    // ==================== è¯ä¹¦ ====================
    private CertificateStats buildCertificateStats(List<MesSafetyCertificateDO> certificates, LocalDate today) {
        CertificateStats stats = new CertificateStats();
        long valid = 0, expired = 0, expiringSoon = 0;
        for (MesSafetyCertificateDO c : certificates) {
            boolean isValid = MesSafetyCertificateDO.STATUS_VALID.equals(c.getStatus());
            if (isValid) {
                valid++;
            }
            if (c.getExpireDate() != null) {
                long days = java.time.temporal.ChronoUnit.DAYS.between(today, c.getExpireDate());
                if (days < 0) {
                    expired++;
                } else if (days <= EXPIRING_SOON_DAYS) {
                    expiringSoon++;
                }
            }
        }
        stats.setTotal((long) certificates.size());
        stats.setValid(valid);
        stats.setExpired(expired);
        stats.setExpiringSoon(expiringSoon);
        return stats;
    }
    // ==================== æœ€è¿‘动态 ====================
    private List<RecentHazard> buildRecentHazards(List<MesSafetyHazardDO> hazards) {
        return hazards.stream()
                .sorted(Comparator.comparing(MesSafetyHazardDO::getId, Comparator.nullsLast(Comparator.reverseOrder())))
                .limit(RECENT_N)
                .map(h -> {
                    RecentHazard r = new RecentHazard();
                    r.setId(h.getId());
                    r.setCode(h.getCode());
                    r.setName(h.getName());
                    r.setHazardLevel(h.getHazardLevel());
                    r.setStatus(h.getStatus());
                    r.setFoundDate(h.getFoundDate());
                    return r;
                })
                .collect(Collectors.toList());
    }
    private List<RecentTicket> buildRecentTickets(List<MesSafetyWorkTicketDO> tickets) {
        return tickets.stream()
                .sorted(Comparator.comparing(MesSafetyWorkTicketDO::getId, Comparator.nullsLast(Comparator.reverseOrder())))
                .limit(RECENT_N)
                .map(t -> {
                    RecentTicket r = new RecentTicket();
                    r.setId(t.getId());
                    r.setCode(t.getCode());
                    r.setTicketType(t.getTicketType());
                    r.setWorkLocation(t.getWorkLocation());
                    r.setStatus(t.getStatus());
                    r.setCreateTime(t.getCreateTime());
                    return r;
                })
                .collect(Collectors.toList());
    }
    // ==================== å·¥å…· ====================
    private Map<Long, String> loadWorkshopNames() {
        return workshopMapper.selectList().stream()
                .collect(Collectors.toMap(MesMdWorkshopDO::getId, MesMdWorkshopDO::getName, (a, b) -> a));
    }
    private List<NameCount> toTopList(Map<Long, Long> countMap, Map<Long, String> nameMap) {
        return countMap.entrySet().stream()
                .sorted(Map.Entry.<Long, Long>comparingByValue().reversed())
                .limit(TOP_N)
                .map(e -> new NameCount(
                        nameMap.getOrDefault(e.getKey(), "未知车间"),
                        e.getValue(),
                        e.getKey().intValue()))
                .collect(Collectors.toList());
    }
}