Bläddra i källkod

feat: 闪送支持按车型计价和路线

qmj 19 timmar sedan
förälder
incheckning
3a6be1fa8b
33 ändrade filer med 320 tillägg och 94 borttagningar
  1. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  2. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java
  3. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  4. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  5. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  6. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  7. 6 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  8. 11 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java
  9. 3 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java
  10. 67 20
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  11. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  12. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  13. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  14. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  15. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  16. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  17. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  18. 4 4
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  19. 21 4
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java
  20. 16 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProviderTest.java
  21. 82 39
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  22. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  23. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java
  24. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryVehicleType.java
  25. 8 4
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java
  26. 1 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  27. 4 2
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java
  28. 11 9
      specs/024-flash-delivery/contracts/api.md
  29. 2 2
      specs/024-flash-delivery/data-model.md
  30. 15 0
      specs/024-flash-delivery/plan.md
  31. 9 0
      specs/024-flash-delivery/spec.md
  32. 2 0
      specs/024-flash-delivery/tasks.md
  33. 14 0
      updatesql/sql.md

+ 2 - 0
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 种)。 */

+ 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;
 

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java

@@ -19,6 +19,9 @@ public class FlashDeliveryQuoteRequest {
      */
     private String deliveryType;
 
+    /** 配送车型,必填:1=机车,2=轿车。 */
+    private Integer vehicleType;
+
     /**
      * 物品类别,必填。
      * 可选值:DOCUMENT、GIFT、CLOTHING、BEAUTY、DAILY_NECESSITIES、
@@ -30,7 +33,7 @@ public class FlashDeliveryQuoteRequest {
     private Integer quantity;
 
     /**
-     * 物品重量范围,填。
+     * 物品重量范围,填。
      * 可选值:UP_TO_5_KG、OVER_5_TO_10_KG、OVER_10_TO_15_KG、OVER_15_TO_20_KG。
      */
     private String weightRange;

+ 2 - 0
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;
     /** 预约取件时间窗开始时间,立即单为空。 */

+ 2 - 0
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;
     /** 物品类别。 */

+ 2 - 0
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;
     /** 用户编辑待接单订单使用的乐观锁版本。 */

+ 6 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.flashdelivery.route;
 
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 import org.springframework.stereotype.Service;
 
 /** 路线距离服务:优先请求地图路线,异常时使用球面直线距离保证报价仍然可用。 */
@@ -12,13 +13,15 @@ public class FlashDeliveryRouteService {
         this.provider = provider;
     }
 
-    /** 计算取送两点路线距离:优先地图路线,异常或无结果时降级球面直线距离。 */
-    public RouteDistance calculate(GeoPoint origin, GeoPoint destination) {
+    /** 按用户选择车型计算取送路线:优先地图路线,异常或无结果时降级球面直线距离。 */
+    public RouteDistance calculate(GeoPoint origin, GeoPoint destination, Integer vehicleType) {
         validate(origin);
         validate(destination);
+        int selectedVehicle = FlashDeliveryVehicleType.isSupported(vehicleType)
+                ? vehicleType : FlashDeliveryVehicleType.MOTORCYCLE;
         try {
             RouteDistance route = provider.route(origin.latitude(), origin.longitude(),
-                    destination.latitude(), destination.longitude());
+                    destination.latitude(), destination.longitude(), selectedVehicle);
             if (route != null && route.distanceMeters() > 0) {
                 return route;
             }

+ 11 - 3
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;
@@ -26,10 +27,11 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
     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) {
         // 不复用可能被浏览器端读取的 sys_googlemap_key,防止服务端 API 权限泄露。
         List<SysDictData> keys = DictUtils.getDictCache("sys_google_routes_key");
         if (keys == null || keys.isEmpty() || keys.get(0).getDictValue() == null
@@ -39,7 +41,7 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         JSONObject body = new JSONObject();
         body.put("origin", waypoint(originLatitude, originLongitude));
         body.put("destination", waypoint(destinationLatitude, destinationLongitude));
-        body.put("travelMode", "DRIVE");
+        body.put("travelMode", travelModeFor(vehicleType));
         body.put("routingPreference", "TRAFFIC_AWARE");
         body.put("computeAlternativeRoutes", false);
         body.put("units", "METRIC");
@@ -81,6 +83,12 @@ 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";
+    }
+
     private Integer parseDuration(String value) {
         if (value == null || !value.endsWith("s")) return null;
         try {

+ 3 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java

@@ -5,7 +5,8 @@ import java.math.BigDecimal;
 @FunctionalInterface
 /** 外部路线距离提供器,失败时由上层服务负责本地降级。 */
 public interface RouteDistanceProvider {
-    /** 按起终点计算路线距离与时长;失败直接抛异常,由上层负责降级。 */
+    /** 按起终点和用户选择车型计算路线距离与时长;失败直接抛异常,由上层负责降级。 */
     RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
-                        BigDecimal destinationLatitude, BigDecimal destinationLongitude);
+                        BigDecimal destinationLatitude, BigDecimal destinationLongitude,
+                        Integer vehicleType);
 }

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

@@ -96,7 +96,7 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryHomeView view = new FlashDeliveryHomeView();
         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;
@@ -114,6 +114,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());
@@ -171,6 +172,7 @@ public class FlashDeliveryApplicationService {
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
         order.setDeliveryType(request.getDeliveryType());
+        order.setVehicleType(request.getVehicleType());
         order.setStatus(WAITING_ACCEPTANCE);
         order.setPackageType(request.getPackageType());
         order.setQuantity(request.getQuantity());
@@ -349,15 +351,18 @@ public class FlashDeliveryApplicationService {
                 && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
             throw fail("flash.delivery.address.same");
         }
+        // 改址不能把既有机车/轿车订单改按另一车型的路线和时长重新报价。
+        Integer selectedVehicle = vehicleTypeOf(order);
         RouteDistance route = routeService.calculate(
                 new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
-                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
+                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()),
+                selectedVehicle);
         if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
         // 待接单尚未成交,改址按"订单定价时点"的最新运价重算(预约单取预约取件时刻、
         // 立即单取当前),管理员调价对未接单的改址立即生效;时段语义保住,预约单不会用到错误时段的价格。
         Date pricingDate = "SCHEDULED".equals(order.getDeliveryMode())
                 ? order.getScheduledPickupStartAt() : new Date();
-        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate), selectedVehicle);
         FlashDeliveryPriceBreakdown breakdown;
         try {
             breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
@@ -416,11 +421,12 @@ public class FlashDeliveryApplicationService {
         BigDecimal effectiveLongitude = "newTask".equals(selectedTab) ? longitude : null;
         BigDecimal effectiveLatitude = "newTask".equals(selectedTab) ? latitude : null;
         if ("newTask".equals(selectedTab)) {
-            // 新任务派单按骑手选择的配送类型过滤;本人已接订单页签不受限制
+            // 待抢单同时受闪送资质和骑手登记车型限制;本人已接订单页签不受车型过滤影响
             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();
@@ -431,7 +437,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)));
@@ -444,8 +450,9 @@ public class FlashDeliveryApplicationService {
         InfoUser rider = requireRider(riderId);
         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 和履约图片。
@@ -463,6 +470,8 @@ public class FlashDeliveryApplicationService {
         if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
         return riderDeliveryLockService.withLock(riderId, () -> {
             FlashDeliveryOrder target = requireOrder(orderId);
+            // 列表过滤不能替代锁内校验,防止骑手绕过列表直接提交不同车型订单。
+            if (!isRiderVehicleCompatible(rider, target)) throw fail("flash.delivery.rider.vehicle.not.match");
             riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
             Date now = new Date();
             // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
@@ -498,7 +507,7 @@ public class FlashDeliveryApplicationService {
     /** 平台运价配置列表,按时段开始时间排序。 */
     public List<FlashDeliveryPricing> adminPricing() {
         return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
-                .orderByAsc("start_time", "id"));
+                .orderByAsc("vehicle_type", "start_time", "id"));
     }
 
     /** 新增运价配置:校验参数合法性与时段不重叠后入库,版本号从 1 开始。 */
@@ -529,6 +538,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()))
@@ -542,6 +552,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()));
@@ -641,11 +652,14 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.address.same");
         }
         Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
