소스 검색

feat(platform): 新增公告下架、设备日历及维修报表功能

- 公告模块新增到期自动下架定时任务及有效期清空逻辑
- 公告发布/恢复显示前增加有效期校验,过期则阻止操作
- 点检模块新增设备近期点检日历接口,支持扫码免登录查看
- 大屏新增近期保养计划列表,重构周期文字为枚举统一处理
- 新增MTBF/MTTR统计报表VO、Mapper查询及按月趋势计算
- 报修模块新增设备未完结报修查询接口,支持重复报修提醒
- 报修DTO新增设备等级/大类多选及导出图表base64字段
- Excel导出新增带统计图表PNG的独立sheet支持
- platform-office pom.xml添加UTF-8编码配置
dazhaxie 1 일 전
부모
커밋
3e4c98b1ae
39개의 변경된 파일과 1569개의 추가작업 그리고 69개의 파일을 삭제
  1. 40 0
      platform-dao/src/main/java/com/platform/dao/dto/repair/RepairApplicationFormDTO.java
  2. 26 7
      platform-dao/src/main/java/com/platform/dao/enums/CheckPlanPeriodTypeEnum.java
  3. 29 9
      platform-dao/src/main/java/com/platform/dao/enums/RepairApplicationFormStatusEnum.java
  4. 17 0
      platform-dao/src/main/java/com/platform/dao/mapper/check/CheckJobMapper.java
  5. 21 0
      platform-dao/src/main/java/com/platform/dao/mapper/repair/RepairApplicationFormMapper.java
  6. 11 0
      platform-dao/src/main/java/com/platform/dao/mapper/upms/AnnouncementMapper.java
  7. 14 0
      platform-dao/src/main/java/com/platform/dao/util/ExcelUtil.java
  8. 52 0
      platform-dao/src/main/java/com/platform/dao/vo/export/report/ExportMtbfReportVO.java
  9. 40 0
      platform-dao/src/main/java/com/platform/dao/vo/export/report/ExportMttrMonthVO.java
  10. 4 0
      platform-dao/src/main/java/com/platform/dao/vo/query/check/CheckJobScreenVO.java
  11. 4 0
      platform-dao/src/main/java/com/platform/dao/vo/query/check/CheckJobVO.java
  12. 29 0
      platform-dao/src/main/java/com/platform/dao/vo/query/check/SbCheckCalendarDayVO.java
  13. 44 0
      platform-dao/src/main/java/com/platform/dao/vo/query/check/SbCheckCalendarVO.java
  14. 56 0
      platform-dao/src/main/java/com/platform/dao/vo/repair/RepairUncompletedOrderVO.java
  15. 52 0
      platform-dao/src/main/java/com/platform/dao/vo/report/MtbfFaultOrderVO.java
  16. 31 0
      platform-dao/src/main/java/com/platform/dao/vo/report/MtbfMonthVO.java
  17. 52 0
      platform-dao/src/main/java/com/platform/dao/vo/report/MtbfReportVO.java
  18. 27 0
      platform-dao/src/main/java/com/platform/dao/vo/report/MtbfResultVO.java
  19. 37 0
      platform-dao/src/main/java/com/platform/dao/vo/report/MttrMonthVO.java
  20. 50 0
      platform-dao/src/main/java/com/platform/dao/vo/sb/SbInfoProblemScreenVO.java
  21. 31 0
      platform-dao/src/main/resources/mapper/check/CheckJobMapper.xml
  22. 120 0
      platform-dao/src/main/resources/mapper/repair/RepairApplicationFormMapper.xml
  23. 37 8
      platform-dao/src/main/resources/mapper/sb/SbInfoMapper.xml
  24. 2 0
      platform-office/pom.xml
  25. 93 0
      platform-office/src/main/java/com/platform/office/tool/ToolPoi.java
  26. 7 0
      platform-office/src/main/java/com/platform/office/util/PoiExcelUtil.java
  27. 17 0
      platform-rest/src/main/java/com/platform/rest/controller/mobile/IgnoreController.java
  28. 12 0
      platform-rest/src/main/java/com/platform/rest/controller/repair/RepairApplicationFormController.java
  29. 79 12
      platform-rest/src/main/java/com/platform/rest/controller/report/RepairReportController.java
  30. 11 0
      platform-rest/src/main/java/com/platform/rest/controller/sb/SbInfoController.java
  31. 27 0
      platform-rest/src/main/java/com/platform/rest/task/AnnouncementTask.java
  32. 13 0
      platform-service/src/main/java/com/platform/service/check/CheckJobService.java
  33. 78 19
      platform-service/src/main/java/com/platform/service/check/impl/CheckJobServiceImpl.java
  34. 29 0
      platform-service/src/main/java/com/platform/service/repair/RepairApplicationFormService.java
  35. 237 9
      platform-service/src/main/java/com/platform/service/repair/impl/RepairApplicationFormServiceImpl.java
  36. 10 0
      platform-service/src/main/java/com/platform/service/sb/SbInfoService.java
  37. 72 5
      platform-service/src/main/java/com/platform/service/sb/impl/SbInfoServiceImpl.java
  38. 7 0
      platform-service/src/main/java/com/platform/service/upms/AnnouncementService.java
  39. 51 0
      platform-service/src/main/java/com/platform/service/upms/impl/AnnouncementServiceImpl.java

+ 40 - 0
platform-dao/src/main/java/com/platform/dao/dto/repair/RepairApplicationFormDTO.java

@@ -1,6 +1,7 @@
 package com.platform.dao.dto.repair;
 
 import com.platform.common.bean.BaseDTO;
+import com.alibaba.fastjson.annotation.JSONField;
 import com.platform.common.bean.DataScope;
 import com.platform.common.validation.group.UpdateGroup;
 import com.platform.dao.entity.upms.SysFile;
@@ -385,4 +386,43 @@ public class RepairApplicationFormDTO extends BaseDTO implements Serializable {
     private String repairTransferList;
 
     private String yearMonth; // 输入年月  yyyy-MM
+
+    /**
+     * MTTR/MTBF 统计年份(旧版参数,月度趋势用;未传月份时间段时按该年 1-12 月兼容)
+     */
+    @Transient
+    private Integer mttrYear;
+
+    /**
+     * MTTR/MTBF 统计开始月份(yyyy-MM,月度趋势用,与 mttrEndMonth 配对,优先于 mttrYear)
+     */
+    @Transient
+    private String mttrStartMonth;
+
+    /**
+     * MTTR/MTBF 统计结束月份(yyyy-MM,月度趋势用,与 mttrStartMonth 配对)
+     */
+    @Transient
+    private String mttrEndMonth;
+
+    /**
+     * 导出时嵌入的统计图 PNG(dataURL/base64,为空则仅导出数据),工时/MTTR/MTBF 导出接口用
+     * 仅用于接收前端 @RequestBody(Jackson 反序列化);日志切面使用 fastjson 序列化,
+     * 标 serialize=false 避免大体积 base64 写入操作日志 params
+     */
+    @Transient
+    @JSONField(serialize = false)
+    private String chartBase64;
+
+    /**
+     * 设备等级多选:对应 t_sb_info.level,字典 SBINFO_LEVEL(A/B/C类)
+     */
+    @Transient
+    private List<Integer> levelList;
+
+    /**
+     * 设备类别多选:对应 t_sb_info.type_id(行车、AGV、起重机等)
+     */
+    @Transient
+    private List<String> typeIdList;
 }

+ 26 - 7
platform-dao/src/main/java/com/platform/dao/enums/CheckPlanPeriodTypeEnum.java

@@ -16,31 +16,50 @@ public enum CheckPlanPeriodTypeEnum {
     /**
      *设备
      */
-    DAY(1),
+    DAY(1, "天"),
     /**
      * 周
      */
-    WEEK(2),
+    WEEK(2, "周"),
     /**
      * 月
      */
-    MONTH(3),
+    MONTH(3, "月"),
     /**
      * 季度
      */
-    SEASON(4),
+    SEASON(4, "季度"),
     /**
      * 年
      */
-    YEAR(5),
+    YEAR(5, "年"),
     /**
      * 行驶公里
      */
-    MILES(6),
+    MILES(6, "公里"),
     /**
      * 工作台式
      */
-    TAISHI(7);
+    TAISHI(7, "台时");
     private final Integer value;
 
+    private final String description;
+
+    /**
+     * 周期类型对应的单位文字,用于大屏、报表等直接展示周期
+     *
+     * @param value 周期类型值
+     * @return 单位文字,未匹配返回""
+     */
+    public static String descriptionOf(Integer value) {
+        if (value == null) {
+            return "";
+        }
+        for (CheckPlanPeriodTypeEnum item : values()) {
+            if (item.getValue().equals(value)) {
+                return item.getDescription();
+            }
+        }
+        return "";
+    }
 }

+ 29 - 9
platform-dao/src/main/java/com/platform/dao/enums/RepairApplicationFormStatusEnum.java

@@ -15,35 +15,55 @@ public enum RepairApplicationFormStatusEnum {
     /**
      * 待接收
      */
-    NOT_ALLOCATED(1),
+    NOT_ALLOCATED(1, "待接收"),
     /**
      * 维修中
      */
-    PROCESSING(2),
+    PROCESSING(2, "维修中"),
     /**
      * 待审核
      */
-    WAIT_SUBMIT(3),
+    WAIT_SUBMIT(3, "待审核"),
     /**
      * 审核中
      */
-    NOT_ACCEPTANCE(4),
+    NOT_ACCEPTANCE(4, "审核中"),
     /**
      * 已驳回
      */
-    REBACK(5),
+    REBACK(5, "已驳回"),
     /**
      * 	已完成
      */
-    FINISHED(6),
-    REFUSED(7),
+    FINISHED(6, "已完成"),
+    REFUSED(7, "已拒单"),
     /**
      * 转派审批中
      */
-    TRANSFER_APPROVAL(8),
+    TRANSFER_APPROVAL(8, "转派审批中"),
     /**
      * 委外维修中
      */
-    ENTRUST_REPAIR(9);
+    ENTRUST_REPAIR(9, "委外维修中");
     private final Integer value;
+
+    private final String description;
+
+    /**
+     * 状态对应的中文描述,与大屏、报修列表展示口径保持一致
+     *
+     * @param value 报修单状态值
+     * @return 中文描述,未匹配返回"未知"
+     */
+    public static String descriptionOf(Integer value) {
+        if (value == null) {
+            return "未知";
+        }
+        for (RepairApplicationFormStatusEnum item : values()) {
+            if (item.getValue().equals(value)) {
+                return item.getDescription();
+            }
+        }
+        return "未知";
+    }
 }

+ 17 - 0
platform-dao/src/main/java/com/platform/dao/mapper/check/CheckJobMapper.java

@@ -8,6 +8,7 @@ import com.platform.dao.vo.query.check.CheckJobVO;
 import org.apache.ibatis.annotations.Param;
 import org.springframework.stereotype.Component;
 
+import java.time.LocalDateTime;
 import java.util.List;
 
 
@@ -110,6 +111,22 @@ public interface CheckJobMapper extends MyMapper<CheckJob> {
                           @Param("checkDeptId") String checkDeptId);
 
     /**
+     * 设备近期点检(保养)日历:查询指定设备在给定时间范围内已完成(status=3)的任务,
+     * 按实际完成时间(actual_end_time)排序,仅取日历展示所需字段。
+     * 该方法不带数据权限,供移动端扫码免登录调用。
+     *
+     * @param sbId      设备ID
+     * @param type      任务类型:1-点检 2-保养 3-润滑,为空则不限类型
+     * @param startTime 完成时间范围起点(含)
+     * @param endTime   完成时间范围终点(含)
+     * @return 已完成的保养任务列表
+     */
+    List<CheckJobVO> selectSbFinishedJobs(@Param("sbId") String sbId,
+                                         @Param("type") Integer type,
+                                         @Param("startTime") LocalDateTime startTime,
+                                         @Param("endTime") LocalDateTime endTime);
+
+    /**
      * 年度保养计划表:按设备类别/设备名称/保养负责人聚合,
      * 统计当年每月是否有已完成(status=3)的保养任务,有则对应月份打勾√;
      * 排除按天(standard.period_type=1)的保养。

+ 21 - 0
platform-dao/src/main/java/com/platform/dao/mapper/repair/RepairApplicationFormMapper.java

@@ -4,6 +4,8 @@ import com.platform.dao.config.MyMapper;
 import com.platform.dao.dto.repair.RepairApplicationFormDTO;
 import com.platform.dao.entity.repair.RepairApplicationForm;
 import com.platform.dao.vo.repair.RepairApplicationFormVO;
+import com.platform.dao.vo.report.MtbfFaultOrderVO;
+import com.platform.dao.vo.report.MttrMonthVO;
 import com.platform.dao.vo.report.RepairWorkHourReportVO;
 import org.springframework.stereotype.Component;
 import java.util.List;
@@ -83,4 +85,23 @@ public interface RepairApplicationFormMapper extends MyMapper<RepairApplicationF
      * @return
      */
     List<RepairWorkHourReportVO> statisticsWorkHourByMonth(RepairApplicationFormDTO dto);
