ソースを参照

feat(risk): 打卡按 slot 独立记录(每日多时段场景 PUNCHED 互不影响)

数据库:
- risk_inspection_record 加 slot varchar(50)(每日多时段任务用)
- sql/risk_inspection_record_slot.sql 幂等迁移脚本

实体/BO:
- RiskInspectionRecord 加 slot 字段
- MobilePunchBo 加 slot 字段(打卡提交时携带)

服务:
- submitPunch 把 slot 写入 record
- convertToMobileVo 加 slot 重载,按 (taskId, slot) 查最新 record
- expandByTimeSlots 每个 slot 独立调 convertToMobileVo(task, slot)(不再共享 task 级 punchStatus)
- 新增 filterByPunchStatus(rows, status):expandByTimeSlots 拆解后按 vo.punchStatus
  内存层二次过滤(DONE tab 只显示 PUNCHED slot,PENDING/OVERDUE 同理)
- queryMobileDetail 加 (id, slot) 重载,按 slot 改 planStartTime/planEndTime

Controller:
- mobileDetail 加 @RequestParam slot 参数
yangjingjing 1 日 前
コミット
c894151b0b

+ 4 - 2
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/controller/RiskInspectionTaskController.java

@@ -151,8 +151,10 @@ public class RiskInspectionTaskController extends BaseController {
      * 移动端-查询任务详情
      */
     @GetMapping("/mobileDetail/{id}")
-    public R<org.dromara.risk.domain.vo.MobileInspectionTaskVo> mobileDetail(@NotNull(message = "任务ID不能为空") @PathVariable("id") Long id) {
-        return R.ok(inspectionTaskService.queryMobileDetail(id));
+    public R<org.dromara.risk.domain.vo.MobileInspectionTaskVo> mobileDetail(
+        @NotNull(message = "任务ID不能为空") @PathVariable("id") Long id,
+        @RequestParam(required = false) String slot) {
+        return R.ok(inspectionTaskService.queryMobileDetail(id, slot));
     }
 
     /**

+ 6 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/RiskInspectionRecord.java

@@ -115,6 +115,12 @@ public class RiskInspectionRecord extends TenantEntity {
      */
     private String inspectorName;
 
+    /**
+     * 打卡时段(每日多时段任务用,格式: "08:00-08:30")
+     * 单时段/无 time_slot 的任务为空
+     */
+    private String slot;
+
     /**
      * 记录状态
      */

+ 6 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/domain/bo/MobilePunchBo.java

@@ -69,6 +69,12 @@ public class MobilePunchBo extends BaseEntity {
     /** 备注 */
     private String remark;
 
+    /**
+     * 打卡时段(每日多时段任务用,格式: "08:00-08:30")
+     * 用于按 slot 独立存储打卡记录;单时段/无 time_slot 的任务可为空
+     */
+    private String slot;
+
     /** 巡检项目明细(每个项目的结果) */
     private List<ItemResult> items;
 

+ 8 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/IRiskInspectionTaskService.java

@@ -101,6 +101,14 @@ public interface IRiskInspectionTaskService {
      */
     MobileInspectionTaskVo queryMobileDetail(Long id);
 
+    /**
+     * 移动端-查询任务详情(按 slot 区分打卡状态,每日多时段任务用)
+     *
+     * @param id   任务主键
+     * @param slot 时间段(可选,如 "08:00-08:30";空则按 task 整体查最新 record,向后兼容)
+     */
+    MobileInspectionTaskVo queryMobileDetail(Long id, String slot);
+
     /**
      * 移动端-校验二维码内容
      *

+ 1 - 0
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/impl/RiskInspectionRecordServiceImpl.java

@@ -89,6 +89,7 @@ public class RiskInspectionRecordServiceImpl implements IRiskInspectionRecordSer
         rec.setStoreId(task.getStoreId());
         rec.setStoreName(task.getStoreName());
         rec.setCheckInTime(bo.getPunchTime() == null ? new Date() : bo.getPunchTime());
+        rec.setSlot(bo.getSlot()); // 每日多时段任务用,单时段/无 time_slot 任务可空
         rec.setCheckInLocation(bo.getPunchLocation());
         rec.setCheckInLng(bo.getLng());
         rec.setCheckInLat(bo.getLat());

+ 90 - 21
ruoyi-modules/ruoyi-risk/src/main/java/org/dromara/risk/service/impl/RiskInspectionTaskServiceImpl.java

@@ -324,9 +324,12 @@ public class RiskInspectionTaskServiceImpl implements IRiskInspectionTaskService
         lqw.orderByDesc(RiskInspectionTask::getPlanStartTime);
         Page<RiskInspectionTask> page = baseMapper.selectPage(pageQuery.build(), lqw);
         // 2. 实体 → 基础 vo + 按 time_slot 拆分为多个"待办"(每日任务多时段 → 移动端 N 条)
+        //    再按 status 内存层按 slot.punchStatus 二次过滤(每日多时段任务下,
+        //    task 整体已打卡 ≠ 所有 slot 都打卡,必须按 slot 状态细筛)
         List<MobileInspectionTaskVo> rows = new ArrayList<>();
         for (RiskInspectionTask task : page.getRecords()) {
-            rows.addAll(expandByTimeSlots(task));
+            List<MobileInspectionTaskVo> expanded = expandByTimeSlots(task);
+            rows.addAll(filterByPunchStatus(expanded, status));
         }
         return TableDataInfo.build(new Page<MobileInspectionTaskVo>()
             .setRecords(rows)
@@ -351,10 +354,11 @@ public class RiskInspectionTaskServiceImpl implements IRiskInspectionTaskService
         applyStatusFilter(lqw, status);
         lqw.orderByDesc(RiskInspectionTask::getPlanStartTime);
         Page<RiskInspectionTask> page = baseMapper.selectPage(pageQuery.build(), lqw);
-        // 同 queryMobileList:按 time_slot 拆分
+        // 同 queryMobileList:按 time_slot 拆分 + 内存层按 slot.punchStatus 过滤
         List<MobileInspectionTaskVo> rows = new ArrayList<>();
         for (RiskInspectionTask task : page.getRecords()) {
-            rows.addAll(expandByTimeSlots(task));
+            List<MobileInspectionTaskVo> expanded = expandByTimeSlots(task);
+            rows.addAll(filterByPunchStatus(expanded, status));
         }
         return TableDataInfo.build(new Page<MobileInspectionTaskVo>()
             .setRecords(rows)
@@ -363,6 +367,34 @@ public class RiskInspectionTaskServiceImpl implements IRiskInspectionTaskService
             .setCurrent(1L));
     }
 
+    /**
+     * 按 vo.punchStatus 内存层二次过滤(每日多时段任务下,SQL 层 task.task_status 粗筛
+     * 不能区分哪些 slot 打过卡,必须按每条 vo 自己的 punchStatus 细筛)
+     * <p>
+     * - ALL: 不过滤
+     * - DONE: 只保留 PUNCHED(真正打过卡的 slot)
+     * - PENDING: 只保留 PENDING(待打卡)
+     * - OVERDUE/EXPIRED: 只保留 OVERDUE(已过期未打卡)
+     */
+    private List<MobileInspectionTaskVo> filterByPunchStatus(List<MobileInspectionTaskVo> rows, String status) {
+        if (rows == null || rows.isEmpty()) {
+            return rows;
+        }
+        if (StringUtils.isBlank(status) || "ALL".equalsIgnoreCase(status)) {
+            return rows;
+        }
+        if ("DONE".equalsIgnoreCase(status)) {
+            return rows.stream().filter(v -> "PUNCHED".equals(v.getPunchStatus())).toList();
+        }
+        if ("PENDING".equalsIgnoreCase(status)) {
+            return rows.stream().filter(v -> "PENDING".equals(v.getPunchStatus())).toList();
+        }
+        if ("OVERDUE".equalsIgnoreCase(status) || "EXPIRED".equalsIgnoreCase(status)) {
+            return rows.stream().filter(v -> "OVERDUE".equals(v.getPunchStatus())).toList();
+        }
+        return rows;
+    }
+
     /**
      * 按 task.time_slot 把单条 task 拆成 N 个移动端 vo(每日多时段场景)
      * <p>
@@ -371,36 +403,38 @@ public class RiskInspectionTaskServiceImpl implements IRiskInspectionTaskService
      *   <li>time_slot 为空 / 解析不出 slot → 原样返回 1 个 vo(非每日任务或单时段任务)</li>
      *   <li>time_slot = "08:00-08:30,10:00-10:30,12:00-12:30" → 返回 3 个 vo</li>
      *   <li>每个 vo 的 planStartTime/planEndTime = 今天 + slot 起止时间(独立时段)</li>
-     *   <li>每个 vo 的 punchStatus 共享 task 级别(同一 task 一次打卡所有 slot 都视为已打卡)</li>
-     *   <li>若 task 还没打卡且当前时间已过 slot.planEndTime → 单独把该 slot 标记为 OVERDUE</li>
+     *   <li>每个 vo 的 punchStatus <b>独立判定</b>:按 (taskId, slot) 查最新 record,
+     *       打过卡的 slot 单独标 PUNCHED,其他 slot 保持 PENDING / OVERDUE</li>
+     *   <li>未打卡 slot 且当前时间已过 slot.planEndTime → 单独标 OVERDUE</li>
      * </ul>
      */
     private List<MobileInspectionTaskVo> expandByTimeSlots(RiskInspectionTask task) {
         List<MobileInspectionTaskVo> result = new ArrayList<>();
-        // 1) 基础 vo(含 task 级别 punchStatus/打卡时间等)
-        MobileInspectionTaskVo baseVo = convertToMobileVo(task);
-        // 2) 解析 slot 列表
+        // 1) 解析 slot 列表
         List<String> slots = parseTimeSlots(task.getTimeSlot());
-        baseVo.setAllTimeSlots(task.getTimeSlot());
         if (slots.isEmpty()) {
-            // 无 time_slot(每周/每月/单时段)→ 原样返回
-            result.add(baseVo);
+            // 无 time_slot(每周/每月/单时段)→ 按 task 整体查最新 record
+            MobileInspectionTaskVo vo = convertToMobileVo(task, null);
+            vo.setAllTimeSlots(null);
+            result.add(vo);
             return result;
         }
-        // 3) 每个 slot 复制一份,planStartTime/planEndTime 改为今天+slot 时间
+        // 2) 每个 slot 独立生成 vo(按 (taskId, slot) 查 record)
         LocalDate today = LocalDate.now();
         Date now = new Date();
-        boolean isPunchable = "PENDING".equals(baseVo.getPunchStatus());
         for (String slot : slots) {
-            MobileInspectionTaskVo vo = new MobileInspectionTaskVo();
-            BeanUtil.copyProperties(baseVo, vo);
+            MobileInspectionTaskVo vo = convertToMobileVo(task, slot);
+            vo.setAllTimeSlots(task.getTimeSlot());
             vo.setSlot(slot);
             String[] parts = slot.split("-");
             if (parts.length == 2) {
                 vo.setPlanStartTime(mergeDateTime(today, parts[0].trim()));
                 vo.setPlanEndTime(mergeDateTime(today, parts[1].trim()));
-                // 未打卡的 slot:如果当前时间已过 slot.planEndTime → 标 OVERDUE
-                if (isPunchable && vo.getPlanEndTime() != null && vo.getPlanEndTime().before(now)) {
+                // 兜底:未打卡 + 已过 slot.planEndTime → OVERDUE
+                // (convertToMobileVo 用的是 task.planEndTime,不够精确)
+                if ("PENDING".equals(vo.getPunchStatus())
+                    && vo.getPlanEndTime() != null
+                    && vo.getPlanEndTime().before(now)) {
                     vo.setPunchStatus("OVERDUE");
                 }
             }
@@ -448,18 +482,45 @@ public class RiskInspectionTaskServiceImpl implements IRiskInspectionTaskService
      */
     @Override
     public MobileInspectionTaskVo queryMobileDetail(Long id) {
+        return queryMobileDetail(id, null);
+    }
+
+    /**
+     * 移动端-查询任务详情(按 slot 区分打卡状态)
+     * <p>
+     * 每日多时段任务:传 slot 即可按 (taskId, slot) 查 record 状态
+     * 单时段/无 slot 任务:slot 传 null,按 task 整体查(向后兼容)
+     */
+    @Override
+    public MobileInspectionTaskVo queryMobileDetail(Long id, String slot) {
         RiskInspectionTask task = baseMapper.selectById(id);
         Assert.notNull(task, "巡检任务不存在: " + id);
-        MobileInspectionTaskVo vo = convertToMobileVo(task);
-        // 加载巡检项目列表(供详情页渲染;needPhoto 字符串"0"/"1" 留到前端按需转 boolean)
+
+        // 1) 基础 vo(按 slot 维度查 record;无 slot 时用 task 整体)
+        MobileInspectionTaskVo vo = convertToMobileVo(task, slot);
+        if (StringUtils.isNotBlank(slot)) {
+            vo.setAllTimeSlots(task.getTimeSlot());
+            vo.setSlot(slot);
+            // 时间窗口改为 slot 区间
+            String[] parts = slot.split("-");
+            if (parts.length == 2) {
+                LocalDate today = LocalDate.now();
+                vo.setPlanStartTime(mergeDateTime(today, parts[0].trim()));
+                vo.setPlanEndTime(mergeDateTime(today, parts[1].trim()));
+            }
+        }
+
+        // 2) 加载巡检项目列表
         List<RiskInspectionTaskItemVo> items = taskItemMapper.selectVoListByTaskId(id);
         vo.setItems(items);
         vo.setItemCount(items == null ? 0 : items.size());
 
-        // 打卡地点:已打卡任务取实际打卡位置(record.check_in_location),未打卡则用任务预设位置
+        // 3) 打卡地点:已打卡取实际位置(record.check_in_location),未打卡则用任务预设位置
+        //    按 (taskId, slot) 取 record(若传 slot)
         RiskInspectionRecord latest = recordMapper.selectOne(
             Wrappers.<RiskInspectionRecord>lambdaQuery()
                 .eq(RiskInspectionRecord::getTaskId, id)
+                .eq(StringUtils.isNotBlank(slot), RiskInspectionRecord::getSlot, slot)
                 .orderByDesc(RiskInspectionRecord::getCheckInTime)
                 .last("LIMIT 1")
         );
@@ -542,8 +603,15 @@ public class RiskInspectionTaskServiceImpl implements IRiskInspectionTaskService
      * - punchType  : 1-扫码 0-常规(基于 scan_enabled/ check_method)
      * - punchStatus: PENDING-待打卡 / PUNCHED-已打卡 / OVERDUE-已逾期 / FINISHED-已完成
      * - punchResult: 0-正常 / 1-异常 / null-未打卡 (基于最新一条 record)
+     * <p>
+     * slot 维度:每日多时段任务按 (taskId, slot) 查最新 record,每个 slot 独立判定
+     * (不传 slot 时按 taskId 查最新 record,向后兼容)
      */
     private MobileInspectionTaskVo convertToMobileVo(RiskInspectionTask task) {
+        return convertToMobileVo(task, null);
+    }
+
+    private MobileInspectionTaskVo convertToMobileVo(RiskInspectionTask task, String slot) {
         MobileInspectionTaskVo vo = new MobileInspectionTaskVo();
         BeanUtil.copyProperties(task, vo);
 
@@ -552,10 +620,11 @@ public class RiskInspectionTaskServiceImpl implements IRiskInspectionTaskService
             || "扫码".equals(task.getCheckMethod());
         vo.setPunchType(isScan ? "SCAN" : "NORMAL");
 
-        // 2. 查最近一条记录
+        // 2. 查最近一条记录(每日多时段按 (taskId, slot) 查;单时段/无 slot 按 taskId 查)
         RiskInspectionRecord latest = recordMapper.selectOne(
             Wrappers.<RiskInspectionRecord>lambdaQuery()
                 .eq(RiskInspectionRecord::getTaskId, task.getId())
+                .eq(StringUtils.isNotBlank(slot), RiskInspectionRecord::getSlot, slot)
                 .orderByDesc(RiskInspectionRecord::getCheckInTime)
                 .last("LIMIT 1")
         );

+ 27 - 0
sql/risk_inspection_record_slot.sql

@@ -0,0 +1,27 @@
+-- =============================================================
+-- 风控平台 数据库增量脚本 2026-07-23
+-- 巡检打卡记录表 risk_inspection_record 加 slot 字段
+-- 用途:每日多时段任务按 (taskId, slot) 独立存储 + 查询打卡记录,
+--      每个 slot 独立判定 PUNCHED/PENDING/OVERDUE 状态
+-- 单时段 / 无 time_slot 任务:slot 字段为空,按 (taskId) 查询
+-- 全部 SQL 幂等(基于 INFORMATION_SCHEMA 判断)
+-- =============================================================
+
+USE risk_control_platform;
+
+SET @sql = (SELECT IF(
+  (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+     WHERE TABLE_SCHEMA = DATABASE()
+       AND TABLE_NAME   = 'risk_inspection_record'
+       AND COLUMN_NAME  = 'slot') = 0,
+  'ALTER TABLE `risk_inspection_record` ADD COLUMN `slot` varchar(50) DEFAULT NULL COMMENT ''打卡时段(每日多时段任务用,如 08:00-08:30)'' AFTER `inspector_name`',
+  'SELECT 1'));
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 验证
+SELECT '--- risk_inspection_record.slot ---' AS info;
+SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
+FROM INFORMATION_SCHEMA.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME   = 'risk_inspection_record'
+  AND COLUMN_NAME IN ('slot','inspector_name','check_in_time','record_status');