-        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
+        // 同一个车型同时决定运价和 Google 路线模式,避免机车按轿车路线报价。
+        Integer selectedVehicle = requireVehicleType(request.getVehicleType());
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate), selectedVehicle);
         // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
         RouteDistance route = routeService.calculate(
                 new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
-                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
+                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()),
+                selectedVehicle);
         if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
         FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
                 route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
@@ -653,15 +667,15 @@ public class FlashDeliveryApplicationService {
     }
 
     /** 查询指定时刻命中的运价配置,未配置时直接报错。 */
-    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);
@@ -673,7 +687,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
@@ -691,7 +706,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");
         }
     }
@@ -699,6 +714,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()));
@@ -811,6 +827,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.setOrderVersion(order.getVersion());
         view.setPackageType(order.getPackageType());
@@ -842,6 +859,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.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
@@ -917,6 +935,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.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
@@ -1099,10 +1118,10 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    /** 校验物品信息:数量、重量范围、规格长度和小费。 */
+    /** 校验物品信息:重量范围和规格均可省略,小费仍必须明确传 0 或正整数。 */
     private void validateItem(FlashDeliveryQuoteRequest request) {
         if (request.getQuantity() == null || request.getQuantity() <= 0
-                || !hasText(request.getWeightRange()) || !WEIGHT_RANGES.contains(request.getWeightRange())
+                || hasText(request.getWeightRange()) && !WEIGHT_RANGES.contains(request.getWeightRange())
                 || tooLong(request.getSpecification(), 255)) {
             throw fail("flash.delivery.item.invalid");
         }
@@ -1111,6 +1130,33 @@ public class FlashDeliveryApplicationService {
         }
     }
 
