Преглед на файлове

提单前车费计算接口提交

wangzhijun преди 4 дни
родител
ревизия
eb251d34c9

+ 19 - 0
nightFragrance-common/src/main/java/com/ylx/common/utils/DictUtils.java

@@ -1,7 +1,10 @@
 package com.ylx.common.utils;
 
 import java.util.Collection;
+import java.util.Comparator;
 import java.util.List;
+import java.util.stream.Collectors;
+
 import com.alibaba.fastjson2.JSONArray;
 import com.ylx.common.constant.CacheConstants;
 import com.ylx.common.core.domain.entity.SysDictData;
@@ -183,4 +186,20 @@ public class DictUtils
     {
         return CacheConstants.SYS_DICT_KEY + configKey;
     }
+
+    /**
+     * 获取按 dict_sort 字段正序排列的字典数据列表
+     *
+     * @param dictType 字典类型
+     * @return 排序后的字典数据列表(按 dict_sort 升序),若无数据则返回 null
+     */
+    public static List<SysDictData> getSortedDictCache(String dictType) {
+        List<SysDictData> datas = getDictCache(dictType);
+        if (datas != null && !datas.isEmpty()) {
+            return datas.stream()
+                    .sorted(Comparator.comparingLong(SysDictData::getDictSort))
+                    .collect(Collectors.toList());
+        }
+        return datas;
+    }
 }

+ 72 - 11
nightFragrance-common/src/main/java/com/ylx/common/utils/DistanceUtil.java

@@ -7,12 +7,17 @@ import cn.hutool.core.util.ObjectUtil;
  */
 public class DistanceUtil {
 
+    private static final double EARTH_RADIUS_M = 6_371_000; // 地球半径,单位米
+    private static final double TO_RADIANS = Math.PI / 180.0;
+
     /**
+     * 格式化两点间距离
+     *
      * @param userLat 用户纬度
      * @param userLon 用户经度
      * @param shopLat 门店纬度
      * @param shopLon 门店经度
-     * @return 返回距离单位为m
+     * @return 距离(米),若坐标无效则返回 "未知"
      */
     public static String formatDistance(Double userLat, Double userLon, Double shopLat, Double shopLon) {
         // 任意坐标空/0 → 未知
@@ -20,19 +25,75 @@ public class DistanceUtil {
                 || userLat == 0 || userLon == 0 || shopLat == 0 || shopLon == 0) {
             return "未知";
         }
+
+        // 验证坐标范围(可选,增加健壮性)
+        if (!isValidCoordinate(userLat, userLon) || !isValidCoordinate(shopLat, shopLon)) {
+            return "未知"; // 或抛出异常
+        }
+
         double meter = getDistance(userLat, userLon, shopLat, shopLon);
         return String.valueOf(Math.round(meter));
     }
 
-    // 球面距离:lat1,lon1,lat2,lon2 → 米
-    private static double getDistance(double lat1, double lon1, double lat2, double lon2) {
-        double radLat1 = Math.toRadians(lat1);
-        double radLon1 = Math.toRadians(lon1);
-        double radLat2 = Math.toRadians(lat2);
-        double radLon2 = Math.toRadians(lon2);
-        double r = 6371;
-        double km = r * Math.acos(Math.cos(radLat1) * Math.cos(radLat2) * Math.cos(radLon2 - radLon1) + Math.sin(radLat1) * Math.sin(radLat2));
-        return km * 1000;
+    /**
+     * 格式化两点间距离(单位:公里)
+     *
+     * @param userLat 用户纬度
+     * @param userLon 用户经度
+     * @param shopLat 门店纬度
+     * @param shopLon 门店经度
+     * @return 距离(公里),若坐标无效则返回 "未知"
+     */
+    public static String formatDistanceInKilometers(Double userLat, Double userLon, Double shopLat, Double shopLon) {
+        if (ObjectUtil.hasNull(userLat, userLon, shopLat, shopLon)
+                || userLat == 0 || userLon == 0 || shopLat == 0 || shopLon == 0) {
+            return "未知";
+        }
+        if (!isValidCoordinate(userLat, userLon) || !isValidCoordinate(shopLat, shopLon)) {
+            return "未知";
+        }
+        double meter = getDistance(userLat, userLon, shopLat, shopLon);
+        double kilometer = meter / 1000.0;
+        // 四舍五入到两位小数
+        return String.format("%.2f", kilometer);
+    }
+
+    /**
+     * 获取两点间球面距离(米)
+     *
+     * @param lat1 纬度1
+     * @param lon1 经度1
+     * @param lat2 纬度2
+     * @param lon2 经度2
+     * @return 距离(米)
+     */
+    public static double getDistance(double lat1, double lon1, double lat2, double lon2) {
+        double lat1Rad = lat1 * TO_RADIANS;
+        double lon1Rad = lon1 * TO_RADIANS;
+        double lat2Rad = lat2 * TO_RADIANS;
+        double lon2Rad = lon2 * TO_RADIANS;
+
+        // 使用 Haversine 公式(更精确,避免 acos 在极近距离下的精度问题)
+        double deltaLat = lat2Rad - lat1Rad;
+        double deltaLon = lon2Rad - lon1Rad;
+
+        double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
+                Math.cos(lat1Rad) * Math.cos(lat2Rad) *
+                        Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
+
+        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+
+        return EARTH_RADIUS_M * c;
     }
 
-}
+    /**
+     * 验证坐标是否在有效范围内
+     *
+     * @param lat 纬度
+     * @param lon 经度
+     * @return 是否有效
+     */
+    private static boolean isValidCoordinate(double lat, double lon) {
+        return Math.abs(lat) <= 90.0 && Math.abs(lon) <= 180.0;
+    }
+}

