Przeglądaj źródła

merge: 融合闪送车型与多取货点

qmj 19 godzin temu
rodzic
commit
6cf171034a
41 zmienionych plików z 427 dodań i 156 usunięć
  1. 3 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  2. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java
  3. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java
  4. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  5. 5 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  6. 3 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  7. 3 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  8. 42 10
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  9. 34 12
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java
  10. 6 5
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java
  11. 66 20
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  12. 7 5
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java
  13. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoAddressController.java
  14. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  15. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  16. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  17. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  18. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  19. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  20. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  21. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  22. 23 7
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java
  23. 21 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProviderTest.java
  24. 62 42
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  25. 14 14
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  26. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java
  27. 3 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  28. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java
  29. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryVehicleType.java
  30. 8 4
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java
  31. 1 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  32. 4 2
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java
  33. 12 10
      specs/024-flash-delivery/contracts/api.md
  34. 2 2
      specs/024-flash-delivery/data-model.md
  35. 1 1
      specs/024-flash-delivery/design.md
  36. 30 0
      specs/024-flash-delivery/plan.md
  37. 1 1
      specs/024-flash-delivery/quickstart.md
  38. 2 2
      specs/024-flash-delivery/research.md
  39. 13 4
      specs/024-flash-delivery/spec.md
  40. 7 0
      specs/024-flash-delivery/tasks.md
  41. 14 0
      updatesql/sql.md

+ 3 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java