+    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));
+    }
+
     /** 支付方式与主订单系统同域(4=现金、6=线下转账);闪送款项直达骑手,仅允许线下方式,缺省现金,下单后锁定。 */
     private String normalizePayType(String payType) {
         if (!hasText(payType)) return PAY_TYPE_CASH;
@@ -1158,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<>();
@@ -1166,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) {

+ 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
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
 flash.delivery.order.version.invalid=请提供有效的订单版本

+ 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
 flash.delivery.edit.not.allowed=Only orders awaiting acceptance with no assigned rider can be edited
 flash.delivery.order.version.invalid=Please provide a valid order version

+ 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=ไรเดอร์มีงานจัดส่งแบบเฉพาะที่กำลังดำเนินการ จึงยังไม่สามารถรับคำสั่งซื้อนี้ได้
 flash.delivery.edit.not.allowed=แก้ไขได้เฉพาะคำสั่งซื้อที่รอรับงานและยังไม่มีไรเดอร์
 flash.delivery.order.version.invalid=กรุณาระบุเวอร์ชันคำสั่งซื้อที่ถูกต้อง

+ 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
 flash.delivery.edit.not.allowed=Chỉ có thể sửa đơn đang chờ nhận và chưa có tài xế
 flash.delivery.order.version.invalid=Vui lòng cung cấp phiên bản đơn hàng hợp lệ

+ 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=骑手存在进行中的独占配送任务,暂时不能接此订单
 flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修改
 flash.delivery.order.version.invalid=请提供有效的订单版本

+ 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=騎手存在進行中的獨佔配送任務,暫時不能接此訂單
 flash.delivery.edit.not.allowed=僅待接單且未分配騎手的訂單可以修改
 flash.delivery.order.version.invalid=請提供有效的訂單版本

+ 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")) {

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

@@ -11,8 +11,8 @@ 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",
-                "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "vehicleType", "status", "packageType",
+                "orderVersion", "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
                 "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
                 "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
                 "estimatedDurationSeconds", "amount", "currency", "deliveredAt", "createTime");
@@ -23,13 +23,13 @@ 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",
                 "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",
                 "estimatedDurationSeconds", "baseDeliveryFee", "distanceFee", "urgentFee",
-                "tipAmount", "amount", "currency", "createTime");
+                "tipAmount", "amount", "currency", "payType", "createTime");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
 

+ 21 - 4
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java

@@ -1,21 +1,38 @@
 package com.ruoyi.app.flashdelivery.route;
 
 import org.junit.jupiter.api.Test;