+ 8 - 6
nightFragrance-massage/src/main/java/com/ylx/fareSetting/domian/dto/FareCalculateDTO.java

@@ -14,26 +14,28 @@ public class FareCalculateDTO implements Serializable {
     private static final long serialVersionUID = 1632111945634156891L;
 
 
-    @ApiModelProperty(value = "商户ID", required = true)
+    @ApiModelProperty("商户ID")
     @NotNull(message = "商户ID不能为空")
     private Long merchantId;
 
-    @ApiModelProperty(value = "项目/服务ID", required = true)
+    @ApiModelProperty("项目/服务ID")
     @NotNull(message = "项目ID不能为空")
     private Long projectId;
 
-    @ApiModelProperty(value = "预约开始时间", required = true, example = "2024-01-07 15:30:00")
+    @ApiModelProperty(value = "预约开始时间", example = "2024-01-07 15:30:00")
     @NotNull(message = "预约时间不能为空")
     private LocalDateTime appointmentStartTime;
 
-    @ApiModelProperty(value = "城市编码", required = true)
+    @ApiModelProperty("城市编码")
     @NotNull(message = "城市编码")
     private String cityCode;
 
-    @ApiModelProperty(value = "用户下单经度", required = true)
+    @ApiModelProperty("用户下单经度")
+    @NotNull(message = "用户下单经度不能为空")
     private Double longitude;
 
-    @ApiModelProperty(value = "用户下单纬度", required = true)
+    @ApiModelProperty("用户下单纬度")
+    @NotNull(message = "用户下单纬度不能为空")
     private Double latitude;
 
 }

+ 13 - 9
nightFragrance-massage/src/main/java/com/ylx/fareSetting/domian/vo/FareCalculateResultVO.java