+
+    /**
+     * MTTR(平均故障修复时间)按月趋势统计
+     * 口径:非计划性、已完成(repair_end_time/repair_minutes 非空),按 repair_start_time 归属月份
+     * mttrMinutes = avg(repair_minutes)*60(repair_minutes 存的是小时)
+     *
+     * @param dto 查询条件(时间范围 searchRepairStartTime/EndTime、设备类别/大类/关键字)
+     * @return 按月聚合结果
+     */
+    List<MttrMonthVO> statisticsMttrByMonth(RepairApplicationFormDTO dto);
+
+    /**
+     * MTBF 计算原始明细:非计划性已完成维修单,按设备、报修时间排序
+     * 供 Service 层在 Java 中计算「上次维修结束 -> 下次报修」故障间隔
+     *
+     * @param dto 查询条件(设备类别/大类/关键字)
+     * @return 明细列表
+     */
+    List<MtbfFaultOrderVO> selectFaultOrdersForMtbf(RepairApplicationFormDTO dto);
 }

+ 11 - 0
platform-dao/src/main/java/com/platform/dao/mapper/upms/AnnouncementMapper.java

@@ -2,6 +2,8 @@ package com.platform.dao.mapper.upms;
 
 import com.platform.dao.config.MyMapper;
 import com.platform.dao.entity.upms.Announcement;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
 import org.springframework.stereotype.Component;
 
 
@@ -14,4 +16,13 @@ import org.springframework.stereotype.Component;
 @Component
 public interface AnnouncementMapper extends MyMapper<Announcement> {
 
+    /**
+     * 定点清空“有效期至”(置为 NULL 表示长期有效)。
+     * 用于编辑时把有效期改回空的场景:updateByPrimaryKeySelective 会跳过 null 字段,无法将其置空。
+     *
+     * @param id 主键
+     * @return :
+     */
+    @Update("update t_announcement set expire_time = null where id = #{id}")
+    int clearExpireTime(@Param("id") String id);
 }

+ 14 - 0
platform-dao/src/main/java/com/platform/dao/util/ExcelUtil.java

@@ -34,6 +34,20 @@ public class ExcelUtil {
     }
 
     /**
+     * 导出包含数据词典 + 统计图(图表 PNG 放入独立“统计图表”sheet)
+     *
+     * @param response    :
+     * @param pojoClass   :
+     * @param dataSet     :
+     * @param name        :
+     * @param chartBase64 :前端图表截图 dataURL/base64(PNG),为空时仅导出数据
+     */
+    public static <T> void exportResponseDictWithChart(HttpServletResponse response, Class<T> pojoClass, Collection<?> dataSet,
+            String name, String chartBase64) {
+        PoiExcelUtil.exportResponseWithChart(response, new MyDictHandle(), pojoClass, dataSet, name, chartBase64);
+    }
+
+    /**
      * 导入
      *
      * @param inputStream :

+ 52 - 0
platform-dao/src/main/java/com/platform/dao/vo/export/report/ExportMtbfReportVO.java

@@ -0,0 +1,52 @@
+package com.platform.dao.vo.export.report;
+
+import com.platform.office.annotation.Excel;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+/**
+ * @Description MTBF 按设备统计导出VO
+ */
+@Data
+@Accessors(chain = true)
+public class ExportMtbfReportVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 设备编号
+     */
+    @Excel(name = "设备编号", orderNum = "1")
+    private String sbNo;
+
+    /**
+     * 设备名称
+     */
+    @Excel(name = "设备名称", orderNum = "2")
+    private String sbName;
+
+    /**
+     * 设备类别(A/B/C类)
+     */
+    @Excel(name = "设备类别", orderNum = "3", dicCode = "SBINFO_LEVEL")
+    private Integer level;
+
+    /**
+     * 设备大类
+     */
+    @Excel(name = "设备大类", orderNum = "4")
+    private String sbTypeName;
+
+    /**
+     * 故障次数
+     */
+    @Excel(name = "故障次数", orderNum = "5")
+    private Integer faultNum;
+
+    /**
+     * MTBF平均故障间隔时间(小时)
+     */
+    @Excel(name = "MTBF(小时)", orderNum = "6")
+    private Double mtbfHours;
+}

+ 40 - 0
platform-dao/src/main/java/com/platform/dao/vo/export/report/ExportMttrMonthVO.java

@@ -0,0 +1,40 @@
+package com.platform.dao.vo.export.report;
+
+import com.platform.office.annotation.Excel;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+/**
+ * @Description MTTR 按月趋势统计导出VO
+ */
+@Data
+@Accessors(chain = true)
+public class ExportMttrMonthVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 月份
+     */
+    @Excel(name = "月份", orderNum = "1")
+    private String month;
+
+    /**
+     * 故障次数
+     */
+    @Excel(name = "故障次数", orderNum = "2")
+    private Integer num;
+
+    /**
+     * 总维修工时(小时)
+     */
+    @Excel(name = "总维修工时(小时)", orderNum = "3")
+    private Double totalRepairHours;
+
+    /**
+     * MTTR平均故障修复时间(分钟)
+     */
+    @Excel(name = "MTTR(分钟)", orderNum = "4")
+    private Double mttrMinutes;
+}

+ 4 - 0
platform-dao/src/main/java/com/platform/dao/vo/query/check/CheckJobScreenVO.java

@@ -40,4 +40,8 @@ public class CheckJobScreenVO implements Serializable{
      * 本周任务
      */
     private List<CheckJobVO> weekList;
+    /**
+     * 近期保养计划:今起 14 天内待执行/执行中/待验证的保养任务,按任务日期正序
+     */
+    private List<CheckJobVO> planList;
 }

+ 4 - 0
platform-dao/src/main/java/com/platform/dao/vo/query/check/CheckJobVO.java

@@ -108,6 +108,10 @@ public class CheckJobVO implements Serializable{
      */
     private Integer periodType;
     /**
+     * 周期文字:计划周期+周期类型单位,如"1周",供大屏等直接展示
+     */
+    private String periodText;
+    /**
      * 要求
      */
     private String requirement;

+ 29 - 0
platform-dao/src/main/java/com/platform/dao/vo/query/check/SbCheckCalendarDayVO.java

@@ -0,0 +1,29 @@
+package com.platform.dao.vo.query.check;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * @Description 设备近期点检(保养)日历单日明细VO
+ * @Version Copyright (c) 2020,北京乾元坤和科技有限公司 All rights reserved.
+ */
+@Data
+@Accessors(chain = true)
+public class SbCheckCalendarDayVO implements Serializable {
+
+    /**
+     * 完成日期:yyyy-MM-dd,与日历格子一一对应
+     */
+    private String date;
+    /**
+     * 当日完成的保养任务条数
+     */
+    private Integer num;
+    /**
+     * 当日完成的保养任务明细(任务名称、执行人/班组、完成时间)
+     */
+    private List<CheckJobVO> jobList;
+}

+ 44 - 0
platform-dao/src/main/java/com/platform/dao/vo/query/check/SbCheckCalendarVO.java

@@ -0,0 +1,44 @@
+package com.platform.dao.vo.query.check;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @Description 设备近期点检(保养)日历VO
+ *              供移动端扫码进入的设备详情页展示:按月返回该设备已完成(status=3)的保养任务,
+ *              前端把 dayList 中的日期标绿打勾,即表示该日被点检(保养)过。
+ * @Version Copyright (c) 2020,北京乾元坤和科技有限公司 All rights reserved.
+ */
+@Data
+@Accessors(chain = true)
+public class SbCheckCalendarVO implements Serializable {
+
+    /**
+     * 日历所属月份:yyyy-MM
+     */
+    private String month;
+    /**
+     * 任务类型:1-点检 2-保养 3-润滑
+     */
+    private Integer type;
+    /**
+     * 当月完成点检(保养)的天数,即日历上标绿的格子数
+     */
+    private Integer finishDays;
+    /**
+     * 当月完成点检(保养)的任务条数
+     */
+    private Integer finishNum;
+    /**
+     * 当月最后一次完成时间,为空表示当月未做过
+     */
+    private LocalDateTime lastFinishTime;
+    /**
+     * 按完成日期分组的每日明细
+     */
+    private List<SbCheckCalendarDayVO> dayList;
+}

+ 56 - 0
platform-dao/src/main/java/com/platform/dao/vo/repair/RepairUncompletedOrderVO.java

@@ -0,0 +1,56 @@
+package com.platform.dao.vo.repair;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * @Description 设备未完结报修单 VO:报修提交前的重复报修提醒
+ * @Author sunzhongwei
+ * @Date 2026-10-28
+ * @Version Copyright (c) 2019,北京乾元坤和科技有限公司 All rights reserved.
+ */
+@Data
+public class RepairUncompletedOrderVO implements Serializable {
+
+    /**
+     * 报修单id
+     */
+    private String id;
+
+    /**
+     * 报修单号
+     */
+    private String no;
+
+    /**
+     * 报修时间
+     */
+    private LocalDateTime applyTime;
+
+    /**
+     * 报修状态
+     */
+    private Integer status;
+
+    /**
+     * 报修状态名称
+     */
+    private String statusLabel;
+
+    /**
+     * 问题描述
+     */
+    private String content;
+
+    /**
+     * 报修人姓名
+     */
+    private String actualUser;
+
+    /**
+     * 维修人姓名
+     */
+    private String repairUserName;
+}

+ 52 - 0
platform-dao/src/main/java/com/platform/dao/vo/report/MtbfFaultOrderVO.java

@@ -0,0 +1,52 @@
+package com.platform.dao.vo.report;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * @Description MTBF 计算原始明细:非计划性已完成维修单的报修/维修结束时间
+ * 仅用于 Service 层按设备分组、按报修时间排序后在 Java 中计算故障间隔
+ */
+@Data
+@Accessors(chain = true)
+public class MtbfFaultOrderVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 设备id
+     */
+    private String sbId;
+
+    /**
+     * 设备编号
+     */
+    private String sbNo;
+
+    /**
+     * 设备名称
+     */
+    private String sbName;
+
+    /**
+     * 设备类别(等级)
+     */
+    private Integer level;
+
+    /**
+     * 设备大类名称
+     */
+    private String sbTypeName;
+
+    /**
+     * 报修时间
+     */
+    private LocalDateTime applyTime;
+
+    /**
+     * 维修结束时间
+     */
+    private LocalDateTime repairEndTime;
+}

+ 31 - 0
platform-dao/src/main/java/com/platform/dao/vo/report/MtbfMonthVO.java

@@ -0,0 +1,31 @@
+package com.platform.dao.vo.report;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+/**
+ * @Description MTBF 按月趋势VO
+ * 归属月 = 该故障间隔「下一次故障报修时间」所在月;mtbfHours = 该月所有故障间隔平均值
+ */
+@Data
+@Accessors(chain = true)
+public class MtbfMonthVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 月份:yyyy-MM
+     */
+    private String month;
+
+    /**
+     * 参与统计的故障间隔次数
+     */
+    private Integer num;
+
+    /**
+     * MTBF 平均故障间隔时间(小时)
+     */
+    private Double mtbfHours;
+}

+ 52 - 0
platform-dao/src/main/java/com/platform/dao/vo/report/MtbfReportVO.java