+import com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 
 import java.math.BigDecimal;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryRouteServiceTest {
 
+    @Test
+    void passesMotorcycleSelectionToTheRouteProvider() {
+        AtomicReference<Integer> requestedVehicle = new AtomicReference<>();
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> {
+            requestedVehicle.set(vehicleType);
+            return new RouteDistance(5210, 780, "ROUTE");
+        };
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+
+        service.calculate(point("25.0330", "121.5645"), point("25.0478", "121.5319"),
+                FlashDeliveryVehicleType.MOTORCYCLE);
+
+        assertEquals(FlashDeliveryVehicleType.MOTORCYCLE, requestedVehicle.get());
+    }
+
     @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"),
-                point("25.0478", "121.5319"));
+                point("25.0478", "121.5319"), FlashDeliveryVehicleType.CAR);
 
         assertEquals(5210, result.distanceMeters());
         assertEquals(780, result.durationSeconds());
@@ -24,11 +41,11 @@ 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"),
-                point("25.0478", "121.5319"));
+                point("25.0478", "121.5319"), FlashDeliveryVehicleType.CAR);
 
         assertTrue(result.distanceMeters() >= 3600 && result.distanceMeters() <= 3800);
         assertEquals("STRAIGHT_LINE", result.source());

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

@@ -0,0 +1,16 @@
+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 mapsUserSelectedVehicleToGoogleTravelMode() {
+        assertEquals("TWO_WHEELER", GoogleRoutesDistanceProvider.travelModeFor(FlashDeliveryVehicleType.MOTORCYCLE));
+        assertEquals("DRIVE", GoogleRoutesDistanceProvider.travelModeFor(FlashDeliveryVehicleType.CAR));
+        assertEquals("TWO_WHEELER", GoogleRoutesDistanceProvider.travelModeFor(null));
+    }
+}

+ 82 - 39
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -55,6 +55,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 +91,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(), any(), anyInt())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
 
         var quote = fixture.service.quote(request());
 
@@ -106,8 +107,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(), any(), anyInt())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
     }
@@ -169,8 +170,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(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
             order.setId(99L);
@@ -244,8 +245,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(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("886912345678"))
                 .thenReturn(List.of(88L, 99L));
         doAnswer(invocation -> {
@@ -266,8 +267,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(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
                 .thenReturn(List.of());
         doAnswer(invocation -> {
@@ -336,9 +337,11 @@ class FlashDeliveryApplicationServiceTest {
         var parameters = query.getValue().getParamNameValuePairs().values();
         assertTrue(sql.contains("status"));
         assertTrue(sql.contains("rider_id is null"));
+        assertTrue(sql.contains("vehicle_type"));
         assertTrue(sql.contains("scheduled_pickup_start_at"));
         assertTrue(sql.contains("st_distance_sphere"));
         assertTrue(parameters.contains("WAITING_ACCEPTANCE"));
+        assertTrue(parameters.contains(1));
         assertTrue(parameters.contains("NOW"));
         assertTrue(parameters.stream().anyMatch(Date.class::isInstance));
 
@@ -348,6 +351,7 @@ class FlashDeliveryApplicationServiceTest {
         String summarySql = summaryQuery.getValue().getSqlSegment().toLowerCase();
         var summaryParameters = summaryQuery.getValue().getParamNameValuePairs().values();
         assertTrue(summarySql.contains("rider_id is null"));
+        assertTrue(summarySql.contains("vehicle_type"));
         assertTrue(summarySql.contains("scheduled_pickup_start_at"));
         assertTrue(summarySql.contains("st_distance_sphere"));
         assertTrue(summaryParameters.contains("WAITING_ACCEPTANCE"));
@@ -655,7 +659,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("00:00", "12:00", 1, 1L)).thenReturn(0);
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
         FlashDeliveryPricingRequest request = pricingRequest();
         request.setEndTime("12:00");
@@ -670,7 +674,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("00:00", "24:00", 1, null)).thenReturn(1);
 
         assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request));
 
@@ -708,7 +712,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("00:00", "24:00", 1, null)).thenReturn(0);
 
         fixture.service.createPricing(7L, request);
 
@@ -720,7 +724,7 @@ class FlashDeliveryApplicationServiceTest {
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("00:00", "24:00", null);
+                .countOverlapping("00:00", "24:00", 1, null);
         writeOrder.verify(fixture.pricingMapper).insert(any(FlashDeliveryPricing.class));
     }
 
@@ -737,18 +741,18 @@ class FlashDeliveryApplicationServiceTest {
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
 
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(), any(), 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(), any(), anyInt());
     }
 
     @Test
@@ -759,12 +763,14 @@ class FlashDeliveryApplicationServiceTest {
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
         FlashDeliveryPricingRequest request = pricingRequest();
         request.setConfigVersion(1);
+        request.setVehicleType(2);
 
-        fixture.service.updatePricing(9L, 1L, request);
+        FlashDeliveryPricing updated = fixture.service.updatePricing(9L, 1L, request);
+        assertEquals(2, updated.getVehicleType());
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("00:00", "24:00", 1L);
+                .countOverlapping("00:00", "24:00", 2, 1L);
         writeOrder.verify(fixture.pricingMapper).update(isNull(), any(Wrapper.class));
     }
 
@@ -782,8 +788,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("16:30", 1)).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(6000, 900, "ROUTE"));
         FlashDeliveryQuoteRequest request = request();
         request.setDeliveryType("URGENT");
         request.setTipAmount(5L);
