feat(ops): 实现工单统计看板功能
Some checks failed
Java CI with Maven / build (11) (push) Has been cancelled
Java CI with Maven / build (17) (push) Has been cancelled
Java CI with Maven / build (8) (push) Has been cancelled

1. 修复 MyBatis 类型安全问题
   - 创建 9 个 DTO 类替换 List<Map<String, Object>>
   - 修复 @MapKey 错误,使用强类型返回值

2. 实现工单统计看板 5 大功能
   - 漏斗统计:支持时间范围过滤
   - 时段热力图:改为近 7 天,Y 轴显示日期(MM-dd)
   - 功能类型排行:替换区域排行,JOIN ops_bus_area 表
   - 今日工单时段分布:X 轴优化为每 2 小时展示
   - 近七天客流统计:独立接口,支持工作台实时趋势

3. 字典转换实现
   - 新增 DictTypeConstants.OPS_AREA_FUNCTION_TYPE(保留供未来扩展)
   - 使用硬编码 Map 实现功能类型中文转换(性能最优)
   - 添加 TODO 说明未来可切换 DictFrameworkUtils

4. SQL 优化
   - 功能类型统计:INNER JOIN ops_bus_area 表
   - 热力图查询:按日期和小时分组统计
   - 时段分布:仅统计当天数据

5. 缓存策略
   - 看板统计:5 分钟缓存(@Cacheable)
   - 客流监测:5 分钟缓存
   - 防止高并发查询压力

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-02-10 23:28:02 +08:00
parent 113e90c726
commit 16441e7c25
19 changed files with 1630 additions and 2 deletions

View File

@@ -55,6 +55,11 @@ public class CleanWorkOrderServiceImpl implements CleanWorkOrderService {
EVENT_TYPE_NAMES.put("COMPLETE", "工单完成");
EVENT_TYPE_NAMES.put("CANCEL", "工单取消");
EVENT_TYPE_NAMES.put("UPGRADE_PRIORITY", "优先级升级");
EVENT_TYPE_NAMES.put("ENQUEUE", "任务入队");
EVENT_TYPE_NAMES.put("QUEUED", "排队中");
EVENT_TYPE_NAMES.put("DISPATCH", "任务派发");
EVENT_TYPE_NAMES.put("DISPATCHED", "已派发");
EVENT_TYPE_NAMES.put("CONFIRMED", "已确认");
}
@Override
@@ -75,9 +80,29 @@ public class CleanWorkOrderServiceImpl implements CleanWorkOrderService {
);
// 3. 转换为时间轴节点
List<OrderTimelineRespDTO.TimelineItemDTO> timeline = events.stream()
List<OrderTimelineRespDTO.TimelineItemDTO> timeline = new java.util.ArrayList<>();
// 3.1 补充工单创建节点PENDING
boolean hasPendingNode = events.stream()
.anyMatch(e -> "PENDING".equals(e.getToStatus()) || "CREATE".equals(e.getEventType()));
if (!hasPendingNode) {
OrderTimelineRespDTO.TimelineItemDTO pendingNode = OrderTimelineRespDTO.TimelineItemDTO.builder()
.status("PENDING")
.statusName("待分配")
.time(formatDateTime(order.getCreateTime()))
.operator("系统")
.description("工单已创建")
.extra(buildPendingExtraInfo(order))
.build();
timeline.add(pendingNode);
}
// 3.2 添加事件节点
List<OrderTimelineRespDTO.TimelineItemDTO> eventNodes = events.stream()
.map(this::convertToTimelineItem)
.toList();
timeline.addAll(eventNodes);
// 4. 构建响应
return OrderTimelineRespDTO.builder()
@@ -195,8 +220,14 @@ public class CleanWorkOrderServiceImpl implements CleanWorkOrderService {
}
}
// 优先使用 toStatus如果为空则使用 eventType
String status = event.getToStatus();
if (status == null || status.isEmpty()) {
status = event.getEventType();
}
return OrderTimelineRespDTO.TimelineItemDTO.builder()
.status(event.getToStatus())
.status(status)
.statusName(statusName)
.time(formatDateTime(event.getEventTime()))
.operator(operator)
@@ -227,4 +258,20 @@ public class CleanWorkOrderServiceImpl implements CleanWorkOrderService {
}
return extra;
}
/**
* 构建 PENDING 节点的额外信息
*/
private Map<String, Object> buildPendingExtraInfo(OpsOrderDO order) {
Map<String, Object> extra = new HashMap<>();
extra.put("event_type", "CREATE");
extra.put("operator_type", "SYSTEM");
extra.put("order_code", order.getOrderCode());
extra.put("priority", order.getPriority());
extra.put("source_type", order.getSourceType());
if (order.getTriggerSource() != null) {
extra.put("trigger_source", order.getTriggerSource());
}
return extra;
}
}

View File

@@ -0,0 +1,18 @@
package com.viewsh.module.ops.enums;
/**
* Ops 字典类型的枚举类
*
* @author lzh
*/
public interface DictTypeConstants {
// ========== OPS 模块 ==========
/**
* 区域功能类型
* MALE_TOILET=男卫/FEMALE_TOILET=女卫/PUBLIC=公共区/ELEVATOR=电梯厅/STAIRWAY=楼梯间/OFFICE=办公区/LOBBY=大堂/PARKING=停车场
*/
String OPS_AREA_FUNCTION_TYPE = "ops_area_function_type";
}

View File

@@ -0,0 +1,30 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 按区域统计工单数 Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 按区域统计工单数 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AreaCountRespVO {
@Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
private Long areaId;
@Schema(description = "总工单数", requiredMode = Schema.RequiredMode.REQUIRED, example = "20")
private Long totalCount;
@Schema(description = "已完成数", requiredMode = Schema.RequiredMode.REQUIRED, example = "15")
private Long completedCount;
}

View File

@@ -0,0 +1,32 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 按日期统计平均时长 Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 按日期统计平均时长 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AvgTimeRespVO {
@Schema(description = "统计日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-02-01")
private LocalDate statDate;
@Schema(description = "平均响应时长(秒)", example = "120.5")
private Double avgResponse;
@Schema(description = "平均完成时长(秒)", example = "3600.0")
private Double avgCompletion;
}

View File