@@ -9,21 +9,25 @@ import java.math.BigDecimal;
 @ApiModel("车费计算结果")
 public class FareCalculateResultVO {
 
-    @ApiModelProperty("实际导航距离(公里)")
+    @ApiModelProperty("预估打车费(元)")
+    private BigDecimal estimatedFare;
+
+    @ApiModelProperty("全程距离(公里)")
     private BigDecimal actualDistanceKm;
 
-    @ApiModelProperty("免费里程额度(公里)")
-    private BigDecimal freeDistanceKm;
+    @ApiModelProperty("免费公里数")
+    private BigDecimal freeKm;
 
-    @ApiModelProperty("超出免费里程的距离(公里)")
-    private BigDecimal exceedDistanceKm;
+    @ApiModelProperty("起步价")
+    private BigDecimal baseFare;
 
-    @ApiModelProperty("预估打车费(元)")
-    private BigDecimal estimatedFare;
+    @ApiModelProperty("起步距离(公里)")
+    private BigDecimal baseDistance;
+
+    @ApiModelProperty("超出起步价后每公里费用")
+    private BigDecimal additionalFarePer;
 
     @ApiModelProperty("是否免车费")
     private Boolean isFree;
 
-    @ApiModelProperty("提示信息,例如:在5公里范围内免车费")
-    private String message;
 }

+ 160 - 71
nightFragrance-massage/src/main/java/com/ylx/fareSetting/service/impl/MaProjectFareSettingServiceImpl.java

@@ -1,12 +1,13 @@
 package com.ylx.fareSetting.service.impl;
 
 import cn.hutool.core.collection.CollUtil;
-import cn.hutool.core.util.CoordinateUtil;
-import cn.hutool.core.util.NumberUtil;
 import cn.hutool.core.util.ObjectUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.ylx.common.core.domain.entity.SysDictData;
 import com.ylx.common.exception.ServiceException;
+import com.ylx.common.utils.DictUtils;
+import com.ylx.common.utils.DistanceUtil;
 import com.ylx.fareSetting.domian.MaProjectFareSetting;
 import com.ylx.fareSetting.domian.dto.FareCalculateDTO;
 import com.ylx.fareSetting.domian.vo.FareCalculateResultVO;
@@ -21,6 +22,8 @@ import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.util.List;
 import java.util.Optional;
@@ -35,106 +38,192 @@ public class MaProjectFareSettingServiceImpl extends ServiceImpl<MaProjectFareSe
     @Resource
     private TAddressService addressService;
 