@@ -804,14 +810,14 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(27L, quote.getUrgentFee());
         assertEquals(5L, quote.getTipAmount());
         assertEquals(167L, quote.getAmount());
-        verify(fixture.pricingMapper).selectAtTime("16:30");
+        verify(fixture.pricingMapper).selectAtTime("16:30", 1);
     }
 
     @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(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         FlashDeliveryCreateRequest request = createRequest();
         request.setQuotedAmount(91L);
 
@@ -826,8 +832,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(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
             order.setId(99L);
@@ -852,8 +858,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(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         FlashDeliveryCreateRequest request = createRequest();
         request.setPayType("2");
 
@@ -884,8 +890,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(), any(), 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 +907,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(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
             doAnswer(invocation -> {
                 FlashDeliveryOrder order = invocation.getArgument(0);
                 order.setId(99L);
@@ -932,12 +938,45 @@ class FlashDeliveryApplicationServiceTest {
         }
     }
 
+    @Test
+    void quoteUsesSelectedVehiclePricingAndRejectsUnsupportedVehicle() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryPricing carPricing = pricing();
+        carPricing.setId(2L);
+        carPricing.setVehicleType(2);
+        carPricing.setStartingFare(180L);
+        when(fixture.pricingMapper.selectAtTime(anyString(), eq(2))).thenReturn(List.of(carPricing));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+
+        FlashDeliveryQuoteRequest carRequest = request();
+        carRequest.setVehicleType(2);
+        assertEquals(180L, fixture.service.quote(carRequest).getAmount());
+        verify(fixture.routeService).calculate(any(), any(), eq(2));
+
+        FlashDeliveryQuoteRequest missingVehicle = request();
+        missingVehicle.setVehicleType(null);
+        assertThrows(ServiceException.class, () -> fixture.service.quote(missingVehicle));
+    }
+
+    @Test
+    void quoteAllowsMissingWeightRangeAndSpecification() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString(), eq(1))).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any(), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        FlashDeliveryQuoteRequest request = request();
+        request.setWeightRange(null);
+        request.setSpecification(null);
+
+        assertDoesNotThrow(() -> fixture.service.quote(request));
+    }
+
     private FlashDeliveryQuoteRequest request() {
         FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
         request.setServiceType("HELP_SEND");
         request.setDeliveryType("NORMAL");
         request.setPackageType("DOCUMENT");
         request.setQuantity(1);
+        request.setVehicleType(1);
         request.setWeightRange("UP_TO_5_KG");
         request.setSpecification("30 x 20 x 10 cm");
         request.setTipAmount(0L);
@@ -953,6 +992,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());
@@ -990,6 +1030,7 @@ class FlashDeliveryApplicationServiceTest {
     private FlashDeliveryPricing pricing() {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
         pricing.setId(1L);
+        pricing.setVehicleType(1);
         pricing.setStartTime("00:00");
         pricing.setEndTime("24:00");
         pricing.setStartingDistance(new BigDecimal("3.00"));
@@ -1004,6 +1045,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"));
@@ -1040,6 +1082,7 @@ class FlashDeliveryApplicationServiceTest {
         rider.setUserId(riderId);
         rider.setUserType("2");
         rider.setDeliveryType("FLASH");
+        rider.setVehicleType("1");
         when(fixture.userMapper.selectInfoUserByUserId(riderId)).thenReturn(rider);
     }
 
@@ -1129,7 +1172,7 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(1, quote.getPricingVersion());
         assertEquals("pickup road", order.getPickupAddress());
         // 改址报价必须查询运价表(按定价时点取最新),不再沿用订单快照
-        verify(fixture.pricingMapper).selectAtTime(anyString());
+        verify(fixture.pricingMapper).selectAtTime(anyString(), anyInt());
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
         verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
     }
@@ -1211,7 +1254,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(), any(), anyInt());
         verify(fixture.orderMapper, never()).updateWaitingTip(any(), any());
     }
 
