wlzboy
2025-11-16 f67945d53b20f6a45ae50b27d74c966eb1355bb4
feat: 增加分段GPS计算行程距离
20个文件已修改
21个文件已添加
3672 ■■■■■ 已修改文件
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleGpsController.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleGpsSegmentMileageController.java 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleInfoController.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleMileageStatsController.java 25 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-framework/src/main/java/com/ruoyi/framework/config/JacksonConfig.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/GpsSyncTask.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/VehicleGpsSegmentMileageTask.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/VehicleMileageStatsTask.java 84 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/VehicleGpsSegmentMileage.java 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/VehicleInfo.java 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/VehicleMileageStats.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleGpsMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleGpsSegmentMileageMapper.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleInfoMapper.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/IVehicleGpsSegmentMileageService.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/IVehicleInfoService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/IVehicleMileageStatsService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/GpsCollectServiceImpl.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VehicleGpsSegmentMileageServiceImpl.java 658 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VehicleInfoServiceImpl.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VehicleMileageStatsServiceImpl.java 205 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/VehicleGpsMapper.xml 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/VehicleGpsSegmentMileageMapper.xml 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/VehicleInfoMapper.xml 87 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/VehicleMileageStatsMapper.xml 50 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/system/gpsSegment.js 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/system/mileageStats.js 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/components/TaskMileageDetail/README.md 187 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/components/TaskMileageDetail/index.vue 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/system/mileageStats/README.md 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/system/mileageStats/index.vue 490 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/system/vehicle/index.vue 31 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/task/detail/index.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/updates/add_segment_count_to_mileage_stats.sql 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/updates/add_task_id_to_segment_mileage.sql 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/updates/remove_dept_id_from_vehicle_info.sql 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/updates/update_vehicle_no_from_vehicle_info.sql 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/vehicle_gps_segment_mileage.sql 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/vehicle_gps_segment_mileage_job.sql 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/vehicle_mileage_stats.sql 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/vehicle_mileage_stats_job.sql 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleGpsController.java
@@ -762,8 +762,7 @@
            
            // 构建天地图地理编码API URL
            String url = "http://api.tianditu.gov.cn/geocoder";
            String params = "ds={\"keyWord\":\"" + address + \"}" +
                           "&tk=" + tiandituMapConfig.getTk();
            String params = "ds={\"keyWord\":\"" + address + "\"}&tk=" + tiandituMapConfig.getTk();
            
            logger.info("天地图地理编码请求: address={}", address);
            
@@ -943,7 +942,7 @@
            
            // 第一步:起点地址转坐标
            String geocodingUrl1 = "http://api.tianditu.gov.cn/geocoder";
            String geocodingParams1 = "ds={\"keyWord\":\"" + fromAddress + \"}" +
            String geocodingParams1 = "ds={\"keyWord\":\"" + fromAddress + "\"}" +
                                     "&tk=" + tiandituMapConfig.getTk();
            
            String geocodingResponse1 = HttpUtils.sendGet(geocodingUrl1, geocodingParams1);
@@ -965,7 +964,7 @@
            
            // 第二步:终点地址转坐标
            String geocodingUrl2 = "http://api.tianditu.gov.cn/geocoder";
            String geocodingParams2 = "ds={\"keyWord\":\"" + toAddress + \"}" +
            String geocodingParams2 = "ds={\"keyWord\":\"" + toAddress + "\"}" +
                                     "&tk=" + tiandituMapConfig.getTk();
            
            String geocodingResponse2 = HttpUtils.sendGet(geocodingUrl2, geocodingParams2);
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleGpsSegmentMileageController.java
New file
@@ -0,0 +1,132 @@
package com.ruoyi.web.controller.system;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.domain.VehicleGpsSegmentMileage;
import com.ruoyi.system.mapper.VehicleGpsSegmentMileageMapper;
import com.ruoyi.system.service.IVehicleGpsSegmentMileageService;
import com.ruoyi.system.service.ISysConfigService;
/**
 * GPS分段里程Controller
 */
@RestController
@RequestMapping("/system/gpsSegment")
public class VehicleGpsSegmentMileageController extends BaseController {
    private static final Logger logger = LoggerFactory.getLogger(VehicleGpsSegmentMileageController.class);
    @Autowired
    private VehicleGpsSegmentMileageMapper segmentMileageMapper;
    @Autowired
    private IVehicleGpsSegmentMileageService segmentMileageService;
    @Autowired
    private ISysConfigService configService;
    /**
     * 查询GPS分段里程列表
     */
    @PreAuthorize("@ss.hasPermi('system:gpsSegment:list')")
    @GetMapping("/list")
    public TableDataInfo list(VehicleGpsSegmentMileage segmentMileage) {
        startPage();
        List<VehicleGpsSegmentMileage> list = segmentMileageMapper.selectVehicleGpsSegmentMileageList(segmentMileage);
        return getDataTable(list);
    }
    /**
     * 按任务ID查询GPS分段里程列表
     */
    @PreAuthorize("@ss.hasPermi('system:gpsSegment:query')")
    @GetMapping("/task/{taskId}")
    public AjaxResult getSegmentsByTask(@PathVariable("taskId") Long taskId) {
        try {
            List<VehicleGpsSegmentMileage> list = segmentMileageMapper.selectSegmentsByTaskId(taskId);
            return success(list);
        } catch (Exception e) {
            logger.error("查询任务GPS里程失败 - taskId: {}", taskId, e);
            return error("查询失败:" + e.getMessage());
        }
    }
    /**
     * 查询任务的总里程
     */
    @PreAuthorize("@ss.hasPermi('system:gpsSegment:query')")
    @GetMapping("/task/{taskId}/total")
    public AjaxResult getTaskTotalMileage(@PathVariable("taskId") Long taskId) {
        try {
            BigDecimal totalMileage = segmentMileageMapper.selectTotalMileageByTaskId(taskId);
            return success(totalMileage);
        } catch (Exception e) {
            logger.error("查询任务总里程失败 - taskId: {}", taskId, e);
            return error("查询失败:" + e.getMessage());
        }
    }
    /**
     * 查询车辆指定日期范围的GPS分段里程
     */
    @PreAuthorize("@ss.hasPermi('system:gpsSegment:query')")
    @GetMapping("/range")
    public AjaxResult getSegmentsByDateRange(
            @RequestParam("vehicleId") Long vehicleId,
            @RequestParam("startDate") String startDateStr,
            @RequestParam("endDate") String endDateStr) {
        try {
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            Date startDate = sdf.parse(startDateStr + " 00:00:00");
            Date endDate = sdf.parse(endDateStr + " 23:59:59");
            List<VehicleGpsSegmentMileage> list = segmentMileageMapper.selectSegmentsByDateRange(vehicleId, startDate, endDate);
            return success(list);
        } catch (Exception e) {
            logger.error("查询GPS分段里程失败 - vehicleId: {}, dateRange: {} - {}",
                        vehicleId, startDateStr, endDateStr, e);
            return error("查询失败:" + e.getMessage());
        }
    }
    /**
     * 补偿计算:查找并计算未被处理的GPS数据
     * 用于服务重启后的数据修复
     */
    @PreAuthorize("@ss.hasPermi('system:gpsSegment:compensate')")
    @Log(title = "GPS里程补偿计算", businessType = BusinessType.OTHER)
    @PostMapping("/compensate")
    public AjaxResult compensateCalculation() {
        try {
            // 从配置中获取回溯天数,默认2天
            String lookbackDaysConfig = configService.selectConfigByKey("gps.mileage.compensate.lookback.days");
            int lookbackDays = lookbackDaysConfig != null ? Integer.parseInt(lookbackDaysConfig) : 2;
            logger.info("开始执行GPS里程补偿计算 - 回溯天数: {}", lookbackDays);
            int successCount = segmentMileageService.compensateCalculation(lookbackDays);
            return success("补偿计算完成,处理 " + successCount + " 辆车");
        } catch (Exception e) {
            logger.error("GPS里程补偿计算失败", e);
            return error("补偿计算失败:" + e.getMessage());
        }
    }
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleInfoController.java
@@ -59,7 +59,7 @@
     */
    @GetMapping(value = "/{vehicleId}")
    public AjaxResult getInfo(@PathVariable("vehicleId") Long vehicleId) {
        return success(vehicleInfoService.selectVehicleInfoById(vehicleId));
        return success(vehicleInfoService.selectVehicleInfoWithDeptsById(vehicleId));
    }
    /**
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleMileageStatsController.java
@@ -4,6 +4,8 @@
import java.util.Date;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -29,6 +31,8 @@
@RestController
@RequestMapping("/system/mileageStats")
public class VehicleMileageStatsController extends BaseController {
    private static final Logger logger = LoggerFactory.getLogger(VehicleMileageStatsController.class);
    
    @Autowired
    private IVehicleMileageStatsService vehicleMileageStatsService;
@@ -103,15 +107,23 @@
    @PostMapping("/calculate")
    public AjaxResult calculate(Long vehicleId, String statDate) {
        try {
            if (statDate == null || statDate.trim().isEmpty()) {
                return error("统计日期不能为空");
            }
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
            Date date = sdf.parse(statDate);
            sdf.setLenient(false); // 严格解析日期
            Date date = sdf.parse(statDate.trim());
            VehicleMileageStats stats = vehicleMileageStatsService.calculateAndSaveMileageStats(vehicleId, date);
            // 修复String到Date转换问题,添加更好的错误处理
            if (stats != null) {
                return success("里程统计计算成功", stats);
                return AjaxResult.success("里程统计计算成功", stats);
            } else {
                return error("该车辆在指定日期无GPS数据");
            }
        } catch (Exception e) {
            logger.error("里程统计计算失败 - 车辆ID: {}, 日期: {}", vehicleId, statDate, e);
            return error("里程统计计算失败:" + e.getMessage());
        }
    }
@@ -124,11 +136,18 @@
    @PostMapping("/batchCalculate")
    public AjaxResult batchCalculate(String statDate) {
        try {
            if (statDate == null || statDate.trim().isEmpty()) {
                return error("统计日期不能为空");
            }
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
            Date date = sdf.parse(statDate);
            sdf.setLenient(false); // 严格解析日期
            Date date = sdf.parse(statDate.trim());
            int count = vehicleMileageStatsService.batchCalculateMileageStats(date);
            return success("批量里程统计完成,成功统计 " + count + " 辆车");
        } catch (Exception e) {
            logger.error("批量里程统计失败 - 日期: {}", statDate, e);
            return error("批量里程统计失败:" + e.getMessage());
        }
    }
ruoyi-framework/src/main/java/com/ruoyi/framework/config/JacksonConfig.java
New file
@@ -0,0 +1,36 @@
package com.ruoyi.framework.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.apache.ibatis.executor.loader.javassist.JavassistProxyFactory;
import org.apache.ibatis.executor.loader.cglib.CglibProxyFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import java.math.BigInteger;
/**
 * Jackson配置类
 * 解决MyBatis延迟加载代理对象序列化问题
 */
@Configuration
public class JacksonConfig {
    @Bean
    public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        // 注册自定义模块
        SimpleModule module = new SimpleModule();
        module.addSerializer(BigInteger.class, ToStringSerializer.instance);
        module.addSerializer(Long.class, ToStringSerializer.instance);
        objectMapper.registerModule(module);
        // 禁用FAIL_ON_EMPTY_BEANS特性,避免代理对象序列化错误
        objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.FAIL_ON_EMPTY_BEANS);
        return objectMapper;
    }
}
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/GpsSyncTask.java
@@ -3,6 +3,7 @@
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -45,8 +46,11 @@
            // 1. 获取所有车辆信息
            List<VehicleInfo> vehicleList = vehicleInfoService.selectVehicleInfoList(new VehicleInfo());
            List<String> deviceIds = vehicleList.stream().map(VehicleInfo::getDeviceId).collect(Collectors.toList());
            // 2. 获取所有车辆的GPS最后位置
            GpsLastPositionResponse gpsLastPositionResponse = gpsCollectService.getLastPosition(new GpsLastPositionRequest());
            GpsLastPositionRequest request = new GpsLastPositionRequest();