+    @Override
     public FareCalculateResultVO calculateFare(FareCalculateDTO dto) {
         FareCalculateResultVO result = new FareCalculateResultVO();
 
         // 1.获取商户的默认地址
-        TAddress address = this.addressService.getOne(new LambdaQueryWrapper<TAddress>().eq(TAddress::getMerchantId, dto.getMerchantId()).eq(TAddress::getType, 1));
+        TAddress address = this.addressService.getOne(new LambdaQueryWrapper<TAddress>()
+                .eq(TAddress::getMerchantId, dto.getMerchantId())
+                .eq(TAddress::getType, 1));
         if (ObjectUtil.isNull(address)) {
             throw new ServiceException("无法获取商户的默认地址");
         }
 
-        // 1. 获取商户车费配置
+        // 2. 计算直线距离(公里)
+        String distanceStr = DistanceUtil.formatDistanceInKilometers(
+                dto.getLongitude(), dto.getLatitude(),
+                address.getLongitude(), address.getLatitude()
+        );
+        double straightLineKm;
+        if ("未知".equals(distanceStr)) {
+            throw new ServiceException("无法获取客户地址信息");
+        }
+        try {
+            straightLineKm = Double.parseDouble(distanceStr);
+        } catch (NumberFormatException e) {
+            log.error("距离字符串解析失败: {}", distanceStr, e);
+            throw new ServiceException("距离数据异常");
+        }
+
+        // 3.根据时间段判断是否白天时间段
+        LocalDateTime appointmentStartTime = dto.getAppointmentStartTime();
+        boolean isDay = isDayTimePeriod(appointmentStartTime);
+
+        // 4. 获取商户车费配置
         LambdaQueryWrapper<MaProjectFareSetting> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(MaProjectFareSetting::getMerchantId, dto.getMerchantId())
                 .eq(MaProjectFareSetting::getIsDelete, 0);
-        List<MaProjectFareSetting> list = this.baseMapper.selectList(wrapper);
+        List<MaProjectFareSetting> configList = this.baseMapper.selectList(wrapper);
 
         MaProjectFareSetting finalConfig = null;
 
-        if (CollUtil.isNotEmpty(list)) {
-            if (list.size() == 1 && ObjectUtil.equals(1, list.get(0).getIsUnified())) {
-                // 【情况A】集合数量为1 -> 是统一配置
-                // 直接取唯一的一条作为最终配置
-                log.info("商户[{}]仅有一条配置,判定为统一设置模式", dto.getMerchantId());
-                finalConfig = list.get(0);
+        if (CollUtil.isNotEmpty(configList)) {
+            // 先查找是否存在统一配置 (isUnified = 1)
+            Optional<MaProjectFareSetting> unifiedOpt = configList.stream()
+                    .filter(c -> ObjectUtil.equals(1, c.getIsUnified()))
+                    .findFirst();
 
+            if (unifiedOpt.isPresent()) {
+                finalConfig = unifiedOpt.get();
             } else {
-                // 【情况B】集合数量大于1 -> 不是统一配置(独立项目配置)
-                // 需要根据传入的 projectId 从列表中筛选出对应的项目配置
-                log.info("商户[{}]存在多条配置,根据projectId[{}]进行匹配", dto.getMerchantId(), dto.getProjectId());
+                // 查找匹配的项目配置
+                log.info("商户[{}]不存在统一配置,根据projectId[{}]进行匹配", dto.getMerchantId(), dto.getProjectId());
 
-                // 使用 Stream API 过滤出当前项目对应的配置
-                Optional<MaProjectFareSetting> projectConfigOpt = list.stream()
+                Optional<MaProjectFareSetting> projectConfigOpt = configList.stream()
                         .filter(item -> item.getProjectId() != null && item.getProjectId().equals(dto.getProjectId()))
                         .findFirst();
 
                 if (projectConfigOpt.isPresent()) {
+                    // 找到了对应项目的配置
                     finalConfig = projectConfigOpt.get();
-                } else {
-                    // 可选:如果没找到对应项目的配置,是否回退到第一条?或者报错?
-                    // 这里假设必须精确匹配,否则抛异常或给默认值
-                    throw new ServiceException("未找到该项目[" + dto.getProjectId() + "]的专属车费配置");
                 }
             }
-        } else {
-            // 【情况C】商户无配置 -> 获取后台城市车费设置
-            log.info("商户[{}]无配置,尝试获取城市[{}]的默认配置", dto.getMerchantId(), dto.getCityCode());
-            // TODO: 调用获取城市配置的 Service/Method
-            // finalConfig = cityFareService.getDefaultByCity(dto.getCityCode());
-            TFareSettingVo fareSetting = fareSettingService.getFareSetting("", dto.getCityCode());
         }
 
-        // 此时 finalConfig 应该已经有值了,继续后续的距离计算逻辑...
         if (ObjectUtil.isNull(finalConfig)) {
             throw new ServiceException("无法获取有效的车费计算规则");
         }
 
-        // 2. 判断时间段 (白天 vs 夜间)
-        // 假设白天是 7:30 - 19:30,其余为夜间 (具体逻辑根据你的业务定)
-        LocalTime time = dto.getAppointmentStartTime().toLocalTime();
-        boolean isDayTime = !time.isBefore(LocalTime.of(7, 30)) && time.isBefore(LocalTime.of(19, 30));
-
-        BigDecimal freeKm = isDayTime ? finalConfig.getDayFreeKm() : finalConfig.getNightFreeKm();
-        result.setFreeDistanceKm(freeKm);
-
-
-        // 3. 计算实际距离 (调用地图API)
-//
-//        // 参数顺序:纬度(lat), 经度(lon)
-//
-////        double distance = CoordinateUtil(37.870000, 112.550000, 37.880000, 112.560000);
-//
-//// 返回值单位是 米 (m),转换为公里
-//        double distanceKm = NumberUtil.div(distance, 1000, 2); // 保留2位小数
-//
-//        double distanceInMeters = this.baseMapper.calculateDistance(
-//                dto.getLatitude(), dto.getLongitude(),
-//                targetLat, targetLon
-//        );
-//
-//        BigDecimal actualKm = new BigDecimal(distanceInMeters).divide(new BigDecimal("1000"), 2, RoundingMode.HALF_UP);
-//        result.setActualDistanceKm(actualKm);
-//
-//        // 4. 计算费用
-//        BigDecimal freeKm = result.getFreeDistanceKm();
-//        BigDecimal exceedKm = actualKm.subtract(freeKm);
-//
-//        // 如果小于0,说明在免费范围内
-//        if (exceedKm.compareTo(BigDecimal.ZERO) <= 0) {
-//            result.setExceedDistanceKm(BigDecimal.ZERO);
-//            result.setEstimatedFare(BigDecimal.ZERO);
-//            result.setIsFree(true);
-//            result.setMessage(String.format("距离 %.2f km,在 %.2f km 免费范围内", actualKm, freeKm));
-//        } else {
-//            result.setExceedDistanceKm(exceedKm);
-//            // 费用 = 超出距离 * 单价
-//            BigDecimal fare = exceedKm.multiply(UNIT_PRICE).setScale(2, RoundingMode.HALF_UP);
-//            result.setEstimatedFare(fare);
-//            result.setIsFree(false);
-//            result.setMessage(String.format("超出免费里程 %.2f km,需支付车费", exceedKm));
-//        }
+        // 商户配置的免车费距离(用于扣减)
+        BigDecimal merchantFreeKm = isDay ? finalConfig.getDayFreeKm() : finalConfig.getNightFreeKm();
+
+        if (ObjectUtil.isNull(merchantFreeKm) || merchantFreeKm.compareTo(BigDecimal.ZERO) <= 0) {
+            merchantFreeKm = BigDecimal.ZERO;
+            log.info("商户[{}]配置的免车费距离为 null 或 <= 0, 视为 0", dto.getMerchantId());
+        } else {
+            log.info("商户[{}]使用配置ID={}, 免费公里数: {}", dto.getMerchantId(), finalConfig.getId(), merchantFreeKm);
+        }
+
+        // 4. 计算【打车距离】(即计费里程)
+        BigDecimal straightLineBigDecimal = new BigDecimal(straightLineKm).setScale(6, RoundingMode.HALF_UP);
+        BigDecimal effectiveDistance = straightLineBigDecimal.subtract(merchantFreeKm);
+        if (effectiveDistance.compareTo(BigDecimal.ZERO) < 0) {
+            effectiveDistance = BigDecimal.ZERO;
+        }
+        log.info("直线距离 {} km, 商户免车费距离 {} km -> 打车距离 {} km",
+                straightLineKm, merchantFreeKm, effectiveDistance);
+
+        // 5. 获取城市车费规则(用于最终计费)
+        TFareSettingVo cityFare = fareSettingService.getFareSetting(appointmentStartTime.toString(), dto.getCityCode());
+        if (ObjectUtil.isNull(cityFare)) {
+            throw new ServiceException("未找到城市[" + dto.getCityCode() + "]的车费配置");
+        }
+
+        // 6. 使用城市规则计算费用
+        BigDecimal baseFare = cityFare.getBaseFare();          // 起步价
+        BigDecimal baseDistance = cityFare.getBaseDistance();  // 起步距离(公里)
+        BigDecimal additionalFarePer = cityFare.getAdditionalFarePer(); // 超出后每公里价格
+
+        BigDecimal estimatedFare;
+        boolean isFree;
+
+        // 如果打车距离为 0,表示全程被商户免车费覆盖,直接免费
+        if (effectiveDistance.compareTo(BigDecimal.ZERO) <= 0) {
+            estimatedFare = BigDecimal.ZERO;
+            isFree = true;
+        } else {
+            // 打车距离 > 0,才进入城市计费逻辑
+            BigDecimal exceedDistance = effectiveDistance.subtract(baseDistance);
+            if (exceedDistance.compareTo(BigDecimal.ZERO) <= 0) {
+                // 在起步距离内(但打车距离 > 0)
+                estimatedFare = baseFare;
+                isFree = false;
+            } else {
+                // 超出起步距离
+                BigDecimal extraFare = exceedDistance.multiply(additionalFarePer).setScale(2, RoundingMode.HALF_UP);
+                estimatedFare = baseFare.add(extraFare).setScale(2, RoundingMode.HALF_UP);
+                isFree = false;
+            }
+        }
+
+        // 7. 设置结果
+        result.setActualDistanceKm(straightLineBigDecimal.setScale(2, RoundingMode.HALF_UP)); // 原始直线距离(展示用)
+        result.setEstimatedFare(estimatedFare);
+        result.setIsFree(isFree);
 
         return result;
     }
+
+    /**
+     * 判断预约时间是否属于白天时间段
+     *
+     * @param appointmentStartTime 预约开始时间
+     * @return true表示是白天时间段,false表示不是白天时间段(可能是夜间或其他时间段)
+     */
+    public boolean isDayTimePeriod(LocalDateTime appointmentStartTime) {
+        // 提取预约时间的小时和分钟,仅用于时间段比较
+        LocalTime appointmentTime = appointmentStartTime.toLocalTime();
+
+        // 查询白天时间段配置
+        List<SysDictData> dayTimeRanges = DictUtils.getSortedDictCache("day_time");
+
+        if (CollUtil.isNotEmpty(dayTimeRanges) && dayTimeRanges.size() >= 2) {
+            // 获取开始时间和结束时间
+            LocalTime dayStartTime = parseTime(CollUtil.getFirst(dayTimeRanges).getDictValue()); // 如 7:30
+            LocalTime dayEndTime = parseTime(CollUtil.getLast(dayTimeRanges).getDictValue());   // 如 19:30
+
+            // 判断是否在白天时间段范围内
+            return isTimeInRange(appointmentTime, dayStartTime, dayEndTime);
+        }
+
+        // 如果没有找到白天时间段配置,默认返回false
+        return false;
+    }
+
+    /**
+     * 解析时间字符串为LocalTime
+     */
+    private LocalTime parseTime(String timeStr) {
+        // 处理可能的格式差异,比如"7:30"转为"07:30"
+        if (!timeStr.contains(":")) {
+            throw new IllegalArgumentException("无效的时间格式: " + timeStr);
+        }
+
+        String[] parts = timeStr.split(":");
+        if (parts.length == 2) {
+            int hour = Integer.parseInt(parts[0]);
+            int minute = Integer.parseInt(parts[1]);
+            return LocalTime.of(hour, minute);
+        } else {
+            throw new IllegalArgumentException("无效的时间格式: " + timeStr);
+        }
+    }
+
+    /**
+     * 判断时间是否在时间段范围内(不跨越午夜)
+     */
+    private boolean isTimeInRange(LocalTime targetTime, LocalTime startTime, LocalTime endTime) {
+        // 如果开始时间小于结束时间(同一天内)
+        if (startTime.isBefore(endTime)) {
+            return (targetTime.equals(startTime) || targetTime.isAfter(startTime)) &&
+                    (targetTime.equals(endTime) || targetTime.isBefore(endTime));
+        } else if (startTime.isAfter(endTime)) {
+            // 如果开始时间大于结束时间(跨越午夜),这应该是夜间时间段
+            return false;
+        } else {
+            // 如果开始时间等于结束时间
+            return targetTime.equals(startTime);
+        }
+    }
 }