tuqin
6 天以前 6e43a9e4daa56a07fe605d6bec1370f36bd5fbfc
feat(safety): 安全生产七期 - 安全数据统计分析

与看板的区别:看板是实时全局概览不可筛选;统计分析支持指定时间范围与车间,
输出分组报表并支持导出。

后端:
- GET /mes/safety/statistics/report 按时间段 + 车间统计四个维度
* 汇总:隐患总数/闭环数/闭环率/超期数、作业票总数/已归档/已驳回、
培训场次/参训人次/平均分、证书总数/已过期数
* 分组:隐患按车间-等级-来源、作业票按类型-状态、培训按类型、证书按类型
- GET /mes/safety/statistics/export-hazard 导出隐患明细 Excel
* 字典值(等级/来源/状态)在导出前转为中文文本,业务人员可直接查看
* 超期标记、车间名、责任人姓名一并填充
- 时间过滤按创建时间,含首尾两天;车间为空则统计全部
- 权限:mes:safety-statistics:query / :export

前端:
- views/safety/statistics:时间范围 + 车间筛选,支持「本月/近三月/本年」快捷区间
- 顶部 4 个汇总卡,异常项(超期隐患、过期证书)红色突出
- Tab 分隐患/作业票/培训三组,每组多个维度的分组表格,完成率按阈值着色
- 一键导出隐患明细 Excel

SQL(mes_safety_statistics_init.sql,可重复执行):
- 菜单「统计分析」+ 2 个按钮权限