//            request.setDeviceids(deviceIds);
            GpsLastPositionResponse gpsLastPositionResponse = gpsCollectService.getLastPosition(request);
            // 3. 遍历车辆列表,获取每个车辆的GPS位置
            for (VehicleInfo vehicle : vehicleList) {
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/VehicleGpsSegmentMileageTask.java
New file
@@ -0,0 +1,121 @@
package com.ruoyi.quartz.task;
import java.util.Calendar;
import java.util.Date;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.ruoyi.system.service.IVehicleGpsSegmentMileageService;
import com.ruoyi.system.service.ISysConfigService;
/**
 * 车辆GPS分段里程计算定时任务
 */
@Component("vehicleGpsSegmentMileageTask")
public class VehicleGpsSegmentMileageTask {
    private static final Logger logger = LoggerFactory.getLogger(VehicleGpsSegmentMileageTask.class);
    @Autowired
    private IVehicleGpsSegmentMileageService segmentMileageService;
    @Autowired
    private ISysConfigService configService;
    /**
     * 计算最近一段时间的GPS分段里程
     * 默认计算最近1小时的数据
     */
    public void calculateRecentSegmentMileage() {
        calculateRecentSegmentMileage("60");
    }
    /**
     * 计算最近指定分钟数的GPS分段里程
     *
     * @param params 参数字符串,格式:分钟数(如:60表示最近60分钟)
     */
    public void calculateRecentSegmentMileage(String params) {
        try {
            // 解析参数:要计算的时间范围(分钟)
            int minutes = 60; // 默认60分钟
            if (params != null && !params.trim().isEmpty()) {
                try {
                    minutes = Integer.parseInt(params.trim());
                } catch (NumberFormatException e) {
                    logger.warn("参数格式错误,使用默认值60分钟: {}", params);
                }
            }
            // 获取配置的时间间隔
            int segmentMinutes = 5; // 默认5分钟
            String segmentConfig = configService.selectConfigByKey("gps.mileage.segment.minutes");
            if (segmentConfig != null && !segmentConfig.isEmpty()) {
                try {
                    segmentMinutes = Integer.parseInt(segmentConfig);
                } catch (NumberFormatException e) {
                    logger.warn("分段时间间隔配置错误,使用默认值5分钟");
                }
            }
            // 计算时间范围
            Calendar cal = Calendar.getInstance();
            Date endTime = cal.getTime();
            cal.add(Calendar.MINUTE, -minutes);
            Date startTime = cal.getTime();
            logger.info("开始计算GPS分段里程 - 时间范围: {} 到 {}, 时间段间隔: {}分钟",
                       startTime, endTime, segmentMinutes);
            // 批量计算
            int successCount = segmentMileageService.batchCalculateSegmentMileage(startTime, endTime);
            logger.info("GPS分段里程计算完成 - 成功处理 {} 辆车", successCount);
        } catch (Exception e) {
            logger.error("GPS分段里程计算任务执行失败", e);
        }
    }
    /**
     * 计算指定日期的GPS分段里程
     *
     * @param params 参数字符串,格式:yyyy-MM-dd(如:2025-01-15)
     */
    public void calculateDateSegmentMileage(String params) {
        try {
            if (params == null || params.trim().isEmpty()) {
                logger.error("日期参数不能为空");
                return;
            }
            // 解析日期
            java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
            Date date = sdf.parse(params.trim());
            // 计算当天的起止时间
            Calendar cal = Calendar.getInstance();
            cal.setTime(date);
            cal.set(Calendar.HOUR_OF_DAY, 0);
            cal.set(Calendar.MINUTE, 0);
            cal.set(Calendar.SECOND, 0);
            cal.set(Calendar.MILLISECOND, 0);
            Date startTime = cal.getTime();
            cal.add(Calendar.DAY_OF_MONTH, 1);
            Date endTime = cal.getTime();
            logger.info("开始计算指定日期GPS分段里程 - 日期: {}, 时间范围: {} 到 {}",
                       params, startTime, endTime);
            // 批量计算
            int successCount = segmentMileageService.batchCalculateSegmentMileage(startTime, endTime);
            logger.info("指定日期GPS分段里程计算完成 - 成功处理 {} 辆车", successCount);
        } catch (Exception e) {
            logger.error("指定日期GPS分段里程计算任务执行失败 - 参数: {}", params, e);
        }
    }
}
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/VehicleMileageStatsTask.java
@@ -1,5 +1,7 @@
package com.ruoyi.quartz.task;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import org.slf4j.Logger;
@@ -55,30 +57,86 @@
        logger.info("开始执行车辆里程统计定时任务 - 统计日期: {}", dateStr);
        
        try {
            // 解析日期字符串
            String[] parts = dateStr.split("-");
            if (parts.length != 3) {
                throw new IllegalArgumentException("日期格式错误,应为: yyyy-MM-dd");
            if (dateStr == null || dateStr.trim().isEmpty()) {
                throw new IllegalArgumentException("日期不能为空");
            }
            
            Calendar calendar = Calendar.getInstance();
            calendar.set(Calendar.YEAR, Integer.parseInt(parts[0]));
            calendar.set(Calendar.MONTH, Integer.parseInt(parts[1]) - 1); // 月份从0开始
            calendar.set(Calendar.DAY_OF_MONTH, Integer.parseInt(parts[2]));
            calendar.set(Calendar.HOUR_OF_DAY, 0);
            calendar.set(Calendar.MINUTE, 0);
            calendar.set(Calendar.SECOND, 0);
            calendar.set(Calendar.MILLISECOND, 0);
            Date targetDate = calendar.getTime();
            // 使用SimpleDateFormat解析日期字符串
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
            sdf.setLenient(false); // 严格解析日期
            Date targetDate = sdf.parse(dateStr.trim());
            
            // 批量计算里程统计
            int successCount = vehicleMileageStatsService.batchCalculateMileageStats(targetDate);
            
            logger.info("车辆里程统计定时任务执行完成 - 日期: {}, 成功统计: {} 辆车", dateStr, successCount);
            
        } catch (ParseException e) {
            logger.error("车辆里程统计定时任务执行失败 - 日期格式错误: {}", dateStr, e);
            throw new RuntimeException("定时任务执行失败: 日期格式错误,应为 yyyy-MM-dd");
        } catch (Exception e) {
            logger.error("车辆里程统计定时任务执行失败 - 日期: {}", dateStr, e);
            throw new RuntimeException("定时任务执行失败: " + e.getMessage());
        }
    }
    /**
     * 从GPS分段里程汇总生成昨日统计数据
     * (推荐使用此方法,基于已计算的分段里程数据汇总,性能更好)
     */
    public void aggregateYesterdayFromSegments() {
        logger.info("开始执行从分段里程汇总任务 - 统计昨日数据");
        try {
            // 获取昨天的日期
            Calendar calendar = Calendar.getInstance();
            calendar.add(Calendar.DAY_OF_MONTH, -1);
            calendar.set(Calendar.HOUR_OF_DAY, 0);
            calendar.set(Calendar.MINUTE, 0);
            calendar.set(Calendar.SECOND, 0);
            calendar.set(Calendar.MILLISECOND, 0);
            Date yesterday = calendar.getTime();
            // 从分段里程汇总生成统计
            int successCount = vehicleMileageStatsService.batchAggregateFromSegmentMileage(yesterday);
            logger.info("从分段里程汇总任务执行完成 - 成功统计: {} 辆车", successCount);
        } catch (Exception e) {
            logger.error("从分段里程汇总任务执行失败", e);
            throw new RuntimeException("汇总任务执行失败: " + e.getMessage());
        }
    }
    /**
     * 从GPS分段里程汇总生成指定日期的统计数据
     *
     * @param dateStr 日期字符串,格式:yyyy-MM-dd
     */
    public void aggregateFromSegmentsByDate(String dateStr) {
        logger.info("开始执行从分段里程汇总任务 - 统计日期: {}", dateStr);
        try {
            if (dateStr == null || dateStr.trim().isEmpty()) {
                throw new IllegalArgumentException("日期不能为空");
            }
            // 使用SimpleDateFormat解析日期字符串
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
            sdf.setLenient(false); // 严格解析日期
            Date targetDate = sdf.parse(dateStr.trim());
            // 从分段里程汇总生成统计
            int successCount = vehicleMileageStatsService.batchAggregateFromSegmentMileage(targetDate);
            logger.info("从分段里程汇总任务执行完成 - 日期: {}, 成功统计: {} 辆车", dateStr, successCount);
        } catch (ParseException e) {
            logger.error("从分段里程汇总任务执行失败 - 日期格式错误: {}", dateStr, e);
            throw new RuntimeException("汇总任务执行失败: 日期格式错误,应为 yyyy-MM-dd");
        } catch (Exception e) {
            logger.error("从分段里程汇总任务执行失败 - 日期: {}", dateStr, e);
            throw new RuntimeException("汇总任务执行失败: " + e.getMessage());
        }
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/VehicleGpsSegmentMileage.java
New file
@@ -0,0 +1,194 @@
package com.ruoyi.system.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
/**
 * 车辆GPS分段里程对象 tb_vehicle_gps_segment_mileage
 */
public class VehicleGpsSegmentMileage extends BaseEntity {
    private static final long serialVersionUID = 1L;
    /** 分段ID */
    private Long segmentId;
    /** 车辆ID */
    @Excel(name = "车辆ID")
    private Long vehicleId;
    /** 车牌号 */
    @Excel(name = "车牌号")
    private String vehicleNo;
    /** 时间段开始时间 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Excel(name = "时间段开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    private Date segmentStartTime;
    /** 时间段结束时间 */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Excel(name = "时间段结束时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    private Date segmentEndTime;
    /** 起点经度 */
    @Excel(name = "起点经度")
    private BigDecimal startLongitude;
    /** 起点纬度 */
    @Excel(name = "起点纬度")
    private BigDecimal startLatitude;
    /** 终点经度 */
    @Excel(name = "终点经度")
    private BigDecimal endLongitude;
    /** 终点纬度 */
    @Excel(name = "终点纬度")
    private BigDecimal endLatitude;
    /** 段距离(公里) */
    @Excel(name = "段距离(公里)")
    private BigDecimal segmentDistance;
    /** GPS点数量 */
    @Excel(name = "GPS点数量")
    private Integer gpsPointCount;
    /** 关联的GPS记录ID列表 */
    private String gpsIds;
    /** 关联的任务ID */
    @Excel(name = "关联任务ID")
    private Long taskId;
    /** 任务编号 */
    @Excel(name = "任务编号")
    private String taskCode;
    /** 计算方式 */
    @Excel(name = "计算方式")
    private String calculateMethod;
    public Long getSegmentId() {
        return segmentId;
    }
    public void setSegmentId(Long segmentId) {
        this.segmentId = segmentId;
    }
    public Long getVehicleId() {
        return vehicleId;
    }
    public void setVehicleId(Long vehicleId) {
        this.vehicleId = vehicleId;
    }
    public String getVehicleNo() {
        return vehicleNo;
    }
    public void setVehicleNo(String vehicleNo) {
        this.vehicleNo = vehicleNo;
    }
    public Date getSegmentStartTime() {
        return segmentStartTime;
    }
    public void setSegmentStartTime(Date segmentStartTime) {
        this.segmentStartTime = segmentStartTime;
    }
    public Date getSegmentEndTime() {
        return segmentEndTime;
    }
    public void setSegmentEndTime(Date segmentEndTime) {
        this.segmentEndTime = segmentEndTime;
    }
    public BigDecimal getStartLongitude() {
        return startLongitude;
    }
    public void setStartLongitude(BigDecimal startLongitude) {
        this.startLongitude = startLongitude;
    }
    public BigDecimal getStartLatitude() {
        return startLatitude;
    }
    public void setStartLatitude(BigDecimal startLatitude) {
        this.startLatitude = startLatitude;
    }
    public BigDecimal getEndLongitude() {
        return endLongitude;
    }
    public void setEndLongitude(BigDecimal endLongitude) {
        this.endLongitude = endLongitude;
    }
    public BigDecimal getEndLatitude() {
        return endLatitude;
    }
    public void setEndLatitude(BigDecimal endLatitude) {
        this.endLatitude = endLatitude;
    }
    public BigDecimal getSegmentDistance() {
        return segmentDistance;
    }
    public void setSegmentDistance(BigDecimal segmentDistance) {
        this.segmentDistance = segmentDistance;
    }
    public Integer getGpsPointCount() {
        return gpsPointCount;
    }
    public void setGpsPointCount(Integer gpsPointCount) {
        this.gpsPointCount = gpsPointCount;
    }
    public String getGpsIds() {
        return gpsIds;
    }
    public void setGpsIds(String gpsIds) {
        this.gpsIds = gpsIds;
    }
    public Long getTaskId() {
        return taskId;
    }
    public void setTaskId(Long taskId) {
        this.taskId = taskId;
    }
    public String getTaskCode() {
        return taskCode;
    }
    public void setTaskCode(String taskCode) {
        this.taskCode = taskCode;
    }
    public String getCalculateMethod() {
        return calculateMethod;
    }
    public void setCalculateMethod(String calculateMethod) {
        this.calculateMethod = calculateMethod;
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/VehicleInfo.java
@@ -6,11 +6,12 @@
import com.ruoyi.common.core.domain.BaseEntity;
import java.util.List;
import java.io.Serializable;
/**
 * 车辆信息对象 tb_vehicle_info
 */
public class VehicleInfo extends BaseEntity {
public class VehicleInfo extends BaseEntity implements Serializable {
    private static final long serialVersionUID = 1L;
    /** 车辆ID */
@@ -187,4 +188,16 @@
                .append("remark", getRemark())
                .toString();
    }
}
    /**
     * 初始化延迟加载的属性,避免序列化问题
     */
    public void initializeLazyProperties() {
        if (this.deptIds != null) {
            this.deptIds.size(); // 触发加载
        }
        if (this.deptNames != null) {
            this.deptNames.size(); // 触发加载
        }
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/VehicleMileageStats.java
@@ -23,6 +23,13 @@
    @Excel(name = "车牌号")
    private String vehicleNo;
    /** 归属分公司 */
    @Excel(name = "归属分公司")
    private String deptName;
    /** 分公司ID(用于查询,不导出) */
    private Long deptId;
    /** 统计日期 */
    @JsonFormat(pattern = "yyyy-MM-dd")
    @Excel(name = "统计日期", width = 30, dateFormat = "yyyy-MM-dd")
@@ -52,6 +59,13 @@
    @Excel(name = "任务数量")
    private Integer taskCount;
    /** 关联的分段数量 */
    @Excel(name = "分段数量")
    private Integer segmentCount;
    /** 数据来源(segment-从分段汇总,gps-直接计算) */
    private String dataSource;
    public Long getStatsId() {
        return statsId;
    }
@@ -74,6 +88,22 @@
    public void setVehicleNo(String vehicleNo) {
        this.vehicleNo = vehicleNo;
    }
    public String getDeptName() {
        return deptName;
    }
    public void setDeptName(String deptName) {
        this.deptName = deptName;
    }
    public Long getDeptId() {
        return deptId;
    }
    public void setDeptId(Long deptId) {
        this.deptId = deptId;
    }
    public Date getStatDate() {
@@ -131,4 +161,20 @@
    public void setTaskCount(Integer taskCount) {
        this.taskCount = taskCount;
    }
    public Integer getSegmentCount() {
        return segmentCount;
    }
    public void setSegmentCount(Integer segmentCount) {
        this.segmentCount = segmentCount;
    }
    public String getDataSource() {
        return dataSource;
    }
    public void setDataSource(String dataSource) {
        this.dataSource = dataSource;
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleGpsMapper.java
@@ -69,4 +69,16 @@
     * @return 车辆ID列表
     */
    public List<Long> selectActiveVehicleIds();
    /**
     * 查询未被计算的GPS坐标(不在tb_vehicle_gps_calculated表中的记录)
     *
     * @param vehicleId 车辆ID
     * @param startTime 开始时间
     * @param endTime 结束时间
     * @return 未被计算的GPS坐标列表
     */
    public List<VehicleGps> selectUncalculatedGps(@Param("vehicleId") Long vehicleId,
                                                    @Param("startTime") Date startTime,
                                                    @Param("endTime") Date endTime);
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleGpsSegmentMileageMapper.java
New file
@@ -0,0 +1,78 @@
package com.ruoyi.system.mapper;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.system.domain.VehicleGpsSegmentMileage;
/**
 * 车辆GPS分段里程Mapper接口
 */
public interface VehicleGpsSegmentMileageMapper {
    /**
     * 查询车辆GPS分段里程
     */
    public VehicleGpsSegmentMileage selectVehicleGpsSegmentMileageById(Long segmentId);
    /**
     * 查询车辆GPS分段里程列表
     */
    public List<VehicleGpsSegmentMileage> selectVehicleGpsSegmentMileageList(VehicleGpsSegmentMileage vehicleGpsSegmentMileage);
    /**
     * 新增车辆GPS分段里程
     */
    public int insertVehicleGpsSegmentMileage(VehicleGpsSegmentMileage vehicleGpsSegmentMileage);
    /**
     * 修改车辆GPS分段里程
     */
    public int updateVehicleGpsSegmentMileage(VehicleGpsSegmentMileage vehicleGpsSegmentMileage);
    /**
     * 删除车辆GPS分段里程
     */
    public int deleteVehicleGpsSegmentMileageById(Long segmentId);
    /**
     * 批量删除车辆GPS分段里程
     */
    public int deleteVehicleGpsSegmentMileageByIds(Long[] segmentIds);
    /**
     * 查询车辆在指定时间段是否已存在分段里程记录
     */
    public VehicleGpsSegmentMileage selectByVehicleIdAndTime(@Param("vehicleId") Long vehicleId,
                                                              @Param("segmentStartTime") Date segmentStartTime);
    /**
     * 查询车辆在指定日期范围内的分段里程统计
     */
    public List<VehicleGpsSegmentMileage> selectSegmentsByDateRange(@Param("vehicleId") Long vehicleId,
                                                                     @Param("startDate") Date startDate,
                                                                     @Param("endDate") Date endDate);
    /**
     * 按任务ID查询分段里程列表
     */
    public List<VehicleGpsSegmentMileage> selectSegmentsByTaskId(@Param("taskId") Long taskId);
    /**
     * 查询任务的总里程(直接求和)
     */
    public BigDecimal selectTotalMileageByTaskId(@Param("taskId") Long taskId);
    /**
     * 记录GPS点已被计算(插入到tb_vehicle_gps_calculated表)
     */
    public int insertGpsCalculated(@Param("gpsId") Long gpsId,
                                    @Param("segmentId") Long segmentId,
                                    @Param("vehicleId") Long vehicleId);
    /**
     * 检查GPS点是否已被计算
     */
    public Long selectGpsCalculatedSegmentId(@Param("gpsId") Long gpsId);
}
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleInfoMapper.java
@@ -10,6 +10,22 @@
 */
public interface VehicleInfoMapper {
    /**
     * 查询车辆信息(包含多分公司关联)
     *
     * @param vehicleId 车辆信息主键
     * @return 车辆信息(包含deptIds和deptNames)
     */
    public VehicleInfo selectVehicleInfoWithDeptsById(Long vehicleId);
    /**
     * 查询车辆信息列表(包含多分公司关联)
     *
     * @param vehicleInfo 车辆信息
     * @return 车辆信息集合(包含deptIds和deptNames)
     */
    public List<VehicleInfo> selectVehicleInfoListWithDepts(VehicleInfo vehicleInfo);
    /**
     * 查询车辆信息
     * 
     * @param vehicleId 车辆信息主键
ruoyi-system/src/main/java/com/ruoyi/system/service/IVehicleGpsSegmentMileageService.java
New file
@@ -0,0 +1,69 @@
package com.ruoyi.system.service;
import java.util.Date;
import java.util.List;
import com.ruoyi.system.domain.VehicleGpsSegmentMileage;
/**
 * 车辆GPS分段里程Service接口
 */
public interface IVehicleGpsSegmentMileageService {
    /**
     * 查询车辆GPS分段里程
     */
    public VehicleGpsSegmentMileage selectVehicleGpsSegmentMileageById(Long segmentId);
    /**
     * 查询车辆GPS分段里程列表
     */
    public List<VehicleGpsSegmentMileage> selectVehicleGpsSegmentMileageList(VehicleGpsSegmentMileage vehicleGpsSegmentMileage);
    /**
     * 新增车辆GPS分段里程
     */
    public int insertVehicleGpsSegmentMileage(VehicleGpsSegmentMileage vehicleGpsSegmentMileage);
    /**
     * 修改车辆GPS分段里程
     */
    public int updateVehicleGpsSegmentMileage(VehicleGpsSegmentMileage vehicleGpsSegmentMileage);
    /**
     * 批量删除车辆GPS分段里程
     */
    public int deleteVehicleGpsSegmentMileageByIds(Long[] segmentIds);
    /**
     * 删除车辆GPS分段里程信息
     */
    public int deleteVehicleGpsSegmentMileageById(Long segmentId);
    /**
     * 批量计算所有车辆的GPS分段里程
     *
     * @param startTime 开始时间
     * @param endTime 结束时间
     * @return 成功计算的车辆数量
     */
    public int batchCalculateSegmentMileage(Date startTime, Date endTime);
    /**
     * 计算单个车辆的GPS分段里程
     *
     * @param vehicleId 车辆ID
     * @param startTime 开始时间
     * @param endTime 结束时间
     * @return 成功计算的分段数量
     */
    public int calculateVehicleSegmentMileage(Long vehicleId, Date startTime, Date endTime);
    /**
     * 补偿计算:查找并计算未被处理的GPS数据
     * 用于服务重启后的数据修复
     *
     * @param lookbackDays 回溯天数(查询多少天前的数据)
     * @return 成功计算的车辆数量
     */
    public int compensateCalculation(int lookbackDays);
}
ruoyi-system/src/main/java/com/ruoyi/system/service/IVehicleInfoService.java
@@ -32,6 +32,14 @@
    public List<VehicleInfo> selectVehicleInfoList(VehicleInfo vehicleInfo);
    /**
     * 查询车辆信息(包含多分公司关联)
     *
     * @param vehicleId 车辆信息主键
     * @return 车辆信息(包含deptIds和deptNames)
     */
    public VehicleInfo selectVehicleInfoWithDeptsById(Long vehicleId);
    /**
     * 新增车辆信息
     * 
     * @param vehicleInfo 车辆信息
ruoyi-system/src/main/java/com/ruoyi/system/service/IVehicleMileageStatsService.java
@@ -73,4 +73,21 @@
     * @return 成功统计的车辆数量
     */
    public int batchCalculateMileageStats(Date statDate);
    /**
     * 从分段里程数据汇总生成按日统计
     *
     * @param vehicleId 车辆ID
     * @param statDate 统计日期
     * @return 统计结果
     */
    public VehicleMileageStats aggregateFromSegmentMileage(Long vehicleId, Date statDate);
    /**
     * 批量从分段里程汇总生成按日统计
     *
     * @param statDate 统计日期
     * @return 成功统计的车辆数量
     */
    public int batchAggregateFromSegmentMileage(Date statDate);
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/GpsCollectServiceImpl.java
@@ -5,6 +5,7 @@
import com.ruoyi.system.service.IGpsCollectService;
import com.ruoyi.system.config.GpsServiceConfig;
import com.ruoyi.common.utils.MD5Util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.HttpEntity;
@@ -30,6 +31,7 @@
/**
 * GPS采集服务实现
 */
@Slf4j
@Service
public class GpsCollectServiceImpl implements IGpsCollectService {
@@ -430,7 +432,7 @@
                // 解析位置记录列表
                JSONArray recordsArray = jsonResult.getJSONArray("records");
                List<GpsLastPosition> records = new ArrayList<>();
                log.info("recordsArray length:{}",recordsArray.size());
                for (int i = 0; i < recordsArray.size(); i++) {
                    JSONObject recordJson = recordsArray.getJSONObject(i);
                    GpsLastPosition record = new GpsLastPosition();
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VehicleGpsSegmentMileageServiceImpl.java
New file
@@ -0,0 +1,658 @@
package com.ruoyi.system.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.config.TiandituMapConfig;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.system.domain.VehicleGps;
import com.ruoyi.system.domain.VehicleGpsSegmentMileage;
import com.ruoyi.system.domain.SysTask;
import com.ruoyi.system.domain.VehicleInfo;
import com.ruoyi.system.mapper.VehicleGpsMapper;
import com.ruoyi.system.mapper.VehicleGpsSegmentMileageMapper;
import com.ruoyi.system.mapper.SysTaskMapper;
import com.ruoyi.system.mapper.VehicleInfoMapper;
import com.ruoyi.system.service.IVehicleGpsSegmentMileageService;
import com.ruoyi.system.service.IVehicleMileageStatsService;
import com.ruoyi.system.service.ISysConfigService;
/**
 * 车辆GPS分段里程Service业务层处理
 */
@Service
public class VehicleGpsSegmentMileageServiceImpl implements IVehicleGpsSegmentMileageService {
    private static final Logger logger = LoggerFactory.getLogger(VehicleGpsSegmentMileageServiceImpl.class);
    /** 地球半径(公里) */
    private static final double EARTH_RADIUS_KM = 6371.0;
    /** 天地图批量路径规划API */
    private static final String TIANDITU_ROUTE_API = "http://api.tianditu.gov.cn/drive";
    @Autowired
    private VehicleGpsSegmentMileageMapper segmentMileageMapper;
    @Autowired
    private VehicleGpsMapper vehicleGpsMapper;
    @Autowired
    private SysTaskMapper sysTaskMapper;
    @Autowired
    private TiandituMapConfig tiandituMapConfig;
    @Autowired
    private ISysConfigService configService;
    @Autowired
    private RedisCache redisCache;
    @Autowired
    private IVehicleMileageStatsService mileageStatsService;
    @Autowired
    private VehicleInfoMapper vehicleInfoMapper;
    @Override
    public VehicleGpsSegmentMileage selectVehicleGpsSegmentMileageById(Long segmentId) {
        return segmentMileageMapper.selectVehicleGpsSegmentMileageById(segmentId);
    }
    @Override
    public List<VehicleGpsSegmentMileage> selectVehicleGpsSegmentMileageList(VehicleGpsSegmentMileage vehicleGpsSegmentMileage) {
        return segmentMileageMapper.selectVehicleGpsSegmentMileageList(vehicleGpsSegmentMileage);
    }
    @Override
    public int insertVehicleGpsSegmentMileage(VehicleGpsSegmentMileage vehicleGpsSegmentMileage) {
        return segmentMileageMapper.insertVehicleGpsSegmentMileage(vehicleGpsSegmentMileage);
    }
    @Override
    public int updateVehicleGpsSegmentMileage(VehicleGpsSegmentMileage vehicleGpsSegmentMileage) {
        return segmentMileageMapper.updateVehicleGpsSegmentMileage(vehicleGpsSegmentMileage);
    }
    @Override
    public int deleteVehicleGpsSegmentMileageByIds(Long[] segmentIds) {
        return segmentMileageMapper.deleteVehicleGpsSegmentMileageByIds(segmentIds);
    }
    @Override
    public int deleteVehicleGpsSegmentMileageById(Long segmentId) {
        return segmentMileageMapper.deleteVehicleGpsSegmentMileageById(segmentId);
    }
    @Override
    public int batchCalculateSegmentMileage(Date startTime, Date endTime) {
        try {
            // 查询所有活跃车辆
            List<Long> vehicleIds = vehicleGpsMapper.selectActiveVehicleIds();
            if (vehicleIds == null || vehicleIds.isEmpty()) {
                logger.info("没有找到活跃车辆");
                return 0;
            }
            int successCount = 0;
            for (Long vehicleId : vehicleIds) {
                try {
                    int segmentCount = calculateVehicleSegmentMileage(vehicleId, startTime, endTime);
                    if (segmentCount > 0) {
                        successCount++;
                    }
                } catch (Exception e) {
                    logger.error("计算车辆 {} 的分段里程失败", vehicleId, e);
                }
            }
            logger.info("批量分段里程计算完成 - 时间范围: {} 到 {}, 总车辆数: {}, 成功: {}",
                       startTime, endTime, vehicleIds.size(), successCount);
            return successCount;
        } catch (Exception e) {
            logger.error("批量计算分段里程失败", e);
            throw new RuntimeException("批量计算失败: " + e.getMessage());
        }
    }
    @Override
    public int compensateCalculation(int lookbackDays) {
        try {
            // 计算时间范围
            Calendar cal = Calendar.getInstance();
            Date endTime = cal.getTime();
            cal.add(Calendar.DAY_OF_MONTH, -lookbackDays);
            Date startTime = cal.getTime();
            logger.info("开始补偿计算 - 回溯天数: {}, 时间范围: {} 到 {}", lookbackDays, startTime, endTime);
            // 查询所有活跃车辆
            List<Long> vehicleIds = vehicleGpsMapper.selectActiveVehicleIds();
            if (vehicleIds == null || vehicleIds.isEmpty()) {
                logger.info("没有找到活跃车辆");
                return 0;
            }
            int successCount = 0;
            int totalUncalculated = 0;
            for (Long vehicleId : vehicleIds) {
                try {
                    // 查询该车辆未被计算的GPS数据
                    List<VehicleGps> uncalculatedGps = vehicleGpsMapper.selectUncalculatedGps(vehicleId, startTime, endTime);
                    if (uncalculatedGps == null || uncalculatedGps.isEmpty()) {
                        logger.debug("车辆 {} 没有未计算的GPS数据", vehicleId);
                        continue;
                    }
                    totalUncalculated += uncalculatedGps.size();
                    logger.info("车辆 {} 发现 {} 个未计算的GPS点,开始补偿计算...",
                               vehicleId, uncalculatedGps.size());
                    // 重新计算该车辆在该时间范围的分段里程
                    // 注意:这里会重新计算整个时间范围,确保边缘节点被正确处理
                    int segmentCount = calculateVehicleSegmentMileage(vehicleId, startTime, endTime);
                    if (segmentCount > 0) {
                        successCount++;
                        logger.info("车辆 {} 补偿计算完成,生成 {} 个分段记录", vehicleId, segmentCount);
                    }
                } catch (Exception e) {
                    logger.error("车辆 {} 补偿计算失败", vehicleId, e);
                }
            }
            logger.info("补偿计算完成 - 总车辆数: {}, 未计算GPS点数: {}, 成功车辆数: {}",
                       vehicleIds.size(), totalUncalculated, successCount);
            return successCount;
        } catch (Exception e) {
            logger.error("补偿计算失败", e);
            throw new RuntimeException("补偿计算失败: " + e.getMessage());
        }
    }
    @Override
    public int calculateVehicleSegmentMileage(Long vehicleId, Date startTime, Date endTime) {
        try {
            // 获取配置的时间间隔(分钟)
            int segmentMinutes = configService.selectConfigByKey("gps.mileage.segment.minutes") != null
                ? Integer.parseInt(configService.selectConfigByKey("gps.mileage.segment.minutes"))
                : 5;
            // 获取计算方式配置
            String calculateMethod = configService.selectConfigByKey("gps.mileage.calculate.method");
            if (calculateMethod == null || calculateMethod.isEmpty()) {
                calculateMethod = "tianditu";
            }
            // 获取是否跳过已计算GPS点的配置
            String skipCalculatedConfig = configService.selectConfigByKey("gps.mileage.skip.calculated");
            boolean skipCalculated = skipCalculatedConfig == null || "true".equalsIgnoreCase(skipCalculatedConfig);
            // 查询车辆在时间范围内的GPS数据
            List<VehicleGps> gpsList = vehicleGpsMapper.selectGpsDataByTimeRange(vehicleId, startTime, endTime);
            if (gpsList == null || gpsList.isEmpty()) {
                logger.debug("车辆ID: {} 在时间范围 {} 到 {} 内无GPS数据", vehicleId, startTime, endTime);
                return 0;
            }
            logger.info("车辆ID: {} 查询到 {} 条GPS数据", vehicleId, gpsList.size());
            // 按时间段分组GPS数据
            Map<Date, List<VehicleGps>> segmentedData = segmentGpsDataByTime(gpsList, segmentMinutes);
            int savedCount = 0;
            VehicleGps previousSegmentLastPoint = null; // 记录上一个时间段的最后一个点
            // 遍历每个时间段,计算里程
            for (Map.Entry<Date, List<VehicleGps>> entry : segmentedData.entrySet()) {
                Date segmentStartTime = entry.getKey();
                List<VehicleGps> segmentGpsList = entry.getValue();
                if (segmentGpsList.size() < 2) {
                    // 如果本段只有1个点,但有上一段的最后一个点,仍可计算跨段距离
                    if (segmentGpsList.size() == 1 && previousSegmentLastPoint != null) {
                        // 保留当前点作为下一段的前置点,但不创建记录
                        previousSegmentLastPoint = segmentGpsList.get(0);
                    }
                    continue; // 至少需要2个点才能计算距离
                }
                // 检查是否已存在该时间段的记录
                VehicleGpsSegmentMileage existing = segmentMileageMapper.selectByVehicleIdAndTime(vehicleId, segmentStartTime);
                if (existing != null) {
                    logger.debug("车辆 {} 时间段 {} 的分段里程已存在,跳过", vehicleId, segmentStartTime);
                    // 更新上一段最后一个点
                    previousSegmentLastPoint = segmentGpsList.get(segmentGpsList.size() - 1);
                    continue;
                }
                // 计算时间段的结束时间
                Calendar cal = Calendar.getInstance();
                cal.setTime(segmentStartTime);
                cal.add(Calendar.MINUTE, segmentMinutes);
                Date segmentEndTime = cal.getTime();
                // 计算该时间段的里程(包括跨段距离)
                BigDecimal distance = calculateSegmentDistanceWithGap(segmentGpsList, calculateMethod, previousSegmentLastPoint);
                // 收集GPS ID列表(包括上一段的最后一个点,因为跨段间隙距离也用到了它)
                List<Long> gpsIdList = new ArrayList<>();
                // 如果有上一段的最后一个点,先添加它的ID
                if (previousSegmentLastPoint != null && previousSegmentLastPoint.getGpsId() != null) {
                    gpsIdList.add(previousSegmentLastPoint.getGpsId());
                }
                // 再添加当前段的所有GPS点ID
                for (VehicleGps gps : segmentGpsList) {
                    if (gps.getGpsId() != null) {
                        gpsIdList.add(gps.getGpsId());
                    }
                }
                String gpsIds = gpsIdList.stream()
                    .map(String::valueOf)
                    .collect(java.util.stream.Collectors.joining(","));
                // 创建分段里程记录
                VehicleGpsSegmentMileage segment = new VehicleGpsSegmentMileage();
                segment.setVehicleId(vehicleId);
                // 从GPS数据或车辆表获取车牌号
                String vehicleNo = segmentGpsList.get(0).getVehicleNo();
                if (vehicleNo == null || vehicleNo.trim().isEmpty()) {
                    // GPS数据中没有车牌号,从车辆表查询
                    VehicleInfo vehicleInfo = vehicleInfoMapper.selectVehicleInfoById(vehicleId);
                    if (vehicleInfo != null) {
                        vehicleNo = vehicleInfo.getVehicleNo();
                    }
                }
                segment.setVehicleNo(vehicleNo);
                segment.setSegmentStartTime(segmentStartTime);
                segment.setSegmentEndTime(segmentEndTime);
                // 起点坐标
                VehicleGps firstPoint = segmentGpsList.get(0);
                segment.setStartLongitude(BigDecimal.valueOf(firstPoint.getLongitude()));
                segment.setStartLatitude(BigDecimal.valueOf(firstPoint.getLatitude()));
                // 终点坐标
                VehicleGps lastPoint = segmentGpsList.get(segmentGpsList.size() - 1);
                segment.setEndLongitude(BigDecimal.valueOf(lastPoint.getLongitude()));
                segment.setEndLatitude(BigDecimal.valueOf(lastPoint.getLatitude()));
                segment.setSegmentDistance(distance);
                segment.setGpsPointCount(gpsIdList.size()); // GPS点数:包括边缘点 + 当前段的点
                segment.setGpsIds(gpsIds); // 设置GPS ID列表
                segment.setCalculateMethod(calculateMethod);
                // 查询并关联正在执行的任务
                associateActiveTask(segment, vehicleId, segmentStartTime, segmentEndTime);
                // 保存到数据库
                segmentMileageMapper.insertVehicleGpsSegmentMileage(segment);
                // 更新上一段最后一个点,供下一段使用
                previousSegmentLastPoint = segmentGpsList.get(segmentGpsList.size() - 1);
                // 记录已计算的GPS点到状态表(如果开启了重复计算控制)
                if (skipCalculated && segment.getSegmentId() != null) {
                    for (Long gpsId : gpsIdList) {
                        try {
                            segmentMileageMapper.insertGpsCalculated(gpsId, segment.getSegmentId(), vehicleId);
                        } catch (Exception e) {
                            // 忽略重复键异常,继续处理
                            logger.debug("记录GPS计算状态失败,可能已存在: gpsId={}", gpsId);
                        }
                    }
                }
                savedCount++;
                logger.debug("车辆 {} 时间段 {} 到 {} 里程: {}km, GPS点数: {}, GPS IDs: {}",
                           vehicleId, segmentStartTime, segmentEndTime, distance, segmentGpsList.size(),
                           gpsIds.length() > 50 ? gpsIds.substring(0, 50) + "..." : gpsIds);
            }
            logger.info("车辆 {} 计算完成,保存了 {} 个时间段的里程数据", vehicleId, savedCount);
            // 自动触发汇总生成每日统计(如果有数据被保存)
            if (savedCount > 0) {
                try {
                    // 获取涉及的日期范围,触发汇总
                    Set<Date> affectedDates = new HashSet<>();
                    Calendar cal = Calendar.getInstance();
                    for (Map.Entry<Date, List<VehicleGps>> entry : segmentedData.entrySet()) {
                        cal.setTime(entry.getKey());
                        cal.set(Calendar.HOUR_OF_DAY, 0);
                        cal.set(Calendar.MINUTE, 0);
                        cal.set(Calendar.SECOND, 0);
                        cal.set(Calendar.MILLISECOND, 0);
                        affectedDates.add(cal.getTime());
                    }
                    // 对每个涉及的日期,触发汇总
                    for (Date statDate : affectedDates) {
                        try {
                            mileageStatsService.aggregateFromSegmentMileage(vehicleId, statDate);
                            logger.info("车辆 {} 日期 {} 的统计数据已自动汇总生成", vehicleId, statDate);
                        } catch (Exception e) {
                            logger.error("车辆 {} 日期 {} 自动汇总统计失败", vehicleId, statDate, e);
                        }
                    }
                } catch (Exception e) {
                    logger.error("触发自动汇总失败", e);
                }
            }
            return savedCount;
        } catch (Exception e) {
            logger.error("计算车辆 {} 分段里程失败", vehicleId, e);
            throw new RuntimeException("计算分段里程失败: " + e.getMessage());
        }
    }
    /**
     * 将GPS数据按时间段分组
     */
    private Map<Date, List<VehicleGps>> segmentGpsDataByTime(List<VehicleGps> gpsList, int segmentMinutes) {
        Map<Date, List<VehicleGps>> segmentedData = new LinkedHashMap<>();
        for (VehicleGps gps : gpsList) {
            // 解析GPS采集时间
            Date collectTime = parseDateTime(gps.getCollectTime());
            // 计算该GPS点所属的时间段起始时间(向下取整到最近的时间段)
            Date segmentStart = getSegmentStartTime(collectTime, segmentMinutes);
            // 添加到对应时间段
            segmentedData.computeIfAbsent(segmentStart, k -> new ArrayList<>()).add(gps);
        }
        return segmentedData;
    }
    /**
     * 获取时间段的起始时间(向下取整)
     */
    private Date getSegmentStartTime(Date time, int segmentMinutes) {
        Calendar cal = Calendar.getInstance();
        cal.setTime(time);
        // 将分钟数向下取整到最近的分段
        int minute = cal.get(Calendar.MINUTE);
        int segmentIndex = minute / segmentMinutes;
        int alignedMinute = segmentIndex * segmentMinutes;
        cal.set(Calendar.MINUTE, alignedMinute);
        cal.set(Calendar.SECOND, 0);
        cal.set(Calendar.MILLISECOND, 0);
        return cal.getTime();
    }
    /**
     * 计算一个时间段内的总里程(包括与上一段的间隙距离)
     * @param gpsList 当前时间段的GPS点列表
     * @param calculateMethod 计算方式
     * @param previousLastPoint 上一个时间段的最后一个点(可为null)
     */
    private BigDecimal calculateSegmentDistanceWithGap(List<VehicleGps> gpsList, String calculateMethod, VehicleGps previousLastPoint) {
        if (gpsList == null || gpsList.size() < 2) {
            return BigDecimal.ZERO;
        }
        BigDecimal totalDistance = BigDecimal.ZERO;
        // 1. 先计算跨段间隙距离(上一段最后一个点 -> 当前段第一个点)
        if (previousLastPoint != null) {
            VehicleGps currentFirstPoint = gpsList.get(0);
            double gapDistance = calculateHaversineDistance(
                previousLastPoint.getLatitude().doubleValue(),
                previousLastPoint.getLongitude().doubleValue(),
                currentFirstPoint.getLatitude().doubleValue(),
                currentFirstPoint.getLongitude().doubleValue()
            );
            totalDistance = totalDistance.add(BigDecimal.valueOf(gapDistance));
            logger.debug("跨段间隙距离: {}km (上一段末点 -> 当前段首点)",
                String.format("%.3f", gapDistance));
        }
        // 2. 再计算当前段内部的距离
        BigDecimal segmentInternalDistance;
        if ("tianditu".equalsIgnoreCase(calculateMethod)) {
            segmentInternalDistance = calculateDistanceByTianditu(gpsList);
        } else {
            segmentInternalDistance = calculateDistanceByHaversine(gpsList);
        }
        totalDistance = totalDistance.add(segmentInternalDistance);
        return totalDistance.setScale(3, RoundingMode.HALF_UP);
    }
    /**
     * 计算一个时间段内的总里程(仅段内距离)
     */
    private BigDecimal calculateSegmentDistance(List<VehicleGps> gpsList, String calculateMethod) {
        if (gpsList == null || gpsList.size() < 2) {
            return BigDecimal.ZERO;
        }
        BigDecimal totalDistance = BigDecimal.ZERO;
        if ("tianditu".equalsIgnoreCase(calculateMethod)) {
            // 使用天地图API计算(批量计算更精确)
            totalDistance = calculateDistanceByTianditu(gpsList);
        } else {
            // 使用Haversine公式计算(直线距离,更快但不够精确)
            totalDistance = calculateDistanceByHaversine(gpsList);
        }
        return totalDistance.setScale(3, RoundingMode.HALF_UP);
    }
    /**
     * 使用天地图API计算距离
     */
    private BigDecimal calculateDistanceByTianditu(List<VehicleGps> gpsList) {
        try {
            // 天地图路径规划API有点数限制,如果点太多需要分批处理
            int maxPointsPerRequest = 50; // 天地图API建议不超过50个点
            BigDecimal totalDistance = BigDecimal.ZERO;
            // 如果GPS点数较少,直接使用Haversine公式(避免频繁调用API)
            if (gpsList.size() <= 3) {
                return calculateDistanceByHaversine(gpsList);
            }
            // 分批处理
            for (int i = 0; i < gpsList.size() - 1; i += maxPointsPerRequest) {
                int endIndex = Math.min(i + maxPointsPerRequest, gpsList.size());
                List<VehicleGps> batchList = gpsList.subList(i, endIndex);
                BigDecimal batchDistance = calculateBatchDistanceByTianditu(batchList);
                totalDistance = totalDistance.add(batchDistance);
            }
            return totalDistance;
        } catch (Exception e) {
            logger.warn("天地图API计算距离失败,降级使用Haversine公式: {}", e.getMessage());
            return calculateDistanceByHaversine(gpsList);
        }
    }
    /**
     * 使用天地图API计算一批GPS点的距离
     */
    private BigDecimal calculateBatchDistanceByTianditu(List<VehicleGps> gpsList) {
        try {
            // 简化处理:计算相邻点之间的直线距离总和
            // 注:天地图的路径规划API主要用于导航,这里用简化的距离计算
            BigDecimal totalDistance = BigDecimal.ZERO;
            for (int i = 0; i < gpsList.size() - 1; i++) {
                VehicleGps p1 = gpsList.get(i);
                VehicleGps p2 = gpsList.get(i + 1);
                double distance = calculateHaversineDistance(
                    p1.getLatitude().doubleValue(),
                    p1.getLongitude().doubleValue(),
                    p2.getLatitude().doubleValue(),
                    p2.getLongitude().doubleValue()
                );
                totalDistance = totalDistance.add(BigDecimal.valueOf(distance));
            }
            return totalDistance;
        } catch (Exception e) {
            logger.error("天地图批量距离计算失败", e);
            throw e;
        }
    }
    /**
     * 使用Haversine公式计算距离
     */
    private BigDecimal calculateDistanceByHaversine(List<VehicleGps> gpsList) {
        BigDecimal totalDistance = BigDecimal.ZERO;
        for (int i = 0; i < gpsList.size() - 1; i++) {
            VehicleGps p1 = gpsList.get(i);
            VehicleGps p2 = gpsList.get(i + 1);
            double distance = calculateHaversineDistance(
                p1.getLatitude().doubleValue(),
                p1.getLongitude().doubleValue(),
                p2.getLatitude().doubleValue(),
                p2.getLongitude().doubleValue()
            );
            totalDistance = totalDistance.add(BigDecimal.valueOf(distance));
        }
        return totalDistance;
    }
    /**
     * 使用Haversine公式计算两点之间的距离(公里)
     */
    private double calculateHaversineDistance(double lat1, double lon1, double lat2, double lon2) {
        // 如果起点和终点经纬度相同,直接返回0,避免不必要的计算
        if (lat1 == lat2 && lon1 == lon2) {
            return 0.0;
        }
        // 将角度转换为弧度
        double dLat = Math.toRadians(lat2 - lat1);
        double dLon = Math.toRadians(lon2 - lon1);
        double rLat1 = Math.toRadians(lat1);
        double rLat2 = Math.toRadians(lat2);
        // Haversine公式
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
                   Math.cos(rLat1) * Math.cos(rLat2) *
                   Math.sin(dLon / 2) * Math.sin(dLon / 2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        return EARTH_RADIUS_KM * c;
    }
    /**
     * 解析日期时间字符串
     */
    private Date parseDateTime(String dateTimeStr) {
        if (dateTimeStr == null || dateTimeStr.trim().isEmpty()) {
            throw new RuntimeException("日期时间字符串不能为空");
        }
        try {
            java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            sdf.setLenient(false);
            return sdf.parse(dateTimeStr.trim());
        } catch (Exception e) {
            throw new RuntimeException("日期时间格式错误: " + dateTimeStr + ", 应为 yyyy-MM-dd HH:mm:ss", e);
        }
    }
    /**
     * 查询并关联车辆正在执行的任务
     * @param segment 分段里程记录
     * @param vehicleId 车辆ID
     * @param segmentStartTime 时间段开始时间
     * @param segmentEndTime 时间段结束时间
     */
    private void associateActiveTask(VehicleGpsSegmentMileage segment, Long vehicleId,
                                      Date segmentStartTime, Date segmentEndTime) {
        try {
            // 查询该车辆正在执行的任务列表
            List<SysTask> activeTasks = sysTaskMapper.selectActiveTasksByVehicleId(vehicleId);
            if (activeTasks == null || activeTasks.isEmpty()) {
                logger.debug("车辆 {} 在时间段 {} - {} 没有正在执行的任务", vehicleId, segmentStartTime, segmentEndTime);
                return;
            }
            // 遍历任务,查找与当前时间段有重叠的任务
            for (SysTask task : activeTasks) {
                // 获取任务的实际执行时间,如果没有实际时间则使用计划时间
                Date taskStart = task.getActualStartTime() != null ? task.getActualStartTime() : task.getPlannedStartTime();
                Date taskEnd = task.getActualEndTime() != null ? task.getActualEndTime() : task.getPlannedEndTime();
                // 判断时间段是否有重叠
                if (isTimeOverlap(segmentStartTime, segmentEndTime, taskStart, taskEnd)) {
                    // 关联任务ID和任务编号
                    segment.setTaskId(task.getTaskId());
                    segment.setTaskCode(task.getTaskCode());
                    logger.debug("车辆 {} 时间段 {} - {} 关联任务: taskId={}, taskCode={}",
                               vehicleId, segmentStartTime, segmentEndTime, task.getTaskId(), task.getTaskCode());
                    break; // 找到一个匹配的任务即可
                }
            }
        } catch (Exception e) {
            // 关联任务失败不影响主流程,只记录日志
            logger.warn("关联车辆 {} 的任务信息失败", vehicleId, e);
        }
    }
    /**
     * 判断两个时间段是否有重叠
     * @param start1 时间段1开始
     * @param end1 时间段1结束
     * @param start2 时间段2开始
     * @param end2 时间段2结束
     * @return true-有重叠, false-无重叠
     */
    private boolean isTimeOverlap(Date start1, Date end1, Date start2, Date end2) {
        // 任何时间为null,返回false
        if (start1 == null || end1 == null || start2 == null || end2 == null) {
            return false;
        }
        // 两个时间段有重叠的条件:
        // start1 < end2 && end1 > start2
        return start1.before(end2) && end1.after(start2);
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VehicleInfoServiceImpl.java
@@ -44,7 +44,23 @@
     */
    @Override
    public VehicleInfo selectVehicleInfoById(Long vehicleId) {
        return vehicleInfoMapper.selectVehicleInfoById(vehicleId);
        return vehicleInfoMapper.selectVehicleInfoWithDeptsById(vehicleId);
    }
    /**
     * 查询车辆信息(包含多分公司关联)
     *
     * @param vehicleId 车辆信息主键
     * @return 车辆信息(包含deptIds和deptNames)
     */
    @Override
    public VehicleInfo selectVehicleInfoWithDeptsById(Long vehicleId) {
        VehicleInfo vehicle = vehicleInfoMapper.selectVehicleInfoWithDeptsById(vehicleId);
        // 初始化延迟加载的属性,避免序列化问题
        if (vehicle != null) {
            vehicle.initializeLazyProperties();
        }
        return vehicle;
    }
    /**
@@ -66,7 +82,12 @@
     */
    @Override
    public List<VehicleInfo> selectVehicleInfoList(VehicleInfo vehicleInfo) {
        return vehicleInfoMapper.selectVehicleInfoList(vehicleInfo);
        List<VehicleInfo> list = vehicleInfoMapper.selectVehicleInfoListWithDepts(vehicleInfo);
        // 初始化延迟加载的属性,避免序列化问题
        for (VehicleInfo vehicle : list) {
            vehicle.initializeLazyProperties();
        }
        return list;
    }
    /**
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VehicleMileageStatsServiceImpl.java
@@ -2,6 +2,8 @@
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
@@ -11,9 +13,13 @@
import org.springframework.stereotype.Service;
import com.ruoyi.system.domain.VehicleGps;
import com.ruoyi.system.domain.VehicleMileageStats;
import com.ruoyi.system.domain.VehicleGpsSegmentMileage;
import com.ruoyi.system.domain.TaskTimeInterval;
import com.ruoyi.system.domain.VehicleInfo;
import com.ruoyi.system.mapper.VehicleGpsMapper;
import com.ruoyi.system.mapper.VehicleMileageStatsMapper;
import com.ruoyi.system.mapper.VehicleGpsSegmentMileageMapper;
import com.ruoyi.system.mapper.VehicleInfoMapper;
import com.ruoyi.system.service.IVehicleMileageStatsService;
/**
@@ -32,6 +38,12 @@
    
    @Autowired
    private VehicleGpsMapper vehicleGpsMapper;
    @Autowired
    private VehicleGpsSegmentMileageMapper segmentMileageMapper;
    @Autowired
    private VehicleInfoMapper vehicleInfoMapper;
    /**
     * 查询车辆里程统计
@@ -122,10 +134,18 @@
                stats.setVehicleId(vehicleId);
                stats.setStatDate(statDate);
                
                // 获取车牌号
                // 获取车牌号:优先从GPS数据,如果没有则从车辆表查询
                String vehicleNo = null;
                if (!gpsList.isEmpty() && gpsList.get(0).getVehicleNo() != null) {
                    stats.setVehicleNo(gpsList.get(0).getVehicleNo());
                    vehicleNo = gpsList.get(0).getVehicleNo();
                }
                if (vehicleNo == null || vehicleNo.trim().isEmpty()) {
                    VehicleInfo vehicleInfo = vehicleInfoMapper.selectVehicleInfoById(vehicleId);
                    if (vehicleInfo != null) {
                        vehicleNo = vehicleInfo.getVehicleNo();
                    }
                }
                stats.setVehicleNo(vehicleNo);
            }
            
            // 6. 设置统计数据
@@ -208,8 +228,8 @@
            );
            
            // 获取这段距离的时间区间
            Date segmentStart = p1.getCollectTime();
            Date segmentEnd = p2.getCollectTime();
            Date segmentStart = parseDateTime(p1.getCollectTime());
            Date segmentEnd = parseDateTime(p2.getCollectTime());
            
            // 计算这段距离在任务时段的占比
            double taskRatio = calculateTaskOverlapRatio(segmentStart, segmentEnd, taskIntervals);
@@ -240,6 +260,11 @@
     * 使用Haversine公式计算两个GPS坐标之间的距离(公里)
     */
    private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
        // 如果起点和终点经纬度相同,直接返回0,避免不必要的计算
        if (lat1 == lat2 && lon1 == lon2) {
            return 0.0;
        }
        // 将角度转换为弧度
        double dLat = Math.toRadians(lat2 - lat1);
        double dLon = Math.toRadians(lon2 - lon1);
@@ -293,4 +318,176 @@
        BigDecimal nonTaskMileage = BigDecimal.ZERO;
        BigDecimal taskRatio = BigDecimal.ZERO;
    }
    /**
     * 解析日期时间字符串
     *
     * @param dateTimeStr 日期时间字符串,格式:yyyy-MM-dd HH:mm:ss
     * @return Date对象
     * @throws RuntimeException 如果解析失败
     */
    private Date parseDateTime(String dateTimeStr) {
        if (dateTimeStr == null || dateTimeStr.trim().isEmpty()) {
            throw new RuntimeException("日期时间字符串不能为空");
        }
        try {
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            sdf.setLenient(false);
            return sdf.parse(dateTimeStr.trim());
        } catch (ParseException e) {
            throw new RuntimeException("日期时间格式错误: " + dateTimeStr + ", 应为 yyyy-MM-dd HH:mm:ss", e);
        }
    }
    /**
     * 从分段里程数据汇总生成按日统计
     */
    @Override
    public VehicleMileageStats aggregateFromSegmentMileage(Long vehicleId, Date statDate) {
        try {
            // 1. 获取统计日期的开始和结束时间
            Calendar calendar = Calendar.getInstance();
            calendar.setTime(statDate);
            calendar.set(Calendar.HOUR_OF_DAY, 0);
            calendar.set(Calendar.MINUTE, 0);
            calendar.set(Calendar.SECOND, 0);
            calendar.set(Calendar.MILLISECOND, 0);
            Date dayStart = calendar.getTime();
            calendar.add(Calendar.DAY_OF_MONTH, 1);
            Date dayEnd = calendar.getTime();
            // 2. 查询该日期范围内的所有分段里程数据
            List<VehicleGpsSegmentMileage> segments = segmentMileageMapper.selectSegmentsByDateRange(vehicleId, dayStart, dayEnd);
            if (segments == null || segments.isEmpty()) {
                logger.info("车辆ID: {} 在日期: {} 无分段里程数据", vehicleId, statDate);
                return null;
            }
            // 3. 汇总里程数据
            BigDecimal totalMileage = BigDecimal.ZERO;
            int totalGpsPoints = 0;
            for (VehicleGpsSegmentMileage segment : segments) {
                if (segment.getSegmentDistance() != null) {
                    totalMileage = totalMileage.add(segment.getSegmentDistance());
                }
                if (segment.getGpsPointCount() != null) {
                    totalGpsPoints += segment.getGpsPointCount();
                }
            }
            // 4. 查询该日期的任务时间区间,计算任务里程和非任务里程
            List<TaskTimeInterval> taskIntervals = vehicleMileageStatsMapper.selectTaskTimeIntervals(vehicleId, dayStart, dayEnd);
            BigDecimal taskMileage = BigDecimal.ZERO;
            BigDecimal nonTaskMileage = BigDecimal.ZERO;
            for (VehicleGpsSegmentMileage segment : segments) {
                Date segStart = segment.getSegmentStartTime();
                Date segEnd = segment.getSegmentEndTime();
                BigDecimal segDistance = segment.getSegmentDistance() != null ? segment.getSegmentDistance() : BigDecimal.ZERO;
                // 计算该分段与任务时段的重叠比例
                double taskRatio = calculateTaskOverlapRatio(segStart, segEnd, taskIntervals);
                // 分摇里程
                BigDecimal taskDist = segDistance.multiply(BigDecimal.valueOf(taskRatio));
                BigDecimal nonTaskDist = segDistance.multiply(BigDecimal.valueOf(1 - taskRatio));
                taskMileage = taskMileage.add(taskDist);
                nonTaskMileage = nonTaskMileage.add(nonTaskDist);
            }
            // 计算任务里程占比
            BigDecimal taskRatio = BigDecimal.ZERO;
            if (totalMileage.compareTo(BigDecimal.ZERO) > 0) {
                taskRatio = taskMileage.divide(totalMileage, 4, RoundingMode.HALF_UP);
            }
            // 5. 查询或创建统计记录
            VehicleMileageStats stats = vehicleMileageStatsMapper.selectByVehicleIdAndDate(vehicleId, statDate);
            boolean isNew = (stats == null);
            if (isNew) {
                stats = new VehicleMileageStats();
                stats.setVehicleId(vehicleId);
                stats.setStatDate(statDate);
                // 获取车牌号:优先从分段数据,如果没有则从车辆表查询
                String vehicleNo = null;
                if (!segments.isEmpty() && segments.get(0).getVehicleNo() != null) {
                    vehicleNo = segments.get(0).getVehicleNo();
                }
                if (vehicleNo == null || vehicleNo.trim().isEmpty()) {
                    VehicleInfo vehicleInfo = vehicleInfoMapper.selectVehicleInfoById(vehicleId);
                    if (vehicleInfo != null) {
                        vehicleNo = vehicleInfo.getVehicleNo();
                    }
                }
                stats.setVehicleNo(vehicleNo);
            }
            // 6. 设置统计数据
            stats.setTotalMileage(totalMileage.setScale(2, RoundingMode.HALF_UP));
            stats.setTaskMileage(taskMileage.setScale(2, RoundingMode.HALF_UP));
            stats.setNonTaskMileage(nonTaskMileage.setScale(2, RoundingMode.HALF_UP));
            stats.setTaskRatio(taskRatio);
            stats.setGpsPointCount(totalGpsPoints);
            stats.setTaskCount(taskIntervals == null ? 0 : taskIntervals.size());
            stats.setSegmentCount(segments.size());
            stats.setDataSource("segment"); // 标记数据来源为分段汇总
            // 7. 保存到数据库
            if (isNew) {
                vehicleMileageStatsMapper.insertVehicleMileageStats(stats);
            } else {
                vehicleMileageStatsMapper.updateVehicleMileageStats(stats);
            }
            logger.info("车辆ID: {} 日期: {} 从分段汇总完成 - 总里程: {}km, 任务里程: {}km, 非任务里程: {}km, 分段数: {}",
                       vehicleId, statDate, totalMileage, taskMileage, nonTaskMileage, segments.size());
            return stats;
        } catch (Exception e) {
            logger.error("从分段汇总里程统计失败 - 车辆ID: {}, 日期: {}", vehicleId, statDate, e);
            throw new RuntimeException("汇总里程统计失败: " + e.getMessage());
        }
    }
    /**
     * 批量从分段里程汇总生成按日统计
     */
    @Override
    public int batchAggregateFromSegmentMileage(Date statDate) {
        try {
            // 查询所有活跃车辆
            List<Long> vehicleIds = vehicleGpsMapper.selectActiveVehicleIds();
            if (vehicleIds == null || vehicleIds.isEmpty()) {
                logger.info("没有找到活跃车辆");
                return 0;
            }
            int successCount = 0;
            for (Long vehicleId : vehicleIds) {
                try {
                    aggregateFromSegmentMileage(vehicleId, statDate);
                    successCount++;
                } catch (Exception e) {
                    logger.error("汇总车辆 {} 的里程统计失败", vehicleId, e);
                }
            }
            logger.info("批量里程汇总完成 - 日期: {}, 总车辆数: {}, 成功: {}", statDate, vehicleIds.size(), successCount);
            return successCount;
        } catch (Exception e) {
            logger.error("批量汇总里程统计失败 - 日期: {}", statDate, e);
            throw new RuntimeException("批量汇总失败: " + e.getMessage());
        }
    }
}
ruoyi-system/src/main/resources/mapper/system/VehicleGpsMapper.xml
@@ -136,4 +136,19 @@
        where collect_time &gt;= DATE_SUB(NOW(), INTERVAL 7 DAY)
        order by vehicle_id
    </select>
    <!-- 查询未被计算的GPS坐标(不在tb_vehicle_gps_calculated表中的记录) -->
    <select id="selectUncalculatedGps" resultMap="VehicleGpsResult">
        SELECT g.gps_id, g.vehicle_id, g.device_id, g.longitude, g.latitude, g.altitude,
               g.speed, g.direction, g.collect_time, g.device_report_time,
               g.platform_process_time, g.create_time, v.vehicle_no
        FROM tb_vehicle_gps g
        LEFT JOIN tb_vehicle_info v ON g.vehicle_id = v.vehicle_id
        LEFT JOIN tb_vehicle_gps_calculated c ON g.gps_id = c.gps_id
        WHERE g.vehicle_id = #{vehicleId}
          AND g.collect_time &gt;= #{startTime}
          AND g.collect_time &lt;= #{endTime}
          AND c.gps_id IS NULL  -- 未被计算的GPS点
        ORDER BY g.collect_time
    </select>
</mapper> 
ruoyi-system/src/main/resources/mapper/system/VehicleGpsSegmentMileageMapper.xml
New file
@@ -0,0 +1,157 @@
<?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.ruoyi.system.mapper.VehicleGpsSegmentMileageMapper">
    <resultMap type="VehicleGpsSegmentMileage" id="VehicleGpsSegmentMileageResult">
        <result property="segmentId"         column="segment_id"        />
        <result property="vehicleId"         column="vehicle_id"        />
        <result property="vehicleNo"         column="vehicle_no"        />
        <result property="segmentStartTime"  column="segment_start_time" />
        <result property="segmentEndTime"    column="segment_end_time"  />
        <result property="startLongitude"    column="start_longitude"   />
        <result property="startLatitude"     column="start_latitude"    />
        <result property="endLongitude"      column="end_longitude"     />
        <result property="endLatitude"       column="end_latitude"      />
        <result property="segmentDistance"   column="segment_distance"  />
        <result property="gpsPointCount"     column="gps_point_count"   />
        <result property="gpsIds"            column="gps_ids"           />
        <result property="taskId"            column="task_id"           />
        <result property="taskCode"          column="task_code"         />
        <result property="calculateMethod"   column="calculate_method"  />
        <result property="createTime"        column="create_time"       />
        <result property="updateTime"        column="update_time"       />
    </resultMap>
    <sql id="selectVehicleGpsSegmentMileageVo">
        SELECT segment_id, vehicle_id, vehicle_no, segment_start_time, segment_end_time,
               start_longitude, start_latitude, end_longitude, end_latitude,
               segment_distance, gps_point_count, gps_ids, task_id, task_code,
               calculate_method, create_time, update_time
        FROM tb_vehicle_gps_segment_mileage
    </sql>
    <select id="selectVehicleGpsSegmentMileageList" parameterType="VehicleGpsSegmentMileage" resultMap="VehicleGpsSegmentMileageResult">
        <include refid="selectVehicleGpsSegmentMileageVo"/>
        <where>
            <if test="vehicleId != null">
                AND vehicle_id = #{vehicleId}
            </if>
            <if test="vehicleNo != null and vehicleNo != ''">
                AND vehicle_no = #{vehicleNo}
            </if>
            <if test="params.beginTime != null and params.beginTime != ''">
                AND segment_start_time &gt;= #{params.beginTime}
            </if>
            <if test="params.endTime != null and params.endTime != ''">
                AND segment_end_time &lt;= #{params.endTime}
            </if>
        </where>
        ORDER BY segment_start_time DESC
    </select>
    <select id="selectVehicleGpsSegmentMileageById" parameterType="Long" resultMap="VehicleGpsSegmentMileageResult">
        <include refid="selectVehicleGpsSegmentMileageVo"/>
        WHERE segment_id = #{segmentId}
    </select>
    <select id="selectByVehicleIdAndTime" resultMap="VehicleGpsSegmentMileageResult">
        <include refid="selectVehicleGpsSegmentMileageVo"/>
        WHERE vehicle_id = #{vehicleId} AND segment_start_time = #{segmentStartTime}
    </select>
    <select id="selectSegmentsByDateRange" resultMap="VehicleGpsSegmentMileageResult">
        <include refid="selectVehicleGpsSegmentMileageVo"/>
        WHERE vehicle_id = #{vehicleId}
          AND segment_start_time &gt;= #{startDate}
          AND segment_end_time &lt;= #{endDate}
        ORDER BY segment_start_time
    </select>
    <!-- 按任务ID查询分段里程列表 -->
    <select id="selectSegmentsByTaskId" resultMap="VehicleGpsSegmentMileageResult">
        <include refid="selectVehicleGpsSegmentMileageVo"/>
        WHERE task_id = #{taskId}
        ORDER BY segment_start_time
    </select>
    <!-- 查询任务的总里程(直接求和) -->
    <select id="selectTotalMileageByTaskId" resultType="java.math.BigDecimal">
        SELECT COALESCE(SUM(segment_distance), 0)
        FROM tb_vehicle_gps_segment_mileage
        WHERE task_id = #{taskId}
    </select>
    <insert id="insertVehicleGpsSegmentMileage" parameterType="VehicleGpsSegmentMileage">
        INSERT INTO tb_vehicle_gps_segment_mileage
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <if test="vehicleId != null">vehicle_id,</if>
            <if test="vehicleNo != null">vehicle_no,</if>
            <if test="segmentStartTime != null">segment_start_time,</if>
            <if test="segmentEndTime != null">segment_end_time,</if>
            <if test="startLongitude != null">start_longitude,</if>
            <if test="startLatitude != null">start_latitude,</if>
            <if test="endLongitude != null">end_longitude,</if>
            <if test="endLatitude != null">end_latitude,</if>
            <if test="segmentDistance != null">segment_distance,</if>
            <if test="gpsPointCount != null">gps_point_count,</if>
            <if test="gpsIds != null">gps_ids,</if>
            <if test="taskId != null">task_id,</if>
            <if test="taskCode != null">task_code,</if>
            <if test="calculateMethod != null">calculate_method,</if>
        </trim>
        <trim prefix="values (" suffix=")" suffixOverrides=",">
            <if test="vehicleId != null">#{vehicleId},</if>
            <if test="vehicleNo != null">#{vehicleNo},</if>
            <if test="segmentStartTime != null">#{segmentStartTime},</if>
            <if test="segmentEndTime != null">#{segmentEndTime},</if>
            <if test="startLongitude != null">#{startLongitude},</if>
            <if test="startLatitude != null">#{startLatitude},</if>
            <if test="endLongitude != null">#{endLongitude},</if>
            <if test="endLatitude != null">#{endLatitude},</if>
            <if test="segmentDistance != null">#{segmentDistance},</if>
            <if test="gpsPointCount != null">#{gpsPointCount},</if>
            <if test="gpsIds != null">#{gpsIds},</if>
            <if test="taskId != null">#{taskId},</if>
            <if test="taskCode != null">#{taskCode},</if>
            <if test="calculateMethod != null">#{calculateMethod},</if>
        </trim>
    </insert>
    <update id="updateVehicleGpsSegmentMileage" parameterType="VehicleGpsSegmentMileage">
        UPDATE tb_vehicle_gps_segment_mileage
        <trim prefix="SET" suffixOverrides=",">
            <if test="vehicleNo != null">vehicle_no = #{vehicleNo},</if>
            <if test="segmentEndTime != null">segment_end_time = #{segmentEndTime},</if>
            <if test="endLongitude != null">end_longitude = #{endLongitude},</if>
            <if test="endLatitude != null">end_latitude = #{endLatitude},</if>
            <if test="segmentDistance != null">segment_distance = #{segmentDistance},</if>
            <if test="gpsPointCount != null">gps_point_count = #{gpsPointCount},</if>
            <if test="calculateMethod != null">calculate_method = #{calculateMethod},</if>
        </trim>
        WHERE segment_id = #{segmentId}
    </update>
    <delete id="deleteVehicleGpsSegmentMileageById" parameterType="Long">
        DELETE FROM tb_vehicle_gps_segment_mileage WHERE segment_id = #{segmentId}
    </delete>
    <delete id="deleteVehicleGpsSegmentMileageByIds" parameterType="String">
        DELETE FROM tb_vehicle_gps_segment_mileage WHERE segment_id IN
        <foreach item="segmentId" collection="array" open="(" separator="," close=")">
            #{segmentId}
        </foreach>
    </delete>
    <!-- 记录GPS点已被计算 -->
    <insert id="insertGpsCalculated">
        INSERT INTO tb_vehicle_gps_calculated (gps_id, segment_id, vehicle_id, create_time)
        VALUES (#{gpsId}, #{segmentId}, #{vehicleId}, NOW())
    </insert>
    <!-- 检查GPS点是否已被计算 -->
    <select id="selectGpsCalculatedSegmentId" resultType="Long">
        SELECT segment_id FROM tb_vehicle_gps_calculated WHERE gps_id = #{gpsId} LIMIT 1
    </select>
</mapper>
ruoyi-system/src/main/resources/mapper/system/VehicleInfoMapper.xml
@@ -14,26 +14,30 @@
        <result property="vehicleModel"   column="vehicle_model"   />
        <result property="status"         column="status"          />
        <result property="platformCode"   column="platform_code"   />
        <result property="deptId"         column="dept_id"         />
        <result property="deptName"       column="dept_name"       />
        <result property="createBy"       column="create_by"       />
        <result property="createTime"     column="create_time"     />
        <result property="updateBy"       column="update_by"       />
        <result property="updateTime"     column="update_time"     />
        <result property="remark"         column="remark"          />
        <!-- 多个分公司关联 -->
    </resultMap>
    <!-- 包含多分公司关联的完整结果映射(仅在需要时使用) -->
    <resultMap type="com.ruoyi.system.domain.VehicleInfo" id="VehicleInfoWithDeptsResult" extends="VehicleInfoResult">
        <!-- 多个分公司关联(立即加载,避免延迟加载导致的序列化问题) -->
        <collection property="deptIds" ofType="Long" 
                    select="selectVehicleDeptIds" 
                    column="vehicle_id"/>
                    column="vehicle_id"
                    fetchType="eager"/>
        <collection property="deptNames" ofType="String" 
                    select="selectVehicleDeptNames" 
                    column="vehicle_id"/>
                    column="vehicle_id"
                    fetchType="eager"/>
    </resultMap>
    <sql id="selectVehicleInfoVo">
        select v.vehicle_id, v.car_id, v.device_id, v.vehicle_no, v.vehicle_type, v.vehicle_brand, v.vehicle_model, v.status, v.platform_code, v.dept_id, d.dept_name, v.create_by, v.create_time, v.update_by, v.update_time, v.remark
        select v.vehicle_id, v.car_id, v.device_id, v.vehicle_no, v.vehicle_type, v.vehicle_brand, v.vehicle_model, v.status, v.platform_code, v.create_by, v.create_time, v.update_by, v.update_time, v.remark
        from tb_vehicle_info v
        left join sys_dept d on v.dept_id = d.dept_id
    </sql>
    
    <!-- 查询车辆关联的所有分公司ID -->
@@ -49,10 +53,13 @@
        WHERE vd.vehicle_id = #{vehicle_id}
    </select>
    <select id="selectVehicleInfoList" parameterType="VehicleInfo" resultMap="VehicleInfoResult">
        <include refid="selectVehicleInfoVo"/>
    <select id="selectVehicleInfoListWithDepts" parameterType="VehicleInfo" resultMap="VehicleInfoWithDeptsResult">
        select v.vehicle_id, v.car_id, v.device_id, v.vehicle_no, v.vehicle_type, v.vehicle_brand,
               v.vehicle_model, v.status, v.platform_code, v.create_by, v.create_time,
               v.update_by, v.update_time, v.remark
        from tb_vehicle_info v
        <where>  
            <if test="vehicleNo != null  and vehicleNo != ''"> and v.vehicle_no = #{vehicleNo}</if>
            <if test="vehicleNo != null  and vehicleNo != ''"> and v.vehicle_no LIKE concat('%', #{vehicleNo}, '%')</if>
            <if test="deviceId != null  and deviceId != ''"> and v.device_id = #{deviceId}</if>
            <if test="vehicleType != null  and vehicleType != ''"> and v.vehicle_type = #{vehicleType}</if>
            <if test="vehicleBrand != null  and vehicleBrand != ''"> and v.vehicle_brand = #{vehicleBrand}</if>
@@ -62,32 +69,73 @@
            <!-- 部门过滤:根据分公司ID查询车辆(通过关联表) -->
            <if test="deptId != null">
                and EXISTS (
                    SELECT 1 FROM tb_vehicle_dept vd
                    WHERE vd.vehicle_id = v.vehicle_id
                    AND vd.dept_id = #{deptId}
                    SELECT 1 FROM tb_vehicle_dept vd2
                    WHERE vd2.vehicle_id = v.vehicle_id
                    AND vd2.dept_id = #{deptId}
                )
            </if>
            <!-- 任务车辆选择必须过滤:只显示car_id和dept_id都不为空的车辆 -->
            and v.car_id is not null and v.car_id != ''
            and v.dept_id is not null
            and v.status=0
        </where>
        group by v.vehicle_id, v.car_id, v.device_id, v.vehicle_no, v.vehicle_type, v.vehicle_brand,
                 v.vehicle_model, v.status, v.platform_code, v.create_by, v.create_time,
                 v.update_by, v.update_time, v.remark
        order by v.create_time desc
    </select>
    <select id="selectVehicleInfoList" parameterType="VehicleInfo" resultMap="VehicleInfoResult">
        select v.vehicle_id, v.car_id, v.device_id, v.vehicle_no, v.vehicle_type, v.vehicle_brand,
               v.vehicle_model, v.status, v.platform_code, v.create_by, v.create_time,
               v.update_by, v.update_time, v.remark,
               GROUP_CONCAT(DISTINCT d.dept_name ORDER BY d.dept_name SEPARATOR ',') as dept_name
        from tb_vehicle_info v
        left join tb_vehicle_dept vd on v.vehicle_id = vd.vehicle_id
        left join sys_dept d on vd.dept_id = d.dept_id
        <where>
            <if test="vehicleNo != null  and vehicleNo != ''"> and v.vehicle_no LIKE concat('%', #{vehicleNo}, '%')</if>
            <if test="deviceId != null  and deviceId != ''"> and v.device_id = #{deviceId}</if>
            <if test="vehicleType != null  and vehicleType != ''"> and v.vehicle_type = #{vehicleType}</if>
            <if test="vehicleBrand != null  and vehicleBrand != ''"> and v.vehicle_brand = #{vehicleBrand}</if>
            <if test="vehicleModel != null  and vehicleModel != ''"> and v.vehicle_model = #{vehicleModel}</if>
            <if test="status != null  and status != ''"> and v.status = #{status}</if>
            <if test="platformCode != null  and platformCode != ''"> and v.platform_code = #{platformCode}</if>
            <!-- 部门过滤:根据分公司ID查询车辆(通过关联表) -->
            <if test="deptId != null">
                and EXISTS (
                    SELECT 1 FROM tb_vehicle_dept vd2
                    WHERE vd2.vehicle_id = v.vehicle_id
                    AND vd2.dept_id = #{deptId}
                )
            </if>
            <!-- 任务车辆选择必须过滤:只显示car_id不为空且已关联分公司的车辆 -->
<!--            and v.car_id is not null and v.car_id != ''-->
<!--            and EXISTS (SELECT 1 FROM tb_vehicle_dept vd WHERE vd.vehicle_id = v.vehicle_id)-->
            and v.status=0
        </where>
        group by v.vehicle_id, v.car_id, v.device_id, v.vehicle_no, v.vehicle_type, v.vehicle_brand,
                 v.vehicle_model, v.status, v.platform_code, v.create_by, v.create_time,
                 v.update_by, v.update_time, v.remark
    </select>
    
    <select id="selectVehicleInfoById" parameterType="Long" resultMap="VehicleInfoResult">
    <select id="selectVehicleInfoById" parameterType="Long" resultMap="VehicleInfoWithDeptsResult">
        <include refid="selectVehicleInfoVo"/>
        where v.vehicle_id = #{vehicleId}
    </select>
    <!-- 查询车辆信息(包含多分公司关联) -->
    <select id="selectVehicleInfoWithDeptsById" parameterType="Long" resultMap="VehicleInfoWithDeptsResult">
        <include refid="selectVehicleInfoVo"/>
        where v.vehicle_id = #{vehicleId}
    </select>
    <select id="selectVehicleInfoByPlateNumber" parameterType="String" resultMap="VehicleInfoResult">
        <include refid="selectVehicleInfoVo"/>
        where v.vehicle_no = #{plateNumber}
        where v.vehicle_no LIKE concat('%', #{plateNumber}, '%')
    </select>
    <select id="selectVehicleInfoByVehicleNo" parameterType="String" resultMap="VehicleInfoResult">
        <include refid="selectVehicleInfoVo"/>
        where v.vehicle_no = #{vehicleNo}
        where v.vehicle_no LIKE concat('%', #{vehicleNo}, '%')
    </select>
        
    <insert id="insertVehicleInfo" parameterType="VehicleInfo" useGeneratedKeys="true" keyProperty="vehicleId">
@@ -101,7 +149,6 @@
            <if test="vehicleModel != null">vehicle_model,</if>
            <if test="status != null">status,</if>
            <if test="platformCode != null">platform_code,</if>
            <if test="deptId != null">dept_id,</if>
            <if test="createBy != null">create_by,</if>
            <if test="createTime != null">create_time,</if>
            <if test="updateBy != null">update_by,</if>
@@ -117,7 +164,6 @@
            <if test="vehicleModel != null">#{vehicleModel},</if>
            <if test="status != null">#{status},</if>
            <if test="platformCode != null">#{platformCode},</if>
            <if test="deptId != null">#{deptId},</if>
            <if test="createBy != null">#{createBy},</if>
            <if test="createTime != null">#{createTime},</if>
            <if test="updateBy != null">#{updateBy},</if>
@@ -137,7 +183,6 @@
            <if test="vehicleModel != null">vehicle_model = #{vehicleModel},</if>
            <if test="status != null">status = #{status},</if>
            <if test="platformCode != null">platform_code = #{platformCode},</if>
            <if test="deptId != null">dept_id = #{deptId},</if>
            <if test="updateBy != null">update_by = #{updateBy},</if>
            <if test="updateTime != null">update_time = #{updateTime},</if>
            <if test="remark != null">remark = #{remark},</if>
ruoyi-system/src/main/resources/mapper/system/VehicleMileageStatsMapper.xml
@@ -8,6 +8,8 @@
        <id     property="statsId"         column="stats_id"           />
        <result property="vehicleId"       column="vehicle_id"         />
        <result property="vehicleNo"       column="vehicle_no"         />
        <result property="deptName"        column="dept_name"          />
        <result property="deptId"          column="dept_id"            />
        <result property="statDate"        column="stat_date"          />
        <result property="totalMileage"    column="total_mileage"      />
        <result property="taskMileage"     column="task_mileage"       />
@@ -15,6 +17,8 @@
        <result property="taskRatio"       column="task_ratio"         />
        <result property="gpsPointCount"   column="gps_point_count"    />
        <result property="taskCount"       column="task_count"         />
        <result property="segmentCount"    column="segment_count"      />
        <result property="dataSource"      column="data_source"        />
        <result property="createTime"      column="create_time"        />
        <result property="updateTime"      column="update_time"        />
    </resultMap>
@@ -26,41 +30,59 @@
    </resultMap>
    <sql id="selectVehicleMileageStatsVo">
        select stats_id, vehicle_id, vehicle_no, stat_date, total_mileage, task_mileage,
               non_task_mileage, task_ratio, gps_point_count, task_count, create_time, update_time
        from tb_vehicle_mileage_stats
        select s.stats_id, s.vehicle_id, s.vehicle_no, s.stat_date, s.total_mileage, s.task_mileage,
               s.non_task_mileage, s.task_ratio, s.gps_point_count, s.task_count, s.segment_count,
               s.data_source, s.create_time, s.update_time,
               GROUP_CONCAT(DISTINCT d.dept_name ORDER BY d.dept_name SEPARATOR ',') as dept_name,
               vd.dept_id
        from tb_vehicle_mileage_stats s
        left join tb_vehicle_info v on s.vehicle_id = v.vehicle_id
        left join tb_vehicle_dept vd on v.vehicle_id = vd.vehicle_id
        left join sys_dept d on vd.dept_id = d.dept_id
    </sql>
    <select id="selectVehicleMileageStatsList" parameterType="VehicleMileageStats" resultMap="VehicleMileageStatsResult">
        <include refid="selectVehicleMileageStatsVo"/>
        <where>  
            <if test="vehicleId != null">
                and vehicle_id = #{vehicleId}
                and s.vehicle_id = #{vehicleId}
            </if>
            <if test="vehicleNo != null and vehicleNo != ''">
                and vehicle_no = #{vehicleNo}
                and s.vehicle_no like concat('%', #{vehicleNo}, '%')
            </if>
            <if test="deptId != null">
                and vd.dept_id = #{deptId}
            </if>
            <if test="statDate != null">
                and stat_date = #{statDate}
                and s.stat_date = #{statDate}
            </if>
            <if test="params.beginStatDate != null and params.beginStatDate != ''">
                and stat_date &gt;= #{params.beginStatDate}
                and s.stat_date &gt;= #{params.beginStatDate}
            </if>
            <if test="params.endStatDate != null and params.endStatDate != ''">
                and stat_date &lt;= #{params.endStatDate}
                and s.stat_date &lt;= #{params.endStatDate}
            </if>
        </where>
        order by stat_date desc, vehicle_id
        group by s.stats_id, s.vehicle_id, s.vehicle_no, s.stat_date, s.total_mileage, s.task_mileage,
                 s.non_task_mileage, s.task_ratio, s.gps_point_count, s.task_count, s.segment_count,
                 s.data_source, s.create_time, s.update_time, vd.dept_id
        order by s.stat_date desc, s.vehicle_id
    </select>
    
    <select id="selectVehicleMileageStatsById" parameterType="Long" resultMap="VehicleMileageStatsResult">
        <include refid="selectVehicleMileageStatsVo"/>
        where stats_id = #{statsId}
        where s.stats_id = #{statsId}
        group by s.stats_id, s.vehicle_id, s.vehicle_no, s.stat_date, s.total_mileage, s.task_mileage,
                 s.non_task_mileage, s.task_ratio, s.gps_point_count, s.task_count, s.segment_count,
                 s.data_source, s.create_time, s.update_time, vd.dept_id
    </select>
    <select id="selectByVehicleIdAndDate" resultMap="VehicleMileageStatsResult">
        <include refid="selectVehicleMileageStatsVo"/>
        where vehicle_id = #{vehicleId} and stat_date = #{statDate}
        where s.vehicle_id = #{vehicleId} and s.stat_date = #{statDate}
        group by s.stats_id, s.vehicle_id, s.vehicle_no, s.stat_date, s.total_mileage, s.task_mileage,
                 s.non_task_mileage, s.task_ratio, s.gps_point_count, s.task_count, s.segment_count,
                 s.data_source, s.create_time, s.update_time, vd.dept_id
    </select>
    <select id="selectTaskTimeIntervals" resultMap="TaskTimeIntervalResult">
@@ -88,6 +110,8 @@
            <if test="taskRatio != null">task_ratio,</if>
            <if test="gpsPointCount != null">gps_point_count,</if>
            <if test="taskCount != null">task_count,</if>
            <if test="segmentCount != null">segment_count,</if>
            <if test="dataSource != null">data_source,</if>
            create_time
         </trim>
        <trim prefix="values (" suffix=")" suffixOverrides=",">
@@ -100,6 +124,8 @@
            <if test="taskRatio != null">#{taskRatio},</if>
            <if test="gpsPointCount != null">#{gpsPointCount},</if>
            <if test="taskCount != null">#{taskCount},</if>
            <if test="segmentCount != null">#{segmentCount},</if>
            <if test="dataSource != null">#{dataSource},</if>
            NOW()
         </trim>
    </insert>
@@ -114,6 +140,8 @@
            <if test="taskRatio != null">task_ratio = #{taskRatio},</if>
            <if test="gpsPointCount != null">gps_point_count = #{gpsPointCount},</if>
            <if test="taskCount != null">task_count = #{taskCount},</if>
            <if test="segmentCount != null">segment_count = #{segmentCount},</if>
            <if test="dataSource != null">data_source = #{dataSource},</if>
            update_time = NOW()
        </trim>
        where stats_id = #{statsId}
ruoyi-ui/src/api/system/gpsSegment.js
New file
@@ -0,0 +1,39 @@
import request from '@/utils/request'
// 查询GPS分段里程列表
export function listSegmentMileage(query) {
  return request({
    url: '/system/gpsSegment/list',
    method: 'get',
    params: query
  })
}
// 按任务ID查询GPS分段里程列表
export function getSegmentsByTaskId(taskId) {
  return request({
    url: '/system/gpsSegment/task/' + taskId,
    method: 'get'
  })
}
// 查询任务总里程
export function getTaskTotalMileage(taskId) {
  return request({
    url: '/system/gpsSegment/task/' + taskId + '/total',
    method: 'get'
  })
}
// 查询车辆指定日期范围的GPS分段里程
export function getSegmentsByDateRange(vehicleId, startDate, endDate) {
  return request({
    url: '/system/gpsSegment/range',
    method: 'get',
    params: {
      vehicleId: vehicleId,
      startDate: startDate,
      endDate: endDate
    }
  })
}
ruoyi-ui/src/api/system/mileageStats.js
New file
@@ -0,0 +1,67 @@
import request from '@/utils/request'
// 查询车辆里程统计列表
export function listMileageStats(query) {
  return request({
    url: '/system/mileageStats/list',
    method: 'get',
    params: query
  })
}
// 查询车辆里程统计详细
export function getMileageStats(statsId) {
  return request({
    url: '/system/mileageStats/' + statsId,
    method: 'get'
  })
}
// 新增车辆里程统计
export function addMileageStats(data) {
  return request({
    url: '/system/mileageStats',
    method: 'post',
    data: data
  })
}
// 修改车辆里程统计
export function updateMileageStats(data) {
  return request({
    url: '/system/mileageStats',
    method: 'put',
    data: data
  })
}
// 删除车辆里程统计
export function delMileageStats(statsId) {
  return request({
    url: '/system/mileageStats/' + statsId,
    method: 'delete'
  })
}
// 手动计算指定车辆指定日期的里程统计
export function calculateMileageStats(vehicleId, statDate) {
  return request({
    url: '/system/mileageStats/calculate',
    method: 'post',
    params: {
      vehicleId: vehicleId,
      statDate: statDate
    }
  })
}
// 批量计算指定日期所有车辆的里程统计
export function batchCalculateMileageStats(statDate) {
  return request({
    url: '/system/mileageStats/batchCalculate',
    method: 'post',
    params: {
      statDate: statDate
    }
  })
}
ruoyi-ui/src/components/TaskMileageDetail/README.md
New file
@@ -0,0 +1,187 @@
# 任务GPS里程统计功能集成指南
## 功能概述
该功能实现了任务与GPS里程的自动关联,可以在任务详情页面直接展示该任务执行期间的GPS行驶里程统计。
## 核心特性
- ✅ 自动关联:GPS里程计算时自动关联正在执行的任务
- ✅ 实时查询:支持按任务ID快速查询关联的GPS里程数据
- ✅ 分段展示:显示任务期间每5分钟的GPS里程分段明细
- ✅ 统计汇总:自动计算任务总里程、分段数、GPS点数
- ✅ 可视化组件:提供开箱即用的Vue组件
## 快速开始
### 1. 执行数据库迁移
```bash
mysql -u root -p < sql/updates/add_task_id_to_segment_mileage.sql
```
### 2. 在任务详情页面集成组件
```vue
<template>
  <div>
    <!-- 任务基本信息 -->
    <el-card>
      <!-- ... 任务详情内容 ... -->
    </el-card>
    <!-- GPS里程统计组件 -->
    <task-mileage-detail :task-id="taskId" />
  </div>
</template>
<script>
import TaskMileageDetail from '@/components/TaskMileageDetail'
export default {
  components: {
    TaskMileageDetail
  },
  data() {
    return {
      taskId: 123 // 任务ID
    }
  }
}
</script>
```
### 3. API调用示例
```javascript
import { getTaskTotalMileage, getSegmentsByTaskId } from '@/api/system/gpsSegment'
// 查询任务总里程
getTaskTotalMileage(taskId).then(res => {
  console.log('任务总里程:', res.data, 'km')
})
// 查询任务GPS分段明细
getSegmentsByTaskId(taskId).then(res => {
  console.log('分段明细:', res.data)
})
```
## 组件属性
### TaskMileageDetail
| 属性 | 类型 | 必填 | 说明 |
|-----|------|------|------|
| taskId | Number/String | 是 | 任务ID |
## API接口说明
### 1. 按任务ID查询GPS分段里程
**接口地址**: `GET /system/gpsSegment/task/{taskId}`
**权限**: `system:gpsSegment:query`
**返回示例**:
```json
{
  "code": 200,
  "data": [
    {
      "segmentId": 1,
      "vehicleId": 10,
      "vehicleNo": "粤A12345",
      "taskId": 100,
      "taskCode": "T20250115001",
      "segmentStartTime": "2025-01-15 10:00:00",
      "segmentEndTime": "2025-01-15 10:05:00",
      "segmentDistance": 2.5,
      "gpsPointCount": 5,
      "startLongitude": 113.264385,
      "startLatitude": 23.129112,
      "endLongitude": 113.280637,
      "endLatitude": 23.125178
    }
  ]
}
```
### 2. 查询任务总里程
**接口地址**: `GET /system/gpsSegment/task/{taskId}/total`
**权限**: `system:gpsSegment:query`
**返回示例**:
```json
{
  "code": 200,
  "data": 15.8
}
```
### 3. 按日期范围查询GPS分段里程
**接口地址**: `GET /system/gpsSegment/range`
**参数**:
- `vehicleId`: 车辆ID
- `startDate`: 开始日期 (格式: yyyy-MM-dd)
- `endDate`: 结束日期 (格式: yyyy-MM-dd)
## 数据流程
```mermaid
graph LR
    A[定时任务触发] --> B[计算GPS里程]
    B --> C[查询车辆正在执行的任务]
    C --> D{时间段是否重叠?}
    D -->|是| E[关联task_id和task_code]
    D -->|否| F[task_id为NULL]
    E --> G[保存分段里程记录]
    F --> G
    G --> H[前端查询展示]
```
## 统计逻辑
1. **时间重叠判断**:
   - GPS分段时间段:`[segmentStartTime, segmentEndTime]`
   - 任务执行时间段:`[actualStartTime, actualEndTime]`
   - 重叠条件:`segmentStartTime < taskEndTime && segmentEndTime > taskStartTime`
2. **优先级**:
   - 优先使用任务的实际开始/结束时间
   - 如果实际时间为空,则使用计划时间
3. **跨段里程**:
   - 自动计算相邻5分钟段之间的间隙距离
   - 避免里程统计遗漏
## 注意事项
1. **权限配置**:确保用户拥有 `system:gpsSegment:query` 权限
2. **数据准确性**:GPS里程计算后才会有关联数据
3. **实时性**:数据由定时任务更新,非实时数据
4. **历史数据**:已计算的历史GPS数据不会自动关联任务,需重新计算
## 扩展建议
1. **任务列表展示**:在任务列表中直接显示任务里程
2. **报表统计**:基于task_id进行任务里程分析
3. **异常检测**:对比预估里程与实际GPS里程,发现异常
4. **成本核算**:基于任务里程进行费用计算
## 性能优化
- ✅ 使用索引:`idx_task_id`, `idx_vehicle_task`
- ✅ 聚合查询:直接SUM计算总里程
- ✅ 分页加载:分段明细支持分页
- ✅ 缓存策略:可对任务总里程进行Redis缓存
## 技术栈
- **后端**: Spring Boot + MyBatis
- **前端**: Vue 2 + Element UI
- **数据库**: MySQL 5.7+
ruoyi-ui/src/components/TaskMileageDetail/index.vue
New file
@@ -0,0 +1,258 @@
<template>
  <div class="task-mileage-detail">
    <el-card class="box-card" shadow="hover">
      <div slot="header" class="clearfix">
        <span class="card-title">
          <i class="el-icon-location-information"></i>
          任务GPS里程统计
        </span>
        <el-button
          style="float: right; padding: 3px 10px"
          type="text"
          size="small"
          @click="refreshData"
          :loading="loading"
        >
          <i class="el-icon-refresh"></i> 刷新
        </el-button>
      </div>
      <!-- 统计概览 -->
      <div class="mileage-summary">
        <el-row :gutter="20">
          <el-col :span="8">
            <div class="stat-item total-mileage">
              <div class="stat-label">总里程</div>
              <div class="stat-value">{{ totalMileage }} <span class="unit">km</span></div>
            </div>
          </el-col>
          <el-col :span="8">
            <div class="stat-item segment-count">
              <div class="stat-label">分段数</div>
              <div class="stat-value">{{ segmentCount }} <span class="unit">段</span></div>
            </div>
          </el-col>
          <el-col :span="8">
            <div class="stat-item gps-count">
              <div class="stat-label">GPS点数</div>
              <div class="stat-value">{{ totalGpsPoints }} <span class="unit">个</span></div>
            </div>
          </el-col>
        </el-row>
      </div>
      <!-- 分段明细 -->
      <div class="segment-detail" v-if="segmentList.length > 0">
        <el-divider content-position="left">
          <i class="el-icon-tickets"></i> 里程分段明细
        </el-divider>
        <el-table
          :data="segmentList"
          size="small"
          :max-height="400"
          stripe
          border
        >
          <el-table-column type="index" label="序号" width="50" align="center" />
          <el-table-column label="时间段开始" prop="segmentStartTime" width="160" align="center">
            <template slot-scope="scope">
              {{ parseTime(scope.row.segmentStartTime, '{h}:{i}:{s}') }}
            </template>
          </el-table-column>
          <el-table-column label="时间段结束" prop="segmentEndTime" width="160" align="center">
            <template slot-scope="scope">
              {{ parseTime(scope.row.segmentEndTime, '{h}:{i}:{s}') }}
            </template>
          </el-table-column>
          <el-table-column label="里程(km)" prop="segmentDistance" width="100" align="center">
            <template slot-scope="scope">
              <el-tag type="success" size="mini">{{ scope.row.segmentDistance }}</el-tag>
            </template>
          </el-table-column>
          <el-table-column label="GPS点数" prop="gpsPointCount" width="90" align="center" />
          <el-table-column label="起点坐标" align="center" width="180">
            <template slot-scope="scope">
              <span class="coordinate">
                {{ scope.row.startLongitude }}, {{ scope.row.startLatitude }}
              </span>
            </template>
          </el-table-column>
          <el-table-column label="终点坐标" align="center" width="180">
            <template slot-scope="scope">
              <span class="coordinate">
                {{ scope.row.endLongitude }}, {{ scope.row.endLatitude }}
              </span>
            </template>
          </el-table-column>
          <el-table-column label="计算方式" prop="calculateMethod" width="100" align="center">
            <template slot-scope="scope">
              <el-tag :type="scope.row.calculateMethod === 'tianditu' ? 'warning' : 'info'" size="mini">
                {{ scope.row.calculateMethod === 'tianditu' ? '天地图' : 'Haversine' }}
              </el-tag>
            </template>
          </el-table-column>
        </el-table>
      </div>
      <!-- 无数据提示 -->
      <el-empty
        v-else-if="!loading"
        description="暂无GPS里程数据"
        :image-size="80"
      ></el-empty>
      <!-- 加载中 -->
      <div v-if="loading" class="loading-container">
        <el-skeleton :rows="5" animated />
      </div>
    </el-card>
  </div>
</template>
<script>
import { getSegmentsByTaskId, getTaskTotalMileage } from '@/api/system/gpsSegment'
export default {
  name: 'TaskMileageDetail',
  props: {
    taskId: {
      type: [Number, String],
      required: true
    }
  },
  data() {
    return {
      loading: false,
      totalMileage: '0.00',
      segmentList: [],
      segmentCount: 0,
      totalGpsPoints: 0
    }
  },
  watch: {
    taskId: {
      handler(newVal) {
        if (newVal) {
          this.loadData()
        }
      },
      immediate: true
    }
  },
  methods: {
    /** 加载数据 */
    loadData() {
      if (!this.taskId) {
        return
      }
      this.loading = true
      // 并行请求总里程和分段明细
      Promise.all([
        getTaskTotalMileage(this.taskId),
        getSegmentsByTaskId(this.taskId)
      ]).then(([totalRes, segmentRes]) => {
        // 处理总里程
        if (totalRes.code === 200) {
          this.totalMileage = (totalRes.data || 0).toFixed(2)
        }
        // 处理分段明细
        if (segmentRes.code === 200 && segmentRes.data) {
          this.segmentList = segmentRes.data
          this.segmentCount = this.segmentList.length
          // 计算总GPS点数
          this.totalGpsPoints = this.segmentList.reduce((sum, item) => {
            return sum + (item.gpsPointCount || 0)
          }, 0)
        }
      }).catch(error => {
        console.error('加载任务GPS里程数据失败', error)
        this.$message.error('加载GPS里程数据失败')
      }).finally(() => {
        this.loading = false
      })
    },
    /** 刷新数据 */
    refreshData() {
      this.loadData()
    }
  }
}
</script>
<style scoped lang="scss">
.task-mileage-detail {
  margin-top: 15px;
  .card-title {
    font-size: 16px;
    font-weight: 500;
    color: #303133;
    i {
      margin-right: 5px;
      color: #409EFF;
    }
  }
  .mileage-summary {
    margin-bottom: 20px;
    .stat-item {
      padding: 20px;
      text-align: center;
      border-radius: 4px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      &.total-mileage {
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      }
      &.segment-count {
        background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
      }
      &.gps-count {
        background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
      }
      .stat-label {
        font-size: 14px;
        opacity: 0.9;
        margin-bottom: 8px;
      }
      .stat-value {
        font-size: 28px;
        font-weight: bold;
        .unit {
          font-size: 14px;
          font-weight: normal;
          margin-left: 4px;
        }
      }
    }
  }
  .segment-detail {
    margin-top: 20px;
    .coordinate {
      font-family: 'Courier New', monospace;
      font-size: 12px;
      color: #606266;
    }
  }
  .loading-container {
    padding: 20px;
  }
}
</style>
ruoyi-ui/src/views/system/mileageStats/README.md
New file
@@ -0,0 +1,172 @@
# 车辆GPS行驶统计管理界面
## 功能概述
在 ruoyi-ui 后台管理系统中新增了车辆GPS行驶统计功能,用于查看和管理车辆的GPS里程统计数据。
## 新增文件
### 1. 前端页面
- **文件路径**: `ruoyi-ui/src/views/system/mileageStats/index.vue`
- **功能**: 车辆里程统计管理界面
### 2. API接口文件
- **文件路径**: `ruoyi-ui/src/api/system/mileageStats.js`
- **功能**: 车辆里程统计相关的API接口调用
## 主要功能
### 1. 数据查询与展示
- ✅ 支持按车牌号、车辆ID、统计日期查询
- ✅ 支持日期范围查询
- ✅ 分页显示统计数据
- ✅ 实时展示以下数据:
  - 车辆ID、车牌号
  - 统计日期
  - 总里程(km)
  - 任务里程(km)
  - 非任务里程(km)
  - 任务占比(带颜色标签)
  - GPS点数量
  - 任务数量
  - 统计时间
### 2. 手动统计
- ✅ 可指定车辆ID和统计日期
- ✅ 手动触发单个车辆的里程计算
- ✅ 实时显示统计进度
### 3. 批量统计
- ✅ 可指定统计日期
- ✅ 自动对所有活跃车辆进行里程计算
- ✅ 提示用户等待时间可能较长
### 4. 数据详情
- ✅ 查看详细的里程统计信息
- ✅ 使用描述列表美观展示
### 5. 数据导出
- ✅ 支持将统计数据导出为Excel文件
- ✅ 可按查询条件导出
### 6. 数据删除
- ✅ 支持单条或批量删除统计记录
- ✅ 删除前二次确认
## 界面特色
### 1. 任务占比颜色标识
- 🟢 **绿色** (success): 占比 ≥ 80%
- 🔵 **蓝色** (primary): 占比 60% - 79%
- 🟡 **橙色** (warning): 占比 40% - 59%
- 🔴 **红色** (danger): 占比 < 40%
- ⚪ **灰色** (info): 无数据
### 2. 数据高亮显示
- 总里程:蓝色加粗
- 任务里程:绿色加粗
- 详情数据:大字号加粗
## 权限配置
需要在系统菜单中配置以下权限标识:
```
system:mileageStats:list      # 查询列表
system:mileageStats:query     # 查看详情
system:mileageStats:export    # 导出数据
system:mileageStats:remove    # 删除数据
system:mileageStats:calculate # 手动统计
system:mileageStats:batch     # 批量统计
```
## 菜单配置示例
在系统管理 → 菜单管理中添加:
```
菜单名称: 车辆里程统计
父菜单: 系统管理
菜单类型: 菜单
路由地址: mileageStats
组件路径: system/mileageStats/index
权限标识: system:mileageStats:list
菜单图标: chart
```
## 后端接口
后端接口已完成,路径为:
- Controller: `ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleMileageStatsController.java`
- Service: `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VehicleMileageStatsServiceImpl.java`
### 接口列表
| 接口路径 | 方法 | 说明 |
|---------|------|------|
| /system/mileageStats/list | GET | 查询统计列表 |
| /system/mileageStats/{statsId} | GET | 获取统计详情 |
| /system/mileageStats/{statsIds} | DELETE | 删除统计记录 |
| /system/mileageStats/export | POST | 导出统计数据 |
| /system/mileageStats/calculate | POST | 手动计算统计 |
| /system/mileageStats/batchCalculate | POST | 批量计算统计 |
## 使用说明
### 1. 查看统计数据
1. 在后台管理系统中访问"车辆里程统计"菜单
2. 可以通过车牌号、车辆ID、日期等条件筛选数据
3. 点击"详情"按钮查看完整的统计信息
### 2. 手动统计单个车辆
1. 点击"手动统计"按钮
2. 输入车辆ID和统计日期
3. 点击"开始统计",系统将计算该车辆在指定日期的里程数据
### 3. 批量统计所有车辆
1. 点击"批量统计"按钮
2. 选择统计日期
3. 点击"开始统计",系统将计算所有活跃车辆在该日期的里程数据
4. 注意:此操作可能需要较长时间,请耐心等待
### 4. 导出数据
1. 设置查询条件(可选)
2. 点击"导出"按钮
3. 系统将生成Excel文件供下载
## 数据说明
- **总里程**: 车辆在统计日期内的总行驶里程
- **任务里程**: 车辆在执行任务期间的行驶里程
- **非任务里程**: 车辆在非任务时段的行驶里程
- **任务占比**: 任务里程 / 总里程的比例
- **GPS点数**: 统计日期内记录的GPS定位点数量
- **任务数**: 统计日期内车辆执行的任务数量
## 注意事项
1. 统计数据基于GPS定位点计算,需要车辆安装GPS设备并正常上传数据
2. 批量统计可能消耗较多系统资源,建议在业务低峰期执行
3. 建议定期清理历史统计数据,保留必要的时间范围即可
4. 如果统计结果为0,可能是该日期内无GPS数据或无任务数据
## 技术实现
### 里程计算方法
- 使用 **Haversine公式** 计算GPS坐标点之间的距离
- 根据任务时间段自动分配里程到任务/非任务类别
- 精确到小数点后2位
### 数据来源
- GPS数据表: `tb_vehicle_gps`
- 任务数据表: `sys_task`
- 统计结果表: `tb_vehicle_mileage_stats`
## 未来扩展
可以考虑增加以下功能:
- 📊 统计图表展示(折线图、饼图等)
- 📅 月度、年度汇总统计
- 📈 车辆里程趋势分析
- 🚗 车辆利用率分析
- 💰 里程成本核算
ruoyi-ui/src/views/system/mileageStats/index.vue
New file
@@ -0,0 +1,490 @@
<template>
  <div class="app-container">
    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="88px">
      <el-form-item label="归属分公司" prop="deptId">
        <el-select
          v-model="queryParams.deptId"
          placeholder="请选择分公司"
          clearable
          filterable
          size="small"
          style="width: 200px"
        >
          <el-option
            v-for="dept in deptOptions"
            :key="dept.deptId"
            :label="dept.deptName"
            :value="dept.deptId"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="车牌号" prop="vehicleNo">
        <el-input
          v-model="queryParams.vehicleNo"
          placeholder="请输入车牌号"
          clearable
          size="small"
          @keyup.enter.native="handleQuery"
        />
      </el-form-item>
      <el-form-item label="统计日期" prop="statDate">
        <el-date-picker
          v-model="queryParams.statDate"
          type="date"
          value-format="yyyy-MM-dd"
          placeholder="选择统计日期"
          clearable
          size="small"
        />
      </el-form-item>
      <el-form-item label="日期范围">
        <el-date-picker
          v-model="dateRange"
          size="small"
          style="width: 240px"
          value-format="yyyy-MM-dd"
          type="daterange"
          range-separator="-"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
        ></el-date-picker>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
      </el-form-item>
    </el-form>
    <el-row :gutter="10" class="mb8">
      <el-col :span="1.5">
        <el-button
          type="success"
          plain
          icon="el-icon-s-operation"
          size="mini"
          @click="handleCalculate"
          v-hasPermi="['system:mileageStats:calculate']"
        >手动统计</el-button>
      </el-col>
      <el-col :span="1.5">
        <el-button
          type="info"
          plain
          icon="el-icon-s-grid"
          size="mini"
          @click="handleBatchCalculate"
          v-hasPermi="['system:mileageStats:batch']"
        >批量统计</el-button>
      </el-col>
      <el-col :span="1.5">
        <el-button
          type="warning"
          plain
          icon="el-icon-download"
          size="mini"
          @click="handleExport"
          v-hasPermi="['system:mileageStats:export']"
        >导出</el-button>
      </el-col>
      <el-col :span="1.5">
        <el-button
          type="danger"
          plain
          icon="el-icon-delete"
          size="mini"
          :disabled="multiple"
          @click="handleDelete"
          v-hasPermi="['system:mileageStats:remove']"
        >删除</el-button>
      </el-col>
      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
    </el-row>
    <el-table v-loading="loading" :data="statsList" @selection-change="handleSelectionChange">
      <el-table-column type="selection" width="55" align="center" />
      <el-table-column label="车牌号" align="center" prop="vehicleNo" width="120" fixed />
      <el-table-column label="归属分公司" align="center" prop="deptName" width="150" show-overflow-tooltip />
      <el-table-column label="统计日期" align="center" prop="statDate" width="120">
        <template slot-scope="scope">
          <span>{{ parseTime(scope.row.statDate, '{y}-{m}-{d}') }}</span>
        </template>
      </el-table-column>
      <el-table-column label="总里程(km)" align="center" prop="totalMileage" width="110">
        <template slot-scope="scope">
          <span class="mileage-value">{{ scope.row.totalMileage || '0.00' }}</span>
        </template>
      </el-table-column>
      <el-table-column label="任务里程(km)" align="center" prop="taskMileage" width="120">
        <template slot-scope="scope">
          <span class="mileage-value task-mileage">{{ scope.row.taskMileage || '0.00' }}</span>
        </template>
      </el-table-column>
      <el-table-column label="非任务里程(km)" align="center" prop="nonTaskMileage" width="130">
        <template slot-scope="scope">
          <span class="mileage-value">{{ scope.row.nonTaskMileage || '0.00' }}</span>
        </template>
      </el-table-column>
      <el-table-column label="任务占比" align="center" prop="taskRatio" width="100">
        <template slot-scope="scope">
          <el-tag :type="getRatioType(scope.row.taskRatio)">
            {{ formatRatio(scope.row.taskRatio) }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="GPS点数" align="center" prop="gpsPointCount" width="90" />
      <el-table-column label="任务数" align="center" prop="taskCount" width="80" />
      <el-table-column label="分段数" align="center" prop="segmentCount" width="80" />
      <el-table-column label="统计时间" align="center" prop="createTime" width="160">
        <template slot-scope="scope">
          <span>{{ parseTime(scope.row.createTime) }}</span>
        </template>
      </el-table-column>
      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="120">
        <template slot-scope="scope">
          <el-button
            size="mini"
            type="text"
            icon="el-icon-view"
            @click="handleView(scope.row)"
            v-hasPermi="['system:mileageStats:query']"
          >详情</el-button>
          <el-button
            size="mini"
            type="text"
            icon="el-icon-delete"
            @click="handleDelete(scope.row)"
            v-hasPermi="['system:mileageStats:remove']"
          >删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    <pagination
      v-show="total>0"
      :total="total"
      :page.sync="queryParams.pageNum"
      :limit.sync="queryParams.pageSize"
      @pagination="getList"
    />
    <!-- 手动统计对话框 -->
    <el-dialog title="手动里程统计" :visible.sync="calculateOpen" width="500px" append-to-body>
      <el-form ref="calculateForm" :model="calculateForm" :rules="calculateRules" label-width="100px">
        <el-form-item label="车辆ID" prop="vehicleId">
          <el-input v-model="calculateForm.vehicleId" placeholder="请输入车辆ID" type="number" />
        </el-form-item>
        <el-form-item label="统计日期" prop="statDate">
          <el-date-picker
            v-model="calculateForm.statDate"
            type="date"
            value-format="yyyy-MM-dd"
            placeholder="选择统计日期"
            style="width: 100%"
          />
        </el-form-item>
      </el-form>
      <div slot="footer" class="dialog-footer">
        <el-button type="primary" @click="submitCalculate" :loading="calculateLoading">开始统计</el-button>
        <el-button @click="calculateOpen = false">取 消</el-button>
      </div>
    </el-dialog>
    <!-- 批量统计对话框 -->
    <el-dialog title="批量里程统计" :visible.sync="batchCalculateOpen" width="500px" append-to-body>
      <el-form ref="batchCalculateForm" :model="batchCalculateForm" :rules="batchCalculateRules" label-width="100px">
        <el-form-item label="统计日期" prop="statDate">
          <el-date-picker
            v-model="batchCalculateForm.statDate"
            type="date"
            value-format="yyyy-MM-dd"
            placeholder="选择统计日期"
            style="width: 100%"
          />
        </el-form-item>
        <el-alert
          title="提示"
          type="warning"
          description="批量统计将对所有活跃车辆进行指定日期的里程计算,可能需要较长时间,请耐心等待。"
          :closable="false"
          show-icon
        />
      </el-form>
      <div slot="footer" class="dialog-footer">
        <el-button type="primary" @click="submitBatchCalculate" :loading="batchCalculateLoading">开始统计</el-button>
        <el-button @click="batchCalculateOpen = false">取 消</el-button>
      </div>
    </el-dialog>
    <!-- 详情对话框 -->
    <el-dialog title="里程统计详情" :visible.sync="detailOpen" width="600px" append-to-body>
      <el-descriptions :column="2" border>
        <el-descriptions-item label="车牌号">{{ detailData.vehicleNo }}</el-descriptions-item>
        <el-descriptions-item label="归属分公司">{{ detailData.deptName || '-' }}</el-descriptions-item>
        <el-descriptions-item label="统计日期">
          {{ parseTime(detailData.statDate, '{y}-{m}-{d}') }}
        </el-descriptions-item>
        <el-descriptions-item label="总里程">
          <span class="detail-value">{{ detailData.totalMileage }} km</span>
        </el-descriptions-item>
        <el-descriptions-item label="任务里程">
          <span class="detail-value task-mileage">{{ detailData.taskMileage }} km</span>
        </el-descriptions-item>
        <el-descriptions-item label="非任务里程">
          <span class="detail-value">{{ detailData.nonTaskMileage }} km</span>
        </el-descriptions-item>
        <el-descriptions-item label="任务占比">
          <el-tag :type="getRatioType(detailData.taskRatio)">
            {{ formatRatio(detailData.taskRatio) }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="GPS点数">{{ detailData.gpsPointCount }}</el-descriptions-item>
        <el-descriptions-item label="任务数">{{ detailData.taskCount }}</el-descriptions-item>
        <el-descriptions-item label="分段数">{{ detailData.segmentCount }}</el-descriptions-item>
        <el-descriptions-item label="数据来源">
          <el-tag :type="detailData.dataSource === 'segment' ? 'success' : 'info'" size="small">
            {{ detailData.dataSource === 'segment' ? '从分段汇总' : '直接计算' }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="统计时间" :span="2">
          {{ parseTime(detailData.createTime) }}
        </el-descriptions-item>
      </el-descriptions>
      <div slot="footer" class="dialog-footer">
        <el-button @click="detailOpen = false">关 闭</el-button>
      </div>
    </el-dialog>
  </div>
</template>
<script>
import { listMileageStats, getMileageStats, delMileageStats, calculateMileageStats, batchCalculateMileageStats } from "@/api/system/mileageStats";
import { listDept } from "@/api/system/dept";
export default {
  name: "MileageStats",
  data() {
    return {
      // 遮罩层
      loading: true,
      // 选中数组
      ids: [],
      // 非单个禁用
      single: true,
      // 非多个禁用
      multiple: true,
      // 显示搜索条件
      showSearch: true,
      // 总条数
      total: 0,
      // 车辆里程统计表格数据
      statsList: [],
      // 分公司选项
      deptOptions: [],
      // 日期范围
      dateRange: [],
      // 弹出层标题
      title: "",
      // 是否显示手动统计弹出层
      calculateOpen: false,
      // 是否显示批量统计弹出层
      batchCalculateOpen: false,
      // 是否显示详情弹出层
      detailOpen: false,
      // 详情数据
      detailData: {},
      // 手动统计加载状态
      calculateLoading: false,
      // 批量统计加载状态
      batchCalculateLoading: false,
      // 查询参数
      queryParams: {
        pageNum: 1,
        pageSize: 10,
        vehicleId: null,
        vehicleNo: null,
        deptId: null,
        statDate: null
      },
      // 手动统计表单
      calculateForm: {
        vehicleId: null,
        statDate: null
      },
      // 批量统计表单
      batchCalculateForm: {
        statDate: null
      },
      // 手动统计表单校验
      calculateRules: {
        vehicleId: [
          { required: true, message: "车辆ID不能为空", trigger: "blur" }
        ],
        statDate: [
          { required: true, message: "统计日期不能为空", trigger: "change" }
        ]
      },
      // 批量统计表单校验
      batchCalculateRules: {
        statDate: [
          { required: true, message: "统计日期不能为空", trigger: "change" }
        ]
      }
    };
  },
  created() {
    this.getList();
    this.getDeptList();
  },
  methods: {
    /** 查询分公司列表 */
    getDeptList() {
      listDept().then(response => {
        this.deptOptions = response.data || [];
      });
    },
    /** 查询车辆里程统计列表 */
    getList() {
      this.loading = true;
      listMileageStats(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
        this.statsList = response.rows;
        this.total = response.total;
        this.loading = false;
      });
    },
    /** 搜索按钮操作 */
    handleQuery() {
      this.queryParams.pageNum = 1;
      this.getList();
    },
    /** 重置按钮操作 */
    resetQuery() {
      this.dateRange = [];
      this.resetForm("queryForm");
      this.handleQuery();
    },
    // 多选框选中数据
    handleSelectionChange(selection) {
      this.ids = selection.map(item => item.statsId)
      this.single = selection.length !== 1
      this.multiple = !selection.length
    },
    /** 手动统计按钮操作 */
    handleCalculate() {
      this.reset();
      this.calculateOpen = true;
    },
    /** 批量统计按钮操作 */
    handleBatchCalculate() {
      this.batchCalculateForm = {
        statDate: null
      };
      this.batchCalculateOpen = true;
    },
    /** 提交手动统计 */
    submitCalculate() {
      this.$refs["calculateForm"].validate(valid => {
        if (valid) {
          this.calculateLoading = true;
          calculateMileageStats(this.calculateForm.vehicleId, this.calculateForm.statDate).then(response => {
            this.$modal.msgSuccess("里程统计完成");
            this.calculateOpen = false;
            this.calculateLoading = false;
            this.getList();
          }).catch(() => {
            this.calculateLoading = false;
          });
        }
      });
    },
    /** 提交批量统计 */
    submitBatchCalculate() {
      this.$refs["batchCalculateForm"].validate(valid => {
        if (valid) {
          this.$modal.confirm('确认要对所有活跃车辆进行里程统计吗?').then(() => {
            this.batchCalculateLoading = true;
            batchCalculateMileageStats(this.batchCalculateForm.statDate).then(response => {
              this.$modal.msgSuccess(response.msg || "批量里程统计完成");
              this.batchCalculateOpen = false;
              this.batchCalculateLoading = false;
              this.getList();
            }).catch(() => {
              this.batchCalculateLoading = false;
            });
          });
        }
      });
    },
    /** 查看详情按钮操作 */
    handleView(row) {
      const statsId = row.statsId;
      getMileageStats(statsId).then(response => {
        this.detailData = response.data;
        this.detailOpen = true;
      });
    },
    /** 删除按钮操作 */
    handleDelete(row) {
      const statsIds = row.statsId || this.ids;
      this.$modal.confirm('是否确认删除选中的车辆里程统计数据?').then(function() {
        return delMileageStats(statsIds);
      }).then(() => {
        this.getList();
        this.$modal.msgSuccess("删除成功");
      }).catch(() => {});
    },
    /** 导出按钮操作 */
    handleExport() {
      this.download('system/mileageStats/export', {
        ...this.queryParams
      }, `车辆里程统计_${new Date().getTime()}.xlsx`)
    },
    /** 格式化任务占比显示 */
    formatRatio(ratio) {
      if (ratio === null || ratio === undefined) {
        return '0%';
      }
      return (ratio * 100).toFixed(2) + '%';
    },
    /** 根据占比获取标签类型 */
    getRatioType(ratio) {
      if (ratio === null || ratio === undefined) {
        return 'info';
      }
      const percent = ratio * 100;
      if (percent >= 80) {
        return 'success';
      } else if (percent >= 60) {
        return '';
      } else if (percent >= 40) {
        return 'warning';
      } else {
        return 'danger';
      }
    },
    // 表单重置
    reset() {
      this.calculateForm = {
        vehicleId: null,
        statDate: null
      };
      this.resetForm("calculateForm");
    }
  }
};
</script>
<style scoped>
.mileage-value {
  font-weight: bold;
  color: #409EFF;
}
.task-mileage {
  color: #67C23A;
}
.detail-value {
  font-size: 16px;
  font-weight: bold;
}
</style>
ruoyi-ui/src/views/system/vehicle/index.vue
@@ -14,7 +14,7 @@
        <el-select v-model="queryParams.vehicleType" placeholder="请选择车辆类型" clearable size="small">
          <el-option
            v-for="dict in dict.type.sys_vehicle_type"
            :key="dict.value"
            :key="'vtype-' + dict.value"
            :label="dict.label"
            :value="dict.value"
          />
@@ -24,7 +24,7 @@
        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable size="small">
          <el-option
            v-for="dict in dict.type.sys_normal_disable"
            :key="dict.value"
            :key="'status-' + dict.value"
            :label="dict.label"
            :value="dict.value"
          />
@@ -34,7 +34,7 @@
        <el-select v-model="queryParams.platformCode" placeholder="请选择平台" clearable size="small">
          <el-option
            v-for="dict in dict.type.sys_platform"
            :key="dict.value"
            :key="'platform-' + dict.value"
            :label="dict.label"
            :value="dict.value"
          />
@@ -174,7 +174,7 @@
          <el-select v-model="form.vehicleType" placeholder="请选择车辆类型" clearable style="width: 100%">
            <el-option
              v-for="dict in dict.type.sys_vehicle_type"
              :key="dict.value"
              :key="'form-vtype-' + dict.value"
              :label="dict.label"
              :value="dict.value"
            />
@@ -190,7 +190,7 @@
          <el-select v-model="form.platformCode" placeholder="请选择平台" clearable>
            <el-option
              v-for="dict in dict.type.sys_platform"
              :key="dict.value"
              :key="'form-platform-' + dict.value"
              :label="dict.label"
              :value="dict.value"
            />
@@ -200,7 +200,7 @@
          <el-radio-group v-model="form.status">
            <el-radio
              v-for="dict in dict.type.sys_normal_disable"
              :key="dict.value"
              :key="'form-status-' + dict.value"
              :label="dict.value"
            >{{dict.label}}</el-radio>
          </el-radio-group>
@@ -315,9 +315,12 @@
    /** 获取部门列表(只显示分公司:parent_id=100) */
    getDeptList() {
      listDept({ parentId: 100 }).then(response => {
        // 过滤出分公司(parent_id=100的部门)
        if (response.data) {
          this.deptList = response.data.filter(dept => dept.parentId === 100);
          this.deptList = response.data.filter(dept => dept.parentId === "100");
          // console.log("deptList: ",this.deptList,response.data.filter(dept => dept.parentId === "100"));
        } else {
          this.deptList = [];
        }
@@ -372,13 +375,13 @@
      const vehicleId = row.vehicleId || this.ids
      getVehicle(vehicleId).then(response => {
        this.form = response.data;
        // 如果没有deptIds,则从 deptId 和 deptName 中预填
        if (!this.form.deptIds || this.form.deptIds.length === 0) {
          if (this.form.deptId) {
            this.form.deptIds = [this.form.deptId];
          } else {
            this.form.deptIds = [];
          }
        // 确保deptIds是一个数组
        if (!this.form.deptIds) {
          this.form.deptIds = [];
        }
        // 如果deptIds为空数组,但deptId有值,则添加deptId到deptIds中
        if (this.form.deptIds.length === 0 && this.form.deptId) {
          this.form.deptIds = [this.form.deptId];
        }
        this.open = true;
        this.title = "修改车辆信息";
ruoyi-ui/src/views/task/detail/index.vue
New file
@@ -0,0 +1,112 @@
<template>
  <div class="app-container">
    <el-page-header @back="goBack" content="任务详情">
    </el-page-header>
    <el-card class="box-card" style="margin-top: 20px;" shadow="hover">
      <div slot="header" class="clearfix">
        <span><i class="el-icon-document"></i> 任务基本信息</span>
      </div>
      <el-descriptions :column="2" border>
        <el-descriptions-item label="任务编号">{{ taskInfo.taskCode }}</el-descriptions-item>
        <el-descriptions-item label="任务状态">
          <el-tag :type="getStatusType(taskInfo.taskStatus)">
            {{ getStatusText(taskInfo.taskStatus) }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="车牌号">{{ taskInfo.vehicleNo }}</el-descriptions-item>
        <el-descriptions-item label="执行人">{{ taskInfo.assigneeName }}</el-descriptions-item>
        <el-descriptions-item label="计划开始时间">{{ taskInfo.plannedStartTime }}</el-descriptions-item>
        <el-descriptions-item label="计划结束时间">{{ taskInfo.plannedEndTime }}</el-descriptions-item>
        <el-descriptions-item label="实际开始时间">{{ taskInfo.actualStartTime || '-' }}</el-descriptions-item>
        <el-descriptions-item label="实际结束时间">{{ taskInfo.actualEndTime || '-' }}</el-descriptions-item>
        <el-descriptions-item label="出发地址" :span="2">{{ taskInfo.departureAddress }}</el-descriptions-item>
        <el-descriptions-item label="目的地址" :span="2">{{ taskInfo.destinationAddress }}</el-descriptions-item>
      </el-descriptions>
    </el-card>
    <!-- GPS里程统计组件 -->
    <task-mileage-detail v-if="taskInfo.taskId" :task-id="taskInfo.taskId" />
  </div>
</template>
<script>
import TaskMileageDetail from '@/components/TaskMileageDetail'
import { getTask } from '@/api/task'
export default {
  name: 'TaskDetail',
  components: {
    TaskMileageDetail
  },
  data() {
    return {
      taskInfo: {
        taskId: null,
        taskCode: '',
        taskStatus: '',
        vehicleNo: '',
        assigneeName: '',
        plannedStartTime: '',
        plannedEndTime: '',
        actualStartTime: '',
        actualEndTime: '',
        departureAddress: '',
        destinationAddress: ''
      }
    }
  },
  created() {
    const taskId = this.$route.params && this.$route.params.taskId
    if (taskId) {
      this.loadTaskInfo(taskId)
    }
  },
  methods: {
    /** 加载任务信息 */
    loadTaskInfo(taskId) {
      getTask(taskId).then(response => {
        this.taskInfo = response.data
      })
    },
    /** 返回 */
    goBack() {
      this.$router.go(-1)
    },
    /** 获取状态类型 */
    getStatusType(status) {
      const statusMap = {
        'PENDING': 'info',
        'DEPARTING': 'warning',
        'ARRIVED': 'primary',
        'RETURNING': 'warning',
        'COMPLETED': 'success',
        'CANCELLED': 'danger'
      }
      return statusMap[status] || 'info'
    },
    /** 获取状态文本 */
    getStatusText(status) {
      const statusMap = {
        'PENDING': '待出发',
        'DEPARTING': '出发中',
        'ARRIVED': '已到达',
        'RETURNING': '返程中',
        'COMPLETED': '已完成',
        'CANCELLED': '已取消'
      }
      return statusMap[status] || status
    }
  }
}
</script>
<style scoped>
.app-container {
  padding: 20px;
}
</style>
sql/updates/add_segment_count_to_mileage_stats.sql
New file
@@ -0,0 +1,26 @@
-- 为车辆里程统计表添加分段数量字段
-- 用途:记录当日关联的GPS分段数量,用于数据完整性校验和业务监控
USE `ry-vue`;
-- 检查字段是否存在,如果不存在则添加
SET @dbname = DATABASE();
SET @tablename = 'tb_vehicle_mileage_stats';
SET @columnname = 'segment_count';
SET @preparedStatement = (SELECT IF(
  (
    SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
    WHERE
      (TABLE_SCHEMA = @dbname)
      AND (TABLE_NAME = @tablename)
      AND (COLUMN_NAME = @columnname)
  ) > 0,
  'SELECT 1',
  CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN `segment_count` int(11) DEFAULT 0 COMMENT ''关联的分段数量'' AFTER `task_count`;')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- 验证字段是否添加成功
DESC tb_vehicle_mileage_stats;
sql/updates/add_task_id_to_segment_mileage.sql
New file
@@ -0,0 +1,27 @@
-- 为车辆GPS分段里程表添加任务关联字段
-- 用途:在计算GPS里程时,自动关联车辆正在执行的任务,方便统计任务里程
USE ry-vue;
-- 添加任务ID字段
ALTER TABLE tb_vehicle_gps_segment_mileage
ADD COLUMN task_id BIGINT(20) NULL COMMENT '关联的任务ID' AFTER gps_ids;
-- 添加任务编号字段
ALTER TABLE tb_vehicle_gps_segment_mileage
ADD COLUMN task_code VARCHAR(50) NULL COMMENT '关联的任务编号' AFTER task_id;
-- 创建索引,提升按任务ID查询的性能
CREATE INDEX idx_task_id ON tb_vehicle_gps_segment_mileage(task_id);
-- 创建复合索引,提升按车辆ID和任务ID组合查询的性能
CREATE INDEX idx_vehicle_task ON tb_vehicle_gps_segment_mileage(vehicle_id, task_id);
-- 查看表结构确认
DESC tb_vehicle_gps_segment_mileage;
-- 使用说明:
-- 1. 运行此SQL脚本添加字段
-- 2. 重新计算GPS里程时,系统会自动关联任务ID
-- 3. 统计任务里程时,可直接通过task_id查询:
--    SELECT SUM(segment_distance) FROM tb_vehicle_gps_segment_mileage WHERE task_id = ?
sql/updates/remove_dept_id_from_vehicle_info.sql
New file
@@ -0,0 +1,26 @@
-- 移除tb_vehicle_info表的dept_id字段
-- 说明:车辆与部门的关联关系现已完全迁移到tb_vehicle_dept多对多关系表
-- 1. 数据迁移检查(确保所有数据已迁移到tb_vehicle_dept表)
SELECT '数据迁移检查' AS 检查项,
       (SELECT COUNT(*) FROM tb_vehicle_info WHERE dept_id IS NOT NULL) AS 原表有dept_id的记录数,
       (SELECT COUNT(DISTINCT vehicle_id) FROM tb_vehicle_dept) AS 关联表中的车辆数;
-- 2. 查看未迁移的数据(如果有)
SELECT vehicle_id, vehicle_no, dept_id, '未迁移到tb_vehicle_dept' AS 状态
FROM tb_vehicle_info
WHERE dept_id IS NOT NULL
  AND vehicle_id NOT IN (SELECT DISTINCT vehicle_id FROM tb_vehicle_dept);
-- 3. 如果上面有未迁移的数据,先执行迁移(可选)
-- INSERT INTO tb_vehicle_dept (vehicle_id, dept_id, create_time)
-- SELECT vehicle_id, dept_id, NOW()
-- FROM tb_vehicle_info
-- WHERE dept_id IS NOT NULL
--   AND vehicle_id NOT IN (SELECT DISTINCT vehicle_id FROM tb_vehicle_dept);
-- 4. 删除dept_id字段
ALTER TABLE tb_vehicle_info DROP COLUMN dept_id;
-- 5. 验证字段已删除
SHOW COLUMNS FROM tb_vehicle_info;
sql/updates/update_vehicle_no_from_vehicle_info.sql
New file
@@ -0,0 +1,25 @@
-- 更新现有的空vehicle_no数据
-- 从tb_vehicle_info表同步车牌号到tb_vehicle_gps_segment_mileage和tb_vehicle_mileage_stats
-- 1. 更新GPS分段里程表的vehicle_no
UPDATE tb_vehicle_gps_segment_mileage seg
INNER JOIN tb_vehicle_info v ON seg.vehicle_id = v.vehicle_id
SET seg.vehicle_no = v.vehicle_no
WHERE seg.vehicle_no IS NULL OR seg.vehicle_no = '';
-- 2. 更新里程统计表的vehicle_no
UPDATE tb_vehicle_mileage_stats stats
INNER JOIN tb_vehicle_info v ON stats.vehicle_id = v.vehicle_id
SET stats.vehicle_no = v.vehicle_no
WHERE stats.vehicle_no IS NULL OR stats.vehicle_no = '';
-- 查询更新结果
SELECT '更新GPS分段里程表' AS 表名, COUNT(*) AS 记录数,
       SUM(CASE WHEN vehicle_no IS NOT NULL AND vehicle_no != '' THEN 1 ELSE 0 END) AS 已有车牌号,
       SUM(CASE WHEN vehicle_no IS NULL OR vehicle_no = '' THEN 1 ELSE 0 END) AS 无车牌号
FROM tb_vehicle_gps_segment_mileage
UNION ALL
SELECT '更新里程统计表' AS 表名, COUNT(*) AS 记录数,
       SUM(CASE WHEN vehicle_no IS NOT NULL AND vehicle_no != '' THEN 1 ELSE 0 END) AS 已有车牌号,
       SUM(CASE WHEN vehicle_no IS NULL OR vehicle_no = '' THEN 1 ELSE 0 END) AS 无车牌号
FROM tb_vehicle_mileage_stats;
sql/vehicle_gps_segment_mileage.sql
New file
@@ -0,0 +1,49 @@
-- 车辆GPS分段里程表(按5分钟时间段统计)
CREATE TABLE `tb_vehicle_gps_segment_mileage` (
  `segment_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '分段ID',
  `vehicle_id` bigint(20) NOT NULL COMMENT '车辆ID',
  `vehicle_no` varchar(20) DEFAULT NULL COMMENT '车牌号',
  `segment_start_time` datetime NOT NULL COMMENT '时间段开始时间',
  `segment_end_time` datetime NOT NULL COMMENT '时间段结束时间',
  `start_longitude` decimal(10,7) DEFAULT NULL COMMENT '起点经度',
  `start_latitude` decimal(10,7) DEFAULT NULL COMMENT '起点纬度',
  `end_longitude` decimal(10,7) DEFAULT NULL COMMENT '终点经度',
  `end_latitude` decimal(10,7) DEFAULT NULL COMMENT '终点纬度',
  `segment_distance` decimal(10,3) DEFAULT 0.000 COMMENT '段距离(公里)',
  `gps_point_count` int(11) DEFAULT 0 COMMENT 'GPS点数量',
  `gps_ids` text COMMENT '关联的GPS记录ID列表(逗号分隔)',
  `calculate_method` varchar(20) DEFAULT 'tianditu' COMMENT '计算方式(tianditu-天地图/haversine-球面距离)',
  `create_time` datetime  COMMENT '创建时间',
  `update_time` datetime  COMMENT '更新时间',
  PRIMARY KEY (`segment_id`),
  UNIQUE KEY `uk_vehicle_time` (`vehicle_id`, `segment_start_time`),
  KEY `idx_vehicle_id` (`vehicle_id`),
  KEY `idx_start_time` (`segment_start_time`),
  KEY `idx_vehicle_date` (`vehicle_id`, `segment_start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆GPS分段里程表';
-- GPS记录计算状态关联表(用于快速查询GPS点是否已被计算)
CREATE TABLE `tb_vehicle_gps_calculated` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `gps_id` bigint(20) NOT NULL COMMENT 'GPS记录ID',
  `segment_id` bigint(20) NOT NULL COMMENT '分段里程ID',
  `vehicle_id` bigint(20) NOT NULL COMMENT '车辆ID',
  `create_time` datetime  COMMENT '计算时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_gps_id` (`gps_id`),
  KEY `idx_segment_id` (`segment_id`),
  KEY `idx_vehicle_id` (`vehicle_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='GPS记录计算状态表';
-- 添加配置参数到系统配置表
INSERT INTO sys_config (config_name, config_key, config_value, config_type, remark, create_by, create_time)
VALUES ('GPS里程计算时间间隔', 'gps.mileage.segment.minutes', '5', 'Y', 'GPS里程计算的时间间隔(分钟),用于将GPS数据分段计算里程', 'admin', NOW())
ON DUPLICATE KEY UPDATE config_value = config_value;
INSERT INTO sys_config (config_name, config_key, config_value, config_type, remark, create_by, create_time)
VALUES ('GPS里程计算方式', 'gps.mileage.calculate.method', 'tianditu', 'Y', 'GPS里程计算方式:tianditu-天地图API,haversine-球面距离公式', 'admin', NOW())
ON DUPLICATE KEY UPDATE config_value = config_value;
INSERT INTO sys_config (config_name, config_key, config_value, config_type, remark, create_by, create_time)
VALUES ('GPS里程重复计算控制', 'gps.mileage.skip.calculated', 'true', 'Y', '是否跳过已计算的GPS点:true-跳过,false-允许重复计算', 'admin', NOW())
ON DUPLICATE KEY UPDATE config_value = config_value;
sql/vehicle_gps_segment_mileage_job.sql
New file
@@ -0,0 +1,59 @@
-- 车辆GPS分段里程计算定时任务配置
-- 1. 实时计算任务(每5分钟执行一次,计算最近10分钟的数据)
INSERT INTO sys_job (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
VALUES (
  'GPS分段里程实时计算',
  'DEFAULT',
  'vehicleGpsSegmentMileageTask.calculateRecentSegmentMileage(''10'')',
  '0 0/5 * * * ?',
  '2',
  '0',
  '1',
  'admin',
  NOW(),
  '每5分钟执行一次,计算最近10分钟内所有车辆的GPS分段里程。参数10表示计算最近10分钟的数据,可根据需要调整'
);
-- 2. 每小时汇总任务(每小时执行一次,计算最近1小时的数据)
INSERT INTO sys_job (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
VALUES (
  'GPS分段里程小时汇总',
  'DEFAULT',
  'vehicleGpsSegmentMileageTask.calculateRecentSegmentMileage(''60'')',
  '0 5 * * * ?',
  '2',
  '0',
  '1',
  'admin',
  NOW(),
  '每小时第5分钟执行,计算最近60分钟内所有车辆的GPS分段里程,用于补充遗漏的数据'
);
-- 3. 每日全天计算任务(每天凌晨执行,计算前一天的数据)
INSERT INTO sys_job (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
VALUES (
  'GPS分段里程每日汇总',
  'DEFAULT',
  'vehicleGpsSegmentMileageTask.calculateDateSegmentMileage(DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 DAY), ''%Y-%m-%d''))',
  '0 10 1 * * ?',
  '2',
  '0',
  '1',
  'admin',
  NOW(),
  '每天凌晨1点10分执行,计算前一天所有车辆的GPS分段里程。使用DATE_FORMAT动态计算前一天的日期'
);
-- 注意事项说明
-- 1. 默认所有任务状态为'1'(暂停),需要在后台管理系统中手动启动
-- 2. 时间间隔配置在 sys_config 表中:gps.mileage.segment.minutes(默认5分钟)
-- 3. 计算方式配置在 sys_config 表中:gps.mileage.calculate.method(默认tianditu)
-- 4. 建议启动顺序:
--    - 先启动"GPS分段里程实时计算",观察运行情况
--    - 确认无误后,再启动"GPS分段里程小时汇总"作为补充
--    - 最后启动"GPS分段里程每日汇总"作为数据完整性保障
-- 5. cron表达式说明:
--    - '0 0/5 * * * ?' : 每5分钟执行一次
--    - '0 5 * * * ?' : 每小时第5分钟执行
--    - '0 10 1 * * ?' : 每天凌晨1点10分执行
sql/vehicle_mileage_stats.sql
@@ -10,6 +10,8 @@
  `task_ratio` decimal(5,4) DEFAULT 0.0000 COMMENT '任务里程占比(0-1)',
  `gps_point_count` int(11) DEFAULT 0 COMMENT 'GPS点数量',
  `task_count` int(11) DEFAULT 0 COMMENT '任务数量',
  `segment_count` int(11) DEFAULT 0 COMMENT '关联的分段数量',
  `data_source` varchar(20) DEFAULT 'gps' COMMENT '数据来源(segment-从分段汇总,gps-直接计算)',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`stats_id`),
@@ -17,24 +19,3 @@
  KEY `idx_vehicle_id` (`vehicle_id`),
  KEY `idx_stat_date` (`stat_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆里程统计表';
-- 车辆里程统计明细表(可选,用于调试和追溯)
CREATE TABLE `tb_vehicle_mileage_detail` (
  `detail_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '明细ID',
  `stats_id` bigint(20) NOT NULL COMMENT '统计ID',
  `vehicle_id` bigint(20) NOT NULL COMMENT '车辆ID',
  `segment_start_time` datetime NOT NULL COMMENT '段起始时间',
  `segment_end_time` datetime NOT NULL COMMENT '段结束时间',
  `start_longitude` decimal(10,7) DEFAULT NULL COMMENT '起点经度',
  `start_latitude` decimal(10,7) DEFAULT NULL COMMENT '起点纬度',
  `end_longitude` decimal(10,7) DEFAULT NULL COMMENT '终点经度',
  `end_latitude` decimal(10,7) DEFAULT NULL COMMENT '终点纬度',
  `segment_distance` decimal(10,3) DEFAULT 0.000 COMMENT '段距离(公里)',
  `task_distance` decimal(10,3) DEFAULT 0.000 COMMENT '任务内距离(公里)',
  `non_task_distance` decimal(10,3) DEFAULT 0.000 COMMENT '任务外距离(公里)',
  `is_in_task` tinyint(1) DEFAULT 0 COMMENT '是否完全在任务时段内(0-否,1-是,2-部分)',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`detail_id`),
  KEY `idx_stats_id` (`stats_id`),
  KEY `idx_vehicle_id` (`vehicle_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆里程统计明细表';
sql/vehicle_mileage_stats_job.sql
@@ -1,17 +1,17 @@
-- 添加车辆里程统计定时任务
-- 添加车辆里程统计定时任务(从GPS分段汇总)
INSERT INTO sys_job (job_id, job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, update_by, update_time, remark) 
VALUES (
  (SELECT IFNULL(MAX(job_id), 0) + 1 FROM sys_job t), 
  '车辆里程统计任务',
  '车辆里程统计任务(从分段汇总)',
  'DEFAULT', 
  'vehicleMileageStatsTask.calculateYesterdayMileage',
  'vehicleMileageStatsTask.aggregateYesterdayFromSegments',
  '0 30 1 * * ?', 
  '3', 
  '1', 
  '0',
  '1',
  'admin', 
  NOW(), 
  '', 
  NULL, 
  '每天凌晨1:30执行,统计昨日所有车辆的行驶里程'
  '每天凌晨1:30执行,从GPS分段里程汇总生成昨日统计数据(推荐方式,性能更好)'
);