@@ -0,0 +1,27 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 按保洁类型统计平均作业时长 Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 按保洁类型统计平均作业时长 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CleaningTypeAvgDurationRespVO {
@Schema(description = "保洁类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "ROUTINE")
private String cleaningType;
@Schema(description = "平均作业时长(分钟)", requiredMode = Schema.RequiredMode.REQUIRED, example = "35")
private Long avgDuration;
}

View File

@@ -0,0 +1,124 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "管理后台 - 工单看板统计 Response VO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DashboardStatsRespVO {
@Schema(description = "待处理工单数")
private Integer pendingCount;
@Schema(description = "进行中工单数")
private Integer inProgressCount;
@Schema(description = "今日完成工单数")
private Integer completedTodayCount;
@Schema(description = "历史完成工单总数")
private Integer completedTotalCount;
@Schema(description = "趋势数据")
private TrendData trendData;
@Schema(description = "今日工单时段分布24小时")
private HourlyDistribution hourlyDistribution;
@Schema(description = "时效趋势数据")
private TimeTrendData timeTrendData;
@Schema(description = "漏斗数据")
private List<FunnelItem> funnelData;
@Schema(description = "热力图数据近7天")
private HeatmapData heatmapData;
@Schema(description = "功能类型排行")
private List<FunctionTypeRankingItem> functionTypeRanking;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TrendData {
@Schema(description = "日期列表")
private List<String> dates;
@Schema(description = "创建工单数")
private List<Integer> createdData;
@Schema(description = "完成工单数")
private List<Integer> completedData;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class HourlyDistribution {
@Schema(description = "小时列表")
private List<String> hours;
@Schema(description = "工单数")
private List<Integer> data;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TimeTrendData {
@Schema(description = "日期列表")
private List<String> dates;
@Schema(description = "响应时长(分钟)")
private List<Double> responseTimeData;
@Schema(description = "完成时长(分钟)")
private List<Double> completionTimeData;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class FunnelItem {
@Schema(description = "阶段名称")
private String name;
@Schema(description = "数量")
private Integer value;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class HeatmapData {
@Schema(description = "日期列表(近7天)")
private List<String> days;
@Schema(description = "小时列表")
private List<String> hours;
@Schema(description = "热力图数据 [dayIndex][hourIndex]")
private List<List<Integer>> data;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class FunctionTypeRankingItem {
@Schema(description = "功能类型")
private String functionType;
@Schema(description = "工单总数")
private Integer count;
@Schema(description = "已完成数")
private Integer completed;
@Schema(description = "完成率(%)")
private Double rate;
}
}

View File

@@ -0,0 +1,29 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
/**
* 按日期统计工单数 Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 按日期统计工单数 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DateCountRespVO {
@Schema(description = "统计日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-02-01")
private LocalDate statDate;
@Schema(description = "工单数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
private Long cnt;
}

View File

@@ -0,0 +1,30 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 按功能类型统计工单数 Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 按功能类型统计工单数 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FunctionTypeCountRespVO {
@Schema(description = "功能类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "CLEANING")
private String functionType;
@Schema(description = "总工单数", requiredMode = Schema.RequiredMode.REQUIRED, example = "20")
private Long totalCount;
@Schema(description = "已完成数", requiredMode = Schema.RequiredMode.REQUIRED, example = "15")
private Long completedCount;
}

View File

@@ -0,0 +1,30 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 按星期和小时统计工单数热力图Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 按星期和小时统计工单数热力图Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class HeatmapRespVO {
@Schema(description = "星期几1=周日, 2=周一, ..., 7=周六)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private Integer dayOfWeek;
@Schema(description = "小时值0-23", requiredMode = Schema.RequiredMode.REQUIRED, example = "14")
private Integer hourVal;
@Schema(description = "工单数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
private Long cnt;
}

View File

@@ -0,0 +1,27 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 按小时统计工单数 Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 按小时统计工单数 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class HourCountRespVO {
@Schema(description = "小时值0-23", requiredMode = Schema.RequiredMode.REQUIRED, example = "8")
private Integer hourVal;
@Schema(description = "工单数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "12")
private Long cnt;
}

View File

@@ -0,0 +1,65 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "管理后台 - 实时客流监测 Response VO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TrafficRealtimeRespVO {
@Schema(description = "今日总进入人数")
private Long totalIn;
@Schema(description = "今日总离开人数")
private Long totalOut;
@Schema(description = "当前在场人数")
private Long currentOccupancy;
@Schema(description = "区域客流明细")
private List<AreaTrafficItem> areas;
@Schema(description = "小时趋势")
private HourlyTrend hourlyTrend;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AreaTrafficItem {
@Schema(description = "区域ID")
private Long areaId;
@Schema(description = "区域名称")
private String areaName;
@Schema(description = "今日进入人数")
private Long todayIn;
@Schema(description = "今日离开人数")
private Long todayOut;
@Schema(description = "当前在场人数")
private Long currentOccupancy;
@Schema(description = "设备数量")
private Integer deviceCount;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class HourlyTrend {
@Schema(description = "小时列表")
private List<String> hours;
@Schema(description = "进入人数")
private List<Long> inData;
@Schema(description = "离开人数")
private List<Long> outData;
}
}

View File

@@ -0,0 +1,41 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 近7天客流统计 Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 近7天客流统计 Response VO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TrafficTrendRespVO {
@Schema(description = "日期列表近7天")
private List<String> dates;
@Schema(description = "每日进入人数")
private List<Long> inData;
@Schema(description = "每日离开人数")
private List<Long> outData;
@Schema(description = "每日净增人数")
private List<Long> netData;
@Schema(description = "总进入人数")
private Long totalIn;
@Schema(description = "总离开人数")
private Long totalOut;
}

View File

@@ -0,0 +1,78 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 工作台统计 Response VO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WorkspaceStatsRespVO {
@Schema(description = "在线员工数")
private Integer onlineStaffCount;
@Schema(description = "员工总数")
private Integer totalStaffCount;
@Schema(description = "待处理工单数")
private Integer pendingCount;
@Schema(description = "平均响应时长(分钟)")
private Double avgResponseMinutes;
@Schema(description = "满意度(%)")
private Double satisfactionRate;
@Schema(description = "今日工单数")
private Integer todayOrderCount;
@Schema(description = "新增工单数(最近1小时)")
private Integer newOrderCount;
@Schema(description = "紧急任务列表")
private List<UrgentTaskItem> urgentTasks;
@Schema(description = "今日工单趋势")
private WorkOrderTrend workOrderTrend;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class UrgentTaskItem {
@Schema(description = "工单ID")
private Long id;
@Schema(description = "工单标题")
private String title;
@Schema(description = "位置")
private String location;
@Schema(description = "优先级")
private Integer priority;
@Schema(description = "状态")
private String status;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "执行人姓名")
private String assigneeName;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class WorkOrderTrend {
@Schema(description = "小时列表")
private List<String> hours;
@Schema(description = "工单数")
private List<Integer> data;
}
}

View File

@@ -2,9 +2,12 @@ package com.viewsh.module.ops.dal.mysql.workorder;
import com.viewsh.framework.mybatis.core.mapper.BaseMapperX;
import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.*;
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -71,6 +74,60 @@ public interface OpsOrderMapper extends BaseMapperX<OpsOrderDO> {
.last("LIMIT 1"));
}
// ==================== 统计聚合查询 ====================
/**
* 按日期分组统计工单创建数
*/
List<DateCountRespVO> selectCreatedCountGroupByDate(@Param("orderType") String orderType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 按日期分组统计工单完成数
*/
List<DateCountRespVO> selectCompletedCountGroupByDate(@Param("orderType") String orderType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 按日期分组统计平均响应时长和完成时长(秒)
*/
List<AvgTimeRespVO> selectAvgTimeGroupByDate(@Param("orderType") String orderType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 按区域分组统计工单数和完成数(非取消的工单)
*/
List<AreaCountRespVO> selectCountGroupByAreaId(@Param("orderType") String orderType);
/**
* 按小时分组统计工单创建数
*/
List<HourCountRespVO> selectCountGroupByHour(@Param("orderType") String orderType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 按星期和小时分组统计工单创建数(用于热力图)
* day_of_week: MySQL DAYOFWEEK 1=周日 2=周一 ... 7=周六
*/
List<HeatmapRespVO> selectCountGroupByDayOfWeekAndHour(@Param("orderType") String orderType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 按保洁类型分组统计平均作业时长(分钟)
* JOIN ops_order_clean_ext使用 arrived_time 和 completed_time 计算
*/
List<CleaningTypeAvgDurationRespVO> selectAvgDurationGroupByCleaningType(@Param("orderType") String orderType);
/**
* 按功能类型分组统计工单数和完成数(非取消的工单)
*/
List<FunctionTypeCountRespVO> selectCountGroupByFunctionType(@Param("orderType") String orderType);
// 注意分页查询方法需要在Service层实现这里只提供基础查询方法
// 具体分页查询请参考Service实现

View File

@@ -0,0 +1,48 @@
package com.viewsh.module.ops.service.statistics;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DashboardStatsRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficRealtimeRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficTrendRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.WorkspaceStatsRespVO;
import java.time.LocalDate;
/**
* 工单统计服务接口
*
* @author lzh
*/
public interface OpsStatisticsService {
/**
* 获取看板统计数据
*
* @param orderType 工单类型,默认 CLEAN
* @param startDate 开始日期
* @param endDate 结束日期
* @return 看板统计数据
*/
DashboardStatsRespVO getDashboardStats(String orderType, LocalDate startDate, LocalDate endDate);
/**
* 获取实时客流监测数据
*
* @return 实时客流数据
*/
TrafficRealtimeRespVO getTrafficRealtime();
/**
* 获取工作台统计数据
*
* @return 工作台统计数据
*/
WorkspaceStatsRespVO getWorkspaceStats();
/**
* 获取近7天客流趋势统计
*
* @return 近7天客流趋势数据
*/
TrafficTrendRespVO getTrafficTrend();
}

View File

@@ -0,0 +1,713 @@
package com.viewsh.module.ops.service.statistics;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.*;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DashboardStatsRespVO.*;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficRealtimeRespVO.AreaTrafficItem;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficRealtimeRespVO.HourlyTrend;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.WorkspaceStatsRespVO.UrgentTaskItem;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.WorkspaceStatsRespVO.WorkOrderTrend;
import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO;
import com.viewsh.module.ops.dal.dataobject.statistics.OpsTrafficStatisticsDO;
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper;
import com.viewsh.module.ops.dal.mysql.statistics.OpsTrafficStatisticsMapper;
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 工单统计服务实现
*
* @author lzh
*/
@Slf4j
@Service
public class OpsStatisticsServiceImpl implements OpsStatisticsService {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MM-dd");
private static final List<String> IN_PROGRESS_STATUSES = List.of("DISPATCHED", "CONFIRMED", "ARRIVED", "QUEUED");
// TODO: 功能类型字典映射用于API返回的中文转换
// 当前使用硬编码 Map性能最优且功能类型相对固定
// 如果未来需要动态管理字典,可考虑:
// 1. 使用 DictFrameworkUtils.parseDictDataLabel(DictTypeConstants.OPS_AREA_FUNCTION_TYPE, value)
// 2. 从系统字典表动态加载并缓存
private static final Map<String, String> FUNCTION_TYPE_DICT = Map.of(
"MALE_TOILET", "男卫",
"FEMALE_TOILET", "女卫",
"PUBLIC", "公共区",
"ELEVATOR", "电梯厅",
"STAIRWAY", "楼梯间",
"OFFICE", "办公区",
"LOBBY", "大堂",
"PARKING", "停车场"
);
@Resource
private OpsOrderMapper opsOrderMapper;
@Resource
private OpsBusAreaMapper opsBusAreaMapper;
@Resource
private OpsTrafficStatisticsMapper trafficStatisticsMapper;
/**
* 在线员工数提供者(由 ops-server 层注入,从 environment-biz 获取工牌状态)
* 返回 [onlineCount, totalCount]
*/
@Autowired(required = false)
private java.util.function.Supplier<int[]> staffCountProvider;
@Override
@Cacheable(value = "ops:statistics:dashboard#5m", key = "#orderType + ':' + #startDate + ':' + #endDate",
unless = "#result == null")
public DashboardStatsRespVO getDashboardStats(String orderType, LocalDate startDate, LocalDate endDate) {
LocalDateTime startDateTime = startDate.atStartOfDay();
LocalDateTime endDateTime = endDate.plusDays(1).atStartOfDay();
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 1. 待处理工单数
Integer pendingCount = countByStatusAndType("PENDING", orderType);
// 2. 进行中工单数
Integer inProgressCount = Math.toIntExact(opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.in(OpsOrderDO::getStatus, IN_PROGRESS_STATUSES)
.eq(OpsOrderDO::getOrderType, orderType)));
// 3. 今日完成数(使用 ge + lt 避免边界重叠)
Integer completedTodayCount = Math.toIntExact(opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getStatus, "COMPLETED")
.eq(OpsOrderDO::getOrderType, orderType)
.ge(OpsOrderDO::getEndTime, todayStart)
.lt(OpsOrderDO::getEndTime, todayEnd)));
// 4. 历史完成总数
Integer completedTotalCount = Math.toIntExact(opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getStatus, "COMPLETED")
.eq(OpsOrderDO::getOrderType, orderType)));
// 5. 趋势数据(单次 GROUP BY 查询)
TrendData trendData = buildTrendData(orderType, startDate, endDate);
// 6. 今日工单时段分布24小时
HourlyDistribution hourlyDistribution = buildHourlyDistribution(orderType, todayStart, todayEnd);
// 7. 时效趋势(单次 GROUP BY 查询)
TimeTrendData timeTrendData = buildTimeTrendData(orderType, startDate, endDate);
// 8. 漏斗数据(基于指定时间范围的工单状态推算)
List<FunnelItem> funnelData = buildFunnelData(orderType, startDateTime, endDateTime);
// 9. 热力图近7天
LocalDate sevenDaysAgo = LocalDate.now().minusDays(6); // 包含今天共7天
LocalDateTime sevenDaysAgoStart = sevenDaysAgo.atStartOfDay();
LocalDateTime nowEnd = LocalDate.now().plusDays(1).atStartOfDay();
HeatmapData heatmapData = buildHeatmapData(orderType, sevenDaysAgoStart, nowEnd);
// 10. 功能类型排行(单次 GROUP BY 查询)
List<FunctionTypeRankingItem> functionTypeRanking = buildFunctionTypeRanking(orderType);
return DashboardStatsRespVO.builder()
.pendingCount(pendingCount)
.inProgressCount(inProgressCount)
.completedTodayCount(completedTodayCount)
.completedTotalCount(completedTotalCount)
.trendData(trendData)
.hourlyDistribution(hourlyDistribution)
.timeTrendData(timeTrendData)
.funnelData(funnelData)
.heatmapData(heatmapData)
.functionTypeRanking(functionTypeRanking)
.build();
}
@Override
@Cacheable(value = "ops:statistics:traffic#5m", unless = "#result == null")
public TrafficRealtimeRespVO getTrafficRealtime() {
LocalDate today = LocalDate.now();
LocalDateTime todayStart = today.atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 从 ops_traffic_statistics 表查询今日汇总(使用 ge + lt 避免边界重叠)
List<OpsTrafficStatisticsDO> todayStats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.ge(OpsTrafficStatisticsDO::getStatHour, todayStart)
.lt(OpsTrafficStatisticsDO::getStatHour, todayEnd));
// 加载区域名称映射
Map<Long, String> areaNameMap = loadAreaNameMap();
// 按区域汇总
Map<Long, List<OpsTrafficStatisticsDO>> byArea = todayStats.stream()
.filter(s -> s.getAreaId() != null)
.collect(Collectors.groupingBy(OpsTrafficStatisticsDO::getAreaId));
long totalIn = 0;
long totalOut = 0;
List<AreaTrafficItem> areas = new ArrayList<>();
for (Map.Entry<Long, List<OpsTrafficStatisticsDO>> entry : byArea.entrySet()) {
Long areaId = entry.getKey();
List<OpsTrafficStatisticsDO> stats = entry.getValue();
long areaIn = stats.stream().mapToLong(s -> s.getPeopleIn() != null ? s.getPeopleIn() : 0).sum();
long areaOut = stats.stream().mapToLong(s -> s.getPeopleOut() != null ? s.getPeopleOut() : 0).sum();
int deviceCount = (int) stats.stream().map(OpsTrafficStatisticsDO::getDeviceId).distinct().count();
totalIn += areaIn;
totalOut += areaOut;
areas.add(AreaTrafficItem.builder()
.areaId(areaId)
.areaName(areaNameMap.getOrDefault(areaId, "未知区域"))
.todayIn(areaIn)
.todayOut(areaOut)
.currentOccupancy(Math.max(0, areaIn - areaOut))
.deviceCount(deviceCount)
.build());
}
// 小时趋势
HourlyTrend hourlyTrend = buildTrafficHourlyTrend(todayStats);
return TrafficRealtimeRespVO.builder()
.totalIn(totalIn)
.totalOut(totalOut)
.currentOccupancy(Math.max(0, totalIn - totalOut))
.areas(areas)
.hourlyTrend(hourlyTrend)
.build();
}
@Override
@Cacheable(value = "ops:statistics:workspace#5m", unless = "#result == null")
public WorkspaceStatsRespVO getWorkspaceStats() {
String orderType = "CLEAN";
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
// 1. 在线/总员工数
int onlineStaffCount = 0;
int totalStaffCount = 0;
if (staffCountProvider != null) {
try {
int[] counts = staffCountProvider.get();
onlineStaffCount = counts[0];
totalStaffCount = counts[1];
} catch (Exception e) {
log.warn("[getWorkspaceStats] 查询员工数量失败", e);
}
}
// 2. 待处理工单数
Integer pendingCount = countByStatusAndType("PENDING", orderType);
// 3. 平均响应时长(今日已完成的工单,使用 ge + lt 避免边界重叠)
List<OpsOrderDO> completedToday = opsOrderMapper.selectList(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getStatus, "COMPLETED")
.eq(OpsOrderDO::getOrderType, orderType)
.ge(OpsOrderDO::getEndTime, todayStart)
.lt(OpsOrderDO::getEndTime, todayEnd)
.isNotNull(OpsOrderDO::getResponseSeconds));
Double avgResponseMinutes = completedToday.stream()
.filter(o -> o.getResponseSeconds() != null && o.getResponseSeconds() > 0)
.mapToInt(OpsOrderDO::getResponseSeconds)
.average()
.orElse(0.0) / 60.0;
avgResponseMinutes = Math.round(avgResponseMinutes * 10) / 10.0;
// 4. 满意度基于评分qualityScore >= 4 视为满意,使用 ge + lt 避免边界重叠)
List<OpsOrderDO> ratedOrders = opsOrderMapper.selectList(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getOrderType, orderType)
.isNotNull(OpsOrderDO::getQualityScore)
.ge(OpsOrderDO::getEndTime, todayStart.minusDays(30))
.lt(OpsOrderDO::getEndTime, todayEnd));
double satisfactionRate = 0.0;
if (!ratedOrders.isEmpty()) {
long satisfiedCount = ratedOrders.stream()
.filter(o -> o.getQualityScore() != null && o.getQualityScore() >= 4)
.count();
satisfactionRate = Math.round(satisfiedCount * 1000.0 / ratedOrders.size()) / 10.0;
}
// 5. 今日工单数(使用 ge + lt 避免边界重叠)
Integer todayOrderCount = Math.toIntExact(opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getOrderType, orderType)
.ge(OpsOrderDO::getCreateTime, todayStart)
.lt(OpsOrderDO::getCreateTime, todayEnd)));
// 6. 新增工单数最近1小时
Integer newOrderCount = Math.toIntExact(opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getOrderType, orderType)
.ge(OpsOrderDO::getCreateTime, oneHourAgo)));
// 7. 紧急任务列表P0 + 待处理/进行中)
List<OpsOrderDO> urgentOrders = opsOrderMapper.selectList(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getOrderType, orderType)
.eq(OpsOrderDO::getPriority, 0)
.notIn(OpsOrderDO::getStatus, "COMPLETED", "CANCELLED")
.orderByDesc(OpsOrderDO::getCreateTime)
.last("LIMIT 10"));
List<UrgentTaskItem> urgentTasks = urgentOrders.stream()
.map(o -> UrgentTaskItem.builder()
.id(o.getId())
.title(o.getTitle())
.location(o.getLocation())
.priority(o.getPriority())
.status(o.getStatus())
.createTime(o.getCreateTime())
.assigneeName(o.getAssigneeName())
.build())
.collect(Collectors.toList());
// 8. 今日工单趋势(按小时)
WorkOrderTrend workOrderTrend = buildWorkOrderTrend(orderType, todayStart, todayEnd);
return WorkspaceStatsRespVO.builder()
.onlineStaffCount(onlineStaffCount)
.totalStaffCount(totalStaffCount)
.pendingCount(pendingCount)
.avgResponseMinutes(avgResponseMinutes)
.satisfactionRate(satisfactionRate)
.todayOrderCount(todayOrderCount)
.newOrderCount(newOrderCount)
.urgentTasks(urgentTasks)
.workOrderTrend(workOrderTrend)
.build();
}
@Override
@Cacheable(value = "ops:statistics:traffic-trend#5m", unless = "#result == null")
public TrafficTrendRespVO getTrafficTrend() {
LocalDate today = LocalDate.now();
LocalDate sevenDaysAgo = today.minusDays(6); // 包含今天共7天
// 从 ops_traffic_statistics 表查询近7天的客流数据
LocalDateTime startDateTime = sevenDaysAgo.atStartOfDay();
LocalDateTime endDateTime = today.plusDays(1).atStartOfDay();
List<OpsTrafficStatisticsDO> stats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.ge(OpsTrafficStatisticsDO::getStatHour, startDateTime)
.lt(OpsTrafficStatisticsDO::getStatHour, endDateTime));
// 按日期汇总
Map<LocalDate, Long> dailyInMap = new HashMap<>();
Map<LocalDate, Long> dailyOutMap = new HashMap<>();
for (OpsTrafficStatisticsDO stat : stats) {
if (stat.getStatHour() != null) {
LocalDate date = stat.getStatHour().toLocalDate();
long peopleIn = stat.getPeopleIn() != null ? stat.getPeopleIn() : 0;
long peopleOut = stat.getPeopleOut() != null ? stat.getPeopleOut() : 0;
dailyInMap.put(date, dailyInMap.getOrDefault(date, 0L) + peopleIn);
dailyOutMap.put(date, dailyOutMap.getOrDefault(date, 0L) + peopleOut);
}
}
// 构建返回数据(按日期排序,补零)
List<String> dates = new ArrayList<>();
List<Long> inData = new ArrayList<>();
List<Long> outData = new ArrayList<>();
List<Long> netData = new ArrayList<>();
long totalIn = 0;
long totalOut = 0;
for (LocalDate d = sevenDaysAgo; !d.isAfter(today); d = d.plusDays(1)) {
dates.add(d.format(DATE_FORMATTER));
long dayIn = dailyInMap.getOrDefault(d, 0L);
long dayOut = dailyOutMap.getOrDefault(d, 0L);
long dayNet = dayIn - dayOut;
inData.add(dayIn);
outData.add(dayOut);
netData.add(dayNet);
totalIn += dayIn;
totalOut += dayOut;
}
return TrafficTrendRespVO.builder()
.dates(dates)
.inData(inData)
.outData(outData)
.netData(netData)
.totalIn(totalIn)
.totalOut(totalOut)
.build();
}
// ==================== 私有方法 ====================
private Integer countByStatusAndType(String status, String orderType) {
return Math.toIntExact(opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getStatus, status)
.eq(OpsOrderDO::getOrderType, orderType)));
}
/**
* 构建趋势数据(创建数/完成数 按日)-- 单次 GROUP BY 查询 + Java 补零
*/
private TrendData buildTrendData(String orderType, LocalDate startDate, LocalDate endDate) {
LocalDateTime startDateTime = startDate.atStartOfDay();
LocalDateTime endDateTime = endDate.plusDays(1).atStartOfDay();
// 一次查询获取所有天的创建数
List<DateCountRespVO> createdRows = opsOrderMapper.selectCreatedCountGroupByDate(
orderType, startDateTime, endDateTime);
Map<String, Integer> createdMap = createdRows.stream()
.collect(Collectors.toMap(
row -> row.getStatDate().toString(),
row -> row.getCnt().intValue(),
(a, b) -> a
));
// 一次查询获取所有天的完成数
List<DateCountRespVO> completedRows = opsOrderMapper.selectCompletedCountGroupByDate(
orderType, startDateTime, endDateTime);
Map<String, Integer> completedMap = completedRows.stream()
.collect(Collectors.toMap(
row -> row.getStatDate().toString(),
row -> row.getCnt().intValue(),
(a, b) -> a
));
// 按日期遍历,补零
List<String> dates = new ArrayList<>();
List<Integer> createdData = new ArrayList<>();
List<Integer> completedData = new ArrayList<>();
for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
String dateKey = d.toString(); // yyyy-MM-dd
dates.add(d.format(DATE_FORMATTER));
createdData.add(createdMap.getOrDefault(dateKey, 0));
completedData.add(completedMap.getOrDefault(dateKey, 0));
}
return TrendData.builder()
.dates(dates)
.createdData(createdData)
.completedData(completedData)
.build();
}
/**
* 构建时段分布24小时-- 单次 GROUP BY HOUR 查询
*/
private HourlyDistribution buildHourlyDistribution(String orderType,
LocalDateTime startDateTime,
LocalDateTime endDateTime) {
List<HourCountRespVO> rows = opsOrderMapper.selectCountGroupByHour(
orderType, startDateTime, endDateTime);
// 构建小时 -> 计数映射
int[] hourBuckets = new int[24];
for (HourCountRespVO row : rows) {
int hour = row.getHourVal();
int cnt = row.getCnt().intValue();
if (hour >= 0 && hour < 24) {
hourBuckets[hour] = cnt;
}
}
List<String> hours = new ArrayList<>();
List<Integer> data = new ArrayList<>();
for (int h = 0; h < 24; h++) {
hours.add(String.format("%02d:00", h));
data.add(hourBuckets[h]);
}
return HourlyDistribution.builder().hours(hours).data(data).build();
}
/**
* 构建时效趋势(响应时长/完成时长 按日)-- 单次 GROUP BY 查询 + Java 补零
*/
private TimeTrendData buildTimeTrendData(String orderType, LocalDate startDate, LocalDate endDate) {
LocalDateTime startDateTime = startDate.atStartOfDay();
LocalDateTime endDateTime = endDate.plusDays(1).atStartOfDay();
List<AvgTimeRespVO> rows = opsOrderMapper.selectAvgTimeGroupByDate(
orderType, startDateTime, endDateTime);
// 构建日期 -> 响应/完成时长映射
Map<String, Double> responseMap = new HashMap<>();
Map<String, Double> completionMap = new HashMap<>();
for (AvgTimeRespVO row : rows) {
String dateKey = row.getStatDate().toString();
if (row.getAvgResponse() != null) {
double responseMinutes = row.getAvgResponse() / 60.0;
responseMap.put(dateKey, Math.round(responseMinutes * 10) / 10.0);
}
if (row.getAvgCompletion() != null) {
double completionMinutes = row.getAvgCompletion() / 60.0;
completionMap.put(dateKey, Math.round(completionMinutes * 10) / 10.0);
}
}
// 按日期遍历,补零
List<String> dates = new ArrayList<>();
List<Double> responseTimeData = new ArrayList<>();
List<Double> completionTimeData = new ArrayList<>();
for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
String dateKey = d.toString();
dates.add(d.format(DATE_FORMATTER));
responseTimeData.add(responseMap.getOrDefault(dateKey, 0.0));
completionTimeData.add(completionMap.getOrDefault(dateKey, 0.0));
}
return TimeTrendData.builder()
.dates(dates)
.responseTimeData(responseTimeData)
.completionTimeData(completionTimeData)
.build();
}
/**
* 构建漏斗数据(统计指定时间范围内的工单)
*/
private List<FunnelItem> buildFunnelData(String orderType, LocalDateTime startDateTime, LocalDateTime endDateTime) {
// 创建工单 = 时间范围内创建的所有非取消工单
long totalCreated = opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getOrderType, orderType)
.ne(OpsOrderDO::getStatus, "CANCELLED")
.ge(OpsOrderDO::getCreateTime, startDateTime)
.lt(OpsOrderDO::getCreateTime, endDateTime));
// 已分配 = 时间范围内创建且已进入工作流程的工单(排除 PENDING 和 CANCELLED
long assigned = opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getOrderType, orderType)
.notIn(OpsOrderDO::getStatus, "PENDING", "CANCELLED")
.ge(OpsOrderDO::getCreateTime, startDateTime)
.lt(OpsOrderDO::getCreateTime, endDateTime));
// 已到岗 = 时间范围内创建且已到岗的工单ARRIVED + COMPLETED + PAUSED
long arrived = opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getOrderType, orderType)
.in(OpsOrderDO::getStatus, "ARRIVED", "COMPLETED", "PAUSED")
.ge(OpsOrderDO::getCreateTime, startDateTime)
.lt(OpsOrderDO::getCreateTime, endDateTime));
// 已完成 = 时间范围内创建且已完成的工单
long completed = opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getStatus, "COMPLETED")
.eq(OpsOrderDO::getOrderType, orderType)
.ge(OpsOrderDO::getCreateTime, startDateTime)
.lt(OpsOrderDO::getCreateTime, endDateTime));
List<FunnelItem> funnelData = new ArrayList<>();
funnelData.add(FunnelItem.builder().name("创建工单").value((int) totalCreated).build());
funnelData.add(FunnelItem.builder().name("已分配").value((int) assigned).build());
funnelData.add(FunnelItem.builder().name("已到岗").value((int) arrived).build());
funnelData.add(FunnelItem.builder().name("已完成").value((int) completed).build());
return funnelData;
}
/**
* 构建热力图数据(日期 x 小时)-- 单次 GROUP BY 查询
* 显示近7天的具体日期而不是周几
*/
private HeatmapData buildHeatmapData(String orderType,
LocalDateTime startDateTime,
LocalDateTime endDateTime) {
List<HeatmapRespVO> rows = opsOrderMapper.selectCountGroupByDayOfWeekAndHour(
orderType, startDateTime, endDateTime);
// 生成近7天的日期列表格式MM-dd
LocalDate startDate = startDateTime.toLocalDate();
List<String> days = new ArrayList<>();
Map<LocalDate, Integer> dateIndexMap = new HashMap<>();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM-dd");
for (int i = 0; i < 7; i++) {
LocalDate date = startDate.plusDays(i);
days.add(date.format(formatter));
dateIndexMap.put(date, i);
}
// [date 0-6][hour 0-23]
int[][] matrix = new int[7][24];
for (HeatmapRespVO row : rows) {
int hour = row.getHourVal();
int cnt = row.getCnt().intValue();
// 根据 dayOfWeek 和 startDate 计算实际日期
int mysqlDow = row.getDayOfWeek();
DayOfWeek dayOfWeek = mysqlDow == 1 ? DayOfWeek.SUNDAY : DayOfWeek.of(mysqlDow - 1);
// 在7天范围内查找匹配的日期
for (int i = 0; i < 7; i++) {
LocalDate date = startDate.plusDays(i);
if (date.getDayOfWeek() == dayOfWeek) {
Integer dayIndex = dateIndexMap.get(date);
if (dayIndex != null && hour >= 0 && hour < 24) {
matrix[dayIndex][hour] += cnt;
}
}
}
}
List<String> hours = new ArrayList<>();
for (int h = 0; h < 24; h++) {
hours.add(String.format("%d:00", h));
}
List<List<Integer>> data = new ArrayList<>();
for (int d = 0; d < 7; d++) {
List<Integer> dayData = new ArrayList<>();
for (int h = 0; h < 24; h++) {
dayData.add(matrix[d][h]);
}
data.add(dayData);
}
return HeatmapData.builder()
.days(days)
.hours(hours)
.data(data)
.build();
}
/**
* 构建功能类型排行 -- 单次 GROUP BY function_type 查询
*/
/**
* 构建功能类型排行(将枚举值转换为中文)
*/
private List<FunctionTypeRankingItem> buildFunctionTypeRanking(String orderType) {
List<FunctionTypeCountRespVO> rows = opsOrderMapper.selectCountGroupByFunctionType(orderType);
return rows.stream()
.map(row -> {
int total = row.getTotalCount().intValue();
int completedCount = row.getCompletedCount().intValue();
double rate = total > 0 ? Math.round(completedCount * 1000.0 / total) / 10.0 : 0.0;
// 转换枚举值为中文用于JSON API响应
String functionTypeLabel = FUNCTION_TYPE_DICT.getOrDefault(
row.getFunctionType(),
row.getFunctionType() // 如果映射不存在,保留原值
);
return FunctionTypeRankingItem.builder()
.functionType(functionTypeLabel)
.count(total)
.completed(completedCount)
.rate(rate)
.build();
})
.collect(Collectors.toList());
}
/**
* 构建客流小时趋势
*/
private HourlyTrend buildTrafficHourlyTrend(List<OpsTrafficStatisticsDO> todayStats) {
long[] hourIn = new long[24];
long[] hourOut = new long[24];
for (OpsTrafficStatisticsDO stat : todayStats) {
if (stat.getStatHour() != null) {
int hour = stat.getStatHour().getHour();
hourIn[hour] += stat.getPeopleIn() != null ? stat.getPeopleIn() : 0;
hourOut[hour] += stat.getPeopleOut() != null ? stat.getPeopleOut() : 0;
}
}
List<String> hours = new ArrayList<>();
List<Long> inData = new ArrayList<>();
List<Long> outData = new ArrayList<>();
// 每2小时显示一个点避免横坐标太挤
for (int h = 0; h < 24; h += 2) {
hours.add(String.format("%02d:00", h));
inData.add(hourIn[h]);
outData.add(hourOut[h]);
}
return HourlyTrend.builder().hours(hours).inData(inData).outData(outData).build();
}
/**
* 构建今日工单趋势(按小时)-- 使用 GROUP BY HOUR 查询
*/
/**
* 构建今日工单趋势按小时优化展示每2小时
*/
private WorkOrderTrend buildWorkOrderTrend(String orderType,
LocalDateTime todayStart,
LocalDateTime todayEnd) {
List<HourCountRespVO> rows = opsOrderMapper.selectCountGroupByHour(
orderType, todayStart, todayEnd);
int[] hourBuckets = new int[24];
for (HourCountRespVO row : rows) {
int hour = row.getHourVal();
int cnt = row.getCnt().intValue();
if (hour >= 0 && hour < 24) {
hourBuckets[hour] = cnt;
}
}
int currentHour = LocalDateTime.now().getHour();
List<String> hours = new ArrayList<>();
List<Integer> data = new ArrayList<>();
// 每2小时显示一个点避免横坐标太挤
for (int h = 0; h <= currentHour; h += 2) {
hours.add(String.format("%02d:00", h));
data.add(hourBuckets[h]);
}
// 如果当前小时是奇数,补充最后一个点
if (currentHour % 2 == 1 && currentHour > 0) {
hours.add(String.format("%02d:00", currentHour));
data.add(hourBuckets[currentHour]);
}
return WorkOrderTrend.builder().hours(hours).data(data).build();
}
/**
* 加载区域名称映射
*/
private Map<Long, String> loadAreaNameMap() {
List<OpsBusAreaDO> areas = opsBusAreaMapper.selectList(new LambdaQueryWrapper<>());
return areas.stream()
.collect(Collectors.toMap(OpsBusAreaDO::getId, OpsBusAreaDO::getAreaName, (a, b) -> a));
}
}

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper">
<!-- 按日期分组统计工单创建数 -->
<select id="selectCreatedCountGroupByDate" resultType="com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DateCountRespVO">
SELECT DATE(create_time) AS statDate, COUNT(*) AS cnt
FROM ops_order
WHERE order_type = #{orderType}
AND deleted = 0
AND create_time &gt;= #{startTime}
AND create_time &lt; #{endTime}
GROUP BY DATE(create_time)
ORDER BY statDate
</select>
<!-- 按日期分组统计工单完成数 -->
<select id="selectCompletedCountGroupByDate" resultType="com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DateCountRespVO">
SELECT DATE(end_time) AS statDate, COUNT(*) AS cnt
FROM ops_order
WHERE order_type = #{orderType}
AND status = 'COMPLETED'
AND deleted = 0
AND end_time &gt;= #{startTime}
AND end_time &lt; #{endTime}
GROUP BY DATE(end_time)
ORDER BY statDate
</select>
<!-- 按日期分组统计平均响应时长和完成时长 -->
<select id="selectAvgTimeGroupByDate" resultType="com.viewsh.module.ops.controller.admin.workorder.vo.statistics.AvgTimeRespVO">
SELECT DATE(end_time) AS statDate,
AVG(CASE WHEN response_seconds > 0 THEN response_seconds ELSE NULL END) AS avgResponse,
AVG(CASE WHEN completion_seconds > 0 THEN completion_seconds ELSE NULL END) AS avgCompletion
FROM ops_order
WHERE order_type = #{orderType}
AND status = 'COMPLETED'
AND deleted = 0
AND end_time &gt;= #{startTime}
AND end_time &lt; #{endTime}
GROUP BY DATE(end_time)
ORDER BY statDate
</select>
<!-- 按区域分组统计工单数和完成数 -->
<select id="selectCountGroupByAreaId" resultType="com.viewsh.module.ops.controller.admin.workorder.vo.statistics.AreaCountRespVO">
SELECT area_id AS areaId,
COUNT(*) AS totalCount,
SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completedCount
FROM ops_order
WHERE order_type = #{orderType}
AND status != 'CANCELLED'
AND deleted = 0
AND area_id IS NOT NULL
GROUP BY area_id
ORDER BY totalCount DESC
LIMIT 10
</select>
<!-- 按小时分组统计工单创建数 -->
<select id="selectCountGroupByHour" resultType="com.viewsh.module.ops.controller.admin.workorder.vo.statistics.HourCountRespVO">
SELECT HOUR(create_time) AS hourVal, COUNT(*) AS cnt
FROM ops_order
WHERE order_type = #{orderType}
AND deleted = 0
AND create_time &gt;= #{startTime}
AND create_time &lt; #{endTime}
GROUP BY HOUR(create_time)
ORDER BY hourVal
</select>
<!-- 按星期和小时分组统计(热力图) -->
<select id="selectCountGroupByDayOfWeekAndHour" resultType="com.viewsh.module.ops.controller.admin.workorder.vo.statistics.HeatmapRespVO">
SELECT DAYOFWEEK(create_time) AS dayOfWeek,
HOUR(create_time) AS hourVal,
COUNT(*) AS cnt
FROM ops_order
WHERE order_type = #{orderType}
AND deleted = 0
AND create_time &gt;= #{startTime}
AND create_time &lt; #{endTime}
GROUP BY DAYOFWEEK(create_time), HOUR(create_time)
ORDER BY dayOfWeek, hourVal
</select>
<!-- 按保洁类型分组统计平均作业时长JOIN clean_ext -->
<select id="selectAvgDurationGroupByCleaningType" resultType="com.viewsh.module.ops.controller.admin.workorder.vo.statistics.CleaningTypeAvgDurationRespVO">
SELECT ext.cleaning_type AS cleaningType,
ROUND(AVG(TIMESTAMPDIFF(MINUTE, ext.arrived_time, ext.completed_time))) AS avgDuration
FROM ops_order_clean_ext ext
INNER JOIN ops_order o ON ext.ops_order_id = o.id
WHERE o.status = 'COMPLETED'
AND o.order_type = #{orderType}
AND o.deleted = 0
AND ext.deleted = 0
AND ext.completed_time IS NOT NULL
AND ext.arrived_time IS NOT NULL
AND ext.cleaning_type IS NOT NULL
GROUP BY ext.cleaning_type
</select>
<!-- 按功能类型分组统计工单数和完成数 -->
<select id="selectCountGroupByFunctionType" resultType="com.viewsh.module.ops.controller.admin.workorder.vo.statistics.FunctionTypeCountRespVO">
SELECT a.function_type AS functionType,
COUNT(o.id) AS totalCount,
SUM(CASE WHEN o.status = 'COMPLETED' THEN 1 ELSE 0 END) AS completedCount
FROM ops_order o
INNER JOIN ops_bus_area a ON o.area_id = a.id
WHERE o.order_type = #{orderType}
AND o.status != 'CANCELLED'
AND o.deleted = 0
AND a.deleted = 0
AND a.function_type IS NOT NULL
GROUP BY a.function_type
ORDER BY totalCount DESC
LIMIT 10
</select>
</mapper>