@@ -0,0 +1,52 @@
+package com.platform.dao.vo.report;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+/**
+ * @Description MTBF(平均故障间隔时间)按设备统计VO
+ * 统计口径:非计划性维修,同一设备「上次维修结束时间 -> 下次报修时间」的间隔时长求平均
+ * 单位:小时;故障次数不足2次时 mtbfHours 为 null
+ */
+@Data
+@Accessors(chain = true)
+public class MtbfReportVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 设备id
+     */
+    private String sbId;
+
+    /**
+     * 设备编号
+     */
+    private String sbNo;
+
+    /**
+     * 设备名称
+     */
+    private String sbName;
+
+    /**
+     * 设备类别(等级):对应字典 SBINFO_LEVEL(A/B/C类)
+     */
+    private Integer level;
+
+    /**
+     * 设备大类名称(行车、AGV、起重机等)
+     */
+    private String sbTypeName;
+
+    /**
+     * 故障次数(非计划性已完成维修单数量)
+     */
+    private Integer faultNum;
+
+    /**
+     * MTBF 平均故障间隔时间(小时),故障不足2次为 null
+     */
+    private Double mtbfHours;
+}

+ 27 - 0
platform-dao/src/main/java/com/platform/dao/vo/report/MtbfResultVO.java

@@ -0,0 +1,27 @@
+package com.platform.dao.vo.report;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * @Description MTBF 统计结果:按设备明细 + 按月趋势
+ * 按月趋势归属月 = 该故障间隔「下一次故障报修时间」所在月
+ */
+@Data
+@Accessors(chain = true)
+public class MtbfResultVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 按设备统计明细
+     */
+    private List<MtbfReportVO> sbList;
+
+    /**
+     * 按月趋势(month=yyyy-MM,mtbfHours=该月完成的所有故障间隔平均值)
+     */
+    private List<MtbfMonthVO> monthList;
+}

+ 37 - 0
platform-dao/src/main/java/com/platform/dao/vo/report/MttrMonthVO.java

@@ -0,0 +1,37 @@
+package com.platform.dao.vo.report;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+/**
+ * @Description MTTR(平均故障修复时间)按月趋势统计VO
+ * 统计口径:非计划性维修、维修结束时间不为空,按维修开始时间归属月份
+ * MTTR = 当月实际维修时长总和 / 当月故障次数(单位:分钟)
+ */
+@Data
+@Accessors(chain = true)
+public class MttrMonthVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 月份:yyyy-MM
+     */
+    private String month;
+
+    /**
+     * 故障次数(非计划性已完成维修单数量)
+     */
+    private Integer num;
+
+    /**
+     * 总维修工时(小时)
+     */
+    private Double totalRepairHours;
+
+    /**
+     * MTTR 平均故障修复时间(分钟)
+     */
+    private Double mttrMinutes;
+}

+ 50 - 0
platform-dao/src/main/java/com/platform/dao/vo/sb/SbInfoProblemScreenVO.java

@@ -0,0 +1,50 @@
+package com.platform.dao.vo.sb;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * @Description 大屏右上区域:当前问题设备(有进行中报修单的设备)
+ * @Author sunzw
+ * @Version Copyright (c) 2019,北京乾元坤和科技有限公司 All rights reserved.
+ */
+@Data
+@Accessors(chain = true)
+public class SbInfoProblemScreenVO implements Serializable {
+
+    /**
+     * 设备id
+     */
+    private String sbId;
+    /**
+     * 设备编号
+     */
+    private String no;
+    /**
+     * 设备名称
+     */
+    private String name;
+    /**
+     * 最新一条进行中报修单id
+     */
+    private String repairId;
+    /**
+     * 最新一条进行中报修单编号
+     */
+    private String repairNo;
+    /**
+     * 维修状态:报修单状态值,参考 RepairApplicationFormStatusEnum
+     */
+    private Integer repairStatus;
+    /**
+     * 维修状态名称:待接收/维修中/转派审批中/委外维修中
+     */
+    private String repairStatusLabel;
+    /**
+     * 报修时间
+     */
+    private LocalDateTime applyTime;
+}

+ 31 - 0
platform-dao/src/main/resources/mapper/check/CheckJobMapper.xml

@@ -1175,6 +1175,37 @@
           and status = #{status}
     </update>
 
+    <!-- 设备近期点检(保养)日历:只查单台设备当月已完成(status=3)的任务,
+         完成日取 actual_end_time,历史数据为空时依次退化为 actual_start_time、end_time(与年度计划表口径一致),
+         不走 selectList 的多表联查(避免脏数据与免登录场景下的额外开销) -->
+    <select id="selectSbFinishedJobs" resultType="com.platform.dao.vo.query.check.CheckJobVO">
+        select
+            checkjob.id as id,
+            checkjob.name as name,
+            checkjob.type as type,
+            checkjob.status as status,
+            checkjob.sb_id as sbId,
+            checkjob.start_time as startTime,
+            checkjob.end_time as endTime,
+            checkjob.actual_user as actualUser,
+            coalesce(checkjob.actual_end_time, checkjob.actual_start_time, checkjob.end_time) as actualEndTime,
+            u.real_name as checkUserName,
+            dept.name as checkDeptName,
+            standard.name as standardName
+        from t_check_job checkjob
+        left join t_check_standard standard on standard.id = checkjob.standard_id
+        left join t_sys_user u on u.user_id = checkjob.check_user_id
+        left join t_sys_dept dept on dept.dept_id = checkjob.check_dept_id
+        where checkjob.sb_id = #{sbId}
+          and checkjob.status = 3
+          and coalesce(checkjob.actual_end_time, checkjob.actual_start_time, checkjob.end_time) <![CDATA[>=]]> #{startTime}
+          and coalesce(checkjob.actual_end_time, checkjob.actual_start_time, checkjob.end_time) <![CDATA[<=]]> #{endTime}
+          <if test="type != null">
+              and checkjob.type = #{type}
+          </if>
+        order by actualEndTime asc, checkjob.id asc
+    </select>
+
     <!-- 年度保养计划表:以“保养标准(t_check_standard)”为行来源,排除按天(period_type=1)的保养,
          即使该标准尚未执行任何任务也会显示一行(月份不打勾);
          当年某设备每月只要有已完成(status=3)的保养任务则该月打勾√ -->

+ 120 - 0
platform-dao/src/main/resources/mapper/repair/RepairApplicationFormMapper.xml

@@ -262,12 +262,37 @@
         left join t_sys_dept dept on application.dept_id = dept.dept_id
         <where>
             application.repair_end_time is not null
+            <!-- MTTR/MTBF 明细只查非计划性维修时传 category -->
+            <if test="category != null">
+                and application.category = #{category}
+            </if>
             <if test="repairUserId != null and repairUserId != ''">
                 and application.repair_user_id = #{repairUserId}
             </if>
             <if test="sbId != null and sbId != ''">
                 and application.sb_id = #{sbId}
             </if>
