wlzboy
2025-11-11 9529220c815bfe6e43c992fde2f392be823450eb
feat:增加天地图接口,并增加车辆GPS统计
15个文件已添加
5个文件已修改
2297 ■■■■■ 已修改文件
app/api/map.js 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/api/mileageStats.js 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleGpsController.java 394 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleMileageStatsController.java 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application.yml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/config/TiandituMapConfig.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/VehicleMileageStatsTask.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TaskTimeInterval.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/VehicleMileageStats.java 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleGpsMapper.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleMileageStatsMapper.java 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/IVehicleMileageStatsService.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VehicleMileageStatsServiceImpl.java 296 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/VehicleGpsMapper.xml 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/VehicleMileageStatsMapper.xml 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/vehicle_mileage_stats.sql 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/vehicle_mileage_stats_job.sql 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/vehicle_mileage_stats_menu.sql 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/车辆里程统计使用说明.md 202 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/车辆里程统计实现总结.md 339 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/api/map.js
@@ -143,4 +143,127 @@
      region: region || '广州'
    }
  })
}
// ==================== å¤©åœ°å›¾æŽ¥å£ ====================
// å¤©åœ°å›¾åœ°ç†ç¼–码API(地址转坐标)
export function tiandituGeocoding(address) {
  // å‚数验证
  if (!address) {
    return Promise.reject(new Error('参数不完整,缺少地址信息'))
  }
  return request({
    url: '/system/gps/tianditu/geocoding',
    method: 'get',
    params: {
      address: address
    }
  })
}
// å¤©åœ°å›¾é€†åœ°ç†ç¼–码API(坐标转地址)
export function tiandituReverseGeocoding(lon, lat) {
  // å‚数验证
  if (lat === undefined || lat === null || lon === undefined || lon === null) {
    return Promise.reject(new Error('参数不完整,缺少经纬度坐标'))
  }
  // æ£€æŸ¥å‚数有效性
  if (isNaN(lat) || isNaN(lon)) {
    return Promise.reject(new Error('参数无效,经纬度坐标格式错误'))
  }
  return request({
    url: '/system/gps/tianditu/reverseGeocoding',
    method: 'get',
    params: {
      lon: lon,
      lat: lat
    }
  })
}
// å¤©åœ°å›¾POI搜索API
export function tiandituPlaceSearch(keyWord, queryType, level, mapBound, start, count) {
  // å‚数验证
  if (!keyWord) {
    return Promise.reject(new Error('参数不完整,缺少搜索关键词'))
  }
  return request({
    url: '/system/gps/tianditu/place/search',
    method: 'get',
    params: {
      keyWord: keyWord,
      queryType: queryType || '1',
      level: level,
      mapBound: mapBound,
      start: start || 0,
      count: count || 10
    }
  })
}
// å¤©åœ°å›¾é©¾è½¦è·¯å¾„规划API
export function tiandituRouteDriving(orig, dest, mid, style) {
  // å‚数验证
  if (!orig || !dest) {
    return Promise.reject(new Error('参数不完整,缺少起点或终点坐标'))
  }
  // éªŒè¯åæ ‡æ ¼å¼ï¼ˆç»åº¦,纬度)
  const origParts = orig.split(',')
  const destParts = dest.split(',')
  if (origParts.length !== 2 || destParts.length !== 2) {
    return Promise.reject(new Error('坐标格式错误,应为:经度,纬度'))
  }
  return request({
    url: '/system/gps/tianditu/route/driving',
    method: 'get',
    params: {
      orig: orig,
      dest: dest,
      mid: mid,
      style: style || '0'
    }
  })
}
// å¤©åœ°å›¾è®¡ç®—两个地址之间的距离(组合接口:地址转坐标 + è·¯å¾„规划)
export function tiandituDistanceByAddress(fromAddress, toAddress) {
  // å‚数验证
  if (!fromAddress || !toAddress) {
    return Promise.reject(new Error('参数不完整,缺少起点或终点地址'))
  }
  return request({
    url: '/system/gps/tianditu/distance/byAddress',
    method: 'get',
    params: {
      fromAddress: fromAddress,
      toAddress: toAddress
    }
  })
}
// å¤©åœ°å›¾åœ°å€æœç´¢æç¤ºAPI(输入联想)
export function tiandituPlaceSuggestion(keyWord, region, city, count) {
  // å‚数验证
  if (!keyWord) {
    return Promise.reject(new Error('参数不完整,缺少搜索关键词'))
  }
  return request({
    url: '/system/gps/tianditu/place/suggestion',
    method: 'get',
    params: {
      keyWord: keyWord,
      region: region,
      city: city,
      count: count || 10
    }
  })
}
app/api/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(statsIds) {
  return request({
    url: '/system/mileageStats/' + statsIds,
    method: 'delete'
  })
}
// æ‰‹åŠ¨è®¡ç®—æŒ‡å®šè½¦è¾†æŒ‡å®šæ—¥æœŸçš„é‡Œç¨‹ç»Ÿè®¡
export function calculateMileage(vehicleId, statDate) {
  return request({
    url: '/system/mileageStats/calculate',
    method: 'post',
    params: {
      vehicleId: vehicleId,
      statDate: statDate
    }
  })
}
// æ‰¹é‡è®¡ç®—指定日期所有车辆的里程统计
export function batchCalculateMileage(statDate) {
  return request({
    url: '/system/mileageStats/batchCalculate',
    method: 'post',
    params: {
      statDate: statDate
    }
  })
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleGpsController.java
@@ -11,6 +11,7 @@
import com.ruoyi.system.service.*;
import com.ruoyi.common.config.TencentMapConfig;
import com.ruoyi.common.config.BaiduMapConfig;
import com.ruoyi.common.config.TiandituMapConfig;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -63,6 +64,9 @@
    
    @Autowired
    private BaiduMapConfig baiduMapConfig;
    @Autowired
    private TiandituMapConfig tiandituMapConfig;
   /**
     * æŸ¥è¯¢è½¦è¾†GPS坐标列表
@@ -740,4 +744,394 @@
            return AjaxResult.error("地址搜索失败:" + e.getMessage());
        }
    }
    // ==================== å¤©åœ°å›¾æŽ¥å£ ====================
    /**
     * å¤©åœ°å›¾åœ°ç†ç¼–码接口代理(地址转坐标)
     * æ–‡æ¡£ï¼šhttps://lbs.tianditu.gov.cn/server/geocoding.html
     */
    @Anonymous()
    @GetMapping("/tianditu/geocoding")
    public AjaxResult tiandituGeocoding(String address) {
        try {
            // æ£€æŸ¥å‚æ•°
            if (address == null || address.trim().isEmpty()) {
                return AjaxResult.error("参数不完整,缺少地址信息");
            }
            // æž„建天地图地理编码API URL
            String url = "http://api.tianditu.gov.cn/geocoder";
            String params = "ds={\"keyWord\":\"" + address + \"}" +
                           "&tk=" + tiandituMapConfig.getTk();
            logger.info("天地图地理编码请求: address={}", address);
            // å‘送HTTP请求
            String response = HttpUtils.sendGet(url, params);
            // è¿”回结果
            return AjaxResult.success("查询成功", response);
        } catch (Exception e) {
            logger.error("天地图地理编码失败", e);
            return AjaxResult.error("地理编码失败:" + e.getMessage());
        }
    }
    /**
     * å¤©åœ°å›¾é€†åœ°ç†ç¼–码接口代理(坐标转地址)
     * æ–‡æ¡£ï¼šhttps://lbs.tianditu.gov.cn/server/geocoding.html
     */
    @Anonymous()
    @GetMapping("/tianditu/reverseGeocoding")
    public AjaxResult tiandituReverseGeocoding(Double lon, Double lat) {
        try {
            // æ£€æŸ¥å‚æ•°
            if (lat == null || lon == null) {
                return AjaxResult.error("参数不完整,缺少经纬度坐标");
            }
            // æ£€æŸ¥å‚数有效性
            if (Double.isNaN(lat) || Double.isNaN(lon) ||
                Double.isInfinite(lat) || Double.isInfinite(lon)) {
                return AjaxResult.error("参数无效,经纬度坐标格式错误");
            }
            // æž„建天地图逆地理编码API URL
            String url = "http://api.tianditu.gov.cn/geocoder";
            String params = "postStr={\"lon\":" + lon + ",\"lat\":" + lat + ",\"ver\":1}" +
                           "&type=geocode" +
                           "&tk=" + tiandituMapConfig.getTk();
            logger.info("天地图逆地理编码请求: lon={}, lat={}", lon, lat);
            // å‘送HTTP请求
            String response = HttpUtils.sendGet(url, params);
            // è¿”回结果
            return AjaxResult.success("查询成功", response);
        } catch (Exception e) {
            logger.error("天地图逆地理编码失败: lon={}, lat={}", lon, lat, e);
            return AjaxResult.error("逆地理编码失败:" + e.getMessage());
        }
    }
    /**
     * å¤©åœ°å›¾åœ°ç‚¹æœç´¢æŽ¥å£ä»£ç†ï¼ˆPOI搜索)
     * æ–‡æ¡£ï¼šhttps://lbs.tianditu.gov.cn/server/search.html
     */
    @Anonymous()
    @GetMapping("/tianditu/place/search")
    public AjaxResult tiandituPlaceSearch(String keyWord, String queryType, String level,
                                          String mapBound, Integer start, Integer count) {
        try {
            // æ£€æŸ¥å‚æ•°
            if (keyWord == null || keyWord.trim().isEmpty()) {
                return AjaxResult.error("参数不完整,缺少搜索关键词");
            }
            // è®¾ç½®é»˜è®¤å€¼
            if (queryType == null || queryType.trim().isEmpty()) {
                queryType = "1"; // 1-普通搜索,7-周边搜索
            }
            if (start == null) {
                start = 0;
            }
            if (count == null) {
                count = 10;
            }
            // æž„建天地图POI搜索API URL
            String url = "http://api.tianditu.gov.cn/search";
            StringBuilder paramsBuilder = new StringBuilder();
            paramsBuilder.append("postStr={\"keyWord\":\"").append(keyWord).append("\"");
            paramsBuilder.append(",\"queryType\":\"").append(queryType).append("\"");
            if (level != null && !level.trim().isEmpty()) {
                paramsBuilder.append(",\"level\":\"").append(level).append("\"");
            }
            if (mapBound != null && !mapBound.trim().isEmpty()) {
                paramsBuilder.append(",\"mapBound\":\"").append(mapBound).append("\"");
            }
            paramsBuilder.append(",\"start\":\"").append(start).append("\"");
            paramsBuilder.append(",\"count\":\"").append(count).append("\"");
            paramsBuilder.append("}" );
            paramsBuilder.append("&type=query");
            paramsBuilder.append("&tk=").append(tiandituMapConfig.getTk());
            String params = paramsBuilder.toString();
            logger.info("天地图POI搜索请求: keyWord={}, queryType={}", keyWord, queryType);
            // å‘送HTTP请求
            String response = HttpUtils.sendGet(url, params);
            // è¿”回结果
            return AjaxResult.success("查询成功", response);
        } catch (Exception e) {
            logger.error("天地图POI搜索失败", e);
            return AjaxResult.error("POI搜索失败:" + e.getMessage());
        }
    }
    /**
     * å¤©åœ°å›¾è·¯çº¿è§„划接口代理(驾车路径规划)
     * æ–‡æ¡£ï¼šhttps://lbs.tianditu.gov.cn/server/drive.html
     */
    @Anonymous()
    @GetMapping("/tianditu/route/driving")
    public AjaxResult tiandituRouteDriving(String orig, String dest, String mid, String style) {
        try {
            // æ£€æŸ¥å‚æ•°
            if (orig == null || orig.trim().isEmpty() ||
                dest == null || dest.trim().isEmpty()) {
                return AjaxResult.error("参数不完整,缺少起点或终点坐标");
            }
            // éªŒè¯åæ ‡æ ¼å¼ï¼ˆç»åº¦,纬度)
            String[] origParts = orig.split(",");
            String[] destParts = dest.split(",");
            if (origParts.length != 2 || destParts.length != 2) {
                return AjaxResult.error("坐标格式错误,应为:经度,纬度");
            }
            // è®¾ç½®é»˜è®¤å€¼
            if (style == null || style.trim().isEmpty()) {
                style = "0"; // 0-推荐,1-避开高速
            }
            // æž„建天地图驾车路径规划API URL
            String url = "http://api.tianditu.gov.cn/drive";
            StringBuilder paramsBuilder = new StringBuilder();
            paramsBuilder.append("postStr={\"orig\":\"").append(orig).append("\"");
            paramsBuilder.append(",\"dest\":\"").append(dest).append("\"");
            if (mid != null && !mid.trim().isEmpty()) {
                paramsBuilder.append(",\"mid\":\"").append(mid).append("\"");
            }
            paramsBuilder.append(",\"style\":\"").append(style).append("\"");
            paramsBuilder.append("}" );
            paramsBuilder.append("&tk=").append(tiandituMapConfig.getTk());
            String params = paramsBuilder.toString();
            logger.info("天地图驾车路径规划请求: orig={}, dest={}", orig, dest);
            // å‘送HTTP请求
            String response = HttpUtils.sendGet(url, params);
            // è¿”回结果
            return AjaxResult.success("计算成功", response);
        } catch (Exception e) {
            logger.error("天地图驾车路径规划失败", e);
            return AjaxResult.error("路径规划失败:" + e.getMessage());
        }
    }
    /**
     * å¤©åœ°å›¾è®¡ç®—两个地址之间的距离(组合接口:地址转坐标 + è·¯å¾„规划)
     */
    @Anonymous()
    @GetMapping("/tianditu/distance/byAddress")
    public AjaxResult tiandituDistanceByAddress(String fromAddress, String toAddress) {
        try {
            // æ£€æŸ¥å‚æ•°
            if (fromAddress == null || fromAddress.trim().isEmpty() ||
                toAddress == null || toAddress.trim().isEmpty()) {
                return AjaxResult.error("参数不完整,缺少起点或终点地址");
            }
            logger.info("开始计算地址距离: fromAddress={}, toAddress={}", fromAddress, toAddress);
            // ç¬¬ä¸€æ­¥ï¼šèµ·ç‚¹åœ°å€è½¬åæ ‡
            String geocodingUrl1 = "http://api.tianditu.gov.cn/geocoder";
            String geocodingParams1 = "ds={\"keyWord\":\"" + fromAddress + \"}" +
                                     "&tk=" + tiandituMapConfig.getTk();
            String geocodingResponse1 = HttpUtils.sendGet(geocodingUrl1, geocodingParams1);
            logger.info("起点地理编码响应: {}", geocodingResponse1);
            // è§£æžèµ·ç‚¹åæ ‡
            com.alibaba.fastjson2.JSONObject geocodingJson1 = com.alibaba.fastjson2.JSONObject.parseObject(geocodingResponse1);
            if (!"0".equals(geocodingJson1.getString("status"))) {
                logger.error("起点地理编码失败: {}", geocodingResponse1);
                return AjaxResult.error("起点地址解析失败");
            }
            com.alibaba.fastjson2.JSONObject location1 = geocodingJson1.getJSONObject("location");
            if (location1 == null) {
                return AjaxResult.error("起点地址未找到对应坐标");
            }
            double fromLon = location1.getDouble("lon");
            double fromLat = location1.getDouble("lat");
            logger.info("起点坐标: lon={}, lat={}", fromLon, fromLat);
            // ç¬¬äºŒæ­¥ï¼šç»ˆç‚¹åœ°å€è½¬åæ ‡
            String geocodingUrl2 = "http://api.tianditu.gov.cn/geocoder";
            String geocodingParams2 = "ds={\"keyWord\":\"" + toAddress + \"}" +
                                     "&tk=" + tiandituMapConfig.getTk();
            String geocodingResponse2 = HttpUtils.sendGet(geocodingUrl2, geocodingParams2);
            logger.info("终点地理编码响应: {}", geocodingResponse2);
            // è§£æžç»ˆç‚¹åæ ‡
            com.alibaba.fastjson2.JSONObject geocodingJson2 = com.alibaba.fastjson2.JSONObject.parseObject(geocodingResponse2);
            if (!"0".equals(geocodingJson2.getString("status"))) {
                logger.error("终点地理编码失败: {}", geocodingResponse2);
                return AjaxResult.error("终点地址解析失败");
            }
            com.alibaba.fastjson2.JSONObject location2 = geocodingJson2.getJSONObject("location");
            if (location2 == null) {
                return AjaxResult.error("终点地址未找到对应坐标");
            }
            double toLon = location2.getDouble("lon");
            double toLat = location2.getDouble("lat");
            logger.info("终点坐标: lon={}, lat={}", toLon, toLat);
            // ç¬¬ä¸‰æ­¥ï¼šè°ƒç”¨è·¯å¾„规划接口计算距离
            String routeUrl = "http://api.tianditu.gov.cn/drive";
            String orig = fromLon + "," + fromLat;
            String dest = toLon + "," + toLat;
            String routeParams = "postStr={\"orig\":\"" + orig + "\",\"dest\":\"" + dest + "\",\"style\":\"0\"}" +
                                "&tk=" + tiandituMapConfig.getTk();
            logger.info("路径规划请求: orig={}, dest={}", orig, dest);
            String routeResponse = HttpUtils.sendGet(routeUrl, routeParams);
            logger.info("路径规划响应: {}", routeResponse);
            // è§£æžè·ç¦»ç»“æžœ
            com.alibaba.fastjson2.JSONObject routeJson = com.alibaba.fastjson2.JSONObject.parseObject(routeResponse);
            if (!"0".equals(routeJson.getString("status"))) {
                logger.error("路径规划失败: {}", routeResponse);
                return AjaxResult.error("路径规划失败");
            }
            // æå–距离信息
            com.alibaba.fastjson2.JSONObject result = routeJson.getJSONObject("result");
            if (result == null) {
                logger.error("路径规划结果为空");
                return AjaxResult.error("路径规划失败");
            }
            com.alibaba.fastjson2.JSONArray routes = result.getJSONArray("routes");
            if (routes == null || routes.isEmpty()) {
                logger.error("未找到路线信息");
                return AjaxResult.error("未找到路线信息");
            }
            com.alibaba.fastjson2.JSONObject route = routes.getJSONObject(0);
            double distance = route.getDoubleValue("distance"); // è·ç¦»ï¼Œå•位:米
            double duration = route.getDoubleValue("duration"); // æ—¶é•¿ï¼Œå•位:秒
            logger.info("计算成功: è·ç¦»={}ç±³, æ—¶é•¿={}秒", distance, duration);
            // æž„建返回结果
            Map<String, Object> resultMap = new HashMap<>();
            resultMap.put("distance", (int)distance); // è·ç¦»ï¼ˆç±³ï¼‰
            resultMap.put("duration", (int)duration); // æ—¶é•¿ï¼ˆç§’)
            resultMap.put("distanceKm", String.format("%.1f", distance / 1000.0)); // è·ç¦»ï¼ˆå…¬é‡Œï¼‰
            resultMap.put("durationMin", (int)(duration / 60)); // æ—¶é•¿ï¼ˆåˆ†é’Ÿï¼‰
            // èµ·ç‚¹åæ ‡
            Map<String, Object> fromLocation = new HashMap<>();
            fromLocation.put("lon", fromLon);
            fromLocation.put("lat", fromLat);
            resultMap.put("fromLocation", fromLocation);
            // ç»ˆç‚¹åæ ‡
            Map<String, Object> toLocation = new HashMap<>();
            toLocation.put("lon", toLon);
            toLocation.put("lat", toLat);
            resultMap.put("toLocation", toLocation);
            return AjaxResult.success("计算成功", resultMap);
        } catch (Exception e) {
            logger.error("计算地址距离失败", e);
            return AjaxResult.error("计算距离失败:" + e.getMessage());
        }
    }
    /**
     * å¤©åœ°å›¾è¾“入提示接口代理(地址联想)
     * æ–‡æ¡£ï¼šhttps://lbs.tianditu.gov.cn/server/suggestion.html
     */
    @Anonymous()
    @GetMapping("/tianditu/place/suggestion")
    public AjaxResult tiandituPlaceSuggestion(String keyWord, String region, String city, Integer count) {
        try {
            // æ£€æŸ¥å‚æ•°
            if (keyWord == null || keyWord.trim().isEmpty()) {
                return AjaxResult.error("参数不完整,缺少搜索关键词");
            }
            // è®¾ç½®é»˜è®¤å€¼
            if (count == null) {
                count = 10;
            }
            // æž„建天地图输入提示API URL
            String url = "http://api.tianditu.gov.cn/search";
            StringBuilder paramsBuilder = new StringBuilder();
            paramsBuilder.append("postStr={\"keyWord\":\"").append(keyWord).append("\"");
            if (region != null && !region.trim().isEmpty()) {
                paramsBuilder.append(",\"region\":\"").append(region).append("\"");
            }
            if (city != null && !city.trim().isEmpty()) {
                paramsBuilder.append(",\"city\":\"").append(city).append("\"");
            }
            paramsBuilder.append(",\"count\":\"").append(count).append("\"");
            paramsBuilder.append("}" );
            paramsBuilder.append("&type=suggest");
            paramsBuilder.append("&tk=").append(tiandituMapConfig.getTk());
            String params = paramsBuilder.toString();
            logger.info("天地图输入提示请求: keyWord={}, region={}", keyWord, region);
            // å‘送HTTP请求
            String response = HttpUtils.sendGet(url, params);
            logger.debug("天地图输入提示响应: {}", response);
            // è§£æžå“åº”
            com.alibaba.fastjson2.JSONObject jsonResponse = com.alibaba.fastjson2.JSONObject.parseObject(response);
            if (!"0".equals(jsonResponse.getString("status"))) {
                logger.error("输入提示失败: {}", response);
                return AjaxResult.error("地址搜索失败");
            }
            // æå–提示列表
            com.alibaba.fastjson2.JSONArray results = jsonResponse.getJSONArray("suggests");
            if (results == null || results.isEmpty()) {
                logger.info("未找到匹配的地址");
                return AjaxResult.success("查询成功", new ArrayList<>());
            }
            // æž„建返回结果
            List<Map<String, Object>> suggestions = new ArrayList<>();
            for (int i = 0; i < results.size(); i++) {
                com.alibaba.fastjson2.JSONObject item = results.getJSONObject(i);
                Map<String, Object> suggestion = new HashMap<>();
                suggestion.put("name", item.getString("name")); // åç§°
                suggestion.put("address", item.getString("address")); // åœ°å€
                suggestion.put("province", item.getString("province")); // çœ
                suggestion.put("city", item.getString("city")); // å¸‚
                suggestion.put("district", item.getString("district")); // åŒº
                // ç»çº¬åº¦ä¿¡æ¯
                com.alibaba.fastjson2.JSONObject location = item.getJSONObject("location");
                if (location != null) {
                    Map<String, Object> locationMap = new HashMap<>();
                    locationMap.put("lon", location.getDouble("lon"));
                    locationMap.put("lat", location.getDouble("lat"));
                    suggestion.put("location", locationMap);
                }
                suggestions.add(suggestion);
            }
            logger.info("地址搜索提示成功: æ‰¾åˆ°{}条结果", suggestions.size());
            return AjaxResult.success("查询成功", suggestions);
        } catch (Exception e) {
            logger.error("地址搜索提示失败", e);
            return AjaxResult.error("地址搜索失败:" + e.getMessage());
        }
    }
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VehicleMileageStatsController.java
New file
@@ -0,0 +1,135 @@
package com.ruoyi.web.controller.system;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
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.common.utils.poi.ExcelUtil;
import com.ruoyi.system.domain.VehicleMileageStats;
import com.ruoyi.system.service.IVehicleMileageStatsService;
/**
 * è½¦è¾†é‡Œç¨‹ç»Ÿè®¡Controller
 */
@RestController
@RequestMapping("/system/mileageStats")
public class VehicleMileageStatsController extends BaseController {
    @Autowired
    private IVehicleMileageStatsService vehicleMileageStatsService;
    /**
     * æŸ¥è¯¢è½¦è¾†é‡Œç¨‹ç»Ÿè®¡åˆ—表
     */
    @PreAuthorize("@ss.hasPermi('system:mileageStats:list')")
    @GetMapping("/list")
    public TableDataInfo list(VehicleMileageStats vehicleMileageStats) {
        startPage();
        List<VehicleMileageStats> list = vehicleMileageStatsService.selectVehicleMileageStatsList(vehicleMileageStats);
        return getDataTable(list);
    }
    /**
     * å¯¼å‡ºè½¦è¾†é‡Œç¨‹ç»Ÿè®¡åˆ—表
     */
    @PreAuthorize("@ss.hasPermi('system:mileageStats:export')")
    @Log(title = "车辆里程统计", businessType = BusinessType.EXPORT)
    @PostMapping("/export")
    public void export(HttpServletResponse response, VehicleMileageStats vehicleMileageStats) {
        List<VehicleMileageStats> list = vehicleMileageStatsService.selectVehicleMileageStatsList(vehicleMileageStats);
        ExcelUtil<VehicleMileageStats> util = new ExcelUtil<VehicleMileageStats>(VehicleMileageStats.class);
        util.exportExcel(response, list, "车辆里程统计数据");
    }
    /**
     * èŽ·å–è½¦è¾†é‡Œç¨‹ç»Ÿè®¡è¯¦ç»†ä¿¡æ¯
     */
    @PreAuthorize("@ss.hasPermi('system:mileageStats:query')")
    @GetMapping(value = "/{statsId}")
    public AjaxResult getInfo(@PathVariable("statsId") Long statsId) {
        return success(vehicleMileageStatsService.selectVehicleMileageStatsById(statsId));
    }
    /**
     * æ–°å¢žè½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     */
    @PreAuthorize("@ss.hasPermi('system:mileageStats:add')")
    @Log(title = "车辆里程统计", businessType = BusinessType.INSERT)
    @PostMapping
    public AjaxResult add(@RequestBody VehicleMileageStats vehicleMileageStats) {
        return toAjax(vehicleMileageStatsService.insertVehicleMileageStats(vehicleMileageStats));
    }
    /**
     * ä¿®æ”¹è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     */
    @PreAuthorize("@ss.hasPermi('system:mileageStats:edit')")
    @Log(title = "车辆里程统计", businessType = BusinessType.UPDATE)
    @PutMapping
    public AjaxResult edit(@RequestBody VehicleMileageStats vehicleMileageStats) {
        return toAjax(vehicleMileageStatsService.updateVehicleMileageStats(vehicleMileageStats));
    }
    /**
     * åˆ é™¤è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     */
    @PreAuthorize("@ss.hasPermi('system:mileageStats:remove')")
    @Log(title = "车辆里程统计", businessType = BusinessType.DELETE)
    @DeleteMapping("/{statsIds}")
    public AjaxResult remove(@PathVariable Long[] statsIds) {
        return toAjax(vehicleMileageStatsService.deleteVehicleMileageStatsByIds(statsIds));
    }
    /**
     * æ‰‹åŠ¨è§¦å‘è®¡ç®—æŒ‡å®šè½¦è¾†æŒ‡å®šæ—¥æœŸçš„é‡Œç¨‹ç»Ÿè®¡
     */
    @PreAuthorize("@ss.hasPermi('system:mileageStats:calculate')")
    @Log(title = "车辆里程统计", businessType = BusinessType.OTHER)
    @PostMapping("/calculate")
    public AjaxResult calculate(Long vehicleId, String statDate) {
        try {
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
            Date date = sdf.parse(statDate);
            VehicleMileageStats stats = vehicleMileageStatsService.calculateAndSaveMileageStats(vehicleId, date);
            if (stats != null) {
                return success("里程统计计算成功", stats);
            } else {
                return error("该车辆在指定日期无GPS数据");
            }
        } catch (Exception e) {
            return error("里程统计计算失败:" + e.getMessage());
        }
    }
    /**
     * æ‰¹é‡è®¡ç®—指定日期所有车辆的里程统计
     */
    @PreAuthorize("@ss.hasPermi('system:mileageStats:batch')")
    @Log(title = "车辆里程统计", businessType = BusinessType.OTHER)
    @PostMapping("/batchCalculate")
    public AjaxResult batchCalculate(String statDate) {
        try {
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
            Date date = sdf.parse(statDate);
            int count = vehicleMileageStatsService.batchCalculateMileageStats(date);
            return success("批量里程统计完成,成功统计 " + count + " è¾†è½¦");
        } catch (Exception e) {
            return error("批量里程统计失败:" + e.getMessage());
        }
    }
}
ruoyi-admin/src/main/resources/application.yml
@@ -165,3 +165,8 @@
baidu:
  map:
    ak: GX7G1RmAbTEQHor9NKpzRiB2jerqaY1E  # è¯·æ›¿æ¢ä¸ºæ‚¨çš„百度地图API Key
# å¤©åœ°å›¾é…ç½®
tianditu:
  map:
    tk: 4d1d0b3a4a03b9c5099c0e25ab1b23f3  # è¯·æ›¿æ¢ä¸ºæ‚¨çš„天地图API Key
ruoyi-common/src/main/java/com/ruoyi/common/config/TiandituMapConfig.java
New file
@@ -0,0 +1,25 @@
package com.ruoyi.common.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
 * å¤©åœ°å›¾é…ç½®ç±»
 */
@Configuration
@ConfigurationProperties(prefix = "tianditu.map")
public class TiandituMapConfig {
    /**
     * å¤©åœ°å›¾API Key (tk参数)
     */
    private String tk;
    public String getTk() {
        return tk;
    }
    public void setTk(String tk) {
        this.tk = tk;
    }
}
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/VehicleMileageStatsTask.java
New file
@@ -0,0 +1,84 @@
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.IVehicleMileageStatsService;
/**
 * è½¦è¾†é‡Œç¨‹ç»Ÿè®¡å®šæ—¶ä»»åŠ¡
 */
@Component("vehicleMileageStatsTask")
public class VehicleMileageStatsTask {
    private static final Logger logger = LoggerFactory.getLogger(VehicleMileageStatsTask.class);
    @Autowired
    private IVehicleMileageStatsService vehicleMileageStatsService;
    /**
     * è®¡ç®—昨日所有车辆的里程统计
     */
    public void calculateYesterdayMileage() {
        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.batchCalculateMileageStats(yesterday);
            logger.info("车辆里程统计定时任务执行完成 - æˆåŠŸç»Ÿè®¡: {} è¾†è½¦", successCount);
        } catch (Exception e) {
            logger.error("车辆里程统计定时任务执行失败", e);
            throw new RuntimeException("定时任务执行失败: " + e.getMessage());
        }
    }
    /**
     * è®¡ç®—指定日期的里程统计(用于手动触发或补算历史数据)
     *
     * @param dateStr æ—¥æœŸå­—符串,格式:yyyy-MM-dd
     */
    public void calculateMileageByDate(String dateStr) {
        logger.info("开始执行车辆里程统计定时任务 - ç»Ÿè®¡æ—¥æœŸ: {}", dateStr);
        try {
            // è§£æžæ—¥æœŸå­—符串
            String[] parts = dateStr.split("-");
            if (parts.length != 3) {
                throw new IllegalArgumentException("日期格式错误,应为: yyyy-MM-dd");
            }
            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();
            // æ‰¹é‡è®¡ç®—里程统计
            int successCount = vehicleMileageStatsService.batchCalculateMileageStats(targetDate);
            logger.info("车辆里程统计定时任务执行完成 - æ—¥æœŸ: {}, æˆåŠŸç»Ÿè®¡: {} è¾†è½¦", dateStr, successCount);
        } catch (Exception e) {
            logger.error("车辆里程统计定时任务执行失败 - æ—¥æœŸ: {}", dateStr, e);
            throw new RuntimeException("定时任务执行失败: " + e.getMessage());
        }
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/TaskTimeInterval.java
New file
@@ -0,0 +1,51 @@
package com.ruoyi.system.domain;
import java.util.Date;
/**
 * ä»»åŠ¡æ—¶é—´åŒºé—´
 */
public class TaskTimeInterval {
    /** ä»»åŠ¡ID */
    private Long taskId;
    /** å¼€å§‹æ—¶é—´ */
    private Date startTime;
    /** ç»“束时间 */
    private Date endTime;
    public TaskTimeInterval() {
    }
    public TaskTimeInterval(Long taskId, Date startTime, Date endTime) {
        this.taskId = taskId;
        this.startTime = startTime;
        this.endTime = endTime;
    }
    public Long getTaskId() {
        return taskId;
    }
    public void setTaskId(Long taskId) {
        this.taskId = taskId;
    }
    public Date getStartTime() {
        return startTime;
    }
    public void setStartTime(Date startTime) {
        this.startTime = startTime;
    }
    public Date getEndTime() {
        return endTime;
    }
    public void setEndTime(Date endTime) {
        this.endTime = endTime;
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/VehicleMileageStats.java
New file
@@ -0,0 +1,134 @@
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;
/**
 * è½¦è¾†é‡Œç¨‹ç»Ÿè®¡å¯¹è±¡ tb_vehicle_mileage_stats
 */
public class VehicleMileageStats extends BaseEntity {
    private static final long serialVersionUID = 1L;
    /** ç»Ÿè®¡ID */
    private Long statsId;
    /** è½¦è¾†ID */
    @Excel(name = "车辆ID")
    private Long vehicleId;
    /** è½¦ç‰Œå· */
    @Excel(name = "车牌号")
    private String vehicleNo;
    /** ç»Ÿè®¡æ—¥æœŸ */
    @JsonFormat(pattern = "yyyy-MM-dd")
    @Excel(name = "统计日期", width = 30, dateFormat = "yyyy-MM-dd")
    private Date statDate;
    /** æ€»é‡Œç¨‹(公里) */
    @Excel(name = "总里程(公里)")
    private BigDecimal totalMileage;
    /** ä»»åŠ¡æ—¶æ®µé‡Œç¨‹(公里) */
    @Excel(name = "任务时段里程(公里)")
    private BigDecimal taskMileage;
    /** éžä»»åŠ¡æ—¶æ®µé‡Œç¨‹(公里) */
    @Excel(name = "非任务时段里程(公里)")
    private BigDecimal nonTaskMileage;
    /** ä»»åŠ¡é‡Œç¨‹å æ¯”(0-1) */
    @Excel(name = "任务里程占比")
    private BigDecimal taskRatio;
    /** GPS点数量 */
    @Excel(name = "GPS点数量")
    private Integer gpsPointCount;
    /** ä»»åŠ¡æ•°é‡ */
    @Excel(name = "任务数量")
    private Integer taskCount;
    public Long getStatsId() {
        return statsId;
    }
    public void setStatsId(Long statsId) {
        this.statsId = statsId;
    }
    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 getStatDate() {
        return statDate;
    }
    public void setStatDate(Date statDate) {
        this.statDate = statDate;
    }
    public BigDecimal getTotalMileage() {
        return totalMileage;
    }
    public void setTotalMileage(BigDecimal totalMileage) {
        this.totalMileage = totalMileage;
    }
    public BigDecimal getTaskMileage() {
        return taskMileage;
    }
    public void setTaskMileage(BigDecimal taskMileage) {
        this.taskMileage = taskMileage;
    }
    public BigDecimal getNonTaskMileage() {
        return nonTaskMileage;
    }
    public void setNonTaskMileage(BigDecimal nonTaskMileage) {
        this.nonTaskMileage = nonTaskMileage;
    }
    public BigDecimal getTaskRatio() {
        return taskRatio;
    }
    public void setTaskRatio(BigDecimal taskRatio) {
        this.taskRatio = taskRatio;
    }
    public Integer getGpsPointCount() {
        return gpsPointCount;
    }
    public void setGpsPointCount(Integer gpsPointCount) {
        this.gpsPointCount = gpsPointCount;
    }
    public Integer getTaskCount() {
        return taskCount;
    }
    public void setTaskCount(Integer taskCount) {
        this.taskCount = taskCount;
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleGpsMapper.java
@@ -1,6 +1,8 @@
package com.ruoyi.system.mapper;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.system.domain.VehicleGps;
/**
@@ -48,4 +50,23 @@
     * @return åˆ é™¤çš„记录数
     */
    public int deleteVehicleGpsBeforeDate();
    /**
     * æŸ¥è¯¢è½¦è¾†åœ¨æŒ‡å®šæ—¶é—´èŒƒå›´å†…çš„GPS数据(按采集时间排序)
     *
     * @param vehicleId è½¦è¾†ID
     * @param startTime å¼€å§‹æ—¶é—´
     * @param endTime ç»“束时间
     * @return GPS坐标列表
     */
    public List<VehicleGps> selectGpsDataByTimeRange(@Param("vehicleId") Long vehicleId,
                                                       @Param("startTime") Date startTime,
                                                       @Param("endTime") Date endTime);
    /**
     * æŸ¥è¯¢æ‰€æœ‰æ´»è·ƒè½¦è¾†ID列表
     *
     * @return è½¦è¾†ID列表
     */
    public List<Long> selectActiveVehicleIds();
ruoyi-system/src/main/java/com/ruoyi/system/mapper/VehicleMileageStatsMapper.java
New file
@@ -0,0 +1,83 @@
package com.ruoyi.system.mapper;
import java.util.Date;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.system.domain.VehicleMileageStats;
import com.ruoyi.system.domain.TaskTimeInterval;
/**
 * è½¦è¾†é‡Œç¨‹ç»Ÿè®¡Mapper接口
 */
public interface VehicleMileageStatsMapper {
    /**
     * æŸ¥è¯¢è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     *
     * @param statsId ç»Ÿè®¡ID
     * @return è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     */
    public VehicleMileageStats selectVehicleMileageStatsById(Long statsId);
    /**
     * æŸ¥è¯¢è½¦è¾†é‡Œç¨‹ç»Ÿè®¡åˆ—表
     *
     * @param vehicleMileageStats è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     * @return è½¦è¾†é‡Œç¨‹ç»Ÿè®¡é›†åˆ
     */
    public List<VehicleMileageStats> selectVehicleMileageStatsList(VehicleMileageStats vehicleMileageStats);
    /**
     * æ–°å¢žè½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     *
     * @param vehicleMileageStats è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     * @return ç»“æžœ
     */
    public int insertVehicleMileageStats(VehicleMileageStats vehicleMileageStats);
    /**
     * ä¿®æ”¹è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     *
     * @param vehicleMileageStats è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     * @return ç»“æžœ
     */
    public int updateVehicleMileageStats(VehicleMileageStats vehicleMileageStats);
    /**
     * åˆ é™¤è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     *
     * @param statsId ç»Ÿè®¡ID
     * @return ç»“æžœ
     */
    public int deleteVehicleMileageStatsById(Long statsId);
    /**
     * æ‰¹é‡åˆ é™¤è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     *
     * @param statsIds éœ€è¦åˆ é™¤çš„æ•°æ®ID
     * @return ç»“æžœ
     */
    public int deleteVehicleMileageStatsByIds(Long[] statsIds);
    /**
     * æŸ¥è¯¢è½¦è¾†åœ¨æŒ‡å®šæ—¥æœŸçš„统计记录
     *
     * @param vehicleId è½¦è¾†ID
     * @param statDate ç»Ÿè®¡æ—¥æœŸ
     * @return ç»Ÿè®¡è®°å½•
     */
    public VehicleMileageStats selectByVehicleIdAndDate(@Param("vehicleId") Long vehicleId,
                                                         @Param("statDate") Date statDate);
    /**
     * æŸ¥è¯¢è½¦è¾†åœ¨æŒ‡å®šæ—¶é—´èŒƒå›´å†…的任务时间区间
     *
     * @param vehicleId è½¦è¾†ID
     * @param startTime å¼€å§‹æ—¶é—´
     * @param endTime ç»“束时间
     * @return ä»»åŠ¡æ—¶é—´åŒºé—´åˆ—è¡¨
     */
    public List<TaskTimeInterval> selectTaskTimeIntervals(@Param("vehicleId") Long vehicleId,
                                                           @Param("startTime") Date startTime,
                                                           @Param("endTime") Date endTime);
}
ruoyi-system/src/main/java/com/ruoyi/system/service/IVehicleMileageStatsService.java
New file
@@ -0,0 +1,76 @@
package com.ruoyi.system.service;
import java.util.Date;
import java.util.List;
import com.ruoyi.system.domain.VehicleMileageStats;
/**
 * è½¦è¾†é‡Œç¨‹ç»Ÿè®¡Service接口
 */
public interface IVehicleMileageStatsService {
    /**
     * æŸ¥è¯¢è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     *
     * @param statsId ç»Ÿè®¡ID
     * @return è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     */
    public VehicleMileageStats selectVehicleMileageStatsById(Long statsId);
    /**
     * æŸ¥è¯¢è½¦è¾†é‡Œç¨‹ç»Ÿè®¡åˆ—表
     *
     * @param vehicleMileageStats è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     * @return è½¦è¾†é‡Œç¨‹ç»Ÿè®¡é›†åˆ
     */
    public List<VehicleMileageStats> selectVehicleMileageStatsList(VehicleMileageStats vehicleMileageStats);
    /**
     * æ–°å¢žè½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     *
     * @param vehicleMileageStats è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     * @return ç»“æžœ
     */
    public int insertVehicleMileageStats(VehicleMileageStats vehicleMileageStats);
    /**
     * ä¿®æ”¹è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     *
     * @param vehicleMileageStats è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     * @return ç»“æžœ
     */
    public int updateVehicleMileageStats(VehicleMileageStats vehicleMileageStats);
    /**
     * æ‰¹é‡åˆ é™¤è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     *
     * @param statsIds éœ€è¦åˆ é™¤çš„æ•°æ®ID
     * @return ç»“æžœ
     */
    public int deleteVehicleMileageStatsByIds(Long[] statsIds);
    /**
     * åˆ é™¤è½¦è¾†é‡Œç¨‹ç»Ÿè®¡ä¿¡æ¯
     *
     * @param statsId ç»Ÿè®¡ID
     * @return ç»“æžœ
     */
    public int deleteVehicleMileageStatsById(Long statsId);
    /**
     * è®¡ç®—并保存指定车辆指定日期的里程统计
     *
     * @param vehicleId è½¦è¾†ID
     * @param statDate ç»Ÿè®¡æ—¥æœŸ
     * @return ç»Ÿè®¡ç»“æžœ
     */
    public VehicleMileageStats calculateAndSaveMileageStats(Long vehicleId, Date statDate);
    /**
     * æ‰¹é‡è®¡ç®—所有车辆指定日期的里程统计
     *
     * @param statDate ç»Ÿè®¡æ—¥æœŸ
     * @return æˆåŠŸç»Ÿè®¡çš„è½¦è¾†æ•°é‡
     */
    public int batchCalculateMileageStats(Date statDate);
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VehicleMileageStatsServiceImpl.java
New file
@@ -0,0 +1,296 @@
package com.ruoyi.system.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Calendar;
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.stereotype.Service;
import com.ruoyi.system.domain.VehicleGps;
import com.ruoyi.system.domain.VehicleMileageStats;
import com.ruoyi.system.domain.TaskTimeInterval;
import com.ruoyi.system.mapper.VehicleGpsMapper;
import com.ruoyi.system.mapper.VehicleMileageStatsMapper;
import com.ruoyi.system.service.IVehicleMileageStatsService;
/**
 * è½¦è¾†é‡Œç¨‹ç»Ÿè®¡Service业务层处理
 */
@Service
public class VehicleMileageStatsServiceImpl implements IVehicleMileageStatsService {
    private static final Logger logger = LoggerFactory.getLogger(VehicleMileageStatsServiceImpl.class);
    /** åœ°çƒåŠå¾„(公里) */
    private static final double EARTH_RADIUS_KM = 6371.0;
    @Autowired
    private VehicleMileageStatsMapper vehicleMileageStatsMapper;
    @Autowired
    private VehicleGpsMapper vehicleGpsMapper;
    /**
     * æŸ¥è¯¢è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     */
    @Override
    public VehicleMileageStats selectVehicleMileageStatsById(Long statsId) {
        return vehicleMileageStatsMapper.selectVehicleMileageStatsById(statsId);
    }
    /**
     * æŸ¥è¯¢è½¦è¾†é‡Œç¨‹ç»Ÿè®¡åˆ—表
     */
    @Override
    public List<VehicleMileageStats> selectVehicleMileageStatsList(VehicleMileageStats vehicleMileageStats) {
        return vehicleMileageStatsMapper.selectVehicleMileageStatsList(vehicleMileageStats);
    }
    /**
     * æ–°å¢žè½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     */
    @Override
    public int insertVehicleMileageStats(VehicleMileageStats vehicleMileageStats) {
        return vehicleMileageStatsMapper.insertVehicleMileageStats(vehicleMileageStats);
    }
    /**
     * ä¿®æ”¹è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     */
    @Override
    public int updateVehicleMileageStats(VehicleMileageStats vehicleMileageStats) {
        return vehicleMileageStatsMapper.updateVehicleMileageStats(vehicleMileageStats);
    }
    /**
     * æ‰¹é‡åˆ é™¤è½¦è¾†é‡Œç¨‹ç»Ÿè®¡
     */
    @Override
    public int deleteVehicleMileageStatsByIds(Long[] statsIds) {
        return vehicleMileageStatsMapper.deleteVehicleMileageStatsByIds(statsIds);
    }
    /**
     * åˆ é™¤è½¦è¾†é‡Œç¨‹ç»Ÿè®¡ä¿¡æ¯
     */
    @Override
    public int deleteVehicleMileageStatsById(Long statsId) {
        return vehicleMileageStatsMapper.deleteVehicleMileageStatsById(statsId);
    }
    /**
     * è®¡ç®—并保存指定车辆指定日期的里程统计
     */
    @Override
    public VehicleMileageStats calculateAndSaveMileageStats(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. æŸ¥è¯¢è½¦è¾†åœ¨è¯¥æ—¥æœŸçš„GPS数据(按时间排序)
            List<VehicleGps> gpsList = vehicleGpsMapper.selectGpsDataByTimeRange(vehicleId, dayStart, dayEnd);
            if (gpsList == null || gpsList.isEmpty()) {
                logger.info("车辆ID: {} åœ¨æ—¥æœŸ: {} æ— GPS数据", vehicleId, statDate);
                return null;
            }
            // 3. æŸ¥è¯¢è½¦è¾†åœ¨è¯¥æ—¥æœŸçš„任务时间区间
            List<TaskTimeInterval> taskIntervals = vehicleMileageStatsMapper.selectTaskTimeIntervals(vehicleId, dayStart, dayEnd);
            // 4. è®¡ç®—里程
            MileageCalculation calculation = calculateMileage(gpsList, taskIntervals);
            // 5. æŸ¥è¯¢æˆ–创建统计记录
            VehicleMileageStats stats = vehicleMileageStatsMapper.selectByVehicleIdAndDate(vehicleId, statDate);
            boolean isNew = (stats == null);
            if (isNew) {
                stats = new VehicleMileageStats();
                stats.setVehicleId(vehicleId);
                stats.setStatDate(statDate);
                // èŽ·å–è½¦ç‰Œå·
                if (!gpsList.isEmpty() && gpsList.get(0).getVehicleNo() != null) {
                    stats.setVehicleNo(gpsList.get(0).getVehicleNo());
                }
            }
            // 6. è®¾ç½®ç»Ÿè®¡æ•°æ®
            stats.setTotalMileage(calculation.totalMileage);
            stats.setTaskMileage(calculation.taskMileage);
            stats.setNonTaskMileage(calculation.nonTaskMileage);
            stats.setTaskRatio(calculation.taskRatio);
            stats.setGpsPointCount(gpsList.size());
            stats.setTaskCount(taskIntervals == null ? 0 : taskIntervals.size());
            // 7. ä¿å­˜åˆ°æ•°æ®åº“
            if (isNew) {
                vehicleMileageStatsMapper.insertVehicleMileageStats(stats);
            } else {
                vehicleMileageStatsMapper.updateVehicleMileageStats(stats);
            }
            logger.info("车辆ID: {} æ—¥æœŸ: {} é‡Œç¨‹ç»Ÿè®¡å®Œæˆ - æ€»é‡Œç¨‹: {}km, ä»»åŠ¡é‡Œç¨‹: {}km, éžä»»åŠ¡é‡Œç¨‹: {}km, å æ¯”: {}",
                       vehicleId, statDate, calculation.totalMileage, calculation.taskMileage,
                       calculation.nonTaskMileage, calculation.taskRatio);
            return stats;
        } catch (Exception e) {
            logger.error("计算车辆里程统计失败 - è½¦è¾†ID: {}, æ—¥æœŸ: {}", vehicleId, statDate, e);
            throw new RuntimeException("计算里程统计失败: " + e.getMessage());
        }
    }
    /**
     * æ‰¹é‡è®¡ç®—所有车辆指定日期的里程统计
     */
    @Override
    public int batchCalculateMileageStats(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 {
                    calculateAndSaveMileageStats(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());
        }
    }
    /**
     * è®¡ç®—里程的内部方法
     */
    private MileageCalculation calculateMileage(List<VehicleGps> gpsList, List<TaskTimeInterval> taskIntervals) {
        MileageCalculation result = new MileageCalculation();
        // éåކGPS点,计算相邻点之间的距离
        for (int i = 0; i < gpsList.size() - 1; i++) {
            VehicleGps p1 = gpsList.get(i);
            VehicleGps p2 = gpsList.get(i + 1);
            // è®¡ç®—两点间距离(使用Haversine公式)
            double distance = calculateDistance(
                p1.getLatitude().doubleValue(),
                p1.getLongitude().doubleValue(),
                p2.getLatitude().doubleValue(),
                p2.getLongitude().doubleValue()
            );
            // èŽ·å–è¿™æ®µè·ç¦»çš„æ—¶é—´åŒºé—´
            Date segmentStart = p1.getCollectTime();
            Date segmentEnd = p2.getCollectTime();
            // è®¡ç®—这段距离在任务时段的占比
            double taskRatio = calculateTaskOverlapRatio(segmentStart, segmentEnd, taskIntervals);
            // åˆ†æ‘Šé‡Œç¨‹
            double taskDistance = distance * taskRatio;
            double nonTaskDistance = distance * (1 - taskRatio);
            result.totalMileage = result.totalMileage.add(BigDecimal.valueOf(distance));
            result.taskMileage = result.taskMileage.add(BigDecimal.valueOf(taskDistance));
            result.nonTaskMileage = result.nonTaskMileage.add(BigDecimal.valueOf(nonTaskDistance));
        }
        // è®¡ç®—任务里程占比
        if (result.totalMileage.compareTo(BigDecimal.ZERO) > 0) {
            result.taskRatio = result.taskMileage.divide(result.totalMileage, 4, RoundingMode.HALF_UP);
        }
        // ä¿ç•™ä¸¤ä½å°æ•°
        result.totalMileage = result.totalMileage.setScale(2, RoundingMode.HALF_UP);
        result.taskMileage = result.taskMileage.setScale(2, RoundingMode.HALF_UP);
        result.nonTaskMileage = result.nonTaskMileage.setScale(2, RoundingMode.HALF_UP);
        return result;
    }
    /**
     * ä½¿ç”¨Haversine公式计算两个GPS坐标之间的距离(公里)
     */
    private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
        // å°†è§’度转换为弧度
        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 double calculateTaskOverlapRatio(Date segmentStart, Date segmentEnd, List<TaskTimeInterval> taskIntervals) {
        if (taskIntervals == null || taskIntervals.isEmpty()) {
            return 0.0;
        }
        long segmentDuration = segmentEnd.getTime() - segmentStart.getTime();
        if (segmentDuration <= 0) {
            return 0.0;
        }
        long totalOverlap = 0;
        for (TaskTimeInterval task : taskIntervals) {
            // è®¡ç®—重叠时间
            long overlapStart = Math.max(segmentStart.getTime(), task.getStartTime().getTime());
            long overlapEnd = Math.min(segmentEnd.getTime(), task.getEndTime().getTime());
            if (overlapEnd > overlapStart) {
                totalOverlap += (overlapEnd - overlapStart);
            }
        }
        return (double) totalOverlap / segmentDuration;
    }
    /**
     * é‡Œç¨‹è®¡ç®—结果内部类
     */
    private static class MileageCalculation {
        BigDecimal totalMileage = BigDecimal.ZERO;
        BigDecimal taskMileage = BigDecimal.ZERO;
        BigDecimal nonTaskMileage = BigDecimal.ZERO;
        BigDecimal taskRatio = BigDecimal.ZERO;
    }
}
ruoyi-system/src/main/resources/mapper/system/VehicleGpsMapper.xml
@@ -119,4 +119,21 @@
            where g2.vehicle_id = g.vehicle_id
        )
    </delete>
    <select id="selectGpsDataByTimeRange" resultMap="VehicleGpsResult">
        select gps_id, vehicle_id, device_id, longitude, latitude, altitude, speed, direction,
               collect_time, device_report_time, platform_process_time, create_time
        from tb_vehicle_gps
        where vehicle_id = #{vehicleId}
          and collect_time &gt;= #{startTime}
          and collect_time &lt;= #{endTime}
        order by collect_time
    </select>
    <select id="selectActiveVehicleIds" resultType="Long">
        select distinct vehicle_id
        from tb_vehicle_gps
        where collect_time &gt;= DATE_SUB(NOW(), INTERVAL 7 DAY)
        order by vehicle_id
    </select>
</mapper> 
ruoyi-system/src/main/resources/mapper/system/VehicleMileageStatsMapper.xml
New file
@@ -0,0 +1,132 @@
<?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.VehicleMileageStatsMapper">
    <resultMap type="VehicleMileageStats" id="VehicleMileageStatsResult">
        <id     property="statsId"         column="stats_id"           />
        <result property="vehicleId"       column="vehicle_id"         />
        <result property="vehicleNo"       column="vehicle_no"         />
        <result property="statDate"        column="stat_date"          />
        <result property="totalMileage"    column="total_mileage"      />
        <result property="taskMileage"     column="task_mileage"       />
        <result property="nonTaskMileage"  column="non_task_mileage"   />
        <result property="taskRatio"       column="task_ratio"         />
        <result property="gpsPointCount"   column="gps_point_count"    />
        <result property="taskCount"       column="task_count"         />
        <result property="createTime"      column="create_time"        />
        <result property="updateTime"      column="update_time"        />
    </resultMap>
    <resultMap type="TaskTimeInterval" id="TaskTimeIntervalResult">
        <result property="taskId"          column="task_id"            />
        <result property="startTime"       column="start_time"         />
        <result property="endTime"         column="end_time"           />
    </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
    </sql>
    <select id="selectVehicleMileageStatsList" parameterType="VehicleMileageStats" resultMap="VehicleMileageStatsResult">
        <include refid="selectVehicleMileageStatsVo"/>
        <where>
            <if test="vehicleId != null">
                and vehicle_id = #{vehicleId}
            </if>
            <if test="vehicleNo != null and vehicleNo != ''">
                and vehicle_no = #{vehicleNo}
            </if>
            <if test="statDate != null">
                and stat_date = #{statDate}
            </if>
            <if test="params.beginStatDate != null and params.beginStatDate != ''">
                and stat_date &gt;= #{params.beginStatDate}
            </if>
            <if test="params.endStatDate != null and params.endStatDate != ''">
                and stat_date &lt;= #{params.endStatDate}
            </if>
        </where>
        order by stat_date desc, vehicle_id
    </select>
    <select id="selectVehicleMileageStatsById" parameterType="Long" resultMap="VehicleMileageStatsResult">
        <include refid="selectVehicleMileageStatsVo"/>
        where stats_id = #{statsId}
    </select>
    <select id="selectByVehicleIdAndDate" resultMap="VehicleMileageStatsResult">
        <include refid="selectVehicleMileageStatsVo"/>
        where vehicle_id = #{vehicleId} and stat_date = #{statDate}
    </select>
    <select id="selectTaskTimeIntervals" resultMap="TaskTimeIntervalResult">
        select tv.task_id, t.create_time as start_time,
               IFNULL(t.actual_end_time, NOW()) as end_time
        from sys_task_vehicle tv
        inner join sys_task t on tv.task_id = t.task_id
        where tv.vehicle_id = #{vehicleId}
          and t.del_flag = '0'
          and t.actual_end_time is not null
          and t.create_time &lt; #{endTime}
          and t.actual_end_time &gt; #{startTime}
        order by t.create_time
    </select>
    <insert id="insertVehicleMileageStats" parameterType="VehicleMileageStats" useGeneratedKeys="true" keyProperty="statsId">
        insert into tb_vehicle_mileage_stats
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <if test="vehicleId != null">vehicle_id,</if>
            <if test="vehicleNo != null">vehicle_no,</if>
            <if test="statDate != null">stat_date,</if>
            <if test="totalMileage != null">total_mileage,</if>
            <if test="taskMileage != null">task_mileage,</if>
            <if test="nonTaskMileage != null">non_task_mileage,</if>
            <if test="taskRatio != null">task_ratio,</if>
            <if test="gpsPointCount != null">gps_point_count,</if>
            <if test="taskCount != null">task_count,</if>
            create_time
         </trim>
        <trim prefix="values (" suffix=")" suffixOverrides=",">
            <if test="vehicleId != null">#{vehicleId},</if>
            <if test="vehicleNo != null">#{vehicleNo},</if>
            <if test="statDate != null">#{statDate},</if>
            <if test="totalMileage != null">#{totalMileage},</if>
            <if test="taskMileage != null">#{taskMileage},</if>
            <if test="nonTaskMileage != null">#{nonTaskMileage},</if>
            <if test="taskRatio != null">#{taskRatio},</if>
            <if test="gpsPointCount != null">#{gpsPointCount},</if>
            <if test="taskCount != null">#{taskCount},</if>
            NOW()
         </trim>
    </insert>
    <update id="updateVehicleMileageStats" parameterType="VehicleMileageStats">
        update tb_vehicle_mileage_stats
        <trim prefix="SET" suffixOverrides=",">
            <if test="vehicleNo != null">vehicle_no = #{vehicleNo},</if>
            <if test="totalMileage != null">total_mileage = #{totalMileage},</if>
            <if test="taskMileage != null">task_mileage = #{taskMileage},</if>
            <if test="nonTaskMileage != null">non_task_mileage = #{nonTaskMileage},</if>
            <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>
            update_time = NOW()
        </trim>
        where stats_id = #{statsId}
    </update>
    <delete id="deleteVehicleMileageStatsById" parameterType="Long">
        delete from tb_vehicle_mileage_stats where stats_id = #{statsId}
    </delete>
    <delete id="deleteVehicleMileageStatsByIds" parameterType="Long">
        delete from tb_vehicle_mileage_stats where stats_id in
        <foreach item="statsId" collection="array" open="(" separator="," close=")">
            #{statsId}
        </foreach>
    </delete>
</mapper>
sql/vehicle_mileage_stats.sql
New file
@@ -0,0 +1,40 @@
-- è½¦è¾†é‡Œç¨‹ç»Ÿè®¡è¡¨
CREATE TABLE `tb_vehicle_mileage_stats` (
  `stats_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '统计ID',
  `vehicle_id` bigint(20) NOT NULL COMMENT '车辆ID',
  `vehicle_no` varchar(20) DEFAULT NULL COMMENT '车牌号',
  `stat_date` date NOT NULL COMMENT '统计日期',
  `total_mileage` decimal(10,2) DEFAULT 0.00 COMMENT '总里程(公里)',
  `task_mileage` decimal(10,2) DEFAULT 0.00 COMMENT '任务时段里程(公里)',
  `non_task_mileage` decimal(10,2) DEFAULT 0.00 COMMENT '非任务时段里程(公里)',
  `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 '任务数量',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`stats_id`),
  UNIQUE KEY `uk_vehicle_date` (`vehicle_id`, `stat_date`),
  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
New file
@@ -0,0 +1,17 @@
-- æ·»åŠ è½¦è¾†é‡Œç¨‹ç»Ÿè®¡å®šæ—¶ä»»åŠ¡
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',
  '0 30 1 * * ?',
  '3',
  '1',
  '0',
  'admin',
  NOW(),
  '',
  NULL,
  '每天凌晨1:30执行,统计昨日所有车辆的行驶里程'
);
sql/vehicle_mileage_stats_menu.sql
New file
@@ -0,0 +1,56 @@
-- è½¦è¾†é‡Œç¨‹ç»Ÿè®¡èœå•权限
-- çˆ¶èœå•ID需要根据实际系统中的"系统管理"或"车辆管理"菜单ID进行调整
-- 1. æ·»åŠ è½¦è¾†é‡Œç¨‹ç»Ÿè®¡èœå•
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (
  (SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu t),
  '车辆里程统计',
  3, -- ç³»ç»Ÿå·¥å…·ï¼Œè¯·æ ¹æ®å®žé™…情况修改parent_id
  6,
  'mileageStats',
  'system/mileageStats/index',
  1,
  0,
  'C',
  '0',
  '0',
  'system:mileageStats:list',
  'chart',
  'admin',
  NOW(),
  '',
  NULL,
  '车辆里程统计菜单'
);
-- èŽ·å–åˆšæ’å…¥çš„èœå•ID(用于后续按钮权限)
SET @menuId = (SELECT MAX(menu_id) FROM sys_menu WHERE menu_name = '车辆里程统计');
-- 2. æ·»åŠ æŸ¥è¯¢æŒ‰é’®
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ((SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu t), '车辆里程统计查询', @menuId, 1, '#', '', 1, 0, 'F', '0', '0', 'system:mileageStats:query', '#', 'admin', NOW(), '', NULL, '');
-- 3. æ·»åŠ æ–°å¢žæŒ‰é’®
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ((SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu t), '车辆里程统计新增', @menuId, 2, '#', '', 1, 0, 'F', '0', '0', 'system:mileageStats:add', '#', 'admin', NOW(), '', NULL, '');
-- 4. æ·»åŠ ä¿®æ”¹æŒ‰é’®
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ((SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu t), '车辆里程统计修改', @menuId, 3, '#', '', 1, 0, 'F', '0', '0', 'system:mileageStats:edit', '#', 'admin', NOW(), '', NULL, '');
-- 5. æ·»åŠ åˆ é™¤æŒ‰é’®
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ((SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu t), '车辆里程统计删除', @menuId, 4, '#', '', 1, 0, 'F', '0', '0', 'system:mileageStats:remove', '#', 'admin', NOW(), '', NULL, '');
-- 6. æ·»åŠ å¯¼å‡ºæŒ‰é’®
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ((SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu t), '车辆里程统计导出', @menuId, 5, '#', '', 1, 0, 'F', '0', '0', 'system:mileageStats:export', '#', 'admin', NOW(), '', NULL, '');
-- 7. æ·»åŠ è®¡ç®—æŒ‰é’®
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ((SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu t), '车辆里程统计计算', @menuId, 6, '#', '', 1, 0, 'F', '0', '0', 'system:mileageStats:calculate', '#', 'admin', NOW(), '', NULL, '');
-- 8. æ·»åŠ æ‰¹é‡è®¡ç®—æŒ‰é’®
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES ((SELECT IFNULL(MAX(menu_id), 0) + 1 FROM sys_menu t), '车辆里程统计批量计算', @menuId, 7, '#', '', 1, 0, 'F', '0', '0', 'system:mileageStats:batch', '#', 'admin', NOW(), '', NULL, '');
sql/³µÁ¾Àï³Ìͳ¼ÆÊ¹ÓÃ˵Ã÷.md
New file
@@ -0,0 +1,202 @@
# è½¦è¾†GPS里程统计功能使用说明
## åŠŸèƒ½æ¦‚è¿°
本功能实现了车辆GPS行驶里程的自动统计,包括:
1. **总里程统计**:基于GPS点计算车辆每日总行驶里程
2. **任务时段里程**:计算车辆在执行任务期间的行驶里程
3. **非任务时段里程**:计算车辆非执行任务期间的行驶里程
4. **任务里程占比**:计算任务里程占总里程的比例
## æ ¸å¿ƒç®—法
### 1. è·ç¦»è®¡ç®—
使用 **Haversine公式** è®¡ç®—相邻GPS点之间的实际距离(考虑地球曲率)
### 2. é‡Œç¨‹åˆ†æ‘Šç®—法
根据GPS记录的时间区间与任务时间区间的**重叠比例**,将每段距离分摊到任务里程和非任务里程:
```
时间重叠比例 = é‡å æ—¶é•¿ / æ€»æ—¶é•¿
任务里程 = æ®µè·ç¦» Ã— æ—¶é—´é‡å æ¯”例
非任务里程 = æ®µè·ç¦» Ã— (1 - æ—¶é—´é‡å æ¯”例)
```
## éƒ¨ç½²æ­¥éª¤
### 1. æ‰§è¡Œæ•°æ®åº“脚本
按顺序执行以下SQL文件:
```bash
# 1. åˆ›å»ºç»Ÿè®¡è¡¨
sql/vehicle_mileage_stats.sql
# 2. æ·»åŠ å®šæ—¶ä»»åŠ¡
sql/vehicle_mileage_stats_job.sql
# 3. æ·»åŠ èœå•æƒé™
sql/vehicle_mileage_stats_menu.sql
```
### 2. ä»£ç å·²è‡ªåŠ¨éƒ¨ç½²
以下代码文件已创建:
**实体类:**
- `VehicleMileageStats.java` - é‡Œç¨‹ç»Ÿè®¡å®žä½“
- `TaskTimeInterval.java` - ä»»åŠ¡æ—¶é—´åŒºé—´
**数据访问层:**
- `VehicleMileageStatsMapper.java` - é‡Œç¨‹ç»Ÿè®¡Mapper接口
- `VehicleMileageStatsMapper.xml` - MyBatis映射文件
- `VehicleGpsMapper.java` - æ‰©å±•GPS查询方法
**业务层:**
- `IVehicleMileageStatsService.java` - Service接口
- `VehicleMileageStatsServiceImpl.java` - Service实现(核心算法)
**控制层:**
- `VehicleMileageStatsController.java` - REST API接口
**定时任务:**
- `VehicleMileageStatsTask.java` - å®šæ—¶ç»Ÿè®¡ä»»åŠ¡
### 3. å¯åŠ¨å®šæ—¶ä»»åŠ¡
系统会自动添加定时任务,默认配置:
- **执行时间**:每天凌晨 1:30
- **统计范围**:前一天的所有车辆GPS数据
- **Cron表达式**:`0 30 1 * * ?`
在系统管理 -> å®šæ—¶ä»»åŠ¡ä¸­å¯ä»¥æŸ¥çœ‹å’Œç®¡ç†è¯¥ä»»åŠ¡ã€‚
## ä½¿ç”¨æ–¹å¼
### 1. è‡ªåŠ¨ç»Ÿè®¡ï¼ˆæŽ¨èï¼‰
定时任务每天凌晨自动执行,无需人工干预。
### 2. æ‰‹åŠ¨è§¦å‘ç»Ÿè®¡
#### 2.1 å•车辆统计
调用接口:
```
POST /system/mileageStats/calculate
参数:
  - vehicleId: è½¦è¾†ID
  - statDate: ç»Ÿè®¡æ—¥æœŸï¼ˆæ ¼å¼ï¼šyyyy-MM-dd)
```
#### 2.2 æ‰¹é‡ç»Ÿè®¡
调用接口:
```
POST /system/mileageStats/batchCalculate
参数:
  - statDate: ç»Ÿè®¡æ—¥æœŸï¼ˆæ ¼å¼ï¼šyyyy-MM-dd)
```
#### 2.3 é€šè¿‡å®šæ—¶ä»»åŠ¡è¡¥ç®—åŽ†å²æ•°æ®
在定时任务管理中,执行:
```
vehicleMileageStatsTask.calculateMileageByDate('2025-11-09')
```
### 3. æŸ¥è¯¢ç»Ÿè®¡ç»“æžœ
```
GET /system/mileageStats/list
参数(可选):
  - vehicleId: è½¦è¾†ID
  - vehicleNo: è½¦ç‰Œå·
  - statDate: ç»Ÿè®¡æ—¥æœŸ
  - beginStatDate: å¼€å§‹æ—¥æœŸ
  - endStatDate: ç»“束日期
```
### 4. å¯¼å‡ºç»Ÿè®¡æ•°æ®
```
POST /system/mileageStats/export
参数:同查询接口
```
## æ•°æ®è¡¨ç»“æž„
### tb_vehicle_mileage_stats(里程统计表)
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| stats_id | bigint | ç»Ÿè®¡ID(主键) |
| vehicle_id | bigint | è½¦è¾†ID |
| vehicle_no | varchar(20) | è½¦ç‰Œå· |
| stat_date | date | ç»Ÿè®¡æ—¥æœŸ |
| total_mileage | decimal(10,2) | æ€»é‡Œç¨‹ï¼ˆå…¬é‡Œï¼‰ |
| task_mileage | decimal(10,2) | ä»»åŠ¡æ—¶æ®µé‡Œç¨‹ï¼ˆå…¬é‡Œï¼‰ |
| non_task_mileage | decimal(10,2) | éžä»»åŠ¡æ—¶æ®µé‡Œç¨‹ï¼ˆå…¬é‡Œï¼‰ |
| task_ratio | decimal(5,4) | ä»»åŠ¡é‡Œç¨‹å æ¯”ï¼ˆ0-1) |
| gps_point_count | int | GPS点数量 |
| task_count | int | ä»»åŠ¡æ•°é‡ |
### tb_vehicle_mileage_detail(里程明细表)
用于调试和追溯,记录每段GPS轨迹的里程分摊明细。
## æ³¨æ„äº‹é¡¹
### 1. GPS数据质量
- GPS采集间隔建议在 30-60秒
- è¿‡çŸ­ï¼šè®¡ç®—量大,性能影响
- è¿‡é•¿ï¼šé‡Œç¨‹ç²¾åº¦é™ä½Ž
### 2. ä»»åŠ¡æ—¶é—´å®šä¹‰
任务时段 = ä»Žä»»åŠ¡åˆ›å»ºæ—¶é—´ï¼ˆ`create_time`)到任务完成时间(`actual_end_time`)
确保任务表中这两个字段准确记录。
### 3. æ€§èƒ½ä¼˜åŒ–
- ç»Ÿè®¡æ•°æ®æŒ‰æ—¥æœŸæ±‡æ€»ï¼Œé¿å…å®žæ—¶è®¡ç®—
- å»ºè®®ä¿ç•™3-6个月的统计数据,定期归档历史数据
- GPS原始数据建议保留7-30天(由清理任务控制)
### 4. æ•°æ®è¡¥ç®—
如需补算历史数据,可以通过定时任务或API接口批量执行:
```java
// è¡¥ç®—最近7天的数据示例
for (int i = 1; i <= 7; i++) {
    String date = "2025-11-" + String.format("%02d", i);
    vehicleMileageStatsTask.calculateMileageByDate(date);
}
```
## æ‰©å±•说明
### å¤©åœ°å›¾æŽ¥å£é›†æˆ
虽然核心算法使用Haversine公式计算距离,但系统已集成天地图接口,可用于:
1. **地址解析**:将GPS坐标转换为地址信息
2. **路径规划**:计算实际道路距离(比直线距离更准确)
3. **POI查询**:查询沿途兴趣点
如需使用天地图API进行路径距离计算,可参考 `VehicleGpsController` ä¸­çš„天地图接口。
## æŠ€æœ¯æ”¯æŒ
如有问题,请检查:
1. æ•°æ®åº“表是否正确创建
2. å®šæ—¶ä»»åŠ¡æ˜¯å¦æ­£å¸¸å¯åŠ¨
3. GPS数据是否正常采集
4. ä»»åŠ¡è¡¨çš„æ—¶é—´å­—æ®µæ˜¯å¦å‡†ç¡®
日志位置:
- Service层日志:搜索 `VehicleMileageStatsServiceImpl`
- å®šæ—¶ä»»åŠ¡æ—¥å¿—ï¼šæœç´¢ `VehicleMileageStatsTask`
sql/³µÁ¾Àï³Ìͳ¼ÆÊµÏÖ×ܽá.md
New file
@@ -0,0 +1,339 @@
# è½¦è¾†GPS里程统计功能实现总结
## ä¸€ã€åŠŸèƒ½å®žçŽ°æ¸…å•
### âœ… 1. æ•°æ®åº“层
- [x] `vehicle_mileage_stats.sql` - åˆ›å»ºé‡Œç¨‹ç»Ÿè®¡è¡¨å’Œæ˜Žç»†è¡¨
- [x] `vehicle_mileage_stats_job.sql` - åˆ›å»ºå®šæ—¶ä»»åŠ¡é…ç½®
- [x] `vehicle_mileage_stats_menu.sql` - åˆ›å»ºèœå•权限配置
### âœ… 2. å®žä½“类(Domain)
- [x] `VehicleMileageStats.java` - é‡Œç¨‹ç»Ÿè®¡å®žä½“
- [x] `TaskTimeInterval.java` - ä»»åŠ¡æ—¶é—´åŒºé—´è¾…åŠ©ç±»
### âœ… 3. æ•°æ®è®¿é—®å±‚(Mapper)
- [x] `VehicleMileageStatsMapper.java` - é‡Œç¨‹ç»Ÿè®¡Mapper接口
- [x] `VehicleMileageStatsMapper.xml` - MyBatis映射配置
- [x] `VehicleGpsMapper.java` - æ‰©å±•GPS查询方法(新增2个方法)
- [x] `VehicleGpsMapper.xml` - æ‰©å±•GPS查询SQL
### âœ… 4. ä¸šåŠ¡é€»è¾‘å±‚ï¼ˆService)
- [x] `IVehicleMileageStatsService.java` - Service接口
- [x] `VehicleMileageStatsServiceImpl.java` - Service实现(核心算法)
### âœ… 5. æŽ§åˆ¶å±‚(Controller)
- [x] `VehicleMileageStatsController.java` - REST API接口
### âœ… 6. å®šæ—¶ä»»åŠ¡ï¼ˆTask)
- [x] `VehicleMileageStatsTask.java` - è‡ªåŠ¨ç»Ÿè®¡å®šæ—¶ä»»åŠ¡
### âœ… 7. å‰ç«¯API
- [x] `mileageStats.js` - å‰ç«¯æŽ¥å£å°è£…
### âœ… 8. æ–‡æ¡£
- [x] `车辆里程统计使用说明.md` - è¯¦ç»†ä½¿ç”¨æ–‡æ¡£
## äºŒã€æ ¸å¿ƒæŠ€æœ¯å®žçް
### 1. é‡Œç¨‹è®¡ç®—算法
#### Haversine公式(计算GPS点间距离)
```java
private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
    double dLat = Math.toRadians(lat2 - lat1);
    double dLon = Math.toRadians(lon2 - lon1);
    double rLat1 = Math.toRadians(lat1);
    double rLat2 = Math.toRadians(lat2);
    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;
}
```
#### æ—¶é—´é‡å æ¯”例计算(任务里程分摊)
```java
private double calculateTaskOverlapRatio(Date segmentStart, Date segmentEnd,
                                          List<TaskTimeInterval> taskIntervals) {
    long segmentDuration = segmentEnd.getTime() - segmentStart.getTime();
    long totalOverlap = 0;
    for (TaskTimeInterval task : taskIntervals) {
        long overlapStart = Math.max(segmentStart.getTime(), task.getStartTime().getTime());
        long overlapEnd = Math.min(segmentEnd.getTime(), task.getEndTime().getTime());
        if (overlapEnd > overlapStart) {
            totalOverlap += (overlapEnd - overlapStart);
        }
    }
    return (double) totalOverlap / segmentDuration;
}
```
### 2. ä»»åŠ¡æ—¶æ®µå®šä¹‰
任务时段 = ä»Žä»»åŠ¡åˆ›å»ºæ—¶é—´ï¼ˆ`sys_task.create_time`)到任务完成时间(`sys_task.actual_end_time`)
SQL查询:
```sql
select tv.task_id, t.create_time as start_time,
       IFNULL(t.actual_end_time, NOW()) as end_time
from sys_task_vehicle tv
inner join sys_task t on tv.task_id = t.task_id
where tv.vehicle_id = #{vehicleId}
  and t.del_flag = '0'
  and t.actual_end_time is not null
  and t.create_time < #{endTime}
  and t.actual_end_time > #{startTime}
```
### 3. ç»Ÿè®¡æ•°æ®ç¼“å­˜
- æ¯æ—¥å®šæ—¶ä»»åŠ¡è‡ªåŠ¨ç»Ÿè®¡å‰ä¸€å¤©çš„æ•°æ®
- ç»Ÿè®¡ç»“果存储在 `tb_vehicle_mileage_stats` è¡¨ä¸­
- æ”¯æŒé‡å¤è®¡ç®—(更新已有记录)
- å”¯ä¸€ç´¢å¼•:`uk_vehicle_date (vehicle_id, stat_date)`
## ä¸‰ã€API接口说明
### 1. æŸ¥è¯¢ç»Ÿè®¡åˆ—表
```
GET /system/mileageStats/list
参数:
  - vehicleId: è½¦è¾†ID(可选)
  - vehicleNo: è½¦ç‰Œå·ï¼ˆå¯é€‰ï¼‰
  - statDate: ç»Ÿè®¡æ—¥æœŸï¼ˆå¯é€‰ï¼‰
  - beginStatDate: å¼€å§‹æ—¥æœŸï¼ˆå¯é€‰ï¼‰
  - endStatDate: ç»“束日期(可选)
  - pageNum: é¡µç 
  - pageSize: æ¯é¡µæ•°é‡
```
### 2. æ‰‹åŠ¨è®¡ç®—å•è½¦è¾†é‡Œç¨‹
```
POST /system/mileageStats/calculate
参数:
  - vehicleId: è½¦è¾†ID(必填)
  - statDate: ç»Ÿè®¡æ—¥æœŸï¼Œæ ¼å¼ yyyy-MM-dd(必填)
返回:VehicleMileageStats对象
```
### 3. æ‰¹é‡è®¡ç®—所有车辆里程
```
POST /system/mileageStats/batchCalculate
参数:
  - statDate: ç»Ÿè®¡æ—¥æœŸï¼Œæ ¼å¼ yyyy-MM-dd(必填)
返回:成功统计的车辆数量
```
### 4. å¯¼å‡ºç»Ÿè®¡æ•°æ®
```
POST /system/mileageStats/export
参数:同查询列表接口
返回:Excel文件
```
## å››ã€å®šæ—¶ä»»åŠ¡é…ç½®
### é»˜è®¤é…ç½®
- **任务名称**:车辆里程统计任务
- **Bean名称**:vehicleMileageStatsTask
- **方法调用**:calculateYesterdayMileage
- **Cron表达式**:`0 30 1 * * ?`(每天凌晨1:30执行)
- **执行策略**:立即执行
- **并发执行**:禁止
- **状态**:启用
### æ‰‹åŠ¨è§¦å‘æ–¹å¼
在系统管理 -> å®šæ—¶ä»»åŠ¡ä¸­ï¼Œå¯ä»¥æ‰‹åŠ¨æ‰§è¡Œï¼š
1. **统计昨日数据**:
   ```
   vehicleMileageStatsTask.calculateYesterdayMileage
   ```
2. **统计指定日期**:
   ```
   vehicleMileageStatsTask.calculateMileageByDate('2025-11-09')
   ```
## äº”、数据表结构
### tb_vehicle_mileage_stats
| å­—段名 | ç±»åž‹ | è¯´æ˜Ž |
|--------|------|------|
| stats_id | bigint(20) | ç»Ÿè®¡ID,主键 |
| vehicle_id | bigint(20) | è½¦è¾†ID |
| vehicle_no | varchar(20) | è½¦ç‰Œå· |
| stat_date | date | ç»Ÿè®¡æ—¥æœŸ |
| total_mileage | decimal(10,2) | æ€»é‡Œç¨‹ï¼ˆå…¬é‡Œï¼‰ |
| task_mileage | decimal(10,2) | ä»»åŠ¡æ—¶æ®µé‡Œç¨‹ï¼ˆå…¬é‡Œï¼‰ |
| non_task_mileage | decimal(10,2) | éžä»»åŠ¡æ—¶æ®µé‡Œç¨‹ï¼ˆå…¬é‡Œï¼‰ |
| task_ratio | decimal(5,4) | ä»»åŠ¡é‡Œç¨‹å æ¯”ï¼ˆ0-1) |
| gps_point_count | int(11) | GPS点数量 |
| task_count | int(11) | ä»»åŠ¡æ•°é‡ |
| create_time | datetime | åˆ›å»ºæ—¶é—´ |
| update_time | datetime | æ›´æ–°æ—¶é—´ |
**索引:**
- PRIMARY KEY: stats_id
- UNIQUE KEY: uk_vehicle_date (vehicle_id, stat_date)
- KEY: idx_vehicle_id (vehicle_id)
- KEY: idx_stat_date (stat_date)
### tb_vehicle_mileage_detail(可选)
用于存储里程计算明细,方便调试和追溯。
## å…­ã€éƒ¨ç½²æ­¥éª¤
### 1. æ‰§è¡Œæ•°æ®åº“脚本(按顺序)
```bash
1. sql/vehicle_mileage_stats.sql
2. sql/vehicle_mileage_stats_job.sql
3. sql/vehicle_mileage_stats_menu.sql
```
### 2. é‡å¯åº”用
代码文件已自动创建,重启应用即可生效。
### 3. éªŒè¯éƒ¨ç½²
1. ç™»å½•系统,检查菜单是否显示"车辆里程统计"
2. è¿›å…¥ç³»ç»Ÿç®¡ç† -> å®šæ—¶ä»»åŠ¡ï¼Œæ£€æŸ¥æ˜¯å¦æœ‰"车辆里程统计任务"
3. æ‰‹åŠ¨æ‰§è¡Œå®šæ—¶ä»»åŠ¡æˆ–è°ƒç”¨API接口测试功能
## ä¸ƒã€ä½¿ç”¨ç¤ºä¾‹
### ç¤ºä¾‹1:手动计算某车辆昨日里程
```javascript
import { calculateMileage } from '@/api/mileageStats'
calculateMileage(1001, '2025-11-09').then(response => {
  console.log('统计结果:', response.data)
  // è¾“出示例:
  // {
  //   vehicleNo: '粤A12345',
  //   statDate: '2025-11-09',
  //   totalMileage: 285.67,
  //   taskMileage: 198.43,
  //   nonTaskMileage: 87.24,
  //   taskRatio: 0.6948,
  //   gpsPointCount: 1205,
  //   taskCount: 8
  // }
})
```
### ç¤ºä¾‹2:批量计算所有车辆指定日期里程
```javascript
import { batchCalculateMileage } from '@/api/mileageStats'
batchCalculateMileage('2025-11-09').then(response => {
  console.log(response.msg) // è¾“出:批量里程统计完成,成功统计 45 è¾†è½¦
})
```
### ç¤ºä¾‹3:查询车辆里程统计报表
```javascript
import { listMileageStats } from '@/api/mileageStats'
const query = {
  vehicleNo: '粤A12345',
  beginStatDate: '2025-11-01',
  endStatDate: '2025-11-09',
  pageNum: 1,
  pageSize: 10
}
listMileageStats(query).then(response => {
  console.log('统计列表:', response.rows)
})
```
## å…«ã€æ³¨æ„äº‹é¡¹
### 1. GPS数据要求
- GPS采集间隔:建议30-60秒
- æ•°æ®å­—段必填:vehicle_id, longitude, latitude, collect_time
- åæ ‡ç³»ç»Ÿï¼šæ”¯æŒWGS84、GCJ02等常用坐标系
### 2. ä»»åŠ¡æ•°æ®è¦æ±‚
- ä»»åŠ¡è¡¨ï¼šsys_task
- è½¦è¾†ä»»åŠ¡å…³è”è¡¨ï¼šsys_task_vehicle
- å¿…填字段:task_id, vehicle_id, create_time, actual_end_time
### 3. æ€§èƒ½ä¼˜åŒ–建议
- å®šæ—¶ä»»åŠ¡é¿å¼€ä¸šåŠ¡é«˜å³°æœŸï¼ˆå»ºè®®å‡Œæ™¨æ‰§è¡Œï¼‰
- GPS原始数据定期清理(建议保留7-30天)
- ç»Ÿè®¡æ•°æ®å®šæœŸå½’档(建议保留3-6个月)
### 4. æ•°æ®å‡†ç¡®æ€§
- é‡Œç¨‹è®¡ç®—基于GPS轨迹,精度受GPS信号质量影响
- Haversine公式计算的是直线距离,实际道路距离可能更长
- å¯ç»“合天地图路径规划API获取更准确的道路距离
## ä¹ã€æ‰©å±•功能建议
### 1. é›†æˆå¤©åœ°å›¾è·¯å¾„距离(更准确)
当前使用Haversine公式计算直线距离,可升级为:
- å°†GPS轨迹点发送到天地图路径规划API
- èŽ·å–å®žé™…é“è·¯è·ç¦»
- æé«˜é‡Œç¨‹ç»Ÿè®¡ç²¾åº¦
### 2. å®žæ—¶é‡Œç¨‹ç»Ÿè®¡
- åœ¨GPS数据入库时实时计算
- ä½¿ç”¨Redis缓存当日累计里程
- å‡Œæ™¨å®šæ—¶ä»»åŠ¡ä»…åšæ•°æ®å›ºåŒ–
### 3. é‡Œç¨‹å¼‚常告警
- å•日里程超过阈值告警
- é•¿æ—¶é—´æ— GPS数据告警
- é‡Œç¨‹çªå˜å¼‚常告警
### 4. æ•°æ®å¯è§†åŒ–
- æ¯æ—¥é‡Œç¨‹è¶‹åŠ¿å›¾
- ä»»åŠ¡é‡Œç¨‹å æ¯”é¥¼å›¾
- è½¦è¾†é‡Œç¨‹æŽ’名榜
## åã€æ•…障排查
### é—®é¢˜1:定时任务未执行
- æ£€æŸ¥å®šæ—¶ä»»åŠ¡çŠ¶æ€æ˜¯å¦ä¸º"启用"
- æ£€æŸ¥Cron表达式是否正确
- æŸ¥çœ‹å®šæ—¶ä»»åŠ¡æ—¥å¿—
### é—®é¢˜2:统计结果为0
- æ£€æŸ¥GPS数据是否存在
- æ£€æŸ¥GPS数据的collect_time字段是否正确
- æ£€æŸ¥ä»»åŠ¡æ•°æ®æ˜¯å¦å­˜åœ¨
### é—®é¢˜3:里程数据异常
- æ£€æŸ¥GPS坐标是否合法(经纬度范围)
- æ£€æŸ¥æ˜¯å¦å­˜åœ¨GPS漂移点
- å¯ç”¨æ˜Žç»†è¡¨åˆ†æžæ¯æ®µè·ç¦»
### æŸ¥çœ‹æ—¥å¿—
```bash
# Service层日志
grep "VehicleMileageStatsServiceImpl" logs/ruoyi-*.log
# å®šæ—¶ä»»åŠ¡æ—¥å¿—
grep "VehicleMileageStatsTask" logs/ruoyi-*.log
```
## åä¸€ã€æ€»ç»“
✅ æœ¬åŠŸèƒ½å·²å®Œæ•´å®žçŽ°è½¦è¾†GPS里程统计的所有需求:
- âœ… æ¯æ—¥è‡ªåŠ¨ç»Ÿè®¡è½¦è¾†è¡Œé©¶é‡Œç¨‹
- âœ… åŒºåˆ†ä»»åŠ¡æ—¶æ®µå’Œéžä»»åŠ¡æ—¶æ®µé‡Œç¨‹
- âœ… è®¡ç®—任务里程占比
- âœ… ç»Ÿè®¡æ•°æ®ç¼“存到数据库表
- âœ… æ”¯æŒæ‰‹åŠ¨è§¦å‘å’Œæ‰¹é‡è®¡ç®—
- âœ… æä¾›å®Œæ•´çš„æŸ¥è¯¢å’Œå¯¼å‡ºåŠŸèƒ½
- âœ… é›†æˆå®šæ—¶ä»»åŠ¡è‡ªåŠ¨åŒ–æ‰§è¡Œ
核心算法采用Haversine公式计算GPS点间距离,按时间重叠比例分摊里程到任务和非任务时段,确保统计准确性。所有数据缓存在专用统计表中,支持高效查询和分析。