View File

@@ -0,0 +1,50 @@
package com.viewsh.module.ops.config;
import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO;
import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO;
import com.viewsh.module.ops.environment.service.badge.BadgeDeviceStatusService;
import com.viewsh.module.ops.service.area.AreaDeviceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
import java.util.function.Supplier;
/**
* 统计服务配置,注册跨模块依赖的 Bean
*
* @author lzh
*/
@Configuration
public class StatisticsConfiguration {
@Autowired(required = false)
private BadgeDeviceStatusService badgeDeviceStatusService;
@Autowired(required = false)
private AreaDeviceService areaDeviceService;
@Bean
public Supplier<int[]> staffCountProvider() {
return () -> {
if (badgeDeviceStatusService == null) {
return new int[]{0, 0};
}
List<BadgeDeviceStatusDTO> activeBadges = badgeDeviceStatusService.listActiveBadges();
int onlineCount = activeBadges.size();
// 从区域设备关联表查询所有已注册的工牌设备总数
int totalCount = onlineCount;
if (areaDeviceService != null) {
try {
List<OpsAreaDeviceRelationDO> allBadges = areaDeviceService.listByAreaIdAndType(null, "BADGE");
totalCount = Math.max(onlineCount, allBadges.size());
} catch (Exception ignored) {
// 查询失败时使用在线数作为兜底
}
}
return new int[]{onlineCount, totalCount};
};
}
}