@@ -15,6 +15,8 @@ public class FlashDeliveryOrderView {
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=加急。 */
     private String deliveryType;
+    /** 配送车型:1=机车、2=轿车。 */
+    private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
     /** 物品类别(DOCUMENT、GIFT 等 9 种)。 */
@@ -43,7 +45,7 @@ public class FlashDeliveryOrderView {
     private String distanceSource;
     /** 预计配送时长(秒)。 */
     private Integer estimatedDurationSeconds;
-    /** 基础配送费(TWD),已含距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 距离附加费(TWD)。 */
     private Long distanceFee;

+ 2 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java

@@ -11,12 +11,12 @@ public class FlashDeliveryPriceBreakdown {
     private BigDecimal billableDistance;
     /** 距离附加费(TWD)。 */
     private Long distanceFee;
-    /** 基础配送费(TWD),等于起步价加距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 加急费(TWD),普通配送为 0。 */
     private Long urgentFee;
     /** 小费(TWD)。 */
     private Long tipAmount;
-    /** 总金额(TWD),等于基础配送费、加费、小费之和。 */
+    /** 总金额(TWD),等于基础配送费、距离附加费、加急费和小费之和。 */
     private Long amount;
 }

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java

@@ -7,6 +7,8 @@ import java.math.BigDecimal;
 /** 平台新增或修改闪送时段运价的请求;开始时间必须早于结束时间,且各运价时段不能重叠。 */
 @Data
 public class FlashDeliveryPricingRequest {
+    /** 配送车型,必填:1=机车,2=轿车。 */
+    private Integer vehicleType;
     /** 运价时段开始时间,必填,格式为 HH:mm,取值范围 00:00 至 23:59,包含该时刻。 */
     private String startTime;
 

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java

@@ -21,6 +21,9 @@ public class FlashDeliveryQuoteRequest {
      */
     private String deliveryType;
 
+    /** 配送车型,必填:1=机车,2=轿车。 */
+    private Integer vehicleType;
+
     /**
      * 旧单点输入的物品类别;使用pickups数组时不得提交。
      * 可选值:DOCUMENT、GIFT、CLOTHING、BEAUTY、DAILY_NECESSITIES、

+ 5 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java

@@ -14,6 +14,8 @@ public class FlashDeliveryQuoteView {
     private String serviceType;
     /** 配送等级(回显请求值):NORMAL=普通、URGENT=加急。 */
     private String deliveryType;
+    /** 用户选择的配送车型:1=机车,2=轿车。 */
+    private Integer vehicleType;
     /** 取件方式(归一化后):NOW=立即、SCHEDULED=预约。 */
     private String deliveryMode;
     /** 预约取件时间窗开始时间,立即单为空。 */
@@ -30,7 +32,7 @@ public class FlashDeliveryQuoteView {
     private Integer distanceMeters;
     /** 距离来源:ROUTE=地图路线、STRAIGHT_LINE=降级直线。 */
     private String distanceSource;
-    /** 预计配送时长(秒),直线降级时为空。 */
+    /** 预计配送时长(秒);直线降级时按车型保守速度估算。 */
     private Integer estimatedDurationSeconds;
     /** 起步距离(公里)。 */
     private BigDecimal startingDistance;
@@ -42,9 +44,9 @@ public class FlashDeliveryQuoteView {
     private Long freight;
     /** 计费距离(公里):超出起步距离部分的计费公里数,不足 0.5 公里为 0、0.5 至 1 公里记 1 公里、1 公里以上按实际公里数。 */
     private BigDecimal billableDistance;
-    /** 距离附加费(TWD),已包含在基础配送费中。 */
+    /** 距离附加费(TWD)。 */
     private Long distanceFee;
-    /** 基础配送费(TWD),等于起步价加距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 加急费比例(百分比)。 */
     private BigDecimal urgentRate;

+ 3 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java

@@ -15,6 +15,8 @@ public class FlashDeliveryRiderOrderListView {
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=加急。 */
     private String deliveryType;
+    /** 配送车型:1=机车、2=轿车。 */
+    private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
     /** 物品类别。 */
@@ -47,7 +49,7 @@ public class FlashDeliveryRiderOrderListView {
     private Integer distanceMeters;
     /** 预计配送时长(秒)。 */
     private Integer estimatedDurationSeconds;
-    /** 基础配送费(TWD),已含距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 距离附加费(TWD)。 */
     private Long distanceFee;

+ 3 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java

@@ -15,6 +15,8 @@ public class FlashDeliveryUserOrderListView {
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=加急。 */
     private String deliveryType;
+    /** 配送车型:1=机车、2=轿车。 */
+    private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
     /** 用户编辑待接单订单使用的乐观锁版本。 */
@@ -41,7 +43,7 @@ public class FlashDeliveryUserOrderListView {
     private String deliveryDetailAddress;
     /** 预计配送时长(秒)。 */
     private Integer estimatedDurationSeconds;
-    /** 基础配送费(TWD),已含距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 加急费(TWD),普通配送为 0。 */
     private Long urgentFee;

+ 42 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java

@@ -1,5 +1,8 @@
 package com.ruoyi.app.flashdelivery.route;
 
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import java.util.Arrays;
@@ -8,30 +11,46 @@ import java.util.List;
 /** 路线距离服务:优先请求地图路线,异常时使用球面直线距离保证报价仍然可用。 */
 @Service
 public class FlashDeliveryRouteService {
-    /** 按用户提供的完整站点序列计算路线,最后一点为收货点。 */
-    public RouteDistance calculate(java.util.List<GeoPoint> points) {
+    private static final double EARTH_RADIUS_METERS = 6_371_000D;
+    private static final int MOTORCYCLE_FALLBACK_SPEED_KPH = 25;
+    private static final int CAR_FALLBACK_SPEED_KPH = 30;
+    private static final int MINIMUM_FALLBACK_DURATION_SECONDS = 60;
+    private static final Logger log = LoggerFactory.getLogger(FlashDeliveryRouteService.class);
+    private final RouteDistanceProvider provider;
+
+    public FlashDeliveryRouteService(RouteDistanceProvider provider) {
+        this.provider = provider;
+    }
+
+    /** 按用户提供的完整站点序列和订单车型计算路线,最后一点为收货点。 */
+    public RouteDistance calculate(java.util.List<GeoPoint> points, Integer vehicleType) {
         // 数量和所有坐标必须先通过校验,输入错误不能伪装成地图故障降级。
         validatePoints(points);
         List<GeoPoint> ordered = List.copyOf(points);
+        int selectedVehicle = FlashDeliveryVehicleType.isSupported(vehicleType)
+                ? vehicleType : FlashDeliveryVehicleType.MOTORCYCLE;
         try {
-            RouteDistance route = provider.route(ordered);
+            RouteDistance route = provider.route(ordered, selectedVehicle);
             if (route != null && route.distanceMeters() > 0
                     && (route.durationSeconds() == null || route.durationSeconds() >= 0)) return route;
-        } catch (RuntimeException ignored) {
-            // 任一地图请求失败时整线降级,不混用局部路线或遗漏未返回的站点。
+        } catch (RuntimeException exception) {
+            // 不记录地址、坐标或 Key;保留车型和失败原因以定位 Google Routes 降级。
+            log.warn("Google Routes unavailable; use straight-line fallback. vehicleType={}, reason={}",
+                    selectedVehicle, exception.getMessage());
         }
         double meters = 0;
         for (int i = 1; i < ordered.size(); i++) {
             meters += straightLineMeters(ordered.get(i - 1), ordered.get(i));
         }
         // 同坐标相邻站贡献零距离,只在整线汇总后取整并保留既有最小距离。
-        return new RouteDistance(Math.max(1, Math.toIntExact(Math.round(meters))), null, "STRAIGHT_LINE");
+        int straightLineMeters = Math.max(1, Math.toIntExact(Math.round(meters)));
+        return new RouteDistance(straightLineMeters, calculateFallbackDurationSeconds(straightLineMeters, selectedVehicle),
+                "STRAIGHT_LINE");
     }
-    private static final double EARTH_RADIUS_METERS = 6_371_000D;
-    private final RouteDistanceProvider provider;
 
-    public FlashDeliveryRouteService(RouteDistanceProvider provider) {
-        this.provider = provider;
+    /** 兼容未携带车型的旧调用,历史订单按机车处理。 */
+    public RouteDistance calculate(java.util.List<GeoPoint> points) {
+        return calculate(points, FlashDeliveryVehicleType.MOTORCYCLE);
     }
 
     /** 计算取送两点路线距离:优先地图路线,异常或无结果时降级球面直线距离。 */
@@ -39,6 +58,19 @@ public class FlashDeliveryRouteService {
         return calculate(Arrays.asList(origin, destination));
     }
 
+    /** 按订单车型计算两点路线,供旧单点入口和新多取货点入口共用。 */
+    public RouteDistance calculate(GeoPoint origin, GeoPoint destination, Integer vehicleType) {
+        return calculate(Arrays.asList(origin, destination), vehicleType);
+    }
+
+    /** 地图路线不可用时,按车型保守平均速度估算履约展示时长;该值不参与闪送计价。 */
+    private int calculateFallbackDurationSeconds(int distanceMeters, int vehicleType) {
+        int speedKph = vehicleType == FlashDeliveryVehicleType.CAR
+                ? CAR_FALLBACK_SPEED_KPH : MOTORCYCLE_FALLBACK_SPEED_KPH;
+        double seconds = distanceMeters * 3_600D / (speedKph * 1_000D);
+        return Math.max(MINIMUM_FALLBACK_DURATION_SECONDS, (int) Math.ceil(seconds));
+    }
+
     /** Haversine 分段距离只用于地图不可用时的整线降级。 */
     private double straightLineMeters(GeoPoint origin, GeoPoint destination) {
         double lat1 = Math.toRadians(origin.latitude().doubleValue());

+ 34 - 12
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java

@@ -5,6 +5,7 @@ import com.alibaba.fastjson2.JSONArray;
 import com.alibaba.fastjson2.JSONObject;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 import org.apache.http.client.config.RequestConfig;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpPost;
@@ -19,27 +20,28 @@ import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 
-/** Google Routes API 的服务端实现,密钥只从后端专用字典读取。 */
+/** Google Routes API 的服务端实现,复用既有地图字典中的 Key。 */
 @Component
 public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
     private static final String ENDPOINT = "https://routes.googleapis.com/directions/v2:computeRoutes";
     private static final RequestConfig TIMEOUTS = RequestConfig.custom()
             .setConnectTimeout(2500).setConnectionRequestTimeout(2500).setSocketTimeout(3500).build();
 
-    /** 调用 Google Routes API 获取驾车路线距离与时长;密钥缺失或响应异常时抛错,由上层降级。 */
+    /** 调用 Google Routes API 获取所选车型路线距离与时长;密钥缺失或响应异常时抛错,由上层降级。 */
     @Override
     public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
-                               BigDecimal destinationLatitude, BigDecimal destinationLongitude) {
+                               BigDecimal destinationLatitude, BigDecimal destinationLongitude,
+                               Integer vehicleType) {
         return route(List.of(new GeoPoint(originLatitude, originLongitude),
-                new GeoPoint(destinationLatitude, destinationLongitude)));
+                new GeoPoint(destinationLatitude, destinationLongitude)), vehicleType);
     }
 
     /** 一次请求完整路线,按原始顺序传中途点;不调用路线优化接口。 */
     @Override
-    public RouteDistance route(List<GeoPoint> points) {
-        JSONObject body = requestBody(points);
-        // 不复用可能被浏览器端读取的 sys_googlemap_key,防止服务端 API 权限泄露
-        List<SysDictData> keys = DictUtils.getDictCache("sys_google_routes_key");
+    public RouteDistance route(List<GeoPoint> points, Integer vehicleType) {
+        JSONObject body = requestBody(points, vehicleType);
+        // 闪送与外卖共用既有地图 Key,避免维护第二套字典配置
+        List<SysDictData> keys = DictUtils.getDictCache(mapKeyDictType());
         if (keys == null || keys.isEmpty() || keys.get(0).getDictValue() == null
                 || keys.get(0).getDictValue().isBlank()) {
             throw new IllegalStateException("google routes key unavailable");
@@ -52,8 +54,10 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         request.setEntity(new StringEntity(body.toJSONString(), ContentType.APPLICATION_JSON));
         try (CloseableHttpClient client = HttpClients.custom().disableAutomaticRetries().build();
              CloseableHttpResponse response = client.execute(request)) {
-            if (response.getStatusLine().getStatusCode() < 200 || response.getStatusLine().getStatusCode() >= 300) {
-                throw new IllegalStateException("google routes non-success response");
+            int statusCode = response.getStatusLine().getStatusCode();
+            if (statusCode < 200 || statusCode >= 300) {
+                // 仅保留 HTTP 状态,绝不记录 Key、地址或完整 Google 响应内容。
+                throw new IllegalStateException("google routes http status " + statusCode);
             }
             JSONObject json = JSON.parseObject(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
             JSONArray routes = json.getJSONArray("routes");
@@ -66,13 +70,15 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
                 throw new IllegalStateException("google routes invalid distance");
             }
             return new RouteDistance(meters, parseDuration(first.getString("duration")), "ROUTE");
+        } catch (IllegalStateException exception) {
+            throw exception;
         } catch (Exception exception) {
             throw new IllegalStateException("google routes request failed", exception);
         }
     }
 
     /** 两点和多点共用的地图请求体,保留重复坐标对应的独立站点。 */
-    JSONObject requestBody(List<GeoPoint> points) {
+    JSONObject requestBody(List<GeoPoint> points, Integer vehicleType) {
         FlashDeliveryRouteService.validatePoints(points);
         JSONObject body = new JSONObject();
         GeoPoint origin = points.get(0);
@@ -87,13 +93,18 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
             body.put("intermediates", intermediates);
         }
         body.put("optimizeWaypointOrder", false);
-        body.put("travelMode", "DRIVE");
+        body.put("travelMode", travelModeFor(vehicleType));
         body.put("routingPreference", "TRAFFIC_AWARE");
         body.put("computeAlternativeRoutes", false);
         body.put("units", "METRIC");
         return body;
     }
 
+    /** 保留测试和旧调用的默认机车车型。 */
+    JSONObject requestBody(List<GeoPoint> points) {
+        return requestBody(points, FlashDeliveryVehicleType.MOTORCYCLE);
+    }
+
     private JSONObject waypoint(BigDecimal latitude, BigDecimal longitude) {
         JSONObject latLng = new JSONObject();
         latLng.put("latitude", latitude);
@@ -105,6 +116,17 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         return waypoint;
     }
 
+    /** Google TWO_WHEELER 对应机车;无效或历史空值安全按机车回落。 */
+    static String travelModeFor(Integer vehicleType) {
+        return Integer.valueOf(FlashDeliveryVehicleType.CAR).equals(vehicleType)
+                ? "DRIVE" : "TWO_WHEELER";
+    }
+
+    /** 闪送与外卖共用的地图 Key 字典类型。 */
+    static String mapKeyDictType() {
+        return "sys_googlemap_key";
+    }
+
     private Integer parseDuration(String value) {
         if (value == null || !value.endsWith("s")) return null;
         try {

+ 6 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java

@@ -6,16 +6,17 @@ import java.util.List;
 @FunctionalInterface
 /** 外部路线距离提供器,失败时由上层服务负责本地降级。 */
 public interface RouteDistanceProvider {
-    /** 按起终点计算路线距离与时长;失败直接抛异常,由上层负责降级。 */
+    /** 按起终点和订单车型计算路线距离与时长;失败直接抛异常,由上层负责降级。 */
     RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
-                        BigDecimal destinationLatitude, BigDecimal destinationLongitude);
+                        BigDecimal destinationLatitude, BigDecimal destinationLongitude,
+                        Integer vehicleType);
 
-    /** 完整有序路线;旧提供器仅兼容两点,多点不允许悄悄省略中间站。 */
-    default RouteDistance route(List<GeoPoint> points) {
+    /** 完整有序路线;多点不允许悄悄省略中间站。 */
+    default RouteDistance route(List<GeoPoint> points, Integer vehicleType) {
         FlashDeliveryRouteService.validatePoints(points);
         if (points.size() != 2) throw new UnsupportedOperationException("multi-stop route unavailable");
         GeoPoint origin = points.get(0);
         GeoPoint destination = points.get(1);
-        return route(origin.latitude(), origin.longitude(), destination.latitude(), destination.longitude());
+        return route(origin.latitude(), origin.longitude(), destination.latitude(), destination.longitude(), vehicleType);
     }
 }

+ 66 - 20
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -107,7 +107,7 @@ public class FlashDeliveryApplicationService {
         view.setShowUrgentOption(dictionaryEnabled("flash_show_urgent_option"));
         view.setServiceTypes(SERVICE_TYPES);
         view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
-        FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()));
+        FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()), FlashDeliveryVehicleType.MOTORCYCLE);
         view.setServices(pricing == null ? List.of() : SERVICE_TYPES.stream()
                 .map(serviceType -> serviceView(pricing, serviceType)).toList());
         return view;
@@ -125,6 +125,7 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
         view.setServiceType(request.getServiceType());
         view.setDeliveryType(request.getDeliveryType());
+        view.setVehicleType(request.getVehicleType());
         view.setDeliveryMode(normalizeDeliveryMode(request.getDeliveryMode()));
         view.setScheduledPickupStartAt(request.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(request.getScheduledPickupEndAt());
@@ -182,6 +183,7 @@ public class FlashDeliveryApplicationService {
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
         order.setDeliveryType(request.getDeliveryType());
+        order.setVehicleType(request.getVehicleType());
         order.setStatus(WAITING_ACCEPTANCE);
         copyPickupSummary(order, quote.pickups());
         order.setPickedUpStopCount(0);
@@ -367,7 +369,7 @@ public class FlashDeliveryApplicationService {
                                                          FlashDeliveryAddressChangeRequest request) {
         List<FlashDeliveryPickupRequest> pickups = editablePickups(order, request);
         validatePickups(pickups, request.getDelivery());
-        RouteDistance route = pickupRoute(pickups, request.getDelivery());
+        RouteDistance route = pickupRoute(pickups, request.getDelivery(), vehicleTypeOf(order));
         // FR-074/093:改址沿用下单快照,只重算整条路线,平台后续调价不影响此单。
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
         pricing.setId(order.getPricingId()); pricing.setConfigVersion(order.getPricingVersion());
@@ -385,6 +387,7 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryQuoteRequest quoteRequest = new FlashDeliveryQuoteRequest();
         quoteRequest.setServiceType(order.getServiceType());
         quoteRequest.setDeliveryType(order.getDeliveryType());
+        quoteRequest.setVehicleType(vehicleTypeOf(order));
         quoteRequest.setDeliveryMode(order.getDeliveryMode());
         quoteRequest.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         quoteRequest.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -438,7 +441,8 @@ public class FlashDeliveryApplicationService {
             if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
             validateRiderCoordinates(effectiveLongitude, effectiveLatitude);
         }
-        QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, selectedTab,
+        Integer riderVehicleType = riderVehicleType(rider);
+        QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, riderVehicleType, selectedTab,
                 effectiveLongitude, effectiveLatitude, true);
         IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
         FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView();
@@ -449,7 +453,7 @@ public class FlashDeliveryApplicationService {
         result.setSize(orders.getSize());
         if ("newTask".equals(selectedTab)) {
             result.setNearbyTaskCount(orders.getTotal());
-            QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, selectedTab,
+            QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, riderVehicleType, selectedTab,
                     effectiveLongitude, effectiveLatitude, false)
                     .select("MAX(amount)");
             result.setHighestOrderAmount(firstLong(orderMapper.selectObjs(maximumQuery)));
@@ -463,7 +467,8 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryOrder order = requireOrder(orderId);
         if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
             // 不兼容闪送的骑手按订单不存在处理,避免泄露待抢任务信息。
-            if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH")) {
+            if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH")
+                    || !isRiderVehicleCompatible(rider, order)) {
                 throw fail("flash.delivery.order.not.found");
             }
             // 接单前显示取送文字地址,但隐藏联系人、电话、精确坐标、实际 PIN 和履约图片。
@@ -478,6 +483,8 @@ public class FlashDeliveryApplicationService {
     public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) {
         return riderDeliveryLockService.withLock(riderId, () -> {
             FlashDeliveryOrder target = requireOrder(orderId);
+            InfoUser rider = requireRider(riderId);
+            if (!isRiderVehicleCompatible(rider, target)) throw fail("flash.delivery.order.not.found");
             // 账号资格、闪送/总任务容量与急送独占统一在骑手锁内复核,列表结果不能替代接单校验。
             riderDeliveryAcceptanceService.assertCanAcceptFlash(riderId, target.getDeliveryType());
             Date now = new Date();
@@ -557,6 +564,7 @@ public class FlashDeliveryApplicationService {
         Date now = new Date();
         UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
                 .eq("id", pricingId).eq("config_version", request.getConfigVersion())
+                .set("vehicle_type", request.getVehicleType())
                 .set("start_time", request.getStartTime())
                 .set("end_time", request.getEndTime())
                 .set("starting_distance", scaleDistance(request.getStartingDistance()))
@@ -570,6 +578,7 @@ public class FlashDeliveryApplicationService {
                 .setSql("config_version = config_version + 1");
         // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
         if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
+        pricing.setVehicleType(request.getVehicleType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
@@ -685,23 +694,24 @@ public class FlashDeliveryApplicationService {
         String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
         validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
         Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
-        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
-        RouteDistance route = pickupRoute(pickups, request.getDelivery());
+        Integer selectedVehicle = requireVehicleType(request.getVehicleType());
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate), selectedVehicle);
+        RouteDistance route = pickupRoute(pickups, request.getDelivery(), selectedVehicle);
         FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
                 route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
         return new QuoteContext(pricing, route, breakdown, pickups);
     }
 
     /** 查询指定时刻命中的运价配置,未配置时直接报错。 */
-    private FlashDeliveryPricing findPricingAt(String targetTime) {
-        FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime);
+    private FlashDeliveryPricing findPricingAt(String targetTime, Integer vehicleType) {
+        FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime, vehicleType);
         if (pricing == null) throw fail("flash.delivery.pricing.not.available");
         return pricing;
     }
 
     /** 查询命中运价;命中多条说明配置重叠,报错交由平台修正。 */
-    private FlashDeliveryPricing findPricingAtOrNull(String targetTime) {
-        List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime);
+    private FlashDeliveryPricing findPricingAtOrNull(String targetTime, Integer vehicleType) {
+        List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime, vehicleType);
         if (pricing == null || pricing.isEmpty()) return null;
         if (pricing.size() > 1) throw fail("flash.delivery.pricing.overlap");
         return pricing.get(0);
@@ -713,7 +723,8 @@ public class FlashDeliveryApplicationService {
         BigDecimal startingDistance = normalizeDistance(request.getStartingDistance());
         BigDecimal distance = normalizeDistance(request.getDistance());
         BigDecimal urgentRate = normalizeRate(request.getUrgentRate());
-        if (!validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
+        if (!FlashDeliveryVehicleType.isSupported(request.getVehicleType())
+                || !validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
                 || toMinute(request.getStartTime()) >= toMinute(request.getEndTime())
                 || startingDistance == null || startingDistance.signum() <= 0
                 || startingDistance.compareTo(MAX_PRICING_DISTANCE) > 0
@@ -731,7 +742,7 @@ public class FlashDeliveryApplicationService {
 
     /** 确保新时段与既有运价不重叠,excludedId 用于修改时排除自身。 */
     private void ensureNoPricingOverlap(FlashDeliveryPricingRequest request, Long excludedId) {
-        if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), excludedId) > 0) {
+        if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), request.getVehicleType(), excludedId) > 0) {
             throw fail("flash.delivery.pricing.overlap");
         }
     }
@@ -739,6 +750,7 @@ public class FlashDeliveryApplicationService {
     /** 将运价请求转换为实体,距离与比例统一保留两位小数。 */
     private FlashDeliveryPricing pricingFrom(FlashDeliveryPricingRequest request) {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
+        pricing.setVehicleType(request.getVehicleType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
@@ -846,6 +858,7 @@ public class FlashDeliveryApplicationService {
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
+        view.setVehicleType(vehicleTypeOf(order));
         view.setStatus(order.getStatus());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
@@ -880,6 +893,7 @@ public class FlashDeliveryApplicationService {
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
+        view.setVehicleType(vehicleTypeOf(order));
         view.setStatus(order.getStatus());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
@@ -958,6 +972,7 @@ public class FlashDeliveryApplicationService {
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
+        view.setVehicleType(vehicleTypeOf(order));
         view.setStatus(order.getStatus());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
@@ -1147,9 +1162,8 @@ public class FlashDeliveryApplicationService {
     private String normalizePayType(String payType) {
         if (!hasText(payType)) return PAY_TYPE_CASH;
         String trimmed = payType.trim();
-        return trimmed;
-//        if (PAY_TYPE_CASH.equals(trimmed) || PAY_TYPE_TRANSFER.equals(trimmed)) return trimmed;
-//        throw fail("flash.delivery.pay.type.invalid");
+        if (PAY_TYPE_CASH.equals(trimmed) || PAY_TYPE_TRANSFER.equals(trimmed)) return trimmed;
+        throw fail("flash.delivery.pay.type.invalid");
     }
 
     /** 将时间转为台北时区 HH:mm,用于命中运价时段。 */
@@ -1190,7 +1204,7 @@ public class FlashDeliveryApplicationService {
     }
 
     /** 按页签构建骑手任务查询:newTask 限定可抢状态与时间窗,可选距离过滤和就近排序。 */
-    private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, String tab,
+    private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, Integer riderVehicleType, String tab,
                                                                 BigDecimal longitude, BigDecimal latitude,
                                                                 boolean orderResults) {
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<>();
@@ -1198,6 +1212,7 @@ public class FlashDeliveryApplicationService {
         switch (tab) {
             case "newTask" -> {
                 query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
+                        .eq("vehicle_type", riderVehicleType)
                         .and(item -> item.eq("delivery_mode", "NOW")
                                 .or().le("scheduled_pickup_start_at", now));
                 if (longitude != null) {
@@ -1374,8 +1389,9 @@ public class FlashDeliveryApplicationService {
             if (!PACKAGE_TYPES.contains(Objects.toString(pickup.getPackageType(), ""))) {
                 throw fail("flash.delivery.package.type.invalid");
             }
+            // 重量与规格仅供骑手判断物品情况,用户可不填写;填写重量时仍须使用受支持的区间。
             if (pickup.getQuantity() == null || pickup.getQuantity() <= 0
-                    || !WEIGHT_RANGES.contains(Objects.toString(pickup.getWeightRange(), ""))
+                    || hasText(pickup.getWeightRange()) && !WEIGHT_RANGES.contains(pickup.getWeightRange())
                     || tooLong(pickup.getSpecification(), 255)) throw fail("flash.delivery.item.invalid");
             if (sameAddressText(pickup, delivery)
                     || pickup.getLatitude().compareTo(delivery.getLatitude()) == 0
@@ -1385,15 +1401,45 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    private RouteDistance pickupRoute(List<FlashDeliveryPickupRequest> pickups, FlashDeliveryAddressRequest delivery) {
+    private RouteDistance pickupRoute(List<FlashDeliveryPickupRequest> pickups, FlashDeliveryAddressRequest delivery,
+                                      Integer vehicleType) {
         List<GeoPoint> points = new ArrayList<>();
         for (FlashDeliveryPickupRequest pickup : pickups) points.add(new GeoPoint(pickup.getLatitude(), pickup.getLongitude()));
         points.add(new GeoPoint(delivery.getLatitude(), delivery.getLongitude()));
-        RouteDistance route = points.size() == 2 ? routeService.calculate(points.get(0), points.get(1)) : routeService.calculate(points);
+        RouteDistance route = points.size() == 2
+                ? routeService.calculate(points.get(0), points.get(1), vehicleType)
+                : routeService.calculate(points, vehicleType);
         if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
         return route;
     }
 
+    private Integer requireVehicleType(Integer vehicleType) {
+        if (!FlashDeliveryVehicleType.isSupported(vehicleType)) {
+            throw fail("flash.delivery.vehicle.type.invalid");
+        }
+        return vehicleType;
+    }
+
+    /** DDL 执行前存在的旧订单按用户确认的机车规则兼容。 */
+    private Integer vehicleTypeOf(FlashDeliveryOrder order) {
+        return FlashDeliveryVehicleType.isSupported(order.getVehicleType())
+                ? order.getVehicleType() : FlashDeliveryVehicleType.MOTORCYCLE;
+    }
+
+    /** 骑手资料复用既有 vehicleType;历史空值和非法值安全回落为机车。 */
+    private Integer riderVehicleType(InfoUser rider) {
+        try {
+            Integer value = Integer.valueOf(rider.getVehicleType());
+            return FlashDeliveryVehicleType.isSupported(value) ? value : FlashDeliveryVehicleType.MOTORCYCLE;
+        } catch (Exception ignored) {
+            return FlashDeliveryVehicleType.MOTORCYCLE;
+        }
+    }
+
+    private boolean isRiderVehicleCompatible(InfoUser rider, FlashDeliveryOrder order) {
+        return riderVehicleType(rider).equals(vehicleTypeOf(order));
+    }
+
     /** 点数配置缺失、非整数或越界时,有效上限回退为1。 */
     private int pickupStopLimit() {
         String value = dictionaryValue("flash_pickup_stop_limit");

+ 7 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java

@@ -21,9 +21,9 @@ public class FlashDeliveryPricingCalculator {
     }
 
     /**
-     * 费用明细计算:基础配送费 = 起步价 + 距离费;距离费按超起步距离部分、以步长折算的
+     * 费用明细计算:基础配送费仅为起步价;距离费按超起步距离部分、以步长折算的
      * 每公里单价线性计价,不足 0.5 公里不计、0.5 至 1 公里按 1 公里计;加急费仅 URGENT 收取,
-     * 取最低加急费与基础配送费乘加急比例的较大者;总金额 = 基础配送费 + 加急费 + 小费,均为整数 TWD。
+     * 按起步价和距离费之和计算;总金额 = 基础配送费 + 距离费 + 加急费 + 小费,均为整数 TWD。
      */
     public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters,
                                                            String deliveryType, Long tipAmount) {
@@ -60,9 +60,11 @@ public class FlashDeliveryPricingCalculator {
         FlashDeliveryPriceBreakdown result = new FlashDeliveryPriceBreakdown();
         result.setBillableDistance(chargeableDistance.setScale(2, RoundingMode.HALF_UP));
         result.setDistanceFee(distanceFee);
-        long baseDeliveryFee = Math.addExact(pricing.getStartingFare(), distanceFee);
+        // 对外基础配送费固定表示起步价;普通配送费仅作内部计算,不单独落库或返回。
+        long baseDeliveryFee = pricing.getStartingFare();
+        long ordinaryDeliveryFee = Math.addExact(baseDeliveryFee, distanceFee);
         long urgentFee = "URGENT".equals(deliveryType)
-                ? Math.max(pricing.getMinimumUrgentFee(), BigDecimal.valueOf(baseDeliveryFee)
+                ? Math.max(pricing.getMinimumUrgentFee(), BigDecimal.valueOf(ordinaryDeliveryFee)
                 .multiply(pricing.getUrgentRate())
                 .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
                 .longValueExact())
@@ -70,7 +72,7 @@ public class FlashDeliveryPricingCalculator {
         result.setBaseDeliveryFee(baseDeliveryFee);
         result.setUrgentFee(urgentFee);
         result.setTipAmount(tipAmount);
-        result.setAmount(Math.addExact(Math.addExact(baseDeliveryFee, urgentFee), tipAmount));
+        result.setAmount(Math.addExact(Math.addExact(ordinaryDeliveryFee, urgentFee), tipAmount));
         return result;
     }
 }

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoAddressController.java

@@ -36,7 +36,7 @@ public class InfoAddressController extends BaseController {
     @Autowired private InfoAddressMapper infoAddressMapper;
     @Autowired private InfoAddressBookService addressBookService;
 
-    /** 返回历史浏览器地图密钥;服务端 Routes 使用另一套不公开的密钥。 */
+    /** 返回既有浏览器地图 Key;闪送 Routes 与外卖共用同一字典配置。 */
     @Anonymous
     @GetMapping("/getGoogleMapKey")
     public AjaxResult getGoogleMapKey() {

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -323,6 +323,7 @@ flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
 flash.delivery.delivery.type.invalid=Unsupported delivery level
+flash.delivery.vehicle.type.invalid=Unsupported vehicle type
 flash.delivery.item.invalid=Item quantity, weight range or specification is invalid
 flash.delivery.tip.invalid=Rider tip must be a non-negative integer TWD amount
 flash.delivery.quote.changed=The quote has changed, please review the latest price
@@ -331,6 +332,7 @@ flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pa
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+flash.delivery.rider.vehicle.not.match=The rider vehicle type does not match this order
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
 rider.accept.offline=The rider is offline and cannot accept orders
 rider.accept.food.limit.reached=The active food-delivery order limit has been reached

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -326,6 +326,7 @@ flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
 flash.delivery.delivery.type.invalid=Unsupported delivery level
+flash.delivery.vehicle.type.invalid=Unsupported vehicle type
 flash.delivery.item.invalid=Item quantity, weight range or specification is invalid
 flash.delivery.tip.invalid=Rider tip must be a non-negative integer TWD amount
 flash.delivery.quote.changed=The quote has changed, please review the latest price
@@ -334,6 +335,7 @@ flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pa
 address.access.denied=Address does not exist or is not accessible
 address.data.invalid=Contact, address, or coordinates are invalid
 flash.delivery.rider.type.not.enabled=This account is not enabled for flash delivery orders
+flash.delivery.rider.vehicle.not.match=The rider vehicle type does not match this order
 flash.delivery.rider.exclusive.conflict=The rider has an active exclusive delivery and cannot accept this order
 rider.accept.offline=The rider is offline and cannot accept orders
 rider.accept.food.limit.reached=The active food-delivery order limit has been reached

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -327,6 +327,7 @@ flash.delivery.scene.invalid=พารามิเตอร์กลุ่มส
 flash.delivery.pricing.not.found=ไม่พบการตั้งค่าราคา
 flash.delivery.pricing.changed=การตั้งค่าราคาเปลี่ยนแปลงแล้ว โปรดรีเฟรชและลองอีกครั้ง
 flash.delivery.delivery.type.invalid=ไม่รองรับระดับการจัดส่งนี้
+flash.delivery.vehicle.type.invalid=ไม่รองรับประเภทยานพาหนะจัดส่งนี้
 flash.delivery.item.invalid=ข้อมูลจำนวน ช่วงน้ำหนัก หรือรายละเอียดสิ่งของไม่ถูกต้อง
 flash.delivery.tip.invalid=ทิปสำหรับไรเดอร์ต้องเป็นจำนวนเต็ม TWD ที่ไม่ติดลบ
 flash.delivery.quote.changed=ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง
@@ -335,6 +336,7 @@ flash.delivery.coordinates.invalid=ต้องระบุละติจูด
 address.access.denied=ไม่มีที่อยู่นี้หรือคุณไม่มีสิทธิ์เข้าถึง
 address.data.invalid=ข้อมูลผู้ติดต่อ ที่อยู่ หรือพิกัดไม่ถูกต้อง
 flash.delivery.rider.type.not.enabled=บัญชีนี้ยังไม่ได้เปิดใช้บริการส่งด่วน
+flash.delivery.rider.vehicle.not.match=ประเภทยานพาหนะของไรเดอร์ไม่ตรงกับคำสั่งซื้อนี้
 flash.delivery.rider.exclusive.conflict=ไรเดอร์มีงานจัดส่งแบบเฉพาะที่กำลังดำเนินการ จึงยังไม่สามารถรับคำสั่งซื้อนี้ได้
 rider.accept.offline=ไรเดอร์ออฟไลน์อยู่และไม่สามารถรับงานได้
 rider.accept.food.limit.reached=จำนวนออเดอร์ส่งอาหารที่กำลังดำเนินการถึงขีดจำกัดแล้ว

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -326,6 +326,7 @@ flash.delivery.scene.invalid=Nhóm trạng thái đơn hàng không hợp lệ
 flash.delivery.pricing.not.found=Không tìm thấy cấu hình giá
 flash.delivery.pricing.changed=Cấu hình giá đã thay đổi, vui lòng tải lại và thử lại
 flash.delivery.delivery.type.invalid=Cấp độ giao hàng không được hỗ trợ
+flash.delivery.vehicle.type.invalid=Loại xe giao hàng không được hỗ trợ
 flash.delivery.item.invalid=Số lượng, khoảng trọng lượng hoặc quy cách hàng hóa không hợp lệ
 flash.delivery.tip.invalid=Tiền boa cho tài xế phải là số nguyên TWD không âm
 flash.delivery.quote.changed=Báo giá đã thay đổi, vui lòng xác nhận lại chi phí mới nhất
@@ -334,6 +335,7 @@ flash.delivery.coordinates.invalid=Vĩ độ và kinh độ phải được cung
 address.access.denied=Địa chỉ không tồn tại hoặc không có quyền truy cập
 address.data.invalid=Thông tin liên hệ, địa chỉ hoặc tọa độ không hợp lệ
 flash.delivery.rider.type.not.enabled=Tài khoản này chưa mở giao hàng nhanh
+flash.delivery.rider.vehicle.not.match=Loại xe của tài xế không phù hợp với đơn hàng này
 flash.delivery.rider.exclusive.conflict=Tài xế đang có nhiệm vụ giao hàng độc quyền và tạm thời không thể nhận đơn này
 rider.accept.offline=Tài xế đang ngoại tuyến và không thể nhận đơn
 rider.accept.food.limit.reached=Đã đạt giới hạn đơn giao đồ ăn đang thực hiện

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -327,6 +327,7 @@ flash.delivery.scene.invalid=订单状态分组参数无效
 flash.delivery.pricing.not.found=计价配置不存在
 flash.delivery.pricing.changed=计价配置已发生变化,请刷新后重试
 flash.delivery.delivery.type.invalid=不支持的配送等级
+flash.delivery.vehicle.type.invalid=不支持的配送车型
 flash.delivery.item.invalid=物品数量、重量范围或规格信息不正确
 flash.delivery.tip.invalid=骑手小费必须是非负整数新台币金额
 flash.delivery.quote.changed=报价已变化,请重新确认最新费用
@@ -335,6 +336,7 @@ flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问
 address.data.invalid=联系人、地址或经纬度信息无效
 flash.delivery.rider.type.not.enabled=当前账号未开通闪送配送
+flash.delivery.rider.vehicle.not.match=骑手配送车型与该订单不匹配
 flash.delivery.rider.exclusive.conflict=骑手存在进行中的独占配送任务,暂时不能接此订单
 rider.accept.offline=骑手当前已离线,不能接单
 rider.accept.food.limit.reached=进行中的外卖订单已达到接单上限

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -327,6 +327,7 @@ flash.delivery.scene.invalid=訂單狀態分組參數無效
 flash.delivery.pricing.not.found=計價設定不存在
 flash.delivery.pricing.changed=計價設定已發生變更,請重新整理後重試
 flash.delivery.delivery.type.invalid=不支援的配送等級
+flash.delivery.vehicle.type.invalid=不支援的配送車型
 flash.delivery.item.invalid=物品數量、重量範圍或規格資訊不正確
 flash.delivery.tip.invalid=騎手小費必須是非負整數新台幣金額
 flash.delivery.quote.changed=報價已變更,請重新確認最新費用
@@ -335,6 +336,7 @@ flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取
 address.data.invalid=聯絡人、地址或經緯度資訊無效
 flash.delivery.rider.type.not.enabled=當前賬號未開通闁送配送
+flash.delivery.rider.vehicle.not.match=騎手配送車型與此訂單不相符
 flash.delivery.rider.exclusive.conflict=騎手存在進行中的獨佔配送任務,暫時不能接此訂單
 rider.accept.offline=騎手目前已離線,不能接單
 rider.accept.food.limit.reached=進行中的外賣訂單已達接單上限

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java

@@ -18,7 +18,7 @@ class FlashDeliveryOrderViewTest {
                 "pricingVersion", "cancelOperatorId", "cancelOperatorType", "version")) {
             assertFalse(fields.contains(forbidden), forbidden);
         }
-        for (String required : Set.of("serviceType", "deliveryType", "packageType", "quantity",
+        for (String required : Set.of("serviceType", "deliveryType", "vehicleType", "packageType", "quantity",
                 "weightRange", "specification", "deliveryMode",
                 "scheduledPickupStartAt", "pinRequired", "pickup", "delivery",
                 "baseDeliveryFee", "distanceFee", "urgentFee", "tipAmount", "amount")) {

+ 2 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java

@@ -11,7 +11,7 @@ class FlashDeliveryRiderOrderListViewTest {
     void userListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryUserOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "vehicleType", "status", "packageType",
                 "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
                 "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
                 "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
@@ -23,7 +23,7 @@ class FlashDeliveryRiderOrderListViewTest {
     void riderListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryRiderOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "vehicleType", "status", "packageType",
                 "quantity", "weightRange", "specification", "deliveryMode",
                 "scheduledPickupStartAt", "scheduledPickupEndAt",
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",

+ 23 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
 import java.math.BigDecimal;
 import java.util.List;
 import java.util.Collections;
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -21,10 +22,10 @@ class FlashDeliveryRouteServiceTest {
         List<GeoPoint> points = List.of(point("25", "121"), point("25.1", "121.1"),
                 point("25.2", "121.2"));
         RouteDistanceProvider provider = mock(RouteDistanceProvider.class);
-        when(provider.route(points)).thenReturn(new RouteDistance(32100, 2200, "ROUTE"));
+        when(provider.route(points, FlashDeliveryVehicleType.MOTORCYCLE)).thenReturn(new RouteDistance(32100, 2200, "ROUTE"));
         assertEquals(new RouteDistance(32100, 2200, "ROUTE"),
                 new FlashDeliveryRouteService(provider).calculate(points));
-        verify(provider).route(points);
+        verify(provider).route(points, FlashDeliveryVehicleType.MOTORCYCLE);
         verifyNoMoreInteractions(provider);
     }
 
@@ -49,7 +50,7 @@ class FlashDeliveryRouteServiceTest {
 
     @Test
     void multiStopFallbackIncludesTheMiddleStopInsteadOfTakingAShortcut() {
-        FlashDeliveryRouteService service = new FlashDeliveryRouteService((a, b, c, d) -> {
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService((a, b, c, d, vehicleType) -> {
             throw new IllegalStateException("offline");
         });
         GeoPoint a = point("25.0", "121.0");
@@ -62,7 +63,7 @@ class FlashDeliveryRouteServiceTest {
 
     @Test
     void multiStopRejectsInvalidCountsAndMiddleCoordinatesBeforeCallingMap() {
-        FlashDeliveryRouteService service = new FlashDeliveryRouteService((a, b, c, d) -> {
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService((a, b, c, d, vehicleType) -> {
             throw new AssertionError("invalid request reached map");
         });
         GeoPoint point = point("25", "121");
@@ -74,7 +75,7 @@ class FlashDeliveryRouteServiceTest {
 
     @Test
     void twentySevenPointsAndDuplicatePickupCoordinatesAreAccepted() {
-        FlashDeliveryRouteService service = new FlashDeliveryRouteService((a, b, c, d) -> {
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService((a, b, c, d, vehicleType) -> {
             throw new IllegalStateException("offline");
         });
         RouteDistance route = service.calculate(Collections.nCopies(27, point("25", "121")));
@@ -83,7 +84,7 @@ class FlashDeliveryRouteServiceTest {
 
     @Test
     void keepsDrivingRouteWhenProviderSucceeds() {
-        RouteDistanceProvider provider = (a, b, c, d) -> new RouteDistance(5210, 780, "ROUTE");
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> new RouteDistance(5210, 780, "ROUTE");
         FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
 
         RouteDistance result = service.calculate(point("25.0330", "121.5645"),
@@ -96,7 +97,7 @@ class FlashDeliveryRouteServiceTest {
 
     @Test
     void fallsBackToStraightLineWhenProviderFails() {
-        RouteDistanceProvider provider = (a, b, c, d) -> { throw new IllegalStateException("down"); };
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> { throw new IllegalStateException("down"); };
         FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
 
         RouteDistance result = service.calculate(point("25.0330", "121.5645"),
@@ -106,6 +107,21 @@ class FlashDeliveryRouteServiceTest {
         assertEquals("STRAIGHT_LINE", result.source());
     }
 
+    @Test
+    void estimatesStraightLineFallbackDurationBySelectedVehicle() {
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> { throw new IllegalStateException("down"); };
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+
+        RouteDistance motorcycle = service.calculate(point("0", "0"), point("0", "0.01"),
+                FlashDeliveryVehicleType.MOTORCYCLE);
+        RouteDistance car = service.calculate(point("0", "0"), point("0", "0.01"),
+                FlashDeliveryVehicleType.CAR);
+
+        assertEquals("STRAIGHT_LINE", motorcycle.source());
+        assertEquals(161, motorcycle.durationSeconds());
+        assertEquals(134, car.durationSeconds());
+    }
+
     private GeoPoint point(String latitude, String longitude) {
         return new GeoPoint(new BigDecimal(latitude), new BigDecimal(longitude));
     }

+ 21 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProviderTest.java

@@ -0,0 +1,21 @@
+package com.ruoyi.app.flashdelivery.route;
+
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class GoogleRoutesDistanceProviderTest {
+
+    @Test
+    void usesExistingSharedMapKeyDictionaryForRoutes() {
+        assertEquals("sys_googlemap_key", GoogleRoutesDistanceProvider.mapKeyDictType());
+    }
+
+    @Test
+    void mapsUserSelectedVehicleToGoogleTravelMode() {
+        assertEquals("TWO_WHEELER", GoogleRoutesDistanceProvider.travelModeFor(FlashDeliveryVehicleType.MOTORCYCLE));
+        assertEquals("DRIVE", GoogleRoutesDistanceProvider.travelModeFor(FlashDeliveryVehicleType.CAR));
+        assertEquals("TWO_WHEELER", GoogleRoutesDistanceProvider.travelModeFor(null));
+    }
+}

+ 62 - 42
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -15,6 +15,7 @@ import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
 import com.ruoyi.app.order.RiderDeliveryAcceptanceService;
 import com.ruoyi.app.order.RiderDeliveryLockService;
@@ -55,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import com.ruoyi.common.exception.ServiceException;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
@@ -90,8 +92,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteUsesServerRouteAndCurrentTimePricing() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
 
         var quote = fixture.service.quote(request());
 
@@ -106,8 +108,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteRejectsRoutesLongerThanFortyKilometres() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
     }
@@ -169,8 +171,8 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
                 .thenReturn(List.of(88L));
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
             order.setId(99L);
@@ -244,8 +246,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createDoesNotBindReceiverWhenNormalizedPhoneMatchesMultipleUsers() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("886912345678"))
                 .thenReturn(List.of(88L, 99L));
         doAnswer(invocation -> {
@@ -266,8 +268,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createLeavesReceiverUnboundWhenPhoneHasNoRegisteredUser() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
                 .thenReturn(List.of());
         doAnswer(invocation -> {
@@ -655,7 +657,7 @@ class FlashDeliveryApplicationServiceTest {
     void concurrentPricingUpdateIsRejected() {
         Fixture fixture = new Fixture();
         when(fixture.pricingMapper.selectById(1L)).thenReturn(pricing());
-        when(fixture.pricingMapper.countOverlapping("00:00", "12:00", 1L)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping(eq("00:00"), eq("12:00"), anyInt(), eq(1L))).thenReturn(0);
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
         FlashDeliveryPricingRequest request = pricingRequest();
         request.setEndTime("12:00");
@@ -670,7 +672,7 @@ class FlashDeliveryApplicationServiceTest {
     void overlappingPricingPeriodIsRejectedBeforeInsert() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(1);
+        when(fixture.pricingMapper.countOverlapping(eq("00:00"), eq("24:00"), anyInt(), isNull())).thenReturn(1);
 
         assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request));
 
@@ -708,7 +710,7 @@ class FlashDeliveryApplicationServiceTest {
     void createPricingPersistsImmediatelyActiveTimePeriod() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping(eq("00:00"), eq("24:00"), anyInt(), isNull())).thenReturn(0);
 
         fixture.service.createPricing(7L, request);
 
@@ -720,7 +722,7 @@ class FlashDeliveryApplicationServiceTest {
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("00:00", "24:00", null);
+                .countOverlapping(eq("00:00"), eq("24:00"), anyInt(), isNull());
         writeOrder.verify(fixture.pricingMapper).insert(any(FlashDeliveryPricing.class));
     }
 
@@ -737,18 +739,18 @@ class FlashDeliveryApplicationServiceTest {
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
 
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt());
     }
 
     @Test
     void multipleCurrentPricingPeriodsFailExplicitlyBeforeRouteLookup() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString()))
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt()))
                 .thenReturn(List.of(pricing(), pricing()));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
 
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt());
     }
 
     @Test
@@ -764,7 +766,7 @@ class FlashDeliveryApplicationServiceTest {
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("00:00", "24:00", 1L);
+                .countOverlapping(eq("00:00"), eq("24:00"), anyInt(), eq(1L));
         writeOrder.verify(fixture.pricingMapper).update(isNull(), any(Wrapper.class));
     }
 
@@ -782,8 +784,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void urgentQuoteReturnsSeparatedFeesAndUsesScheduledPickupTime() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime("16:30")).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(6000, 900, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(eq("16:30"), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(6000, 900, "ROUTE"));
         FlashDeliveryQuoteRequest request = request();
         request.setDeliveryType("URGENT");
         request.setTipAmount(5L);
@@ -800,18 +802,19 @@ class FlashDeliveryApplicationServiceTest {
         var quote = fixture.service.quote(request);
 
         assertEquals(45L, quote.getDistanceFee());
-        assertEquals(135L, quote.getBaseDeliveryFee());
+        // 起步价与 45 元距离附加费分开返回,避免前端重复相加。
+        assertEquals(90L, quote.getBaseDeliveryFee());
         assertEquals(27L, quote.getUrgentFee());
         assertEquals(5L, quote.getTipAmount());
         assertEquals(167L, quote.getAmount());
-        verify(fixture.pricingMapper).selectAtTime("16:30");
+        verify(fixture.pricingMapper).selectAtTime(eq("16:30"), anyInt());
     }
 
     @Test
     void createRejectsChangedQuoteAndReturnsLatestQuoteWithoutInsert() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         FlashDeliveryCreateRequest request = createRequest();
         request.setQuotedAmount(91L);
 
@@ -826,8 +829,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createDefaultsPayTypeToCashAndStoresTransferSelection() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
             order.setId(99L);
@@ -852,8 +855,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createRejectsUnsupportedPayType() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         FlashDeliveryCreateRequest request = createRequest();
         request.setPayType("2");
 
@@ -884,8 +887,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteAcceptsAllFourWeightRanges() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
 
         for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
                 "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
@@ -901,8 +904,8 @@ class FlashDeliveryApplicationServiceTest {
         for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
                 "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
             Fixture fixture = new Fixture();
-            when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-            when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+            when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+            when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
             doAnswer(invocation -> {
                 FlashDeliveryOrder order = invocation.getArgument(0);
                 order.setId(99L);
@@ -923,9 +926,22 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void quoteRejectsMissingAndUnknownWeightRanges() {
+    void quoteAllowsMissingWeightRange() {
         Fixture fixture = new Fixture();
-        for (String weightRange : new String[]{null, "", "SMALL", "OVER_20_KG"}) {
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt()))
+                .thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+
+        FlashDeliveryQuoteRequest request = request();
+        request.setWeightRange(null);
+
+        assertDoesNotThrow(() -> fixture.service.quote(request));
+    }
+
+    @Test
+    void quoteRejectsUnknownWeightRangeWhenProvided() {
+        Fixture fixture = new Fixture();
+        for (String weightRange : new String[]{"", "SMALL", "OVER_20_KG"}) {
             FlashDeliveryQuoteRequest request = request();
             request.setWeightRange(weightRange);
             assertThrows(ServiceException.class, () -> fixture.service.quote(request), String.valueOf(weightRange));
@@ -936,6 +952,7 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
         request.setServiceType("HELP_SEND");
         request.setDeliveryType("NORMAL");
+        request.setVehicleType(1);
         request.setPackageType("DOCUMENT");
         request.setQuantity(1);
         request.setWeightRange("UP_TO_5_KG");
@@ -953,6 +970,7 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryQuoteRequest quote = request();
         request.setServiceType(quote.getServiceType());
         request.setDeliveryType(quote.getDeliveryType());
+        request.setVehicleType(quote.getVehicleType());
         request.setPackageType(quote.getPackageType());
         request.setQuantity(quote.getQuantity());
         request.setWeightRange(quote.getWeightRange());
@@ -1004,6 +1022,7 @@ class FlashDeliveryApplicationServiceTest {
 
     private FlashDeliveryPricingRequest pricingRequest() {
         FlashDeliveryPricingRequest request = new FlashDeliveryPricingRequest();
+        request.setVehicleType(1);
         request.setStartTime("00:00");
         request.setEndTime("24:00");
         request.setStartingDistance(new BigDecimal("3.00"));
@@ -1123,14 +1142,15 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(4500, quote.getDistanceMeters());
         assertEquals(900, quote.getEstimatedDurationSeconds());
         assertEquals(23L, quote.getDistanceFee());
-        assertEquals(113L, quote.getBaseDeliveryFee());
+        // 基础配送费只表示起步价,距离附加费单独返回。
+        assertEquals(90L, quote.getBaseDeliveryFee());
         assertEquals(10L, quote.getTipAmount());
         assertEquals(123L, quote.getAmount());
         assertEquals(2, quote.getOrderVersion());
         assertEquals(1, quote.getPricingVersion());
         assertEquals("pickup road", order.getPickupAddress());
         // 地址变化只重算路线,不查询最新运价。
-        verify(fixture.pricingMapper, never()).selectAtTime(anyString());
+        verify(fixture.pricingMapper, never()).selectAtTime(anyString(), anyInt());
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
         verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
     }
@@ -1212,7 +1232,7 @@ class FlashDeliveryApplicationServiceTest {
             assertThrows(ServiceException.class, () -> fixture.service.updateAddress(user, 10L, new FlashDeliveryAddressConfirmRequest()));
             assertThrows(ServiceException.class, () -> fixture.service.addTip(user, 10L, tipRequest(5L, 2)));
         }
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt());
         verify(fixture.orderMapper, never()).updateWaitingTip(any(), any());
     }
 
@@ -1256,7 +1276,7 @@ class FlashDeliveryApplicationServiceTest {
         input.setDelivery(request().getDelivery());
         input.getPickup().setPhone("");
         assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, input));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1000, "GOOGLE"));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(40001, 1000, "GOOGLE"));
         assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange()));
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
     }
@@ -1287,8 +1307,8 @@ class FlashDeliveryApplicationServiceTest {
         assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(20L, 2)));
         assertEquals(30L, order.getTipAmount());
         verify(fixture.orderMapper).updateWaitingTip(any(), eq(2));
-        verify(fixture.routeService, never()).calculate(any(), any());
-        verify(fixture.pricingMapper, never()).selectAtTime(anyString());
+        verify(fixture.routeService, never()).calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt());
+        verify(fixture.pricingMapper, never()).selectAtTime(anyString(), anyInt());
     }
 
     @Test
@@ -1360,9 +1380,9 @@ class FlashDeliveryApplicationServiceTest {
         order.setDistance(new BigDecimal("1.00"));
         order.setFreight(15L);
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
         // 后台现价与快照不同;改址仍以订单保存的价格计算。
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
         return order;
     }
 

+ 14 - 14
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java

@@ -217,7 +217,7 @@ class FlashDeliveryMultiPickupTest {
             assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(2)));
             f.service.pickup(8L, 10L, proof(11L));
             assertEquals(1, f.order.getPickedUpStopCount());
-            verify(f.routes, never()).calculate(anyList());
+            verify(f.routes, never()).calculate(anyList(), eq(1));
         }
     }
 
@@ -238,10 +238,10 @@ class FlashDeliveryMultiPickupTest {
             dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("26")));
             dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
             FlashDeliveryQuoteRequest request = quoteRequest(2);
-            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
             assertEquals(4000, f.service.quote(request).getDistanceMeters());
             var points = org.mockito.ArgumentCaptor.forClass(List.class);
-            verify(f.routes).calculate(points.capture()); assertEquals(3, points.getValue().size());
+            verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(3, points.getValue().size());
             for (int size : new int[] {0, 27}) {
                 assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(size)));
             }
@@ -251,7 +251,7 @@ class FlashDeliveryMultiPickupTest {
                 assertEquals(1, f.service.home().getPickupStopLimit());
                 assertThrows(ServiceException.class, () -> f.service.quote(request));
             }
-            verify(f.routes, times(1)).calculate(anyList());
+            verify(f.routes, times(1)).calculate(anyList(), eq(1));
         }
     }
 
@@ -264,24 +264,24 @@ class FlashDeliveryMultiPickupTest {
             assertThrows(ServiceException.class, () -> f.service.quote(request));
             request.setPackageType(null); request.getPickups().get(1).setQuantity(0);
             assertThrows(ServiceException.class, () -> f.service.quote(request));
-            verify(f.routes, never()).calculate(anyList());
+            verify(f.routes, never()).calculate(anyList(), eq(1));
         }
     }
 
     @Test void twentySixPickupsAreAcceptedAndAllTwentySevenRoutePointsRemainOrdered() {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = multiEnabled()) {
-            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(39000, 1800, "ROUTE"));
+            when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(39000, 1800, "ROUTE"));
             assertEquals(39000, f.service.quote(quoteRequest(26)).getDistanceMeters());
             var points = org.mockito.ArgumentCaptor.forClass(List.class);
-            verify(f.routes).calculate(points.capture()); assertEquals(27, points.getValue().size());
+            verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(27, points.getValue().size());
         }
     }
 
     @Test void multiCreatePersistsEveryItemAndOnlyFirstItemAsOrderSummary() {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = multiEnabled()) {
-            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
             FlashDeliveryCreateRequest request = new FlashDeliveryCreateRequest();
             org.springframework.beans.BeanUtils.copyProperties(quoteRequest(2), request);
             request.getPickups().get(1).setPackageType("GIFT");
@@ -312,10 +312,10 @@ class FlashDeliveryMultiPickupTest {
             assertThrows(ServiceException.class, () -> f.service.quoteAddress(7L, 10L, request));
             request.setPickup(null); request.setPickups(new ArrayList<>(data.getPickups()));
             Collections.reverse(request.getPickups());
-            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
             var quote = f.service.quoteAddress(7L, 10L, request);
             assertEquals(100L, quote.getStartingFare()); assertEquals(110L, quote.getAmount());
-            verify(f.prices, never()).selectAtTime(anyString());
+            verify(f.prices, never()).selectAtTime(anyString(), anyInt());
             FlashDeliveryAddressConfirmRequest confirmation = new FlashDeliveryAddressConfirmRequest();
             org.springframework.beans.BeanUtils.copyProperties(request, confirmation);
             confirmation.setQuotedDistanceMeters(quote.getDistanceMeters()); confirmation.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee());
@@ -335,7 +335,7 @@ class FlashDeliveryMultiPickupTest {
         try (MockedStatic<DictUtils> dict = multiEnabled()) {
             var data = quoteRequest(2); FlashDeliveryAddressConfirmRequest request = new FlashDeliveryAddressConfirmRequest();
             request.setOrderVersion(0); request.setPickups(data.getPickups()); request.setDelivery(data.getDelivery());
-            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
             var quote = f.service.quoteAddress(7L, 10L, request);
             request.setQuotedDistanceMeters(quote.getDistanceMeters()); request.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee());
             request.setQuotedDistanceFee(quote.getDistanceFee()); request.setQuotedUrgentFee(quote.getUrgentFee()); request.setQuotedAmount(quote.getAmount());
@@ -371,14 +371,14 @@ class FlashDeliveryMultiPickupTest {
 
     private static void waiting(Fixture f) {
         f.order.setStatus("WAITING_ACCEPTANCE"); f.order.setRiderId(null); f.order.setServiceType("HELP_SEND");
-        f.order.setDeliveryType("NORMAL"); f.order.setTipAmount(0L); f.order.setPricingId(1L); f.order.setPricingVersion(1);
+        f.order.setDeliveryType("NORMAL"); f.order.setVehicleType(1); f.order.setTipAmount(0L); f.order.setPricingId(1L); f.order.setPricingVersion(1);
         f.order.setStartingFare(100L); f.order.setStartingDistance(BigDecimal.valueOf(3));
         f.order.setDistance(BigDecimal.ONE); f.order.setFreight(10L); f.order.setUrgentRate(BigDecimal.ZERO); f.order.setMinimumUrgentFee(0L);
     }
 
     private static FlashDeliveryQuoteRequest quoteRequest(int count) {
         FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
-        request.setServiceType("HELP_SEND"); request.setDeliveryType("URGENT"); request.setTipAmount(0L);
+        request.setServiceType("HELP_SEND"); request.setDeliveryType("URGENT"); request.setVehicleType(1); request.setTipAmount(0L);
         List<FlashDeliveryPickupRequest> pickups = new ArrayList<>();
         for (int i = 0; i < count; i++) {
             FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
@@ -438,7 +438,7 @@ class FlashDeliveryMultiPickupTest {
             FlashDeliveryPricing price = new FlashDeliveryPricing(); price.setId(1L); price.setConfigVersion(1);
             price.setStartingDistance(BigDecimal.valueOf(3)); price.setStartingFare(80L); price.setDistance(BigDecimal.ONE);
             price.setFreight(10L); price.setUrgentRate(BigDecimal.ZERO); price.setMinimumUrgentFee(0L);
-            when(prices.selectAtTime(anyString())).thenReturn(List.of(price));
+            when(prices.selectAtTime(anyString(), anyInt())).thenReturn(List.of(price));
         }
     }
 }

+ 2 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java

@@ -53,11 +53,11 @@ class FlashDeliveryPricingCalculatorTest {
     }
 
     @Test
-    void calculatesUrgentFeeFromBaseDeliveryFeeAndAddsTipSeparately() {
+    void exposesStartingFareAsBaseDeliveryFeeAndAddsDistanceFeeSeparately() {
         var breakdown = calculator.calculateBreakdown(pricing(), 6000, "URGENT", 5L);
 
         assertEquals(45L, breakdown.getDistanceFee());
-        assertEquals(135L, breakdown.getBaseDeliveryFee());
+        assertEquals(90L, breakdown.getBaseDeliveryFee());
         assertEquals(27L, breakdown.getUrgentFee());
         assertEquals(5L, breakdown.getTipAmount());
         assertEquals(167L, breakdown.getAmount());

+ 3 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -29,6 +29,8 @@ public class FlashDeliveryOrder {
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=一对一加急。 */
     private String deliveryType;
+    /** 用户下单选择的车型快照:1=机车,2=轿车。 */
+    private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
     /** 用户申报的包裹类别和重量范围。 */
@@ -98,7 +100,7 @@ public class FlashDeliveryOrder {
     private Long freight;
     /** 距离附加费(TWD),按超起步距离部分以每公里单价计算。 */
     private Long distanceFee;
-    /** 基础配送费(TWD),等于起步价加距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 加急费比例(百分比)。 */
     private BigDecimal urgentRate;

+ 2 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java

@@ -15,6 +15,8 @@ public class FlashDeliveryPricing {
     @TableId(type = IdType.AUTO)
     /** 主键 ID,数据库自增。 */
     private Long id;
+    /** 车型:1=机车,2=轿车。 */
+    private Integer vehicleType;
     /** 时段开始时间(HH:mm,包含该时刻)。 */
     private String startTime;
     /** 时段结束时间(HH:mm,不包含该时刻,24:00 表示收尾到全天结束)。 */

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryVehicleType.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.domain.flash;
+
+/** 闪送车型代码:接口与数据库固定使用 1=机车、2=轿车。 */
+public final class FlashDeliveryVehicleType {
+    public static final int MOTORCYCLE = 1;
+    public static final int CAR = 2;
+
+    private FlashDeliveryVehicleType() { }
+
+    public static boolean isSupported(Integer value) {
+        return value != null && (value == MOTORCYCLE || value == CAR);
+    }
+}

+ 8 - 4
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java

@@ -9,27 +9,31 @@ import java.util.List;
 
 /** 闪送时段运价持久化接口。 */
 public interface FlashDeliveryPricingMapper extends BaseMapper<FlashDeliveryPricing> {
-    /** 查询指定时刻(HH:mm)命中的运价配置,时段为左闭右开。 */
+    /** 查询指定车型在指定时刻(HH:mm)命中的运价配置,时段为左闭右开。 */
     @Select("""
-            select id, start_time, end_time, starting_distance, starting_fare,
+            select id, vehicle_type, start_time, end_time, starting_distance, starting_fare,
                    distance, freight, urgent_rate, minimum_urgent_fee,
                    config_version, updated_by, create_time, update_time
             from flash_delivery_pricing
             where start_time <= #{targetTime}
               and end_time > #{targetTime}
+              and vehicle_type = #{vehicleType}
             order by start_time desc
             """)
-    List<FlashDeliveryPricing> selectAtTime(@Param("targetTime") String targetTime);
+    List<FlashDeliveryPricing> selectAtTime(@Param("targetTime") String targetTime,
+                                            @Param("vehicleType") Integer vehicleType);
 
-    /** 统计与指定时段重叠的运价数量,新增和修改时用于校验时段不重叠。 */
+    /** 统计同车型与指定时段重叠的运价数量,新增和修改时用于校验时段不重叠。 */
     @Select("""
             select count(1)
             from flash_delivery_pricing
             where start_time < #{endTime}
               and end_time > #{startTime}
+              and vehicle_type = #{vehicleType}
               and (#{excludedId} is null or id <> #{excludedId})
             """)
     int countOverlapping(@Param("startTime") String startTime,
                          @Param("endTime") String endTime,
+                         @Param("vehicleType") Integer vehicleType,
                          @Param("excludedId") Long excludedId);
 }

+ 1 - 0
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml

@@ -10,6 +10,7 @@
         <result property="riderId" column="rider_id"/>
         <result property="serviceType" column="service_type"/>
         <result property="deliveryType" column="delivery_type"/>
+        <result property="vehicleType" column="vehicle_type"/>
         <result property="status" column="status"/>
         <result property="packageType" column="package_type"/>
         <result property="quantity" column="quantity"/>

+ 4 - 2
ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java

@@ -14,11 +14,12 @@ class FlashDeliveryPricingMapperContractTest {
 
     @Test
     void currentPeriodUsesInclusiveStartExclusiveEndAndDoesNotHideDuplicates() throws Exception {
-        Method method = FlashDeliveryPricingMapper.class.getMethod("selectAtTime", String.class);
+        Method method = FlashDeliveryPricingMapper.class.getMethod("selectAtTime", String.class, Integer.class);
         String sql = sql(method);
 
         assertTrue(sql.contains("start_time <= #{targettime}"));
         assertTrue(sql.contains("end_time > #{targettime}"));
+        assertTrue(sql.contains("vehicle_type = #{vehicletype}"));
         assertFalse(sql.contains("service_type"));
         assertFalse(sql.contains("limit"));
     }
@@ -26,11 +27,12 @@ class FlashDeliveryPricingMapperContractTest {
     @Test
     void overlapQueryAllowsAdjacentPeriodsIncludingEndOfDay() throws Exception {
         Method method = FlashDeliveryPricingMapper.class.getMethod(
-                "countOverlapping", String.class, String.class, Long.class);
+                "countOverlapping", String.class, String.class, Integer.class, Long.class);
         String sql = sql(method);
 
         assertTrue(sql.contains("start_time < #{endtime}"));
         assertTrue(sql.contains("end_time > #{starttime}"));
+        assertTrue(sql.contains("vehicle_type = #{vehicletype}"));
         assertFalse(sql.contains("service_type"));
     }
 

+ 12 - 10
specs/024-flash-delivery/contracts/api.md

@@ -9,7 +9,9 @@
 - `serviceType`:`HELP_SEND`、`HELP_PICKUP`。
 - `deliveryType`:`NORMAL`、`URGENT`。
 - `packageType`:`DOCUMENT`、`GIFT`、`CLOTHING`、`BEAUTY`、`DAILY_NECESSITIES`、`FOOD_INGREDIENTS`、`ELECTRONICS`、`SMALL_APPLIANCE`、`OTHER`。
-- 物品信息提交 `packageType`、正整数 `quantity`、必填 `weightRange` 及最长 255 字符的可选 `specification`;`weightRange` 只接受 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG`。
+- 物品信息提交 `packageType`、正整数 `quantity`、可选 `weightRange` 及最长 255 字符的可选 `specification`;传入时 `weightRange` 只接受 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG`。
+- `vehicleType`:`1`=机车、`2`=轿车;报价和创建均必填并必须一致。历史订单、骑手资料和运价配置的空值按机车处理。
+- 路线距离和 `estimatedDurationSeconds` 按 `vehicleType` 获取:机车使用 Google Routes 的 `TWO_WHEELER`,轿车使用 `DRIVE`。地图路线不可用时,`distanceSource=STRAIGHT_LINE`,`estimatedDurationSeconds` 仍必返:机车以 25 km/h、轿车以 30 km/h 的保守速度按直线距离估算,向上取整且最少 60 秒;该时长不参与金额计算。两轮路线是 Google Beta 能力;本期不修改用户 App 的提示文案。
 - `deliveryMode`:`NOW`、`SCHEDULED`。预约起止时间使用 ISO 日期时间,时段固定 30 分钟且开始时间不晚于三天后。
 - `status`:`WAITING_ACCEPTANCE`、`ACCEPTED`、`PICKED_UP`、`DELIVERED`、`COMPLETED`、`CANCELLED`。
 
@@ -32,18 +34,18 @@
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
 | GET | `/system/flashDelivery/home` | 返回 `serviceTypes=[HELP_SEND,HELP_PICKUP]`、`deliveryTypes=[NORMAL,URGENT]` 及当前时刻命中的统一运价摘要;不返回内部更新人、时间或地图密钥 |
-| POST | `/system/flashDelivery/quote` | `serviceType,deliveryType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,packageType,quantity,weightRange,specification?,tipAmount,pickup,delivery`;立即订单按当前时刻、预约订单按预约开始时刻匹配运价 |
-| POST | `/system/flashDelivery/orders` | 报价请求字段 + `clientRequestId,pricingId,pricingVersion,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount,pinRequired?,senderImageUrls?,userNote?`;服务端复算并逐项校验后幂等创建 |
+| POST | `/system/flashDelivery/quote` | `serviceType,deliveryType,vehicleType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,packageType,quantity,weightRange?,specification?,tipAmount,pickup,delivery`;立即订单按当前时刻、预约订单按预约开始时刻匹配对应车型运价 |
+| POST | `/system/flashDelivery/orders` | 报价请求字段 + `clientRequestId,pricingId,pricingVersion,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount,pinRequired?,senderImageUrls?,userNote?`;服务端按相同车型复算并逐项校验后幂等创建 |
 | GET | `/system/flashDelivery/orders` | `page,size,role?`;`role=sender/receiver`,省略为 `sender`;按创建时间倒序返回当前用户作为寄件人或已绑定收件人的订单摘要 |
 | GET | `/system/flashDelivery/orders/{id}` | 订单创建人或创建时已绑定收件人的 App 订单详情;`data` 直接为订单字段,不返回原始状态日志 |
 | POST | `/system/flashDelivery/orders/{id}/cancel` | `{"reason":"行程变化"}`;仅订单创建人且状态为待接/已接 |
 | POST | `/system/flashDelivery/orders/{id}/confirmReceipt` | 无 body;订单创建人或已绑定收件人可对 DELIVERED 订单确认签收 |
 
-用户列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。`receiver` 不是按当前手机号动态查询:订单只在创建时唯一匹配已注册普通用户并固化 `receiverUserId`,未匹配订单以后不追溯认领。
+用户列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,vehicleType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。`receiver` 不是按当前手机号动态查询:订单只在创建时唯一匹配已注册普通用户并固化 `receiverUserId`,未匹配订单以后不追溯认领。
 
-报价响应 data:`serviceType,deliveryType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,pricingId,startTime,endTime,distanceMeters,distanceSource,estimatedDurationSeconds?,startingDistance,startingFare,distance,freight,billableDistance,distanceFee,baseDeliveryFee,urgentRate,minimumUrgentFee,urgentFee,tipAmount,amount,currency,pricingVersion`。`billableDistance` 为按外卖规则处理 0.5 公里边界后的计费公里数;全部费用为整数 TWD,`urgentRate` 为百分比。`baseDeliveryFee=startingFare+distanceFee`;普通配送 `urgentFee=0`;加急配送 `urgentFee=max(minimumUrgentFee,roundHalfUp(baseDeliveryFee*urgentRate/100))`;`amount=baseDeliveryFee+urgentFee+tipAmount`。
+报价响应 data:`serviceType,deliveryType,vehicleType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,pricingId,startTime,endTime,distanceMeters,distanceSource,estimatedDurationSeconds?,startingDistance,startingFare,distance,freight,billableDistance,distanceFee,baseDeliveryFee,urgentRate,minimumUrgentFee,urgentFee,tipAmount,amount,currency,pricingVersion`。`billableDistance` 为按外卖规则处理 0.5 公里边界后的计费公里数;全部费用为整数 TWD,`urgentRate` 为百分比。`baseDeliveryFee=startingFare`,仅表示起步价;普通配送费在服务端按 `baseDeliveryFee+distanceFee` 计算但不单独返回;普通配送 `urgentFee=0`;加急配送 `urgentFee=max(minimumUrgentFee,roundHalfUp((baseDeliveryFee+distanceFee)*urgentRate/100))`;`amount=baseDeliveryFee+distanceFee+urgentFee+tipAmount`。已有订单的金额快照不回写,新口径仅用于此调整发布后的新报价和新订单。
 
-创建时服务端重新获取路线、按配送方式匹配运价并复算费用。`pricingId`、`pricingVersion`、`quotedBaseDeliveryFee`、`quotedDistanceFee`、`quotedUrgentFee`、`quotedAmount` 任一不一致时不创建订单,返回国际化“报价已变化,请重新确认”及完整最新报价。地址快照字段为联系人、电话、市/区、交付方式、地址、详细地址和经纬度。用户创建和详情响应的 `data` 直接为订单详情字段,照片分别为 `senderImageUrls,pickupImageUrls,deliveryImageUrls`,不返回原始状态日志;`deliveryPinCode` 仅在启用时向订单参与用户返回,用户仅在 `ACCEPTED`、`PICKED_UP` 阶段获得骑手位置。
+创建时服务端按 `vehicleType` 重新获取对应车型路线、匹配运价并复算费用。`pricingId`、`pricingVersion`、`quotedBaseDeliveryFee`、`quotedDistanceFee`、`quotedUrgentFee`、`quotedAmount` 任一不一致时不创建订单,返回国际化“报价已变化,请重新确认”及完整最新报价。地址快照字段为联系人、电话、市/区、交付方式、地址、详细地址和经纬度。用户创建和详情响应的 `data` 直接为订单详情字段,照片分别为 `senderImageUrls,pickupImageUrls,deliveryImageUrls`,不返回原始状态日志;`deliveryPinCode` 仅在启用时向订单参与用户返回,用户仅在 `ACCEPTED`、`PICKED_UP` 阶段获得骑手位置。
 
 ## 骑手端
 
@@ -59,7 +61,7 @@
 
 骑手页签状态映射:`newTask=WAITING_ACCEPTANCE`、`toPickup=ACCEPTED`、`delivering=PICKED_UP`、`completed=DELIVERED+COMPLETED`、`cancelled=CANCELLED`。除 `newTask` 外只返回当前骑手本人任务;闪送没有 `refund` 页签。
 
-骑手列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 分页 data 在 `records,total,current,size` 外增加 `nearbyTaskCount,highestOrderAmount`;不返回加急比例、最低加急费或骑手收入字段。
+骑手列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,vehicleType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 只返回与骑手登记车型一致的待接单任务;骑手查看待接单详情或接单时也会重新校验车型。分页 data 在 `records,total,current,size` 外增加 `nearbyTaskCount,highestOrderAmount`;不返回加急比例、最低加急费或骑手收入字段。
 
 骑手接单前可按原型查看完整取送文字地址,但不返回用户 ID、联系人、电话、实际 PIN、精确经纬度、备注、幂等请求号、履约凭证或日志。抢单成功后才向中单骑手返回完整联系方式、坐标和有权限查看的照片数组。骑手详情在接单前后使用同一个直接 DTO,不返回 `{order,images,logs}` 包装,也不要求 App 判断 `data.order`。
 
@@ -67,8 +69,8 @@
 
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
-| GET | `/system/flashDelivery/admin/pricing` | 返回帮送和帮取共享的全部统一运价时段,按开始时间排序 |
-| POST | `/system/flashDelivery/admin/pricing` | `startTime,endTime,startingDistance,startingFare,distance,freight,urgentRate,minimumUrgentFee`;保存即生效 |
+| GET | `/system/flashDelivery/admin/pricing` | 返回全部车型运价时段,按车型、开始时间排序 |
+| POST | `/system/flashDelivery/admin/pricing` | `vehicleType,startTime,endTime,startingDistance,startingFare,distance,freight,urgentRate,minimumUrgentFee`;保存即生效 |
 | PUT | `/system/flashDelivery/admin/pricing/{id}` | 与新增相同的完整请求体;版本原子加一,并发覆盖返回业务错误 |
 | DELETE | `/system/flashDelivery/admin/pricing/{id}` | 删除运价时段并立即停止用于报价 |
 | GET | `/system/flashDelivery/admin/orders` | 分页;可按状态、类型、订单号、用户、骑手筛选 |
@@ -138,7 +140,7 @@
 }
 ```
 
-金额仅为示例,客户端必须使用实际报价;`distanceFee` 已包含于 `baseDeliveryFee`,总金额为 `baseDeliveryFee + urgentFee + tipAmount`。
+金额仅为示例,客户端必须使用实际报价;`baseDeliveryFee` 仅为起步价,`distanceFee` 为独立距离附加费,总金额为 `baseDeliveryFee + distanceFee + urgentFee + tipAmount`。
 
 保存前服务端重新计算路线和价格。距离或任一确认金额不同(包括漏传)时,返回非成功 AjaxResult,`msg` 为国际化 `flash.delivery.quote.changed`,`data` 为最新完整报价,订单不变。客户端展示最新报价,经用户再次确认后重新提交,不能自动按新报价扣改金额。
 

+ 2 - 2
specs/024-flash-delivery/data-model.md

@@ -2,13 +2,13 @@
 
 ## `flash_delivery_order`
 
-核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、创建人 `user_id`、创建时按收件手机号唯一匹配并固化的可空 `receiver_user_id`、`rider_id`、帮送/帮取 `service_type`、普通/加急 `delivery_type`、`status`;`package_type`、正整数 `quantity`、四档 `weight_range`、可空 `specification`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee`、`base_delivery_fee`、`urgent_rate`、`minimum_urgent_fee`、`urgent_fee`、`tip_amount` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
+核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、创建人 `user_id`、创建时按收件手机号唯一匹配并固化的可空 `receiver_user_id`、`rider_id`、帮送/帮取 `service_type`、普通/加急 `delivery_type`、`vehicle_type`(1=机车、2=轿车)、`status`;`package_type`、正整数 `quantity`、可空的四档 `weight_range`、可空 `specification`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee`、`base_delivery_fee`、`urgent_rate`、`minimum_urgent_fee`、`urgent_fee`、`tip_amount` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
 
 索引:订单号唯一、用户幂等号唯一、待抢排序、创建人列表、收件人列表、骑手列表、自动完成扫描。`receiver_user_id` 只保存创建当时唯一匹配的普通用户;空值和历史订单不补写,用户手机号变更也不回写。
 
 ## `flash_delivery_pricing`
 
-帮送与帮取共享多个统一运价时段,不再保存 `service_type`。字段为 `start_time`、`end_time`、`starting_distance`、整数 `starting_fare`、`distance`、整数 `freight`、非负两位小数百分比 `urgent_rate`、非负整数 `minimum_urgent_fee`、`config_version`、`updated_by` 和创建/更新时间。配置保存即生效,不保存 `enabled`;全部时间段不得重叠,索引支持按时间匹配目标时刻。有效修改后版本加一,历史订单不回写。时段查询不使用 `LIMIT 1` 隐藏异常数据;若匹配到多条则明确返回配置重叠错误。运价写入不再维护专用锁表;新增和修改仍执行重叠校验,修改仍使用 `config_version` 防止旧版本覆盖。
+帮送与帮取共享多个统一运价时段,不再保存 `service_type`,但每条配置必须有 `vehicle_type`(1=机车、2=轿车)其余字段为 `start_time`、`end_time`、`starting_distance`、整数 `starting_fare`、`distance`、整数 `freight`、非负两位小数百分比 `urgent_rate`、非负整数 `minimum_urgent_fee`、`config_version`、`updated_by` 和创建/更新时间。配置保存即生效,不保存 `enabled`;仅同一车型的时间段不得重叠,索引支持按车型和时间匹配目标时刻。有效修改后版本加一,历史订单不回写。时段查询不使用 `LIMIT 1` 隐藏异常数据;若同一车型匹配到多条则明确返回配置重叠错误。运价写入不再维护专用锁表;新增和修改仍执行同车型重叠校验,修改仍使用 `config_version` 防止旧版本覆盖。
 
 ## `flash_delivery_order_image`
 

+ 1 - 1
specs/024-flash-delivery/design.md

@@ -251,7 +251,7 @@ COMPLETED
 
 平台每个时间段配置 `startTime`、`endTime`、`startingDistance`、`startingFare`、`distance`、`freight`、`urgentRate` 和 `minimumUrgentFee`。所有时间段全局互斥,不再按 `serviceType` 分组。立即订单按报价时刻匹配,预约订单按 `scheduledPickupStartAt` 匹配;日期只用于确定预约有效性,时间段按业务当地时间的时分命中。
 
-普通配送费先沿用现有起送与超距规则计算:`baseDeliveryFee = startingFare + distanceFee`。普通配送 `urgentFee=0`;加急配送使用 `urgentFee = max(minimumUrgentFee, roundHalfUp(baseDeliveryFee × urgentRate / 100))`。骑手小费 `tipAmount` 是用户输入的独立非负整数 TWD,不参与加急费计算;最终 `amount = baseDeliveryFee + urgentFee + tipAmount`。
+基础配送费固定为起步价:`baseDeliveryFee = startingFare`;普通配送费只在服务端按 `baseDeliveryFee + distanceFee` 计算,不单独保存或返回。普通配送 `urgentFee=0`;加急配送使用 `urgentFee = max(minimumUrgentFee, roundHalfUp((baseDeliveryFee + distanceFee) × urgentRate / 100))`。骑手小费 `tipAmount` 是用户输入的独立非负整数 TWD,不参与加急费计算;最终 `amount = baseDeliveryFee + distanceFee + urgentFee + tipAmount`。
 
 ### 12.3 报价回传与创建校验
 

+ 30 - 0
specs/024-flash-delivery/plan.md

@@ -239,6 +239,14 @@ foodie-admin-vue/
 6. `home` 对三种服务分别读取当前时段,只返回有配置的服务;`quote/create` 每次重新读取当前时段并计算。订单固化 `pricingId,pricingVersion,pricingStartTime,pricingEndTime,startingDistance,startingFare,distance,freight,distanceFee`。
 7. 报价与公开服务 DTO 移除旧计价字段,保留 `estimatedDurationSeconds`;金额 DTO 和订单实体改为整数 `Long`。
 
+### 2026-09-17 基础配送费口径调整
+
+`baseDeliveryFee` 统一表示起步价,`distanceFee` 保持独立距离附加费;不增加配送小计字段。服务端计算加急费时仍使用起步价与距离附加费之和,订单总额为基础配送费、距离附加费、加急费与小费之和。既有订单快照不回写。
+
+### 2026-09-17 路线降级预计时长
+
+Google Routes 无路线、异常或不可用时,`FlashDeliveryRouteService` 继续以 Haversine 直线距离并写入 `distanceSource=STRAIGHT_LINE`。同时按订单车型生成 `estimatedDurationSeconds`:机车按 25 km/h,轿车按 30 km/h,结果向上取整且最少 60 秒。该值仅用于履约展示,不参与报价或订单金额计算;不新增来源字段,既有 `distanceSource` 即可标识降级估算。
+
 ### 平台前端交互
 
 - 价格页表格按服务类型、开始时间排序,列出起止时间、起送距离/价格、计价距离/金额、版本和更新时间。
@@ -731,3 +739,25 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 11. **补充验收(T100)**:覆盖默认 A、沟通并保存调整后前往 B、仅导航状态不变、缺原因/声明或直接绕过调整操作拒绝、B 取件后默认回 A、原顺序/物品/金额不变、部分取件进度及取消入口、平台明确标注骑手声明、无法取货的介入与交接记录;覆盖调整请求重复、与取件/取消竞争、目标变化后的取件幂等重试。联调验证前不得将 App 行为标记为已验证,骑手声明不能作为已验证用户同意真实性的依据。
 
 外部约束:Google Routes 单次最多 25 个中途点,Maps URL 使用 two-wheeler 且途经点数量受平台限制;本期采用字典有效上限最多 26 个取货点与单站导航,不做无限站点分段请求。
+
+## 2026-09-17:按配送车型计价实施计划
+
+1. 为运价、报价请求/响应和订单快照增加 `vehicleType`(1=机车、2=轿车),数据库迁移仅写入 `updatesql/sql.md`,旧数据以默认值 1 保持原行为。
+2. 运价查询和时段重叠检查按车型隔离;报价、创建、待接单地址改价均使用订单车型,并将机车映射到 Google Routes `TWO_WHEELER`、轿车映射到 `DRIVE`,重量范围和规格仅在传入时校验。
+3. 骑手沿用既有 `info_user.vehicle_type`;待抢查询、待抢详情和接单重复核验订单车型,历史空值或非法值安全按机车处理。
+4. 平台端运价页面将车型作为必填配置维度,新增车型展示和四语言文案;用户 App 不在本次改动范围内。
+5. 补充服务、DTO 与平台前端定向测试,执行 JDK 21 受影响模块测试、前端测试及两个仓库的差异/换行检查;MySQL 迁移和真实 App 联调单列为未执行项。
+
+### 2026-09-17 实施进度
+
+- [x] 车型运价、报价/订单快照、骑手车型接单限制、重量/规格选填和 SQL 迁移登记已实现;SQL 未直接执行。
+- [x] 路线服务已按车型透传:机车为 Google Routes `TWO_WHEELER`,轿车为 `DRIVE`;报价、创建及待接单改址均覆盖,历史空值按机车回落。
+- [x] 路线车型映射、路线服务透传、车型报价、Mapper/DTO/i18n/接口契约定向测试通过;JDK 21 执行 `mvn -q -pl ruoyi-admin -am -DskipTests package` 通过;平台端 `npm run test:flash-delivery` 8 项通过;两个仓库 `git diff --check` 通过。
+- [ ] 未执行 MySQL 迁移、真实 Google Routes 请求、真实用户 App 联调或完整回归。平台端 `npm run build:prod` 因本机缺少 `vue-cli-service` 未完成。两轮路线是 Google Beta;用户 App 若展示两轮路线,需要另行补充 Google 要求的提示文案。
+
+### 2026-09-17 共享地图 Key 与路线降级诊断
+
+- [x] 闪送改为与外卖共用 `sys_googlemap_key`,不再依赖测试库中缺失的 `sys_google_routes_key`;Key 不进入闪送响应或业务日志。
+- [x] Google Routes 失败时记录车型与脱敏失败原因后降级直线距离;HTTP 非成功响应记录状态码,不记录地址、坐标、Key 或完整响应。
+- [x] `GoogleRoutesDistanceProviderTest` 与 `FlashDeliveryRouteServiceTest` 共 5 项通过。
+- [ ] 从当前开发机访问 `routes.googleapis.com:443` 连接超时,未能验证共享 Key 的 Routes API 权限;需在测试服务器部署后查看新告警日志或从该服务器网络发起验证。

+ 1 - 1
specs/024-flash-delivery/quickstart.md

@@ -1,6 +1,6 @@
 # 闪送 API 联调快速开始
 
-1. 开发者人工执行 `updatesql/sql.md` 中既有闪送迁移及 2026-09-07 统一运价增量,核对每个时段的加急比例和最低加急费;并在字典 `sys_google_routes_key` 配置仅供后端使用、已启用 Google Routes API 的独立密钥,不得复用匿名接口可读取的 `sys_googlemap_key`
+1. 开发者人工执行 `updatesql/sql.md` 中既有闪送迁移及 2026-09-07 统一运价增量,核对每个时段的加急比例和最低加急费;闪送与外卖共用字典 `sys_googlemap_key`,该 Key 必须启用 Google Routes API,并在 Google Cloud 配置 API 限制、来源限制和配额
 2. 启动 `ruoyi-admin`;用户和骑手请求携带现有 `token` 请求头,平台请求使用后台权限。
 3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;报价提交帮送/帮取业务场景、普通/加急配送等级、立即/预约取件时段、完整物品信息和骑手小费。创建时回传报价 ID、版本及各项金额,服务端复算不一致时返回最新报价且不创建订单;收件账号匹配结果固化在新订单中,不追溯历史订单。
 4. 用户通过 `/system/flashDelivery/orders?role=sender` 查看“我发的”,通过 `role=receiver` 查看“我收的”;省略 `role` 时默认 `sender`。发件人与固化收件人均可查看详情和确认收货,只有发件人可以取消。

+ 2 - 2
specs/024-flash-delivery/research.md

@@ -4,11 +4,11 @@
 
 采用 Google Routes API v2 的 `POST https://routes.googleapis.com/directions/v2:computeRoutes`。请求头使用 `X-Goog-Api-Key` 和 `X-Goog-FieldMask: routes.distanceMeters,routes.duration`,请求体使用取件、收件经纬度、`DRIVE`、`TRAFFIC_AWARE`。参考 Google 官方 [Compute Routes](https://developers.google.com/maps/documentation/routes/reference/rest/v2/TopLevel/computeRoutes)、[计算路线](https://developers.google.com/maps/documentation/routes/compute_route_directions) 和 [字段掩码](https://developers.google.com/maps/documentation/routes/choose_fields)。
 
-服务端只从字典 `sys_google_routes_key` 读取独立密钥,不复用可能由现有匿名接口返回的浏览器密钥 `sys_googlemap_key`,也不在响应或日志中输出服务端密钥。该密钥需启用 Routes API 并限制服务端来源。连接/读取超时、HTTP 非成功、JSON 无路线或距离不大于 0 均降级。
+闪送与外卖共用字典 `sys_googlemap_key`,不在闪送响应或日志中输出 Key。该 Key 需启用 Routes API,并在 Google Cloud 配置 API 限制、来源限制和配额。连接/读取超时、HTTP 非成功、JSON 无路线或距离不大于 0 均降级;降级告警仅记录车型和失败原因,不记录地址、坐标或 Key
 
 ## 降级与精度
 
-外部路线不可用时使用 Haversine 公式计算球面直线距离,四舍五入为整数米并标记 `STRAIGHT_LINE`;路线成功标记 `ROUTE`。闪送计价与现有外卖订单保持同一业务规则:按当前时间匹配运价时段,起送距离内收起送价格,超出不足 0.5 公里不加价、0.5 至不足 1 公里按 1 公里计、至少 1 公里按实际超出距离计。距离与单价除法使用 `BigDecimal`,里程费用使用 `HALF_UP` 四舍五入为整数新台币;路线预计时长只用于履约展示,不参与金额计算。
+外部路线不可用时使用 Haversine 公式计算球面直线距离,四舍五入为整数米并标记 `STRAIGHT_LINE`;路线成功标记 `ROUTE`。直线降级仍返回预计时长:机车按 25 km/h、轿车按 30 km/h 计算,结果向上取整且最少 60 秒;这是保守履约展示估算,不参与计价。闪送计价与现有外卖订单保持同一业务规则:按当前时间匹配运价时段,起送距离内收起送价格,超出不足 0.5 公里不加价、0.5 至不足 1 公里按 1 公里计、至少 1 公里按实际超出距离计。距离与单价除法使用 `BigDecimal`,里程费用使用 `HALF_UP` 四舍五入为整数新台币;路线预计时长只用于履约展示,不参与金额计算。
 
 ## 并发与安全
 

+ 13 - 4
specs/024-flash-delivery/spec.md

@@ -178,7 +178,7 @@
 - **FR-005**:立即订单必须按报价时刻匹配唯一运价时段,预约订单必须按 `scheduledPickupStartAt` 匹配唯一运价时段;全部时间段之间不得重叠,没有匹配时段时必须拒绝报价和创建订单。
 - **FR-006**:计价必须与现有外卖订单一致:起送距离内只收起送价格;超出不足 0.5 公里不加价,超出 0.5 公里但不足 1 公里按 1 公里计,超出至少 1 公里按实际超出距离计;里程费用四舍五入到整数元后与起送价格相加,所有 TWD 金额均为整数且不再按百位或千位特殊取整。
 - **FR-007**:系统必须优先使用地图路线距离,地图失败时使用经纬度直线距离并向客户端返回距离来源。
-- **FR-008**:地图密钥必须从服务端配置读取,不得返回客户端或写入业务日志
+- **FR-008**:闪送与外卖共用字典 `sys_googlemap_key`;闪送接口和业务日志不得返回或写入该 Key,Google Cloud 必须限制其 API、来源和配额
 - **FR-009**:创建订单时前端必须回传报价配置 ID、版本、基础配送费、距离费、加急费和总金额;服务端必须重新计算距离和价格并逐项精确校验,一致后才能保存地址、路线、金额和计价配置快照,不一致时不得创建订单并必须返回最新报价。
 - **FR-010**:系统必须使用客户端请求号保证同一用户的订单创建幂等。
 - **FR-011**:闪送订单不得复用餐饮订单或打车订单数据模型。
@@ -240,9 +240,9 @@
 - **FR-067**:闪送继续保持非支付业务,创建成功直接进入 `WAITING_ACCEPTANCE`,不增加待支付状态、支付倒计时、支付接口或退款流程。
 - **FR-068**:所有闪送金额继续使用整数 TWD;蓝湖稿中的小数金额仅为视觉占位,不改变接口和数据库金额类型。
 - **FR-069**:用户端不得展示固定或伪造的附近骑手人数及预计接单分钟数,统一使用“发布后等待附近骑手接单”;本期不增加实时骑手数量或接单预测接口。
-- **FR-070**:普通配送费等于起送价格与距离费之和;普通配送的加急费为 0,加急配送的加急费等于“普通配送费 × 当前时段加急比例”四舍五入到整数 TWD 后与最低加急费取较大值。
-- **FR-071**:骑手小费由用户提交,为独立的非负整数 TWD;订单总金额等于普通配送费、加急费和骑手小费之和,小费不得参与加急费计算。
-- **FR-072**:报价和订单响应必须分别返回 `baseDeliveryFee`、`distanceFee`、`urgentFee`、`tipAmount` 和 `amount`;加急报价还必须返回命中的 `urgentRate` 和 `minimumUrgentFee`,费用名称不得混用。
+- **FR-070**:`baseDeliveryFee` 仅表示起送价格,`distanceFee` 表示距离附加费;普通配送费仅在服务端按两者之和计算,不增加小计字段。普通配送的加急费为 0,加急配送的加急费等于“普通配送费 × 当前时段加急比例”四舍五入到整数 TWD 后与最低加急费取较大值。
+- **FR-071**:骑手小费由用户提交,为独立的非负整数 TWD;订单总金额等于基础配送费、距离附加费、加急费和骑手小费之和,小费不得参与加急费计算。
+- **FR-072**:报价和订单响应必须分别返回 `baseDeliveryFee`、`distanceFee`、`urgentFee`、`tipAmount` 和 `amount`;加急报价还必须返回命中的 `urgentRate` 和 `minimumUrgentFee`,费用名称不得混用。历史订单金额快照不回写。
 
 ### 关键实体
 
@@ -366,3 +366,12 @@
 **方案来源**:上述为本项目 2026-09-15 已确认的方式 2。Lalamove 的业务规范仅作为讨论背景;骑手声明、调整记录及具体 App 操作是本项目设计,不标注为已核实的 Lalamove 操作流程。
 
 地图约束依据:[Google Routes 中途点限制](https://developers.google.com/maps/documentation/routes/intermed_waypoints)、[Google Maps URL 参数及平台途经点限制](https://developers.google.com/maps/documentation/urls/get-started)。
+
+## 2026-09-16 确认、2026-09-17 实施:按配送车型计价
+
+- 用户报价与创建闪送订单均传 `vehicleType`:`1`=机车、`2`=轿车;服务端拒绝缺失或其他数值。
+- 闪送运价按“车型 + 时段”分别配置;报价、创建及待接单改址均以订单车型命中运价,订单保存车型与既有价格快照,后续调价不改变历史订单。
+- 报价、创建及待接单改址调用 Google Routes 时同样传订单车型:机车使用 `TWO_WHEELER`,轿车使用 `DRIVE`;返回的路线距离和预计时长随订单保存。地图失败时降级为直线距离并标记 `STRAIGHT_LINE`,预计时长按机车 25 km/h、轿车 30 km/h 保守估算,向上取整且最少 60 秒;该展示估算不参与计价。
+- 骑手沿用既有 `info_user.vehicle_type`(`1`=机车、`2`=轿车);待抢列表、待抢详情和接单均要求与订单车型一致。历史骑手、运价和订单均默认机车,保证迁移后既有行为不变。
+- 重量范围与体积/规格说明改为选填;未传时保存 `NULL`,不参与本期运费计算。物品类别、数量、小费及地址校验保持必填规则。
+- 本期不修改用户 App 界面;后续 App 仅需在报价和创建请求都传相同的 `vehicleType`。

+ 7 - 0
specs/024-flash-delivery/tasks.md

@@ -201,3 +201,10 @@
 - JDK 21 执行 `mvn -pl ruoyi-admin -am -DskipTests package`,全部参与模块 `BUILD SUCCESS`;`git diff --check` 通过。
 - 扩大运行完整 `FlashDeliveryApplicationServiceTest` 时发现仓库基线既有矛盾:`normalizePayType` 当前允许任意非空支付类型,而 `createRejectsUnsupportedPayType` 仍要求拒绝 `2`;本次未修改该逻辑,也未为接单容量需求顺带修复。
 - 三个字典 SQL 仅记录、未执行;未进行真实 MySQL 并发、Redis 多实例或骑手 App 联调,待抢列表和位置/距离规则按 FR-104 保持未实施。
+
+## Phase 20:合并车型路线、计价与多取货点
+
+- [x] T105 将 `test` 的车型运价、Google 路线车型、直线降级预计时长和骑手车型匹配合入 `test-202609v1`,保留多取一送的完整站点序列、骑手容量限制及其 SQL。
+- [x] T106 统一基础配送费语义:`baseDeliveryFee` 仅为起步价,距离附加费独立使用 `distanceFee`,订单金额由各费用相加;地址改动仍按订单快照计算。
+- [x] T107 重申重量区间和规格为选填:未填写不阻断报价;填写重量时仍校验四档枚举,规格最长 255 字符。
+- [x] T108 使用 JDK 21 运行闪送相关定向测试(127 项),并记录车型迁移 SQL;真实 MySQL 迁移、Google 地图及 App 联调未执行。

+ 14 - 0
updatesql/sql.md

@@ -1645,3 +1645,17 @@ INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_defa
 SELECT 1, '进行中配送任务总上限', '1', 'sys_rider_total_active_order_limit', 'Y', '0', 'admin', NOW()
 WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='sys_rider_total_active_order_limit');
 ```
+
+## 2026-09-17 闪送按车型计价(024-flash-delivery)
+
+> 用途:闪送运价和订单快照增加 `vehicle_type`(1=机车、2=轿车)。默认值为 1,确保历史运价和订单继续按机车处理;骑手复用既有 `info_user.vehicle_type` 字段。脚本只登记,由开发者备份后手动执行。
+
+```sql
+ALTER TABLE flash_delivery_pricing
+  ADD COLUMN vehicle_type TINYINT NOT NULL DEFAULT 1 COMMENT '配送车型:1=机车,2=轿车' AFTER id,
+  ADD KEY idx_flash_pricing_vehicle_period (vehicle_type, start_time, end_time);
+
+ALTER TABLE flash_delivery_order
+  ADD COLUMN vehicle_type TINYINT NOT NULL DEFAULT 1 COMMENT '下单车型快照:1=机车,2=轿车' AFTER delivery_type,
+  ADD KEY idx_flash_available_vehicle (status, vehicle_type, delivery_mode, scheduled_pickup_start_at);
+```