@@ -1255,7 +1298,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(), any(), anyInt())).thenReturn(new RouteDistance(40001, 1000, "GOOGLE"));
         assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange()));
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
     }
@@ -1286,8 +1329,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(), any(), anyInt());
+        verify(fixture.pricingMapper, never()).selectAtTime(anyString(), anyInt());
     }
 
     @Test
@@ -1359,9 +1402,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(), any(), anyInt())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
         // 031 后改址按订单定价时点读最新运价:stub 与订单快照同值,金额断言维持不变
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
         return order;
     }
 

+ 2 - 0
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;
     /** 用户申报的包裹类别和重量范围。 */

+ 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"));
     }
 

+ 11 - 9
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`。两轮路线是 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+distanceFee`;普通配送 `urgentFee=0`;加急配送 `urgentFee=max(minimumUrgentFee,roundHalfUp(baseDeliveryFee*urgentRate/100))`;`amount=baseDeliveryFee+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` | 分页;可按状态、类型、订单号、用户、骑手筛选 |

+ 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`
 

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

@@ -721,3 +721,18 @@ 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 要求的提示文案。

+ 9 - 0
specs/024-flash-delivery/spec.md

@@ -355,3 +355,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`;返回的路线距离和预计时长随订单保存。地图失败仍仅降级为直线距离,不伪造预计时长。
+- 骑手沿用既有 `info_user.vehicle_type`(`1`=机车、`2`=轿车);待抢列表、待抢详情和接单均要求与订单车型一致。历史骑手、运价和订单均默认机车,保证迁移后既有行为不变。
+- 重量范围与体积/规格说明改为选填;未传时保存 `NULL`,不参与本期运费计算。物品类别、数量、小费及地址校验保持必填规则。
+- 本期不修改用户 App 界面;后续 App 仅需在报价和创建请求都传相同的 `vehicleType`。

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

@@ -179,3 +179,5 @@
 - [ ] T098 实现 FR-095、FR-096 当前目标及调整记录保存、DTO/实体/Mapper/视图/必要 SQL、原因与声明校验、订单权限和状态保护、重复请求与并发保护;取件校验有效目标及必要记录,目标完成后回到原顺序最早未取站,不修改原顺序/物品/金额
 - [ ] T099 落实用户端排序与提示、骑手端默认下一站/沟通后选择目标/前往与取件分离、各端实际取件进度及取消入口;平台可核对调整声明、异常处理结果和已取物品去向/交接凭证,用户可见文案国际化,聊天可作为沟通依据
 - [ ] T100 补充并执行顺序调整验收:有无原因/声明、直接取件绕过、导航不推进状态、B 取件后回 A、原顺序与金额不变、进度/取消按钮、声明展示、异常交接、调整重复及与取件/取消并发、已取站重试;配合 T095 记录实际测试与 App/数据库联调结果
+
+- [x] T101 【2026-09-17】按 `vehicleType`(1=机车、2=轿车)扩展闪送运价、报价、订单快照、骑手待抢/详情/接单校验、Google Routes(机车 TWO_WHEELER、轿车 DRIVE)与平台运价配置;历史数据默认机车;重量范围和规格改为选填。本期不修改用户 App,详见 spec.md「按配送车型计价」。

+ 14 - 0
updatesql/sql.md

@@ -1539,3 +1539,17 @@ ALTER TABLE flash_delivery_order
 ALTER TABLE info_user
   MODIFY COLUMN avatar VARCHAR(1000) DEFAULT '' COMMENT '头像地址';
 ```
+
+## 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);
+```