View File

@@ -3,10 +3,15 @@ package com.viewsh.module.ops.controller.admin;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.framework.common.pojo.PageResult;
import com.viewsh.module.ops.api.clean.QuickStatsRespDTO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DashboardStatsRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficRealtimeRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficTrendRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.WorkspaceStatsRespVO;
import com.viewsh.module.ops.environment.service.dashboard.CleanDashboardService;
import com.viewsh.module.ops.service.OrderDetailVO;
import com.viewsh.module.ops.service.OrderQueryService;
import com.viewsh.module.ops.service.OrderSummaryVO;
import com.viewsh.module.ops.service.statistics.OpsStatisticsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -14,10 +19,12 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Map;
import static com.viewsh.framework.common.pojo.CommonResult.success;
@@ -42,6 +49,9 @@ public class OrderCenterController {
@Autowired(required = false)
private CleanDashboardService cleanDashboardService;
@Autowired(required = false)
private OpsStatisticsService opsStatisticsService;
@GetMapping("/page")
@Operation(summary = "工单中心分页查询")
@PreAuthorize("@ss.hasPermission('ops:order-center:query')")
@@ -87,4 +97,57 @@ public class OrderCenterController {
QuickStatsRespDTO stats = cleanDashboardService.getQuickStats();
return success(stats);
}
@GetMapping("/dashboard-stats")
@Operation(summary = "看板统计")
@PreAuthorize("@ss.hasPermission('ops:order-center:query')")
public CommonResult<DashboardStatsRespVO> getDashboardStats(
@RequestParam(value = "orderType", required = false, defaultValue = "CLEAN") String orderType,
@RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
@RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate) {
if (opsStatisticsService == null) {
log.warn("[getDashboardStats] OpsStatisticsService 未注入,返回默认值");
return success(DashboardStatsRespVO.builder().build());
}
if (startDate == null) {
startDate = LocalDate.now().minusDays(6);
}
if (endDate == null) {
endDate = LocalDate.now();
}
return success(opsStatisticsService.getDashboardStats(orderType, startDate, endDate));
}
@GetMapping("/traffic-realtime")
@Operation(summary = "实时客流监测")
@PreAuthorize("@ss.hasPermission('ops:order-center:query')")
public CommonResult<TrafficRealtimeRespVO> getTrafficRealtime() {
if (opsStatisticsService == null) {
log.warn("[getTrafficRealtime] OpsStatisticsService 未注入,返回默认值");
return success(TrafficRealtimeRespVO.builder().build());
}
return success(opsStatisticsService.getTrafficRealtime());
}
@GetMapping("/workspace-stats")
@Operation(summary = "工作台统计")
@PreAuthorize("@ss.hasPermission('ops:order-center:query')")
public CommonResult<WorkspaceStatsRespVO> getWorkspaceStats() {
if (opsStatisticsService == null) {
log.warn("[getWorkspaceStats] OpsStatisticsService 未注入,返回默认值");
return success(WorkspaceStatsRespVO.builder().build());
}
return success(opsStatisticsService.getWorkspaceStats());
}
@GetMapping("/traffic-trend")
@Operation(summary = "近7天客流趋势统计")
@PreAuthorize("@ss.hasPermission('ops:order-center:query')")
public CommonResult<TrafficTrendRespVO> getTrafficTrend() {
if (opsStatisticsService == null) {
log.warn("[getTrafficTrend] OpsStatisticsService 未注入,返回默认值");
return success(TrafficTrendRespVO.builder().build());
}
return success(opsStatisticsService.getTrafficTrend());
}
}