注:本期只提交代码,未部署。
已添加7个文件
661 ■■■■■ 文件已修改
sql/mysql/mes_safety_statistics_init.sql 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/MesSafetyStatisticsController.java 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/statistics/MesSafetyHazardExcelVO.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/statistics/MesSafetyStatisticsReqVO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/statistics/MesSafetyStatisticsRespVO.java 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/MesSafetyStatisticsService.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/MesSafetyStatisticsServiceImpl.java 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_safety_statistics_init.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
-- =====================================================================
-- å®‰å…¨ç”Ÿäº§æ¨¡å— â€”— ä¸ƒæœŸï¼šå®‰å…¨æ•°æ®ç»Ÿè®¡åˆ†æž
--
-- å®šä½ï¼šæŒ‰æ—¶é—´æ®µä¸Žè½¦é—´ç­›é€‰ï¼Œè¾“出隐患/作业票/培训/证书四个维度的分组统计,
--       å¹¶æ”¯æŒé𐿂£æ˜Žç»†å¯¼å‡º Excel。不新增业务表。
--
-- ä¸Žã€Œå®‰å…¨çœ‹æ¿ã€çš„区别:
--   çœ‹æ¿     = å®žæ—¶å…¨å±€æ¦‚览,不可筛选、不导出
--   ç»Ÿè®¡åˆ†æž = å¯æŒ‡å®šæ—¶é—´èŒƒå›´ä¸Žè½¦é—´ï¼Œå‡ºåˆ†ç»„报表,可导出
--
-- å†…容:菜单「统计分析」
--
-- ç‰¹æ€§ï¼šå¯é‡å¤æ‰§è¡Œ
-- =====================================================================
DELETE FROM system_role_menu WHERE menu_id BETWEEN 1065963 AND 1065970;
DELETE FROM system_menu WHERE id BETWEEN 1065963 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
(1065963, '统计分析', '', 2, 8, 1065910, 'statistics', 'ep:trend-charts',
 'safety/statistics/index', 'SafetyStatistics', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1065964, '统计查询', 'mes:safety-statistics:query',  3, 1, 1065963, '', '', NULL, NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'),
(1065965, '报表导出', 'mes:safety-statistics:export', 3, 2, 1065963, '', '', 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 1065963 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/MesSafetyStatisticsController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics.MesSafetyHazardExcelVO;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics.MesSafetyStatisticsReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics.MesSafetyStatisticsRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkshopDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.MesSafetyHazardDO;
import cn.iocoder.yudao.module.mes.dal.mysql.md.workstation.MesMdWorkshopMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.safety.MesSafetyHazardMapper;
import cn.iocoder.yudao.module.mes.service.safety.MesSafetyStatisticsService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
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 java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * å®‰å…¨ç”Ÿäº§ - ç»Ÿè®¡åˆ†æž Controller
 *
 * @author è¶…级管理员
 */
@Tag(name = "管理后台 - å®‰å…¨ç”Ÿäº§-统计分析")
@RestController
@RequestMapping("/mes/safety/statistics")
@Validated
public class MesSafetyStatisticsController {
    @Resource
    private MesSafetyStatisticsService statisticsService;
    @Resource
    private MesSafetyHazardMapper hazardMapper;
    @Resource
    private MesMdWorkshopMapper workshopMapper;
    @Resource
    private AdminUserApi adminUserApi;
    /** é𐿂£ç­‰çº§æ–‡æœ¬ */
    private static final String[] HAZARD_LEVEL_NAMES = {null, "一般隐患", "重大隐患"};
    /** é𐿂£æ¥æºæ–‡æœ¬ */
    private static final String[] HAZARD_SOURCE_NAMES = {null, "日常排查", "专项检查", "上级检查", "员工上报", "其他"};
    @GetMapping("/report")
    @Operation(summary = "获得安全生产统计报表")
    @PreAuthorize("@ss.hasPermission('mes:safety-statistics:query')")
    public CommonResult<MesSafetyStatisticsRespVO> getReport(@Valid MesSafetyStatisticsReqVO reqVO) {
        return success(statisticsService.getStatistics(reqVO));
    }
    @GetMapping("/export-hazard")
    @Operation(summary = "导出隐患明细报表")
    @PreAuthorize("@ss.hasPermission('mes:safety-statistics:export')")
    public void exportHazard(@Valid MesSafetyStatisticsReqVO reqVO,
                             HttpServletResponse response) throws IOException {
        List<MesSafetyHazardDO> list = hazardMapper.selectAllList().stream()
                .filter(h -> inRange(h.getCreateTime(), reqVO))
                .filter(h -> reqVO.getWorkshopId() == null
                        || Objects.equals(h.getWorkshopId(), reqVO.getWorkshopId()))
                .collect(Collectors.toList());
        // å­—典与关联名称在导出前转为中文,业务人员可直接查看
        Map<Long, String> workshopNameMap = workshopMapper.selectList().stream()
                .collect(Collectors.toMap(MesMdWorkshopDO::getId, MesMdWorkshopDO::getName, (a, b) -> a));
        Set<Long> userIds = list.stream().map(MesSafetyHazardDO::getChargeUserId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, AdminUserRespDTO> userMap = userIds.isEmpty()
                ? Collections.emptyMap() : adminUserApi.getUserMap(userIds);
        LocalDate today = LocalDate.now();
        List<MesSafetyHazardExcelVO> excelList = new ArrayList<>(list.size());
        for (MesSafetyHazardDO hazard : list) {
            MesSafetyHazardExcelVO vo = BeanUtils.toBean(hazard, MesSafetyHazardExcelVO.class);
            vo.setHazardLevelName(dictName(HAZARD_LEVEL_NAMES, hazard.getHazardLevel()));
            vo.setSourceName(dictName(HAZARD_SOURCE_NAMES, hazard.getSource()));
            vo.setWorkshopName(workshopNameMap.get(hazard.getWorkshopId()));
            vo.setStatusName(hazardStatusName(hazard.getStatus()));
            AdminUserRespDTO charge = hazard.getChargeUserId() == null
                    ? null : userMap.get(hazard.getChargeUserId());
            vo.setChargeUserName(charge == null ? null : charge.getNickname());
            boolean overdue = hazard.getDeadline() != null
                    && !MesSafetyHazardDO.STATUS_CLOSED.equals(hazard.getStatus())
                    && hazard.getDeadline().isBefore(today);
            vo.setOverdueText(overdue ? "是" : "否");
            excelList.add(vo);
        }
        ExcelUtils.write(response, "隐患明细.xlsx", "隐患明细", MesSafetyHazardExcelVO.class, excelList);
    }
    // ==================== å·¥å…· ====================
    private String dictName(String[] names, Integer value) {
        if (value == null || value < 0 || value >= names.length) {
            return null;
        }
        return names[value];
    }
    private String hazardStatusName(Integer status) {
        if (status == null) {
            return null;
        }
        return switch (status) {
            case 0 -> "待整改";
            case 10 -> "整改中";
            case 20 -> "待验收";
            case 30 -> "已闭环";
            default -> "未知";
        };
    }
    private boolean inRange(LocalDateTime createTime, MesSafetyStatisticsReqVO reqVO) {
        if (reqVO.getStartDate() == null && reqVO.getEndDate() == null) {
            return true;
        }
        if (createTime == null) {
            return false;
        }
        if (reqVO.getStartDate() != null && createTime.isBefore(reqVO.getStartDate().atStartOfDay())) {
            return false;
        }
        return reqVO.getEndDate() == null || !createTime.isAfter(reqVO.getEndDate().atTime(23, 59, 59));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/statistics/MesSafetyHazardExcelVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics;
import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.write.style.ColumnWidth;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
 * å®‰å…¨ç”Ÿäº§ - é𐿂£æ˜Žç»†å¯¼å‡º Excel VO
 *
 * å­—典值在导出前已转为中文文本,便于业务人员直接查看
 *
 * @author è¶…级管理员
 */
@Data
public class MesSafetyHazardExcelVO {
    @ExcelProperty("隐患编码")
    @ColumnWidth(20)
    private String code;
    @ExcelProperty("隐患名称")
    @ColumnWidth(30)
    private String name;
    @ExcelProperty("隐患等级")
    @ColumnWidth(12)
    private String hazardLevelName;
    @ExcelProperty("隐患来源")
    @ColumnWidth(14)
    private String sourceName;
    @ExcelProperty("所属车间")
    @ColumnWidth(16)
    private String workshopName;
    @ExcelProperty("具体位置")
    @ColumnWidth(24)
    private String location;
    @ExcelProperty("隐患描述")
    @ColumnWidth(40)
    private String description;
    @ExcelProperty("发现日期")
    @ColumnWidth(14)
    private LocalDate foundDate;
    @ExcelProperty("整改责任人")
    @ColumnWidth(14)
    private String chargeUserName;
    @ExcelProperty("整改期限")
    @ColumnWidth(14)
    private LocalDate deadline;
    @ExcelProperty("状态")
    @ColumnWidth(12)
    private String statusName;
    @ExcelProperty("是否超期")
    @ColumnWidth(12)
    private String overdueText;
    @ExcelProperty("整改措施")
    @ColumnWidth(40)
    private String measure;
    @ExcelProperty("整改完成日期")
    @ColumnWidth(16)
    private LocalDate finishDate;
    @ExcelProperty("验收意见")
    @ColumnWidth(30)
    private String checkResult;
    @ExcelProperty("备注")
    @ColumnWidth(24)
    private String remark;
    /**
     * å ä½å­—段,避免 EasyExcel å¯¹æ— æ•°æ®è¡¨å¤´æŠ¥é”™
     */
    @ExcelProperty("")
    @ColumnWidth(2)
    private BigDecimal placeholder;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/statistics/MesSafetyStatisticsReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
@Schema(description = "管理后台 - å®‰å…¨ç”Ÿäº§ç»Ÿè®¡åˆ†æž Request VO")
@Data
public class MesSafetyStatisticsReqVO {
    @Schema(description = "统计起始日期(按创建时间过滤)", example = "2026-01-01")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
    private LocalDate startDate;
    @Schema(description = "统计结束日期", example = "2026-12-31")
    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
    private LocalDate endDate;
    @Schema(description = "车间编号(可选,为空则统计全部)", example = "7")
    private Long workshopId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/statistics/MesSafetyStatisticsRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,103 @@
package cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
 * å®‰å…¨ç”Ÿäº§ - ç»Ÿè®¡åˆ†æž Response VO
 *
 * æŒ‰æ—¶é—´æ®µç»Ÿè®¡é𐿂£ã€ä½œä¸šç¥¨ã€åŸ¹è®­ã€è¯ä¹¦å››ä¸ªç»´åº¦
 *
 * @author è¶…级管理员
 */
@Schema(description = "管理后台 - å®‰å…¨ç”Ÿäº§ç»Ÿè®¡åˆ†æž Response VO")
@Data
public class MesSafetyStatisticsRespVO {
    @Schema(description = "汇总指标")
    private Summary summary;
    @Schema(description = "隐患按车间统计")
    private List<GroupStat> hazardByWorkshop;
    @Schema(description = "隐患按等级统计")
    private List<GroupStat> hazardByLevel;
    @Schema(description = "隐患按来源统计")
    private List<GroupStat> hazardBySource;
    @Schema(description = "作业票按类型统计")
    private List<GroupStat> ticketByType;
    @Schema(description = "作业票按状态统计")
    private List<GroupStat> ticketByStatus;
    @Schema(description = "培训按类型统计")
    private List<GroupStat> trainingByType;
    @Schema(description = "证书按类型统计")
    private List<GroupStat> certificateByType;
    // ==================== å†…部类 ====================
    @Schema(description = "汇总指标")
    @Data
    public static class Summary {
        @Schema(description = "隐患总数")
        private Long hazardTotal;
        @Schema(description = "隐患已闭环数")
        private Long hazardClosed;
        @Schema(description = "隐患闭环率(%)")
        private BigDecimal hazardClosedRate;
        @Schema(description = "隐患超期数")
        private Long hazardOverdue;
        @Schema(description = "作业票总数")
        private Long ticketTotal;
        @Schema(description = "作业票已归档数")
        private Long ticketArchived;
        @Schema(description = "作业票已驳回数")
        private Long ticketRejected;
        @Schema(description = "培训场次")
        private Long trainingTotal;
        @Schema(description = "累计参训人次")
        private Long trainingAttendee;
        @Schema(description = "培训平均分")
        private BigDecimal trainingAvgScore;
        @Schema(description = "证书总数")
        private Long certificateTotal;
        @Schema(description = "证书已过期数")
        private Long certificateExpired;
    }
    @Schema(description = "分组统计项")
    @Data
    public static class GroupStat {
        @Schema(description = "分组名称", example = "造纸车间")
        private String name;
        @Schema(description = "分组编码(车间/类型/等级等原始值)", example = "7")
        private Integer code;
        @Schema(description = "总数量")
        private Long total;
        @Schema(description = "已完成数量(隐患=已闭环;作业票=已归档;其他可为空)")
        private Long finished;
        @Schema(description = "完成率(%)")
        private BigDecimal finishRate;
        @Schema(description = "累计参训人次(仅培训维度)")
        private Long attendeeTotal;
        @Schema(description = "平均分(仅培训维度)")
        private BigDecimal avgScore;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/MesSafetyStatisticsService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
package cn.iocoder.yudao.module.mes.service.safety;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics.MesSafetyStatisticsReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics.MesSafetyStatisticsRespVO;
/**
 * å®‰å…¨ç”Ÿäº§ - ç»Ÿè®¡åˆ†æž Service æŽ¥å£
 *
 * @author è¶…级管理员
 */
public interface MesSafetyStatisticsService {
    /**
     * æŒ‰æ—¶é—´æ®µä¸Žè½¦é—´ç»´åº¦ç»Ÿè®¡æ•°æ®
     */
    MesSafetyStatisticsRespVO getStatistics(MesSafetyStatisticsReqVO reqVO);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/MesSafetyStatisticsServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,240 @@
package cn.iocoder.yudao.module.mes.service.safety;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics.MesSafetyStatisticsReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics.MesSafetyStatisticsRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics.MesSafetyStatisticsRespVO.GroupStat;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.statistics.MesSafetyStatisticsRespVO.Summary;
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.stream.Collectors;
/**
 * å®‰å…¨ç”Ÿäº§ - ç»Ÿè®¡åˆ†æž Service å®žçް
 *
 * æ•°æ®è§„模较小,采用全量查询 + å†…存过滤分组,便于灵活组合筛选条件
 *
 * @author è¶…级管理员
 */
@Service
@Validated
public class MesSafetyStatisticsServiceImpl implements MesSafetyStatisticsService {
    @Resource
    private MesSafetyHazardMapper hazardMapper;
    @Resource
    private MesSafetyWorkTicketMapper ticketMapper;
    @Resource
    private MesSafetyTrainingMapper trainingMapper;
    @Resource
    private MesSafetyCertificateMapper certificateMapper;
    @Resource
    private MesMdWorkshopMapper workshopMapper;
    /** å­—典标签(与 SQL ä¸­ system_dict_data ä¿æŒä¸€è‡´ï¼‰ */
    private static final String[] HAZARD_LEVEL_NAMES = {null, "一般隐患", "重大隐患"};
    private static final String[] HAZARD_SOURCE_NAMES = {null, "日常排查", "专项检查", "上级检查", "员工上报", "其他"};
    private static final String[] TICKET_TYPE_NAMES = {null, "动火作业", "受限空间作业", "高处作业", "吊装作业",
            "临时用电作业", "断路作业", "盲板抽堵作业", "设备检修作业"};
    private static final String[] TICKET_STATUS_NAMES = {null, null, null, null, null, null, null, null, null, null,
            "已批准"}; // ä»…占位,实际用 switch
    private static final String[] TRAINING_TYPE_NAMES = {null, "三级安全教育", "专项培训", "复训", "日常安全教育", "其他"};
    private static final String[] CERT_TYPE_NAMES = {null, "特种作业操作证", "安全管理人员证", "主要负责人证", "其他资格证"};
    @Override
    public MesSafetyStatisticsRespVO getStatistics(MesSafetyStatisticsReqVO reqVO) {
        LocalDate today = LocalDate.now();
        MesSafetyStatisticsRespVO vo = new MesSafetyStatisticsRespVO();
        // è½¦é—´åç§°æ˜ å°„
        Map<Long, String> workshopNameMap = workshopMapper.selectList().stream()
                .collect(Collectors.toMap(MesMdWorkshopDO::getId, MesMdWorkshopDO::getName, (a, b) -> a));
        // ========== é𐿂£ ==========
        List<MesSafetyHazardDO> hazards = hazardMapper.selectAllList().stream()
                .filter(h -> inRange(h.getCreateTime(), reqVO))
                .filter(h -> matchWorkshop(h.getWorkshopId(), reqVO))
                .collect(Collectors.toList());
        Summary summary = new Summary();
        long hazardClosed = hazards.stream()
                .filter(h -> MesSafetyHazardDO.STATUS_CLOSED.equals(h.getStatus())).count();
        long hazardOverdue = hazards.stream()
                .filter(h -> h.getDeadline() != null
                        && !MesSafetyHazardDO.STATUS_CLOSED.equals(h.getStatus())
                        && h.getDeadline().isBefore(today))
                .count();
        summary.setHazardTotal((long) hazards.size());
        summary.setHazardClosed(hazardClosed);
        summary.setHazardOverdue(hazardOverdue);
        summary.setHazardClosedRate(rate(hazardClosed, hazards.size()));
        // é𐿂£æŒ‰è½¦é—´
        Map<Long, List<MesSafetyHazardDO>> byWorkshop = hazards.stream()
                .filter(h -> h.getWorkshopId() != null)
                .collect(Collectors.groupingBy(MesSafetyHazardDO::getWorkshopId));
        vo.setHazardByWorkshop(byWorkshop.entrySet().stream()
                .map(e -> buildStat(workshopNameMap.getOrDefault(e.getKey(), "未知车间"),
                        e.getKey().intValue(), e.getValue(),
                        h -> MesSafetyHazardDO.STATUS_CLOSED.equals(h.getStatus())))
                .sorted(Comparator.comparing(GroupStat::getTotal).reversed())
                .collect(Collectors.toList()));
        // é𐿂£æŒ‰ç­‰çº§
        vo.setHazardByLevel(groupByInteger(hazards, MesSafetyHazardDO::getHazardLevel, HAZARD_LEVEL_NAMES,
                h -> MesSafetyHazardDO.STATUS_CLOSED.equals(h.getStatus())));
        // é𐿂£æŒ‰æ¥æº
        vo.setHazardBySource(groupByInteger(hazards, MesSafetyHazardDO::getSource, HAZARD_SOURCE_NAMES,
                h -> MesSafetyHazardDO.STATUS_CLOSED.equals(h.getStatus())));
        // ========== ä½œä¸šç¥¨ ==========
        List<MesSafetyWorkTicketDO> tickets = ticketMapper.selectAllList().stream()
                .filter(t -> inRange(t.getCreateTime(), reqVO))
                .filter(t -> matchWorkshop(t.getWorkshopId(), reqVO))
                .collect(Collectors.toList());
        long ticketArchived = tickets.stream()
                .filter(t -> MesSafetyWorkTicketDO.STATUS_ARCHIVED.equals(t.getStatus())).count();
        long ticketRejected = tickets.stream()
                .filter(t -> MesSafetyWorkTicketDO.STATUS_REJECTED.equals(t.getStatus())).count();
        summary.setTicketTotal((long) tickets.size());
        summary.setTicketArchived(ticketArchived);
        summary.setTicketRejected(ticketRejected);
        vo.setTicketByType(groupByInteger(tickets, MesSafetyWorkTicketDO::getTicketType, TICKET_TYPE_NAMES,
                t -> MesSafetyWorkTicketDO.STATUS_ARCHIVED.equals(t.getStatus())));
        vo.setTicketByStatus(groupByInteger(tickets, MesSafetyWorkTicketDO::getStatus, null,
                t -> MesSafetyWorkTicketDO.STATUS_ARCHIVED.equals(t.getStatus())));
        // ========== åŸ¹è®­ ==========
        List<MesSafetyTrainingDO> trainings = trainingMapper.selectAllList().stream()
                .filter(t -> inRange(t.getCreateTime(), reqVO))
                .collect(Collectors.toList());
        long attendeeTotal = trainings.stream()
                .mapToLong(t -> t.getAttendeeCount() == null ? 0 : t.getAttendeeCount()).sum();
        BigDecimal scoreSum = BigDecimal.ZERO;
        int scoredCount = 0;
        for (MesSafetyTrainingDO t : trainings) {
            if (t.getAvgScore() != null) {
                scoreSum = scoreSum.add(t.getAvgScore());
                scoredCount++;
            }
        }
        summary.setTrainingTotal((long) trainings.size());
        summary.setTrainingAttendee(attendeeTotal);
        summary.setTrainingAvgScore(scoredCount > 0
                ? scoreSum.divide(BigDecimal.valueOf(scoredCount), 1, RoundingMode.HALF_UP) : null);
        vo.setTrainingByType(groupByInteger(trainings, MesSafetyTrainingDO::getTrainingType, TRAINING_TYPE_NAMES,
                t -> MesSafetyTrainingDO.STATUS_FINISHED.equals(t.getStatus())));
        // ========== è¯ä¹¦ ==========
        List<MesSafetyCertificateDO> certificates = certificateMapper.selectAllList().stream()
                .filter(c -> inRange(c.getCreateTime(), reqVO))
                .collect(Collectors.toList());
        long certExpired = certificates.stream()
                .filter(c -> c.getExpireDate() != null && c.getExpireDate().isBefore(today))
                .count();
        summary.setCertificateTotal((long) certificates.size());
        summary.setCertificateExpired(certExpired);
        vo.setCertificateByType(groupByInteger(certificates, MesSafetyCertificateDO::getCertType, CERT_TYPE_NAMES,
                c -> MesSafetyCertificateDO.STATUS_VALID.equals(c.getStatus())));
        vo.setSummary(summary);
        return vo;
    }
    // ==================== å·¥å…· ====================
    /**
     * é€šç”¨åˆ†ç»„统计:按 Integer å­—段分组,输出名称/数量/已完成/完成率
     */
    private <T> List<GroupStat> groupByInteger(List<T> list,
                                               java.util.function.Function<T, Integer> keyFn,
                                               String[] nameArray,
                                               java.util.function.Predicate<T> finishedFn) {
        Map<Integer, List<T>> grouped = list.stream()
                .filter(item -> keyFn.apply(item) != null)
                .collect(Collectors.groupingBy(keyFn));
        return grouped.entrySet().stream()
                .map(e -> {
                    Integer key = e.getKey();
                    String name = resolveName(key, nameArray);
                    return buildStat(name, key, e.getValue(), finishedFn);
                })
                .sorted(Comparator.comparing(GroupStat::getTotal).reversed())
                .collect(Collectors.toList());
    }
    private String resolveName(Integer key, String[] nameArray) {
        if (nameArray != null && key >= 0 && key < nameArray.length && nameArray[key] != null) {
            return nameArray[key];
        }
        // çŠ¶æ€ç±»å­—å…¸æ²¡æœ‰é€šç”¨åç§°æ•°ç»„ï¼Œç›´æŽ¥æŒ‰å·²çŸ¥å€¼æ˜ å°„
        return switch (key) {
            case 0 -> "待处理";
            case 10 -> "处理中";
            case 20 -> "已完成";
            case 30 -> "已归档";
            case 90 -> "已驳回";
            default -> "其他(" + key + ")";
        };
    }
    private <T> GroupStat buildStat(String name, Integer code, List<T> items,
                                    java.util.function.Predicate<T> finishedFn) {
        GroupStat stat = new GroupStat();
        stat.setName(name);
        stat.setCode(code);
        stat.setTotal((long) items.size());
        long finished = items.stream().filter(finishedFn).count();
        stat.setFinished(finished);
        stat.setFinishRate(rate(finished, items.size()));
        return stat;
    }
    private BigDecimal rate(long part, long total) {
        if (total <= 0) {
            return BigDecimal.ZERO;
        }
        return BigDecimal.valueOf(part * 100.0 / total).setScale(1, RoundingMode.HALF_UP);
    }
    /**
     * æ—¶é—´èŒƒå›´è¿‡æ»¤ï¼ˆæŒ‰åˆ›å»ºæ—¶é—´ï¼Œå«é¦–尾两天)
     */
    private boolean inRange(LocalDateTime createTime, MesSafetyStatisticsReqVO reqVO) {
        if (reqVO.getStartDate() == null && reqVO.getEndDate() == null) {
            return true;
        }
        if (createTime == null) {
            return false;
        }
        if (reqVO.getStartDate() != null && createTime.isBefore(reqVO.getStartDate().atStartOfDay())) {
            return false;
        }
        if (reqVO.getEndDate() != null && createTime.isAfter(reqVO.getEndDate().atTime(23, 59, 59))) {
            return false;
        }
        return true;
    }
    private boolean matchWorkshop(Long workshopId, MesSafetyStatisticsReqVO reqVO) {
        return reqVO.getWorkshopId() == null || Objects.equals(workshopId, reqVO.getWorkshopId());
    }
}