+            <!-- MTTR 月度明细:与趋势统计保持同样的设备筛选条件 -->
+            <if test="keyword != null and keyword != ''">
+                and (sb.name like concat('%',#{keyword},'%') or sb.no like concat('%',#{keyword},'%'))
+            </if>
+            <if test="levelList != null and levelList.size() > 0">
+                and sb.level in
+                <foreach collection="levelList" item="item" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+            </if>
+            <if test="typeIdList != null and typeIdList.size() > 0">
+                and (sb.type_id in
+                <foreach collection="typeIdList" item="item" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+                or sb.type_id in (select st.id from t_sb_type st where st.parent_id in
+                <foreach collection="typeIdList" item="item" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+                ))
+            </if>
             <if test="searchRepairStartTime != null">
                 and application.repair_start_time <![CDATA[ >= ]]> #{searchRepairStartTime}
             </if>
@@ -324,4 +349,99 @@
         having totalRepairHours > 0
         order by totalRepairHours desc
     </select>
+
+    <!-- MTTR(平均故障修复时间)按月趋势统计:非计划性已完成工单,按维修开始时间归属月份 -->
+    <select id="statisticsMttrByMonth" parameterType="com.platform.dao.dto.repair.RepairApplicationFormDTO"
+            resultType="com.platform.dao.vo.report.MttrMonthVO">
+        select DATE_FORMAT(application.repair_start_time, '%Y-%m') as month,
+        count(1) as num,
+        IFNULL(sum(application.repair_minutes), 0) as totalRepairHours,
+        ROUND(IFNULL(avg(application.repair_minutes), 0) * 60, 2) as mttrMinutes
+        from t_repair_application_form application
+        left join t_sb_info sb on application.sb_id = sb.id
+        <where>
+            application.repair_end_time is not null
+            and application.repair_minutes is not null
+            and application.repair_start_time is not null
+            and application.category = #{category}
+            <if test="searchRepairStartTime != null">
+                and application.repair_start_time <![CDATA[ >= ]]> #{searchRepairStartTime}
+            </if>
+            <if test="searchRepairEndTime != null">
+                and application.repair_start_time <![CDATA[ <= ]]> #{searchRepairEndTime}
+            </if>
+            <if test="sbId != null and sbId != ''">
+                and application.sb_id = #{sbId}
+            </if>
+            <if test="keyword != null and keyword != ''">
+                and (sb.name like concat('%',#{keyword},'%') or sb.no like concat('%',#{keyword},'%'))
+            </if>
+            <if test="levelList != null and levelList.size() > 0">
+                and sb.level in
+                <foreach collection="levelList" item="item" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+            </if>
+            <if test="typeIdList != null and typeIdList.size() > 0">
+                <!-- 树形筛选:选中大类需连带子类设备,按 parent_id 扩展(与设备台账 SbInfoMapper 规则一致) -->
+                and (sb.type_id in
+                <foreach collection="typeIdList" item="item" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+                or sb.type_id in (select st.id from t_sb_type st where st.parent_id in
+                <foreach collection="typeIdList" item="item" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+                ))
+            </if>
+        </where>
+        group by DATE_FORMAT(application.repair_start_time, '%Y-%m')
+        order by month asc
+    </select>
+
+    <!-- MTBF 计算原始明细:非计划性已完成工单,按设备、报修时间排序,间隔在 Java 中计算 -->
+    <select id="selectFaultOrdersForMtbf" parameterType="com.platform.dao.dto.repair.RepairApplicationFormDTO"
+            resultType="com.platform.dao.vo.report.MtbfFaultOrderVO">
+        select application.sb_id as sbId,
+        sb.no as sbNo,
+        sb.name as sbName,
+        sb.level as level,
+        sbType.name as sbTypeName,
+        application.apply_time as applyTime,
+        application.repair_end_time as repairEndTime
+        from t_repair_application_form application
+        left join t_sb_info sb on application.sb_id = sb.id
+        left join t_sb_type sbType on sb.type_id = sbType.id
+        <where>
+            application.repair_end_time is not null
+            and application.apply_time is not null
+            and application.sb_id is not null and application.sb_id != ''
+            and application.category = #{category}
+            <if test="sbId != null and sbId != ''">
+                and application.sb_id = #{sbId}
+            </if>
+            <if test="keyword != null and keyword != ''">
+                and (sb.name like concat('%',#{keyword},'%') or sb.no like concat('%',#{keyword},'%'))
+            </if>
+            <if test="levelList != null and levelList.size() > 0">
+                and sb.level in
+                <foreach collection="levelList" item="item" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+            </if>
+            <if test="typeIdList != null and typeIdList.size() > 0">
+                <!-- 树形筛选:选中大类需连带子类设备,按 parent_id 扩展(与设备台账 SbInfoMapper 规则一致) -->
+                and (sb.type_id in
+                <foreach collection="typeIdList" item="item" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+                or sb.type_id in (select st.id from t_sb_type st where st.parent_id in
+                <foreach collection="typeIdList" item="item" open="(" separator="," close=")">
+                    #{item}
+                </foreach>
+                ))
+            </if>
+        </where>
+        order by application.sb_id asc, application.apply_time asc
+    </select>
 </mapper>

+ 37 - 8
platform-dao/src/main/resources/mapper/sb/SbInfoMapper.xml

@@ -216,8 +216,10 @@ user.real_name as saveUserName
         <if test="producerName != null and producerName!=''">
             and producer.name like concat('%',#{producerName},'%')
         </if>
+        <!-- 所属车间:选中节点时需连带查询其下级位置 -->
         <if test="positionId != null and positionId!=''">
-            and sb.position_id  = #{positionId}
+            and (sb.position_id = #{positionId}
+            or sb.position_id in (select sp.id from t_sb_position sp where sp.parent_id = #{positionId}))
         </if>
         <if test="likeDeptCode != null and likeDeptCode!=''">
             and sdDept.dept_code like concat(#{likeDeptCode},'%')
@@ -258,14 +260,21 @@ user.real_name as saveUserName
         <if test="useProject != null">
             and sb.use_project = #{useProject}
         </if>
+        <!-- 设备类型:选中大类时需连带查询其子类 -->
         <if test="typeId != null">
-            and sb.type_id = #{typeId}
+            and (sb.type_id = #{typeId}
+            or sb.type_id in (select st.id from t_sb_type st where st.parent_id = #{typeId}))
         </if>
         <if test="typeIdList != null and typeIdList.size > 0">
-            AND sb.type_id in
+            AND (sb.type_id in
             <foreach item="item" index="index" collection="typeIdList" open="(" close=")" separator=",">
                 #{item}
             </foreach>
+            OR sb.type_id in (select st.id from t_sb_type st where st.parent_id in
+            <foreach item="item" index="index" collection="typeIdList" open="(" close=")" separator=",">
+                #{item}
+            </foreach>
+            ))
         </if>
         <if test="parentId != null">
             and sb.parent_id = #{parentId}
@@ -349,11 +358,17 @@ user.real_name as saveUserName
                 #{item}
             </foreach>
         </if>
+        <!-- 所属车间多选:选中节点时需连带查询其下级位置 -->
         <if test="positionIdList != null and positionIdList.size > 0">
-            AND sb.position_id in
+            AND (sb.position_id in
+            <foreach item="item" index="index" collection="positionIdList" open="(" close=")" separator=",">
+                #{item}
+            </foreach>
+            OR sb.position_id in (select sp.id from t_sb_position sp where sp.parent_id in
             <foreach item="item" index="index" collection="positionIdList" open="(" close=")" separator=",">
                 #{item}
             </foreach>
+            ))
         </if>
         <if test="isChildList != null and isChildList.size > 0">
             AND sb.is_child in
@@ -418,14 +433,21 @@ user.real_name as saveUserName
         <if test="useProject != null">
             and sb.use_project = #{useProject}
         </if>
+        <!-- 设备类型:选中大类时需连带查询其子类 -->
         <if test="typeId != null">
-            and sb.type_id = #{typeId}
+            and (sb.type_id = #{typeId}
+            or sb.type_id in (select st.id from t_sb_type st where st.parent_id = #{typeId}))
         </if>
         <if test="typeIdList != null and typeIdList.size > 0">
-            AND sb.type_id in
+            AND (sb.type_id in
             <foreach item="item" index="index" collection="typeIdList" open="(" close=")" separator=",">
                 #{item}
             </foreach>
+            OR sb.type_id in (select st.id from t_sb_type st where st.parent_id in
+            <foreach item="item" index="index" collection="typeIdList" open="(" close=")" separator=",">
+                #{item}
+            </foreach>
+            ))
         </if>
         <if test="parentId != null">
             and sb.parent_id = #{parentId}
@@ -531,14 +553,21 @@ user.real_name as saveUserName
         <if test="useProject != null">
             and sb.use_project = #{useProject}
         </if>
+        <!-- 设备类型:选中大类时需连带查询其子类 -->
         <if test="typeId != null">
-            and sb.type_id = #{typeId}
+            and (sb.type_id = #{typeId}
+            or sb.type_id in (select st.id from t_sb_type st where st.parent_id = #{typeId}))
         </if>
         <if test="typeIdList != null and typeIdList.size > 0">
-            AND sb.type_id in
+            AND (sb.type_id in
+            <foreach item="item" index="index" collection="typeIdList" open="(" close=")" separator=",">
+                #{item}
+            </foreach>
+            OR sb.type_id in (select st.id from t_sb_type st where st.parent_id in
             <foreach item="item" index="index" collection="typeIdList" open="(" close=")" separator=",">
                 #{item}
             </foreach>
+            ))
         </if>
         <if test="model != null">
             and sb.model like concat('%',#{model},'%')

+ 2 - 0
platform-office/pom.xml

@@ -13,6 +13,8 @@
     <url>http://www.example.com</url>
 
     <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <maven.compiler.encoding>UTF-8</maven.compiler.encoding>
         <sub.version>2.1.4</sub.version>
         <poi.version>3.9</poi.version>
         <xerces.version>2.9.1</xerces.version>

+ 93 - 0
platform-office/src/main/java/com/platform/office/tool/ToolPoi.java

@@ -14,7 +14,13 @@ import com.platform.office.poi.util.DateUtils;
 import com.platform.office.poi.util.PoiPublicUtil;
 import com.platform.office.vo.ExcelData;
 import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
 import org.apache.poi.hssf.usermodel.HSSFWorkbook;
+import org.apache.poi.ss.usermodel.ClientAnchor;
+import org.apache.poi.ss.usermodel.CreationHelper;
+import org.apache.poi.ss.usermodel.Drawing;
+import org.apache.poi.ss.usermodel.Picture;
+import org.apache.poi.ss.usermodel.Sheet;
 import org.apache.poi.ss.usermodel.Workbook;
 
 import javax.servlet.http.HttpServletResponse;
@@ -25,6 +31,7 @@ import java.io.OutputStream;
 import java.lang.reflect.Field;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
+import java.util.Base64;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -35,6 +42,7 @@ import java.util.Map;
  * @author chenli
  * @create 2017-12-12
  */
+@Slf4j
 public class ToolPoi {
 
     /**
@@ -187,6 +195,91 @@ public class ToolPoi {
     }
 
     /**
+     * 导出包含统计图:数据 sheet 不变,图表 PNG 以图片形式放入独立的“统计图表”sheet;
+     * chartBase64 为空或解析失败时等同普通 dict 导出,不阻断数据导出
+     *
+     * @param response     输出对象
+     * @param dictHandle   词典处理器
+     * @param pojoClass    Excel对象Class
+     * @param dataSet      Excel对象数据List
+     * @param name         文件名/标题
+     * @param chartBase64  前端图表截图(dataURL 或纯 base64,PNG)
+     */
+    public static <T> void exportResponseWithChart(HttpServletResponse response, DictHandle dictHandle, Class<T> pojoClass,
+                                                   Collection<?> dataSet, String name, String chartBase64) {
+        ExportParams exportParams = new ExportParams();
+        exportParams.setTitle(name);
+        exportParams.setSheetName(name);
+        exportParams.setStyle(ExcelExportStylerBorderImpl.class);
+        String fileName = name + DateUtils.dateToString(LocalDateTime.now(), "yyyyMMddHHmmssSSS");
+        Workbook workbook = ExcelExportUtil.exportExcel(dictHandle, exportParams, pojoClass, dataSet);
+        appendChartSheet(workbook, chartBase64);
+        writeWorkbookToResponse(response, workbook, fileName);
+    }
+
+    /**
+     * 向工作簿追加“统计图表”sheet 并按原始像素嵌入图表 PNG
+     */
+    private static void appendChartSheet(Workbook workbook, String chartBase64) {
+        if (chartBase64 == null || chartBase64.trim().isEmpty()) {
+            return;
+        }
+        String base64 = chartBase64;
+        // 兼容 dataURL 前缀:data:image/png;base64,xxx
+        int comma = base64.indexOf("base64,");
+        if (comma >= 0) {
+            base64 = base64.substring(comma + "base64,".length());
+        }
+        byte[] image;
+        try {
+            image = Base64.getMimeDecoder().decode(base64);
+        } catch (IllegalArgumentException e) {
+            log.warn("导出嵌图 base64 解析失败,降级为仅导出数据", e);
+            return;
+        }
+        try {
+            Sheet chartSheet = workbook.createSheet("统计图表");
+            int pictureIndex = workbook.addPicture(image, Workbook.PICTURE_TYPE_PNG);
+            CreationHelper helper = workbook.getCreationHelper();
+            @SuppressWarnings("all")
+            Drawing drawing = chartSheet.createDrawingPatriarch();
+            ClientAnchor anchor = helper.createClientAnchor();
+            anchor.setCol1(1);
+            anchor.setRow1(1);
+            Picture picture = drawing.createPicture(anchor, pictureIndex);
+            // 按图片原始尺寸展开,避免拉伸变形(G2 截图为白底 PNG,Excel 中观感一致)
+            picture.resize();
+        } catch (Exception e) {
+            // 嵌图失败不影响数据:移除空图表 sheet 后继续导出
+            log.warn("导出嵌图失败,降级为仅导出数据", e);
+            int index = workbook.getSheetIndex("统计图表");
+            if (index >= 0) {
+                workbook.removeSheetAt(index);
+            }
+        }
+    }
+
+    /**
+     * 工作簿写出到响应(与既有 exportResponse 保持一致的头信息与编码)
+     */
+    @SneakyThrows
+    private static void writeWorkbookToResponse(HttpServletResponse response, Workbook workbook, String fileName) {
+        if (workbook instanceof HSSFWorkbook) {
+            fileName += HSSF;
+        } else {
+            fileName += XSSF;
+        }
+        response.setCharacterEncoding("UTF-8");
+        // 设置contentType为excel格式
+        response.setContentType("application/vnd.ms-excel;charset=utf-8");
+        // 默认Excel名称
+        response.setHeader("Content-Disposition",
+                "attachment;filename=" + java.net.URLEncoder.encode(fileName, "UTF-8"));
+        response.flushBuffer();
+        workbook.write(response.getOutputStream());
+    }
+
+    /**
      * @param response     输出对象
      * @param fileName     文件名
      * @param exportParams 表格标题属性

+ 7 - 0
platform-office/src/main/java/com/platform/office/util/PoiExcelUtil.java

@@ -63,6 +63,13 @@ public class PoiExcelUtil {
     }
 
     /**
+     * 导出带统计图:图表 PNG 放入独立“统计图表”sheet,chartBase64 为空时等同普通导出
+     */
+    public static <T> void exportResponseWithChart(HttpServletResponse response, DictHandle dictHandle, Class<T> pojoClass, Collection<?> dataSet, String name, String chartBase64) {
+        ToolPoi.exportResponseWithChart(response, dictHandle, pojoClass, dataSet, name, chartBase64);
+    }
+
+    /**
      * @param entityList Excel对象Class
      * @param dataSet    Excel对象数据List
      */

+ 17 - 0
platform-rest/src/main/java/com/platform/rest/controller/mobile/IgnoreController.java

@@ -22,6 +22,7 @@ import com.platform.dao.vo.export.sb.ExportSbInfoMeasureVO;
 import com.platform.dao.vo.export.sb.ExportSbInfoVO;
 import com.platform.dao.vo.index.GatherTaskVO;
 import com.platform.dao.vo.query.check.CheckJobVO;
+import com.platform.dao.vo.query.check.SbCheckCalendarVO;
 import com.platform.dao.vo.sb.SbInfoVO;
 import com.platform.office.poi.excel.ExcelImportUtil;
 import com.platform.office.poi.excel.entity.ImportParams;
@@ -156,6 +157,22 @@ public class IgnoreController {
     }
 
     /**
+     * 设备近期点检日历(数据源为保养任务):按月返回该设备已完成的保养任务,
+     * 已完成的日期在移动端日历上标绿打勾,扫码免登录即可查看。
+     *
+     * @param sbId  设备ID
+     * @param month 月份 yyyy-MM,不传默认当前月
+     * @param type  任务类型:1-点检 2-保养 3-润滑,不传默认保养
+     * @return R
+     */
+    @GetMapping("/sb/check-calendar/{sbId}")
+    public R<SbCheckCalendarVO> getSbCheckCalendar(@PathVariable("sbId") String sbId,
+                                                   @RequestParam(required = false) String month,
+                                                   @RequestParam(required = false) Integer type) {
+        return new R<>(checkJobService.getSbCheckCalendar(sbId, month, type));
+    }
+
+    /**
      * 新增记录
      *
      * @param repairApplicationFormDTO 保修单DTO

+ 12 - 0
platform-rest/src/main/java/com/platform/rest/controller/repair/RepairApplicationFormController.java

@@ -8,6 +8,7 @@ import com.platform.dao.dto.repair.RepairApplicationFormDTO;
 import com.platform.dao.util.ExcelUtil;
 import com.platform.dao.vo.export.repair.ExportRepairApplicationFormVO;
 import com.platform.dao.vo.repair.RepairApplicationFormVO;
+import com.platform.dao.vo.repair.RepairUncompletedOrderVO;
 import com.platform.rest.log.annotation.SysLog;
 import com.platform.service.repair.RepairApplicationFormService;
 import lombok.AllArgsConstructor;
@@ -55,6 +56,17 @@ public class RepairApplicationFormController {
     }
 
     /**
+     * 查询设备未完结的报修单(排除已完成/已拒单),提交报修前的重复报修提醒
+     *
+     * @param sbId 设备id
+     * @return R 未完结报修单列表,前端据此弹确认框,确认后仍允许提交
+     */
+    @GetMapping("/uncompleted")
+    public R<List<RepairUncompletedOrderVO>> getUncompleted(@RequestParam("sbId") String sbId) {
+        return new R<>(repairApplicationFormService.getUncompletedRepairs(sbId));
+    }
+
+    /**
      * 新增记录
      *
      * @param repairApplicationFormDTO 保修单DTO

+ 79 - 12
platform-rest/src/main/java/com/platform/rest/controller/report/RepairReportController.java

@@ -8,6 +8,7 @@ import com.platform.common.exception.BusinessException;
 import com.platform.common.util.BeanConverterUtil;
 import com.platform.common.util.DateUtils;
 import com.platform.common.util.R;
+import com.platform.common.util.StringUtils;
 import com.platform.dao.dto.check.CheckJobDTO;
 import com.platform.dao.dto.repair.RepairApplicationFormDTO;
 import com.platform.dao.dto.repair.RepairFeeDTO;
@@ -17,6 +18,8 @@ import com.platform.dao.util.ExcelUtil;
 import com.platform.dao.vo.export.check.ExportCheckJobVO;
 import com.platform.dao.vo.export.report.ExportRepairWorkHourSbVO;
 import com.platform.dao.vo.export.report.ExportRepairWorkHourUserVO;
+import com.platform.dao.vo.export.report.ExportMtbfReportVO;
+import com.platform.dao.vo.export.report.ExportMttrMonthVO;
 import com.platform.dao.vo.export.repair.ExportRepairApplicationFormVO;
 import com.platform.dao.vo.export.repair.ExportRepairFeeVO;
 import com.platform.dao.vo.query.check.CheckJobVO;
@@ -482,31 +485,31 @@ public class RepairReportController {
     }
 
     /**
-     * 维修工时统计导出:按维修人
+     * 维修工时统计导出:按维修人(支持携带统计图 chartBase64,图片嵌入“统计图表”sheet)
      *
-     * @param dto 查询条件(维修人姓名模糊、维修开始时间范围)
+     * @param dto 查询条件(维修人姓名模糊、维修开始时间范围、chartBase64 可选)
      */
-    @GetMapping("/workhour/export/user")
+    @PostMapping("/workhour/export/user")
     @SysLog("维修工时统计-按维修人导出")
-    public void exportWorkHourUser(HttpServletResponse response, RepairApplicationFormDTO dto) {
+    public void exportWorkHourUser(HttpServletResponse response, @RequestBody RepairApplicationFormDTO dto) {
         List<RepairWorkHourReportVO> list = repairApplicationFormService.statisticsWorkHourByUser(dto, 1, Integer.MAX_VALUE)
                 .getRows();
-        ExcelUtil.exportResponseDict(response, ExportRepairWorkHourUserVO.class,
-                BeanConverterUtil.copyListProperties(list, ExportRepairWorkHourUserVO.class), "维修工时统计-按维修人");
+        ExcelUtil.exportResponseDictWithChart(response, ExportRepairWorkHourUserVO.class,
+                BeanConverterUtil.copyListProperties(list, ExportRepairWorkHourUserVO.class), "维修工时统计-按维修人", dto.getChartBase64());
     }
 
     /**
-     * 维修工时统计导出:按设备
+     * 维修工时统计导出:按设备(支持携带统计图 chartBase64,图片嵌入“统计图表”sheet)
      *
-     * @param dto 查询条件(设备名称/编号模糊、维修开始时间范围)
+     * @param dto 查询条件(设备名称/编号模糊、维修开始时间范围、chartBase64 可选)
      */
-    @GetMapping("/workhour/export/sb")
+    @PostMapping("/workhour/export/sb")
     @SysLog("维修工时统计-按设备导出")
-    public void exportWorkHourSb(HttpServletResponse response, RepairApplicationFormDTO dto) {
+    public void exportWorkHourSb(HttpServletResponse response, @RequestBody RepairApplicationFormDTO dto) {
         List<RepairWorkHourReportVO> list = repairApplicationFormService.statisticsWorkHourBySb(dto, 1, Integer.MAX_VALUE)
                 .getRows();
-        ExcelUtil.exportResponseDict(response, ExportRepairWorkHourSbVO.class,
-                BeanConverterUtil.copyListProperties(list, ExportRepairWorkHourSbVO.class), "维修工时统计-按设备");
+        ExcelUtil.exportResponseDictWithChart(response, ExportRepairWorkHourSbVO.class,
+                BeanConverterUtil.copyListProperties(list, ExportRepairWorkHourSbVO.class), "维修工时统计-按设备", dto.getChartBase64());
     }
 
     /**
@@ -575,4 +578,68 @@ public class RepairReportController {
         ExcelUtil.exportResponseDict(response, ExportRepairWorkHourSbVO.class,
                 BeanConverterUtil.copyListProperties(list, ExportRepairWorkHourSbVO.class), title);
     }
+
+    /**
+     * MTTR(平均故障修复时间)按月趋势统计
+     * 口径:非计划性已完成维修单,按维修开始时间归属月份,MTTR 单位分钟
+     *
+     * @param dto 查询条件(mttrStartMonth/mttrEndMonth(yyyy-MM) 必填,levelList/typeIdList/keyword 可选)
+     * @return R 指定时间段逐月趋势
+     */
+    @GetMapping("/workhour/mttr/month")
+    @SysLog("MTTR按月趋势统计")
+    public R<List<MttrMonthVO>> getMttrMonthTrend(RepairApplicationFormDTO dto) {
+        return new R<>(repairApplicationFormService.getMttrMonthTrend(dto));
+    }
+
+    /**
+     * MTBF(平均故障间隔时间)统计:按设备明细 + 按月趋势
+     * 口径:非计划性已完成维修单,同一设备「上次维修结束 -> 下次报修」间隔求平均,单位小时
+     *
+     * @param dto 查询条件(mttrStartMonth/mttrEndMonth(yyyy-MM) 必填,levelList/typeIdList/keyword 可选)
+     * @return R { sbList, monthList }
+     */
+    @GetMapping("/workhour/mtbf")
+    @SysLog("MTBF统计")
+    public R<MtbfResultVO> getMtbfReport(RepairApplicationFormDTO dto) {
+        return new R<>(repairApplicationFormService.getMtbfReport(dto));
+    }
+
+    /**
+     * MTTR 按月趋势导出(支持携带统计图 chartBase64,图片嵌入“统计图表”sheet)
+     *
+     * @param dto 查询条件(mttrStartMonth/mttrEndMonth(yyyy-MM) 必填,levelList/typeIdList/keyword 可选)
+     */
+    @PostMapping("/workhour/mttr/export")
+    @SysLog("MTTR按月趋势导出")
+    public void exportMttrMonth(HttpServletResponse response, @RequestBody RepairApplicationFormDTO dto) {
+        List<MttrMonthVO> list = repairApplicationFormService.getMttrMonthTrend(dto);
+        String title = buildMttrPeriodLabel(dto) + "-MTTR月趋势统计";
+        ExcelUtil.exportResponseDictWithChart(response, ExportMttrMonthVO.class,
+                BeanConverterUtil.copyListProperties(list, ExportMttrMonthVO.class), title, dto.getChartBase64());
+    }
+
+    /**
+     * MTBF 按设备统计导出(支持携带统计图 chartBase64,图片嵌入“统计图表”sheet)
+     *
+     * @param dto 查询条件(mttrStartMonth/mttrEndMonth(yyyy-MM) 必填,levelList/typeIdList/keyword 可选)
+     */
+    @PostMapping("/workhour/mtbf/export")
+    @SysLog("MTBF按设备统计导出")
+    public void exportMtbf(HttpServletResponse response, @RequestBody RepairApplicationFormDTO dto) {
+        List<MtbfReportVO> list = repairApplicationFormService.getMtbfReport(dto).getSbList();
+        String title = buildMttrPeriodLabel(dto) + "-MTBF统计";
+        ExcelUtil.exportResponseDictWithChart(response, ExportMtbfReportVO.class,
+                BeanConverterUtil.copyListProperties(list, ExportMtbfReportVO.class), title, dto.getChartBase64());
+    }
+
+    /**
+     * 导出文件名时间段:优先月份范围(yyyy-MM~yyyy-MM),兼容旧版仅传年份
+     */
+    private String buildMttrPeriodLabel(RepairApplicationFormDTO dto) {
+        if (StringUtils.isNotBlank(dto.getMttrStartMonth()) && StringUtils.isNotBlank(dto.getMttrEndMonth())) {
+            return dto.getMttrStartMonth() + "~" + dto.getMttrEndMonth();
+        }
+        return dto.getMttrYear() + "年";
+    }
 }

+ 11 - 0
platform-rest/src/main/java/com/platform/rest/controller/sb/SbInfoController.java

@@ -253,6 +253,17 @@ public class SbInfoController {
     }
 
     /**
+     * 获取大屏问题设备数据(有进行中报修单的设备:编号、名称、维修状态)
+     * 不区分底图,返回全部问题设备
+     *
+     * @return R
+     */
+    @GetMapping("/screen/problem-list")
+    public R queryScreenProblemList() {
+        return new R<>(sbInfoService.getScreenProblemList());
+    }
+
+    /**
      * 获取分页:显示保养项目条数,可以直接跳转到保养内容里面,进行新增和查看页面
      *
      * @param pageNum   当前页码

+ 27 - 0
platform-rest/src/main/java/com/platform/rest/task/AnnouncementTask.java

@@ -0,0 +1,27 @@
+package com.platform.rest.task;
+
+import com.platform.service.upms.AnnouncementService;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * @Description 公告定时任务
+ * @Author sunzhongwei
+ * @Date 2026-10-27
+ * @Version Copyright (c) 2019,北京乾元坤和科技有限公司 All rights reserved.
+ */
+@Slf4j
+@AllArgsConstructor
+@Component("announcementTask")
+public class AnnouncementTask {
+
+    private final AnnouncementService announcementService;
+
+    /**
+     * 到期公告自动下架:把已发布且已过有效期的公告置为已下架
+     */
+    public void stopExpiredForTimer() {
+        announcementService.stopExpired();
+    }
+}

+ 13 - 0
platform-service/src/main/java/com/platform/service/check/CheckJobService.java

@@ -12,6 +12,7 @@ import com.platform.dao.vo.query.check.CheckJobNum;
 import com.platform.dao.vo.query.check.CheckJobAnnualPlanVO;
 import com.platform.dao.vo.query.check.CheckJobScreenVO;
 import com.platform.dao.vo.query.check.CheckJobVO;
+import com.platform.dao.vo.query.check.SbCheckCalendarVO;
 import com.platform.dao.vo.report.CheckJobReportVO;
 import com.platform.dao.vo.report.CheckJobReportWeekHoursVO;
 import com.platform.dao.vo.tuicalendar.TuiCalendar;
@@ -222,4 +223,16 @@ public interface CheckJobService extends IBaseService<CheckJob, CheckJobDTO> {
      * @return 年度计划表行数据
      */
     List<CheckJobAnnualPlanVO> getAnnualPlan(CheckJobDTO dto);
+
+    /**
+     * 设备近期点检(保养)日历:按月返回该设备已完成的保养任务,并按完成日期分组,
+     * 供移动端扫码进入的设备详情页免登录查看“哪几天被点检过”。
+     * 注意:免登录接口,不得调用 SecurityUtils。
+     *
+     * @param sbId  设备ID
+     * @param month 月份 yyyy-MM,为空默认当前月
+     * @param type  任务类型:1-点检 2-保养 3-润滑,为空默认保养
+     * @return 日历数据(含每月汇总与每日任务明细)
+     */
+    SbCheckCalendarVO getSbCheckCalendar(String sbId, String month, Integer type);
 }

+ 78 - 19
platform-service/src/main/java/com/platform/service/check/impl/CheckJobServiceImpl.java

@@ -81,6 +81,7 @@ import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.time.temporal.ChronoUnit;
 import java.time.temporal.TemporalAdjusters;
 import java.util.*;
@@ -874,6 +875,13 @@ public class CheckJobServiceImpl extends BaseServiceImpl<CheckJobMapper, CheckJo
         model.setStatusList(statusList);
         model.setStatus(null);
         List<CheckJobVO> tenList = mapper.selectList(model);
+        // 近期保养计划:今起 14 天内未完成的保养任务,展示设备、周期、日期
+        LocalDate today = LocalDate.now();
+        model.setSearchStartTime(LocalDateTime.of(today, LocalTime.of(0, 0, 0)));
+        model.setSearchEndTime(LocalDateTime.of(today.plusDays(13), LocalTime.of(23, 59, 59)));
+        List<CheckJobVO> planList = mapper.selectList(model);
+        fillPeriodText(tenList);
+        fillPeriodText(planList);
         /*
          * // 查询已过期任务(receiveOvertime=true的任务),添加到weekList中
          * model.setStatusList(null);
@@ -896,9 +904,25 @@ public class CheckJobServiceImpl extends BaseServiceImpl<CheckJobMapper, CheckJo
                     BigDecimalUtil.div(new BigDecimal(vo.getFinishNum() * 100), new BigDecimal(vo.getMonthNum()), 2));
         }
         vo.setWeekList(tenList);
+        vo.setPlanList(planList);
         return vo;
     }
 
+    /**
+     * 填充周期文字(计划周期 + 周期类型单位,如"1周"),供大屏保养计划直接展示
+     *
+     * @param list 任务列表
+     */
+    private void fillPeriodText(List<CheckJobVO> list) {
+        if (CollectionUtil.isEmpty(list)) {
+            return;
+        }
+        for (CheckJobVO item : list) {
+            String unit = CheckPlanPeriodTypeEnum.descriptionOf(item.getPeriodType());
+            item.setPeriodText(item.getPeriod() == null ? unit : item.getPeriod() + unit);
+        }
+    }
+
     @Override
     public List<CheckJobVO> getVOListByDTO(CheckJobDTO model) {
         if (model.getFilter() != null && DataFilterTypeEnum.SELF.getValue() == model.getFilter().intValue()) {
@@ -1843,25 +1867,7 @@ public class CheckJobServiceImpl extends BaseServiceImpl<CheckJobMapper, CheckJo
         String responsiblePerson = sbInfoVO.getSaveUserName();
 
         // 周期:直接填写保养标准的周期(计划周期 + 周期类型)
-        String periodUnit = "";
-        Integer periodTypeVal = detail.getPeriodType();
-        if (periodTypeVal != null) {
-            if (periodTypeVal.equals(CheckPlanPeriodTypeEnum.DAY.getValue())) {
-                periodUnit = "天";
-            } else if (periodTypeVal.equals(CheckPlanPeriodTypeEnum.WEEK.getValue())) {
-                periodUnit = "周";
-            } else if (periodTypeVal.equals(CheckPlanPeriodTypeEnum.MONTH.getValue())) {
-                periodUnit = "月";
-            } else if (periodTypeVal.equals(CheckPlanPeriodTypeEnum.SEASON.getValue())) {
-                periodUnit = "季度";
-            } else if (periodTypeVal.equals(CheckPlanPeriodTypeEnum.YEAR.getValue())) {
-                periodUnit = "年";
-            } else if (periodTypeVal.equals(CheckPlanPeriodTypeEnum.MILES.getValue())) {
-                periodUnit = "公里";
-            } else {
-                periodUnit = "台时";
-            }
-        }
+        String periodUnit = CheckPlanPeriodTypeEnum.descriptionOf(detail.getPeriodType());
         String cycleText = detail.getPeriod() == null ? periodUnit : detail.getPeriod() + periodUnit;
 
         // 根据标准 id 查询保养项集合
@@ -3204,4 +3210,57 @@ public class CheckJobServiceImpl extends BaseServiceImpl<CheckJobMapper, CheckJo
         return checkJobMapper.selectAnnualPlan(dto);
     }
 
+    /**
+     * 设备近期点检(保养)日历:按月返回该设备已完成的任务并按完成日期分组。
+     * 调用方为扫码免登录通道,故此处不取登录人信息、不加数据权限。
+     */
+    @Override
+    public SbCheckCalendarVO getSbCheckCalendar(String sbId, String month, Integer type) {
+        if (StringUtils.isBlank(sbId)) {
+            throw new BusinessException("设备ID不能为空");
+        }
+        LocalDate firstDay;
+        if (StringUtils.isBlank(month)) {
+            firstDay = LocalDate.now().with(TemporalAdjusters.firstDayOfMonth());
+        } else {
+            try {
+                firstDay = LocalDate.parse(month + "-01").with(TemporalAdjusters.firstDayOfMonth());
+            } catch (DateTimeParseException e) {
+                log.warn("设备点检日历月份参数格式有误,month:{}", month);
+                throw new BusinessException("月份格式有误,示例:2026-10");
+            }
+        }
+        LocalDate lastDay = firstDay.with(TemporalAdjusters.lastDayOfMonth());
+        // 本项目日常执行的全部是保养任务,类型为空时默认保养,需要看点检时前端传 type=1
+        Integer jobType = type == null ? CheckStandardTypeEnum.POLLING.getValue() : type;
+        List<CheckJobVO> jobList = checkJobMapper.selectSbFinishedJobs(sbId, jobType,
+                LocalDateTime.of(firstDay, LocalTime.of(0, 0, 0)),
+                LocalDateTime.of(lastDay, LocalTime.of(23, 59, 59)));
+        // 按完成日期分组:SQL 已按完成时间升序,LinkedHashMap 保证日历明细同样按日期递增
+        Map<String, List<CheckJobVO>> dayMap = new LinkedHashMap<>();
+        LocalDateTime lastFinishTime = null;
+        for (CheckJobVO job : jobList) {
+            LocalDateTime finishTime = job.getActualEndTime();
+            if (finishTime == null) {
+                continue;
+            }
+            String day = finishTime.toLocalDate().toString();
+            List<CheckJobVO> dayJobs = dayMap.computeIfAbsent(day, key -> new ArrayList<>());
+            dayJobs.add(job);
+            if (lastFinishTime == null || finishTime.isAfter(lastFinishTime)) {
+                lastFinishTime = finishTime;
+            }
+        }
+        List<SbCheckCalendarDayVO> dayList = new ArrayList<>(dayMap.size());
+        dayMap.forEach((day, jobs) -> dayList.add(new SbCheckCalendarDayVO()
+                .setDate(day).setNum(jobs.size()).setJobList(jobs)));
+        return new SbCheckCalendarVO()
+                .setMonth(DateTimeFormatter.ofPattern("yyyy-MM").format(firstDay))
+                .setType(jobType)
+                .setFinishDays(dayList.size())
+                .setFinishNum(dayMap.values().stream().mapToInt(List::size).sum())
+                .setLastFinishTime(lastFinishTime)
+                .setDayList(dayList);
+    }
+
 }

+ 29 - 0
platform-service/src/main/java/com/platform/service/repair/RepairApplicationFormService.java

@@ -4,6 +4,9 @@ import com.platform.dao.bean.MyVOPage;
 import com.platform.dao.dto.repair.RepairApplicationFormDTO;
 import com.platform.dao.entity.repair.RepairApplicationForm;
 import com.platform.dao.vo.repair.RepairApplicationFormVO;
+import com.platform.dao.vo.repair.RepairUncompletedOrderVO;
+import com.platform.dao.vo.report.MtbfResultVO;
+import com.platform.dao.vo.report.MttrMonthVO;
 import com.platform.dao.vo.report.RepairReport24VO;
 import com.platform.dao.vo.report.RepairReportMttr;
 import com.platform.dao.vo.report.RepairSbInfoReport;
@@ -214,4 +217,30 @@ public interface RepairApplicationFormService extends IBaseService<RepairApplica
      * @return 报修单列表
      */
     List<RepairApplicationFormVO> getWorkHourDetailByMonth(RepairApplicationFormDTO model);
+
+    /**
+     * 查询设备未完结的报修单(排除已完成/已拒单),用于提交报修前的重复报修提醒
+     *
+     * @param sbId 设备id
+     * @return 未完结报修单列表,按报修时间倒序
+     */
+    List<RepairUncompletedOrderVO> getUncompletedRepairs(String sbId);
+
+    /**
+     * MTTR(平均故障修复时间)按月趋势统计
+     * 口径:非计划性、已完成维修单,按维修开始时间归属月份,MTTR 单位分钟
+     *
+     * @param model 查询条件(mttrStartMonth/mttrEndMonth(yyyy-MM) 必填,levelList/typeIdList/keyword 可选)
+     * @return 指定时间段逐月一条记录(无数据月份补 0)
+     */
+    List<MttrMonthVO> getMttrMonthTrend(RepairApplicationFormDTO model);
+
+    /**
+     * MTBF(平均故障间隔时间)统计:按设备明细 + 按月趋势
+     * 口径:非计划性、已完成维修单,同一设备「上次维修结束 -> 下次报修」间隔求平均,单位小时
+     *
+     * @param model 查询条件(mttrStartMonth/mttrEndMonth(yyyy-MM) 用于月趋势范围,levelList/typeIdList/keyword 可选)
+     * @return 按设备列表 + 按月趋势
+     */
+    MtbfResultVO getMtbfReport(RepairApplicationFormDTO model);
 }

+ 237 - 9
platform-service/src/main/java/com/platform/service/repair/impl/RepairApplicationFormServiceImpl.java

@@ -33,6 +33,12 @@ import com.platform.dao.mapper.upms.SysUserMapper;
 import com.platform.dao.util.MessageTemplateUtil;
 import com.platform.dao.vo.SysUserVO;
 import com.platform.dao.vo.repair.RepairApplicationFormVO;
+import com.platform.dao.vo.repair.RepairUncompletedOrderVO;
+import com.platform.dao.vo.report.MtbfFaultOrderVO;
+import com.platform.dao.vo.report.MtbfMonthVO;
+import com.platform.dao.vo.report.MtbfReportVO;
+import com.platform.dao.vo.report.MtbfResultVO;
+import com.platform.dao.vo.report.MttrMonthVO;
 import com.platform.dao.vo.report.RepairReport24VO;
 import com.platform.dao.vo.report.RepairReportMttr;
 import com.platform.dao.vo.report.RepairSbInfoReport;
@@ -61,6 +67,9 @@ import javax.annotation.Resource;
 import java.math.BigDecimal;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.time.temporal.ChronoUnit;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
@@ -1091,12 +1100,17 @@ public class RepairApplicationFormServiceImpl
         super.modModelByPrimaryKey(applicationForm);
 
         // 更新设备状态为使用中(走带状态变更日志的链路)
-        SbInfoDTO sbInfoDTO = new SbInfoDTO();
-        sbInfoDTO.setId(applicationForm.getSbId());
-        sbInfoDTO.setStatus(SbInfoStatusEnum.IN_USE.getValue());
-        sbInfoDTO.setChangeReason("维修完成恢复使用");
-        sbInfoDTO.setActualUser(applicationForm.getRepairUserName());
-        sbInfoService.updateStatus(sbInfoDTO);
+        // 仅停机单需要恢复,且该设备没有其他未完结的停机报修单时才恢复,避免多个停机单只修完一张就把设备置为在用
+        if (applicationForm.getNeedStop() != null
+                && applicationForm.getNeedStop().intValue() == RepairApplicationFormStopEnum.STOP.getValue()
+                && isSbStopClear(applicationForm.getSbId(), applicationForm.getId())) {
+            SbInfoDTO sbInfoDTO = new SbInfoDTO();
+            sbInfoDTO.setId(applicationForm.getSbId());
+            sbInfoDTO.setStatus(SbInfoStatusEnum.IN_USE.getValue());
+            sbInfoDTO.setChangeReason("维修完成恢复使用");
+            sbInfoDTO.setActualUser(applicationForm.getRepairUserName());
+            sbInfoService.updateStatus(sbInfoDTO);
+        }
 
         // 维修完成通知报修人:邮件+站内信+微信,任何异常不影响完成主流程
         // 已调整:改到审核通过(approve)后发送,点“完成”时不再通知
@@ -1802,10 +1816,19 @@ public class RepairApplicationFormServiceImpl
     public void approve(RepairApplicationFormDTO dto) {
         // 1.修改验收单状态和验收验收时间
         RepairApplicationForm applicationForm = mapper.selectById(dto.getId());
-        // 如果选择了设备停机,则要修改设备状态
         SbInfo info = sbInfoService.getModelById(applicationForm.getSbId());
-        info.setStatus(SbInfoStatusEnum.IN_USE.getValue());
-        sbInfoService.modModelByPrimaryKey(info);
+        // 仅停机单需要恢复,且该设备没有其他未完结的停机报修单时才恢复为使用中(走带状态变更日志和停机记录闭合的链路)
+        if (applicationForm.getNeedStop() != null
+                && applicationForm.getNeedStop().intValue() == RepairApplicationFormStopEnum.STOP.getValue()
+                && isSbStopClear(applicationForm.getSbId(), applicationForm.getId())) {
+            SbInfoDTO sbInfoDTO = new SbInfoDTO();
+            sbInfoDTO.setId(applicationForm.getSbId());
+            sbInfoDTO.setStatus(SbInfoStatusEnum.IN_USE.getValue());
+            sbInfoDTO.setChangeReason("维修验收通过恢复使用");
+            UserInfo approveUser = SecurityUtils.getUserInfo();
+            sbInfoDTO.setActualUser(approveUser != null ? approveUser.getRealName() : applicationForm.getRepairUserName());
+            sbInfoService.updateStatus(sbInfoDTO);
+        }
         applicationForm.setCheckEndTime(LocalDateTime.now());
         applicationForm.setStatus(RepairApplicationFormStatusEnum.FINISHED.getValue());
         applicationForm.setUpdateTime(applicationForm.getCheckEndTime());
@@ -2011,6 +2034,77 @@ public class RepairApplicationFormServiceImpl
         return mapper.selectCountByExample(weekend);
     }
 
+    /**
+     * 查询设备未完结的报修单(排除已完成/已拒单),用于提交报修前的重复报修提醒
+     *
+     * @param sbId 设备id
+     * @return 未完结报修单列表,按报修时间倒序
+     */
+    @Override
+    public List<RepairUncompletedOrderVO> getUncompletedRepairs(String sbId) {
+        List<RepairUncompletedOrderVO> result = new ArrayList<>();
+        if (StringUtils.isBlank(sbId)) {
+            return result;
+        }
+        Weekend<RepairApplicationForm> weekend = new Weekend<>(RepairApplicationForm.class);
+        weekend.weekendCriteria()
+                .andEqualTo(RepairApplicationForm::getSbId, sbId)
+                .andNotIn(RepairApplicationForm::getStatus, Arrays.asList(
+                        RepairApplicationFormStatusEnum.FINISHED.getValue(),
+                        RepairApplicationFormStatusEnum.REFUSED.getValue()));
+        weekend.orderBy("applyTime").desc();
+        List<RepairApplicationForm> list = mapper.selectByExample(weekend);
+        if (CollectionUtil.isEmpty(list)) {
+            return result;
+        }
+        for (RepairApplicationForm form : list) {
+            RepairUncompletedOrderVO vo = new RepairUncompletedOrderVO();
+            vo.setId(form.getId());
+            vo.setNo(form.getNo());
+            vo.setApplyTime(form.getApplyTime());
+            vo.setStatus(form.getStatus());
+            vo.setStatusLabel(getStatusLabel(form.getStatus()));
+            vo.setContent(form.getContent());
+            vo.setActualUser(form.getActualUser());
+            vo.setRepairUserName(form.getRepairUserName());
+            result.add(vo);
+        }
+        return result;
+    }
+
+    /**
+     * 判断设备是否可以恢复为在用:不存在其他未完结(排除已完成/已拒单)且选择停机的报修单
+     * 一台设备可能同时有多张停机报修单,需全部完结后才允许设备恢复使用
+     *
+     * @param sbId       设备id
+     * @param excludeId  排除的报修单id(当前正在完结的单据,状态未落库时传入)
+     * @return true-可以恢复在用
+     */
+    private boolean isSbStopClear(String sbId, String excludeId) {
+        if (StringUtils.isBlank(sbId)) {
+            return false;
+        }
+        Weekend<RepairApplicationForm> weekend = new Weekend<>(RepairApplicationForm.class);
+        weekend.weekendCriteria()
+                .andEqualTo(RepairApplicationForm::getSbId, sbId)
+                .andEqualTo(RepairApplicationForm::getNeedStop,
+                        RepairApplicationFormStopEnum.STOP.getValue())
+                .andNotIn(RepairApplicationForm::getStatus, Arrays.asList(
+                        RepairApplicationFormStatusEnum.FINISHED.getValue(),
+                        RepairApplicationFormStatusEnum.REFUSED.getValue()));
+        if (StringUtils.isNotBlank(excludeId)) {
+            weekend.weekendCriteria().andNotEqualTo(RepairApplicationForm::getId, excludeId);
+        }
+        return mapper.selectCountByExample(weekend) == 0;
+    }
+
+    /**
+     * 报修单状态名称,统一取自 RepairApplicationFormStatusEnum
+     */
+    private String getStatusLabel(Integer status) {
+        return RepairApplicationFormStatusEnum.descriptionOf(status);
+    }
+
     @Override
     public void autoAssignTaskForTimer() {
         LocalDateTime minutesAgo = DateUtils.minus(LocalDateTime.now(), 10, ChronoUnit.MINUTES);
@@ -2295,4 +2389,138 @@ public class RepairApplicationFormServiceImpl
         model.setSearchRepairEndTime(endTime);
         model.setSearchRepairStartTime(startTime);
     }
+
+    /** MTBF 月趋势分组 key 格式,同时用于解析 MTTR/MTBF 统计月份参数 */
+    private static final DateTimeFormatter MTBF_MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
+
+    /** MTTR/MTBF 统计时间段跨度上限(月),防止前端传入过大区间拖垮查询 */
+    private static final int MTTR_MAX_MONTH_SPAN = 36;
+
+    /**
+     * 解析 MTTR/MTBF 统计时间段:优先 mttrStartMonth/mttrEndMonth(yyyy-MM),
+     * 未传时兼容旧版 mttrYear(按该年 1-12 月)
+     *
+     * @return [开始月份, 结束月份]
+     */
+    private YearMonth[] resolveMttrMonthRange(RepairApplicationFormDTO model) {
+        YearMonth start;
+        YearMonth end;
+        if (StringUtils.isNotBlank(model.getMttrStartMonth()) && StringUtils.isNotBlank(model.getMttrEndMonth())) {
+            start = parseMttrMonth(model.getMttrStartMonth());
+            end = parseMttrMonth(model.getMttrEndMonth());
+            if (start.isAfter(end)) {
+                throw new DeniedException("开始月份不能晚于结束月份");
+            }
+        } else if (model.getMttrYear() != null) {
+            start = YearMonth.of(model.getMttrYear(), 1);
+            end = YearMonth.of(model.getMttrYear(), 12);
+        } else {
+            throw new DeniedException("统计时间段不可为空");
+        }
+        if (start.plusMonths(MTTR_MAX_MONTH_SPAN - 1L).isBefore(end)) {
+            throw new DeniedException("统计时间段跨度不能超过" + MTTR_MAX_MONTH_SPAN + "个月");
+        }
+        return new YearMonth[]{start, end};
+    }
+
+    private YearMonth parseMttrMonth(String month) {
+        try {
+            return YearMonth.parse(month, MTBF_MONTH_FORMATTER);
+        } catch (DateTimeParseException e) {
+            throw new DeniedException("统计月份格式错误,应为 yyyy-MM:" + month);
+        }
+    }
+
+    @Override
+    public List<MttrMonthVO> getMttrMonthTrend(RepairApplicationFormDTO model) {
+        YearMonth[] range = resolveMttrMonthRange(model);
+        YearMonth start = range[0];
+        YearMonth end = range[1];
+        // 仅统计非计划性维修(故障)
+        model.setCategory(RepairApplicationFormCategoryEnum.PLAN_NOT.getValue());
+        model.setSearchRepairStartTime(start.atDay(1).atStartOfDay());
+        model.setSearchRepairEndTime(end.atEndOfMonth().atTime(23, 59, 59));
+        List<MttrMonthVO> dbList = mapper.statisticsMttrByMonth(model);
+        Map<String, MttrMonthVO> monthMap = new HashMap<>();
+        for (MttrMonthVO item : dbList) {
+            monthMap.put(item.getMonth(), item);
+        }
+        // 按选择时间段逐月返回,无数据月份补 0,保证趋势图横轴完整
+        List<MttrMonthVO> result = new ArrayList<>();
+        for (YearMonth m = start; !m.isAfter(end); m = m.plusMonths(1)) {
+            String key = m.format(MTBF_MONTH_FORMATTER);
+            MttrMonthVO vo = monthMap.get(key);
+            if (vo == null) {
+                vo = new MttrMonthVO().setMonth(key).setNum(0).setTotalRepairHours(0.0).setMttrMinutes(0.0);
+            }
+            result.add(vo);
+        }
+        return result;
+    }
+
+    @Override
+    public MtbfResultVO getMtbfReport(RepairApplicationFormDTO model) {
+        YearMonth[] range = resolveMttrMonthRange(model);
+        YearMonth start = range[0];
+        YearMonth end = range[1];
+        // 仅统计非计划性维修;不限时间段拉全量明细,保证跨年故障间隔计算正确,月趋势再按时间段截取
+        model.setCategory(RepairApplicationFormCategoryEnum.PLAN_NOT.getValue());
+        List<MtbfFaultOrderVO> orders = mapper.selectFaultOrdersForMtbf(model);
+        // SQL 已按 sb_id、apply_time 升序,这里保持分组内顺序
+        Map<String, List<MtbfFaultOrderVO>> sbGroups = orders.stream()
+                .collect(Collectors.groupingBy(MtbfFaultOrderVO::getSbId, LinkedHashMap::new, Collectors.toList()));
+        // 月趋势聚合:key=yyyy-MM(下一次故障报修时间所在月),value=[间隔总小时, 间隔次数]
+        Map<String, double[]> monthAgg = new HashMap<>();
+        List<MtbfReportVO> sbList = new ArrayList<>(sbGroups.size());
+        for (List<MtbfFaultOrderVO> group : sbGroups.values()) {
+            MtbfFaultOrderVO first = group.get(0);
+            MtbfReportVO vo = new MtbfReportVO()
+                    .setSbId(first.getSbId())
+                    .setSbNo(first.getSbNo())
+                    .setSbName(first.getSbName())
+                    .setLevel(first.getLevel())
+                    .setSbTypeName(first.getSbTypeName())
+                    .setFaultNum(group.size());
+            double totalHours = 0.0;
+            int intervals = 0;
+            LocalDateTime prevRepairEnd = null;
+            for (MtbfFaultOrderVO order : group) {
+                if (prevRepairEnd != null) {
+                    // 故障间隔 = 上次维修结束 -> 下次报修
+                    double hours = ChronoUnit.MINUTES.between(prevRepairEnd, order.getApplyTime()) / 60.0;
+                    if (hours < 0) {
+                        // 数据异常兑底(报修时间早于上次维修结束),间隔计 0
+                        hours = 0;
+                    }
+                    totalHours += hours;
+                    intervals++;
+                    String monthKey = order.getApplyTime().format(MTBF_MONTH_FORMATTER);
+                    double[] agg = monthAgg.computeIfAbsent(monthKey, k -> new double[2]);
+                    agg[0] += hours;
+                    agg[1] += 1;
+                }
+                prevRepairEnd = order.getRepairEndTime();
+            }
+            // 故障不足 2 次无法计算间隔,返回 null 前端显示 "-"
+            vo.setMtbfHours(intervals > 0 ? BigDecimalUtil.decimal(totalHours / intervals, 2) : null);
+            sbList.add(vo);
+        }
+        // 按选择时间段逐月返回趋势
+        List<MtbfMonthVO> monthList = new ArrayList<>();
+        for (YearMonth m = start; !m.isAfter(end); m = m.plusMonths(1)) {
+            String key = m.format(MTBF_MONTH_FORMATTER);
+            double[] agg = monthAgg.get(key);
+            MtbfMonthVO monthVO = new MtbfMonthVO().setMonth(key);
+            if (agg == null || agg[1] == 0) {
+                monthVO.setNum(0).setMtbfHours(null);
+            } else {
+                monthVO.setNum((int) agg[1]).setMtbfHours(BigDecimalUtil.decimal(agg[0] / agg[1], 2));
+            }
+            monthList.add(monthVO);
+        }
+        // 按设备列表:MTBF 升序(间隔越短越不稳定排前),无值排最后
+        sbList.sort(Comparator.comparing(MtbfReportVO::getMtbfHours,
+                Comparator.nullsLast(Comparator.naturalOrder())));
+        return new MtbfResultVO().setSbList(sbList).setMonthList(monthList);
+    }
 }

+ 10 - 0
platform-service/src/main/java/com/platform/service/sb/SbInfoService.java

@@ -4,6 +4,7 @@ import com.platform.common.bean.AbstractPageResultBean;
 import com.platform.dao.bean.MyVOPage;
 import com.platform.dao.dto.sb.SbInfoDTO;
 import com.platform.dao.entity.sb.SbInfo;
+import com.platform.dao.vo.sb.SbInfoProblemScreenVO;
 import com.platform.dao.vo.sb.SbInfoScreenDetailVO;
 import com.platform.dao.vo.sb.SbInfoScreenVO;
 import com.platform.dao.vo.sb.SbInfoVO;
@@ -43,6 +44,15 @@ public interface SbInfoService extends IBaseService<SbInfo, SbInfoDTO> {
      * @return
      */
     public List<SbInfoScreenVO> getModelListRepairAndShowByDTO(SbInfoDTO model);
+
+    /**
+     * 大屏右上区域:当前问题设备(有进行中报修单的设备)
+     *  不区分底图,一台设备有多条进行中报修单时取最新一条
+     *
+     * @return 问题设备列表,按报修时间倒序
+     */
+    List<SbInfoProblemScreenVO> getScreenProblemList();
+
     /**
      * 批量删除
      *

+ 72 - 5
platform-service/src/main/java/com/platform/service/sb/impl/SbInfoServiceImpl.java

@@ -52,6 +52,7 @@ import com.platform.dao.util.UserUtil;
 import com.platform.dao.vo.SysUserDeptVO;
 import com.platform.dao.vo.SysUserVO;
 import com.platform.dao.vo.query.upms.SysDeptVO;
+import com.platform.dao.vo.sb.SbInfoProblemScreenVO;
 import com.platform.dao.vo.sb.SbInfoScreenDetailVO;
 import com.platform.dao.vo.sb.SbInfoScreenVO;
 import com.platform.dao.vo.sb.SbInfoVO;
@@ -254,11 +255,7 @@ public class SbInfoServiceImpl extends BaseServiceImpl<SbInfoMapper, SbInfo, SbI
         List<String> ids = sbInfoList.stream().map(SbInfoScreenDetailVO::getId).collect(Collectors.toList());
         Weekend<RepairApplicationForm> weekendRepair = new Weekend<>(RepairApplicationForm.class);
         WeekendCriteria<RepairApplicationForm, Object> weekendCriteria = weekendRepair.weekendCriteria();
-        List<Integer> statusList = new ArrayList<Integer>();
-        statusList.add(RepairApplicationFormStatusEnum.NOT_ALLOCATED.getValue());
-        statusList.add(RepairApplicationFormStatusEnum.PROCESSING.getValue());
-        statusList.add(RepairApplicationFormStatusEnum.TRANSFER_APPROVAL.getValue());
-        statusList.add(RepairApplicationFormStatusEnum.ENTRUST_REPAIR.getValue());
+        List<Integer> statusList = getProcessingRepairStatusList();
         if (CollectionUtil.isNotEmpty(ids)) {
             weekendCriteria.andIn(RepairApplicationForm::getSbId, ids);
         }
@@ -321,6 +318,76 @@ public class SbInfoServiceImpl extends BaseServiceImpl<SbInfoMapper, SbInfo, SbI
     }
 
     /**
+     * 大屏右上区域:当前问题设备(有进行中报修单:待接收、维修中、转派审批中、委外维修中)
+     * 1:不按底图过滤,只要设备有进行中的报修单就展示
+     * 2:一台设备有多条进行中报修单时,只取报修时间最新的一条
+     *
+     * @return 问题设备列表,按报修时间倒序
+     */
+    @Override
+    public List<SbInfoProblemScreenVO> getScreenProblemList() {
+        List<SbInfoProblemScreenVO> resultList = ListUtils.newArrayList();
+        Weekend<RepairApplicationForm> weekendRepair = new Weekend<>(RepairApplicationForm.class);
+        WeekendCriteria<RepairApplicationForm, Object> weekendCriteria = weekendRepair.weekendCriteria();
+        weekendCriteria.andIn(RepairApplicationForm::getStatus, getProcessingRepairStatusList());
+        weekendRepair.orderBy("applyTime").desc();
+        List<RepairApplicationForm> repairList = repairApplicationFormMapper.selectByExample(weekendRepair);
+        if (CollectionUtil.isEmpty(repairList)) {
+            return resultList;
+        }
+        // 查询已按报修时间倒序,同一设备只保留第一次出现(即最新)的一条
+        Map<String, RepairApplicationForm> lastRepairMap = new LinkedHashMap<String, RepairApplicationForm>();
+        for (RepairApplicationForm repair : repairList) {
+            if (repair.getSbId() != null && !lastRepairMap.containsKey(repair.getSbId())) {
+                lastRepairMap.put(repair.getSbId(), repair);
+            }
+        }
+        if (CollectionUtil.isEmpty(lastRepairMap.keySet())) {
+            return resultList;
+        }
+        Weekend<SbInfo> weekendSb = new Weekend<>(SbInfo.class);
+        weekendSb.weekendCriteria().andIn(SbInfo::getId, new ArrayList<String>(lastRepairMap.keySet()));
+        List<SbInfo> sbInfoList = mapper.selectByExample(weekendSb);
+        Map<String, SbInfo> sbInfoMap = new HashMap<String, SbInfo>();
+        for (SbInfo sbInfo : sbInfoList) {
+            sbInfoMap.put(sbInfo.getId(), sbInfo);
+        }
+        for (Map.Entry<String, RepairApplicationForm> entry : lastRepairMap.entrySet()) {
+            // 设备可能已被删除或调拨出去,取不到设备信息的不展示
+            SbInfo sbInfo = sbInfoMap.get(entry.getKey());
+            if (sbInfo == null) {
+                continue;
+            }
+            RepairApplicationForm repair = entry.getValue();
+            SbInfoProblemScreenVO vo = new SbInfoProblemScreenVO();
+            vo.setSbId(sbInfo.getId());
+            vo.setNo(sbInfo.getNo());
+            vo.setName(sbInfo.getName());
+            vo.setRepairId(repair.getId());
+            vo.setRepairNo(repair.getNo());
+            vo.setRepairStatus(repair.getStatus());
+            vo.setRepairStatusLabel(RepairApplicationFormStatusEnum.descriptionOf(repair.getStatus()));
+            vo.setApplyTime(repair.getApplyTime());
+            resultList.add(vo);
+        }
+        return resultList;
+    }
+
+    /**
+     * 进行中的报修单状态:待接收、维修中、转派审批中、委外维修中
+     *
+     * @return 状态值列表
+     */
+    private List<Integer> getProcessingRepairStatusList() {
+        List<Integer> statusList = new ArrayList<Integer>();
+        statusList.add(RepairApplicationFormStatusEnum.NOT_ALLOCATED.getValue());
+        statusList.add(RepairApplicationFormStatusEnum.PROCESSING.getValue());
+        statusList.add(RepairApplicationFormStatusEnum.TRANSFER_APPROVAL.getValue());
+        statusList.add(RepairApplicationFormStatusEnum.ENTRUST_REPAIR.getValue());
+        return statusList;
+    }
+
+    /**
      * 设置查询条件
      */
     private void setWeekendCriteria(Weekend<SbInfo> weekend, SbInfoDTO model) {

+ 7 - 0
platform-service/src/main/java/com/platform/service/upms/AnnouncementService.java

@@ -48,6 +48,13 @@ public interface AnnouncementService extends IBaseService<Announcement, Announce
     int show(String id);
 
     /**
+     * 到期自动下架:把已发布且已过有效期的公告置为已下架(定时任务调用)
+     *
+     * @return 翻转条数
+     */
+    int stopExpired();
+
+    /**
      * 前台已发布且未过期公告列表:置顶优先、发布时间倒序
      *
      * @param limit 条数

+ 51 - 0
platform-service/src/main/java/com/platform/service/upms/impl/AnnouncementServiceImpl.java

@@ -2,6 +2,7 @@ package com.platform.service.upms.impl;
 
 import com.github.pagehelper.PageHelper;
 import com.platform.common.bean.AbstractPageResultBean;
+import com.platform.common.exception.BusinessException;
 import com.platform.common.util.StringUtils;
 import com.platform.dao.bean.MyPage;
 import com.platform.dao.dto.upms.AnnouncementDTO;
@@ -10,6 +11,7 @@ import com.platform.dao.mapper.upms.AnnouncementMapper;
 import com.platform.service.base.impl.BaseServiceImpl;
 import com.platform.service.upms.AnnouncementService;
 import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import tk.mybatis.mapper.weekend.Weekend;
 import tk.mybatis.mapper.weekend.WeekendCriteria;
@@ -23,6 +25,7 @@ import java.util.List;
  * @Date 2026-10-22
  * @Version Copyright (c) 2019,北京乾元坤和科技有限公司 All rights reserved.
  */
+@Slf4j
 @AllArgsConstructor
 @Service("announcementService")
 public class AnnouncementServiceImpl extends BaseServiceImpl<AnnouncementMapper, Announcement, AnnouncementDTO> implements AnnouncementService {
@@ -40,6 +43,19 @@ public class AnnouncementServiceImpl extends BaseServiceImpl<AnnouncementMapper,
      */
     private static final int STATUS_HIDDEN = 2;
 
+    /**
+     * 修改:父类 updateByPrimaryKeySelective 会跳过 null 字段,
+     * 导致编辑时把“有效期至”清空后无法置为 NULL(仍保留旧值)。
+     * 这里在更新完成后,若有效期为空则定点将其置空。
+     */
+    @Override
+    public void modModelByDTO(AnnouncementDTO model) {
+        super.modModelByDTO(model);
+        if (model.getExpireTime() == null && StringUtils.isNotEmpty(model.getId())) {
+            mapper.clearExpireTime(model.getId());
+        }
+    }
+
     @Override
     public int batchDelete(List<String> ids) {
         Weekend<Announcement> weekend = new Weekend<>(Announcement.class);
@@ -51,6 +67,8 @@ public class AnnouncementServiceImpl extends BaseServiceImpl<AnnouncementMapper,
 
     @Override
     public int publish(String id) {
+        Announcement exist = mapper.selectByPrimaryKey(id);
+        checkNotExpired(exist);
         Announcement update = new Announcement();
         update.setId(id);
         update.setStatus(STATUS_PUBLISHED);
@@ -71,6 +89,8 @@ public class AnnouncementServiceImpl extends BaseServiceImpl<AnnouncementMapper,
     @Override
     public int show(String id) {
         // 恢复显示,不改发布时间
+        Announcement exist = mapper.selectByPrimaryKey(id);
+        checkNotExpired(exist);
         Announcement update = new Announcement();
         update.setId(id);
         update.setStatus(STATUS_PUBLISHED);
@@ -79,6 +99,37 @@ public class AnnouncementServiceImpl extends BaseServiceImpl<AnnouncementMapper,
     }
 
     @Override
+    public int stopExpired() {
+        // 已发布且已过有效期 -> 已下架
+        Weekend<Announcement> weekend = new Weekend<>(Announcement.class);
+        WeekendCriteria<Announcement, Object> criteria = weekend.weekendCriteria();
+        criteria.andEqualTo(Announcement::getStatus, STATUS_PUBLISHED);
+        criteria.andIsNotNull(Announcement::getExpireTime);
+        criteria.andLessThanOrEqualTo(Announcement::getExpireTime, LocalDateTime.now());
+
+        Announcement update = new Announcement();
+        update.setStatus(STATUS_HIDDEN);
+        update.setUpdateTime(LocalDateTime.now());
+        int rows = mapper.updateByExampleSelective(update, weekend);
+        if (rows > 0) {
+            log.info("公告到期自动下架完成,共 {} 条", rows);
+        }
+        return rows;
+    }
+
+    /**
+     * 发布/恢复显示前校验:有效期已过则不允许显示,需先修改有效期
+     *
+     * @param exist 公告记录
+     */
+    private void checkNotExpired(Announcement exist) {
+        if (exist != null && exist.getExpireTime() != null
+                && exist.getExpireTime().isBefore(LocalDateTime.now())) {
+            throw new BusinessException("该公告有效期已过,请先修改有效期再显示");
+        }
+    }
+
+    @Override
     public List<Announcement> getPublishedList(int limit) {
         Weekend<Announcement> weekend = new Weekend<>(Announcement.class);
         // status=1 且 (expire_time is null 或 expire_time > now)