Selaa lähdekoodia

feat(闪送): 实现多点取送v2契约与逐站送达

- v2 报价/创建接口:统一 stops 数组(type 区分取送、顺序即路线)、订单级物品一次设置、整线按序计价、取前收后与上限开关校验、按站固化收件人
- 骑手逐站送达:按站收货码与凭证幂等,末站完成才推进已送达,部分送达后禁止自助取消
- 站点表与订单表同步新结构(备注/按站收件人/收货码/送达计数),旧 quote/orders/deliver 标注废弃过渡
- i18n 五语言包补 6 个 key;定向 FlashDelivery*+FlashLinePay* 与后端全量 1024/1024 通过
qmj 5 päivää sitten
vanhempi
sitoutus
8c17ec2a3c
25 muutettua tiedostoa jossa 824 lisäystä ja 71 poistoa
  1. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  2. 26 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  3. 5 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  4. 2 10
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopView.java
  5. 41 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2CreateRequest.java
  6. 57 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2QuoteRequest.java
  7. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2StopRequest.java
  8. 323 8
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  9. 6 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  10. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  11. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  12. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  13. 6 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  14. 0 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  15. 11 13
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  16. 253 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryV2Test.java
  17. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  18. 8 11
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderStop.java
  19. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  20. 11 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  21. 3 6
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderStopMapper.xml
  22. 1 1
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  23. 1 1
      specs/024-flash-delivery/spec.md
  24. 13 6
      specs/024-flash-delivery/tasks.md
  25. 6 12
      updatesql/sql.md

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java

@@ -97,6 +97,7 @@ public class FlashDeliveryRiderController extends BaseController {
     }
 
     /** 上传送达凭证并将已取件订单推进为已送达。 */
+    @Deprecated
     @PostMapping("/orders/{id}/deliver")
     @Auth
     public AjaxResult deliver(@RequestHeader String token, @PathVariable Long id,
@@ -105,6 +106,16 @@ public class FlashDeliveryRiderController extends BaseController {
         return success();
     }
 
+    /** v2 逐站送达:校验按站收货码并上传凭证,全部收货站完成订单才进入已送达。 */
+    @PostMapping("/orders/{id}/stops/{stopId}/deliver")
+    @Auth
+    public AjaxResult deliverAtStop(@RequestHeader String token, @PathVariable Long id,
+                                    @PathVariable Long stopId,
+                                    @RequestBody FlashDeliveryDeliverRequest request) {
+        service.deliverAtStop(userId(token), id, stopId, request);
+        return success();
+    }
+
     /** 只确认指定站点取货,不自动选择其他待取站点。 */
     @PostMapping("/orders/{id}/stops/{stopId}/pickup")
     @Auth

+ 26 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -24,7 +24,8 @@ public class FlashDeliveryUserController extends BaseController {
     @Auth
     public AjaxResult home(@RequestHeader String token) { return success(service.home()); }
 
-    /** 根据取送坐标获取服务端实时报价。 */
+    /** 根据取送坐标获取服务端实时报价;v2 上线起废弃,App 切换后随退役批次删除。 */
+    @Deprecated
     @PostMapping("/quote")
     @Auth
     public AjaxResult quote(@RequestHeader String token, @RequestBody FlashDeliveryQuoteRequest request) {
@@ -32,7 +33,19 @@ public class FlashDeliveryUserController extends BaseController {
         return success(service.quote(request));
     }
 
+    /**
+     * v2 多点取送实时报价:取货点与收货点统一放在 stops 数组,顺序即路线顺序;
+     * 物品主体整单一次设置。旧 /quote 过渡期保留,见契约废弃说明。
+     */
+    @PostMapping("/v2/quote")
+    @Auth
+    public AjaxResult quoteV2(@RequestHeader String token, @RequestBody FlashDeliveryV2QuoteRequest request) {
+        userId(token);
+        return success(service.quoteV2(request));
+    }
+
     /** 幂等创建闪送订单,客户端不能指定用户、距离或金额。 */
+    @Deprecated
     @PostMapping("/orders")
     @Auth
     public AjaxResult create(@RequestHeader String token, @RequestBody FlashDeliveryCreateRequest request) {
@@ -44,6 +57,18 @@ public class FlashDeliveryUserController extends BaseController {
         }
     }
 
+    /** v2 幂等创建多点取送订单;旧 /orders 过渡期保留,全部功能调整完成后随退役批次删除。 */
+    @PostMapping("/v2/orders")
+    @Auth
+    public AjaxResult createV2(@RequestHeader String token, @RequestBody FlashDeliveryV2CreateRequest request) {
+        try {
+            return success(service.createV2(userId(token), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"),
+                    exception.getLatestQuote());
+        }
+    }
+
     /**
      * 分页查询当前用户参与的闪送订单。
      *

+ 5 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java

@@ -15,10 +15,14 @@ public class FlashDeliveryHomeView {
     private List<FlashDeliveryServiceView> services;
     /** 闪送维度可用支付方式,供用户端下单页展示。 */
     private List<com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption> payMethods;
-    /** 取货点有效上限,合法范围1至26,配置非法时为1。 */
+    /** 取货点有效上限,合法范围1至10,配置非法时为1。 */
     private Integer pickupStopLimit;
     /** 是否开放多取货点提交,不影响已有订单履约。 */
     private Boolean showMultiPickup;
+    /** v2 收货点有效上限,合法范围1至10,配置非法时为1。 */
+    private Integer deliveryStopLimit;
+    /** v2 是否开放多收货点提交,不影响已有订单履约。 */
+    private Boolean showMultiDropoff;
     /** 仅控制加急选项展示,后端仍按deliveryType校验。 */
     private Boolean showUrgentOption;
 }

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

@@ -15,19 +15,11 @@ public class FlashDeliveryStopView {
     private String phone;
     private String address;
     private String addressDetail;
-    private String city;
-    private String area;
     private String handoffMethod;
     private BigDecimal longitude;
     private BigDecimal latitude;
-    /** 本站独立申报的物品类别。 */
-    private String packageType;
-    /** 本站物品数量,必须为正整数。 */
-    private Integer quantity;
-    /** 本站物品重量范围,不代表整单重量。 */
-    private String weightRange;
-    /** 本站物品尺寸或规格,选填,最长255字符。 */
-    private String specification;
+    /** 选填备注,告知骑手该站取什么或送什么。 */
+    private String remark;
     private String status;
     private Date pickedUpAt;
     private Date deliveredAt;

+ 41 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2CreateRequest.java

@@ -0,0 +1,41 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/** v2 创建闪送订单请求;服务端按最新条件重新报价,回传报价不一致时拒绝创建。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryV2CreateRequest extends FlashDeliveryV2QuoteRequest {
+    /** 客户端幂等请求号,必填,最长 64 字符;同一用户重复提交返回首次创建的订单。 */
+    private String clientRequestId;
+
+    /** 报价响应中的运价配置 ID,必填,创建时原样回传。 */
+    private Long pricingId;
+
+    /** 报价响应中的运价版本,必填,创建时原样回传。 */
+    private Integer pricingVersion;
+
+    /** 报价响应中的基础配送费,必填,整数 TWD。 */
+    private Long quotedBaseDeliveryFee;
+
+    /** 报价响应中的距离附加费,必填,整数 TWD。 */
+    private Long quotedDistanceFee;
+
+    /** 报价响应中的加急费,必填,整数 TWD;普通配送为 0。 */
+    private Long quotedUrgentFee;
+
+    /** 报价响应中的订单总金额,必填,整数 TWD。 */
+    private Long quotedAmount;
+
+    /** 是否启用交付收货码,选填;默认 true,启用时按收货站逐站生成。 */
+    private Boolean pinRequired;
+
+    /** 寄件图片地址,选填,最多 9 张。 */
+    private List<String> senderImageUrls;
+
+    /** 用户对本次闪送的备注,选填,最长 500 个字符。 */
+    private String userNote;
+}

+ 57 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2QuoteRequest.java

@@ -0,0 +1,57 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * v2 多点取送报价请求:物品主体整单只设置一次,取货点与收货点统一放在 stops 数组。
+ * 与旧报价结构互斥;旧接口过渡期保留,见 FR-122。
+ */
+@Data
+public class FlashDeliveryV2QuoteRequest {
+    /** 业务场景,必填:HELP_SEND 帮送、HELP_PICKUP 帮取。 */
+    private String serviceType;
+
+    /** 配送等级,必填:NORMAL 普通、URGENT 一对一加急。 */
+    private String deliveryType;
+
+    /** 配送车型,必填:1=机车,2=轿车。 */
+    private Integer vehicleType;
+
+    /** 物品类别,整单一次;可选值与旧契约一致。 */
+    private String packageType;
+
+    /** 物品数量,整单一次,必须为大于 0 的整数。 */
+    @JsonDeserialize(using = FlashDeliveryQuantityDeserializer.class)
+    private Integer quantity;
+
+    /** 物品重量范围,整单一次,决定车型运价;可选值与旧契约一致。 */
+    private String weightRange;
+
+    /** 物品体积、尺寸或其他规格说明,选填,最长 255 个字符。 */
+    private String specification;
+
+    /** 骑手小费,必填,整数新台币,没有时传 0。 */
+    private Long tipAmount;
+
+    /** 取件方式,选填,默认 NOW;SCHEDULED 表示预约取件。 */
+    private String deliveryMode;
+
+    /** 预约取件开始时间;SCHEDULED 时必填,NOW 时不得传。 */
+    private Date scheduledPickupStartAt;
+
+    /** 预约取件结束时间;SCHEDULED 时必填且晚于开始 30 分钟,NOW 时不得传。 */
+    private Date scheduledPickupEndAt;
+
+    /** 支付方式选填,缺省现金;服务端按闪送支付闸门校验。 */
+    private String payType;
+
+    /**
+     * 统一站点数组,必填,顺序即用户添加顺序与骑手路线顺序。
+     * 至少 1 个取货站与 1 个收货站,取货站必须全部位于收货站之前。
+     */
+    private List<FlashDeliveryV2StopRequest> stops;
+}

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2StopRequest.java

@@ -0,0 +1,15 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** v2 统一站点:取货点与收货点放在同一数组,顺序即用户添加顺序与骑手路线顺序。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryV2StopRequest extends FlashDeliveryAddressRequest {
+    /** 站点类型,必填:PICKUP 取货、DELIVERY 收货;类型是取/送的唯一区别。 */
+    private String type;
+
+    /** 选填备注,去除首尾空格后最长 200 字,告知骑手该站取什么或送什么。 */
+    private String remark;
+}

+ 323 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -131,6 +131,8 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryHomeView view = new FlashDeliveryHomeView();
         view.setPickupStopLimit(pickupStopLimit());
         view.setShowMultiPickup(dictionaryEnabled("flash_show_multi_pickup"));
+        view.setDeliveryStopLimit(deliveryStopLimit());
+        view.setShowMultiDropoff(dictionaryEnabled("flash_show_multi_dropoff"));
         view.setShowUrgentOption(dictionaryEnabled("flash_show_urgent_option"));
         view.setServiceTypes(SERVICE_TYPES);
         view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
@@ -664,6 +666,302 @@ public class FlashDeliveryApplicationService {
         notificationService.notifyDelivered(requireOrder(orderId));
     }
 
+    /** v2 逐站送达:校验按站收货码并提交凭证;全部收货站完成才整单推进为已送达,仅推进一次。 */
+    @Transactional
+    public void deliverAtStop(Long riderId, Long orderId, Long stopId, FlashDeliveryDeliverRequest request) {
+        requireRider(riderId);
+        FlashDeliveryOrder order = lockOrder(orderId);
+        if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
+        List<FlashDeliveryOrderStop> rows = requireStops(order);
+        if (!allPicked(rows)) throw fail("flash.delivery.transition.not.allowed");
+        FlashDeliveryOrderStop stop = requireStop(rows, stopId);
+        if (!"DELIVERY".equals(stop.getStopType())) throw fail("flash.delivery.stop.invalid");
+        // 已确认站点的重试直接成功,不重复写凭证或推进计数。
+        if ("DELIVERED".equals(stop.getStatus())) return;
+        // 优先使用按站收货码;订单级摘要字段作为回退。
+        String expectedPin = stop.getDeliveryPinCode() != null ? stop.getDeliveryPinCode() : order.getDeliveryPinCode();
+        if (Boolean.TRUE.equals(order.getPinRequired())
+                && (request == null || !Objects.equals(expectedPin, trimToNull(request.getPinCode())))) {
+            throw fail("flash.delivery.pin.invalid");
+        }
+        List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
+        Date now = new Date();
+        stop.setStatus("DELIVERED"); stop.setDeliveredAt(now);
+        if (stopMapper.updateById(stop) != 1) throw fail("flash.delivery.state.changed");
+        saveStationImages(orderId, stopId, "DELIVERY", "RIDER", riderId, urls, now);
+        order.setDeliveredStopCount((int) rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())
+                && "DELIVERED".equals(s.getStatus())).count());
+        order.setUpdateTime(now);
+        boolean allDelivered = rows.stream().filter(s -> "DELIVERY".equals(s.getStopType()))
+                .allMatch(s -> "DELIVERED".equals(s.getStatus()));
+        if (allDelivered) {
+            // 末站完成才整单推进,与取件侧同一行锁与条件更新路径。
+            order.setStatus(DELIVERED); order.setDeliveredAt(now);
+        }
+        if (orderMapper.updateDeliveryFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
+        writeLog(orderId, PICKED_UP, order.getStatus(), "RIDER", riderId, "DELIVERY_STOP:" + stopId, now);
+        if (DELIVERED.equals(order.getStatus())) notificationService.notifyDelivered(requireOrder(orderId));
+    }
+
+    // ==== v2 多点取送统一契约(2026-10-14):统一 stops 数组、订单级物品、独立计价 ====
+
+    /** v2 实时报价:统一站点数组顺序即路线顺序,物品主体在订单级,独立计价方法。 */
+    public FlashDeliveryQuoteView quoteV2(FlashDeliveryV2QuoteRequest request) {
+        QuoteContext context = calculateQuoteV2(request);
+        return quoteView(quoteAdapter(request), context);
+    }
+
+    /** v2 幂等创建:结构与旧创建互斥;按站固化收件人与收货码,回传报价不一致时拒绝。 */
+    @Transactional
+    public FlashDeliveryOrderDetailView createV2(Long userId, FlashDeliveryV2CreateRequest request) {
+        requireUserId(userId);
+        if (request == null || !hasText(request.getClientRequestId())
+                || request.getClientRequestId().trim().length() > 64) {
+            throw fail("flash.delivery.client.request.id.invalid");
+        }
+        String requestId = request.getClientRequestId().trim();
+        FlashDeliveryOrder existing = orderMapper.selectByUserRequestId(userId, requestId);
+        if (existing != null) return participantDetail(lockOrder(existing.getId()), true, false);
+        if (request.getUserNote() != null && request.getUserNote().length() > 500) {
+            throw fail("flash.delivery.note.too.long");
+        }
+        List<FlashDeliveryV2StopRequest> stops = normalizeV2Stops(request.getStops());
+        QuoteContext quote = calculateQuoteV2(request);
+        FlashDeliveryQuoteView latestQuote = quoteView(quoteAdapter(request), quote);
+        if (!matchesQuotedPriceV2(request, latestQuote)) {
+            throw new FlashDeliveryQuoteChangedException(latestQuote);
+        }
+        Date now = new Date();
+        String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
+        validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
+        boolean pinRequired = request.getPinRequired() == null || request.getPinRequired();
+        String payType = normalizePayType(request.getPayType());
+        List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
+        List<FlashDeliveryV2StopRequest> deliveries = stops.stream()
+                .filter(s -> "DELIVERY".equals(s.getType())).toList();
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setOrderNo(generateOrderNo());
+        order.setClientRequestId(requestId);
+        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);
+        order.setDeliveryStopCount(deliveries.size());
+        order.setDeliveredStopCount(0);
+        order.setIsDisplay("NOW".equals(deliveryMode));
+        order.setDeliveryMode(deliveryMode);
+        order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
+        order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
+        order.setPinRequired(pinRequired);
+        // 订单级收货码作为按站收货码的回退摘要;v2 各收货站独立生成。
+        order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null);
+        FlashDeliveryV2StopRequest firstDelivery = deliveries.get(0);
+        copyAddress(firstDelivery, order, false);
+        // 订单级收件人沿用首收货站匹配,逐站收件人保存在站点表。
+        order.setReceiverUserId(resolveReceiverUserId(firstDelivery.getPhone()));
+        order.setDistanceMeters(quote.route().distanceMeters());
+        order.setDistanceSource(quote.route().source());
+        order.setEstimatedDurationSeconds(quote.route().durationSeconds());
+        order.setAmount(quote.breakdown().getAmount());
+        order.setCurrency("TWD");
+        order.setPayType(payType);
+        order.setPricingId(quote.pricing().getId());
+        order.setPricingVersion(quote.pricing().getConfigVersion());
+        order.setPricingStartTime(quote.pricing().getStartTime());
+        order.setPricingEndTime(quote.pricing().getEndTime());
+        order.setStartingDistance(quote.pricing().getStartingDistance());
+        order.setStartingFare(quote.pricing().getStartingFare());
+        order.setDistance(quote.pricing().getDistance());
+        order.setFreight(quote.pricing().getFreight());
+        order.setDistanceFee(quote.breakdown().getDistanceFee());
+        order.setBaseDeliveryFee(quote.breakdown().getBaseDeliveryFee());
+        order.setUrgentRate(quote.pricing().getUrgentRate());
+        order.setMinimumUrgentFee(quote.pricing().getMinimumUrgentFee());
+        order.setUrgentFee(quote.breakdown().getUrgentFee());
+        order.setTipAmount(quote.breakdown().getTipAmount());
+        order.setUserNote(trimToNull(request.getUserNote()));
+        order.setVersion(0);
+        order.setCreateTime(now);
+        order.setUpdateTime(now);
+        try {
+            orderMapper.insert(order);
+        } catch (DataIntegrityViolationException duplicate) {
+            if (isOrderNoConflict(duplicate)) {
+                order.setId(null);
+                order.setOrderNo(generateOrderNo());
+                orderMapper.insert(order);
+            } else {
+                FlashDeliveryOrder concurrent = orderMapper.selectOne(new QueryWrapper<FlashDeliveryOrder>()
+                        .eq("user_id", userId).eq("client_request_id", requestId).last("FOR UPDATE"));
+                if (concurrent != null) return participantDetail(lockOrder(concurrent.getId()), true, false);
+                throw duplicate;
+            }
+        }
+        saveStopsV2(order, stops, pinRequired, now);
+        saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
+        writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
+        if (Boolean.TRUE.equals(order.getIsDisplay())) {
+            notificationService.notifyAvailable(order);
+        }
+        return participantDetail(order, true, false);
+    }
+
+    /** v2 物品主体在订单级一次设置;校验规则与旧逐站物品一致。 */
+    private void validateOrderLevelItem(FlashDeliveryV2QuoteRequest request) {
+        if (!PACKAGE_TYPES.contains(Objects.toString(request.getPackageType(), ""))) {
+            throw fail("flash.delivery.package.type.invalid");
+        }
+        if (request.getQuantity() == null || request.getQuantity() <= 0
+                || hasText(request.getWeightRange()) && !WEIGHT_RANGES.contains(request.getWeightRange())
+                || tooLong(request.getSpecification(), 255)) throw fail("flash.delivery.item.invalid");
+    }
+
+    /** v2 报价核心:站点结构校验后命中运价,路线经点为全部取货站与收货站。 */
+    private QuoteContext calculateQuoteV2(FlashDeliveryV2QuoteRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        validateServiceType(request.getServiceType());
+        validateDeliveryType(request.getDeliveryType());
+        List<FlashDeliveryV2StopRequest> stops = normalizeV2Stops(request.getStops());
+        if (request.getTipAmount() == null || request.getTipAmount() < 0) throw fail("flash.delivery.tip.invalid");
+        validateOrderLevelItem(request);
+        Date now = new Date();
+        String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
+        validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
+        Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
+        Integer vehicle = requireVehicleType(request.getVehicleType());
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate), vehicle);
+        RouteDistance route = v2Route(stops, vehicle);
+        FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
+                route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
+        List<FlashDeliveryV2StopRequest> pickups = stops.stream()
+                .filter(s -> "PICKUP".equals(s.getType())).toList();
+        return new QuoteContext(pricing, route, breakdown, pickupAdapters(pickups, request));
+    }
+
+    /** v2 物品主体在订单级;内部取货站适配器统一携带订单级物品供既有校验与摘要复用。 */
+    private List<FlashDeliveryPickupRequest> pickupAdapters(List<FlashDeliveryV2StopRequest> pickups,
+                                                            FlashDeliveryV2QuoteRequest request) {
+        return pickups.stream().map(stop -> {
+            FlashDeliveryPickupRequest adapter = new FlashDeliveryPickupRequest();
+            BeanUtils.copyProperties(stop, adapter);
+            adapter.setPackageType(request.getPackageType());
+            adapter.setQuantity(request.getQuantity());
+            adapter.setWeightRange(request.getWeightRange());
+            adapter.setSpecification(request.getSpecification());
+            return adapter;
+        }).toList();
+    }
+
+    /** v2 站点结构校验:类型、地址、备注、上限与开关、取前收后及站点去重。 */
+    private List<FlashDeliveryV2StopRequest> normalizeV2Stops(List<FlashDeliveryV2StopRequest> stops) {
+        if (stops == null || stops.isEmpty()) throw fail("flash.delivery.stops.required");
+        for (FlashDeliveryV2StopRequest stop : stops) {
+            if (!"PICKUP".equals(stop.getType()) && !"DELIVERY".equals(stop.getType())) {
+                throw fail("flash.delivery.stop.type.invalid");
+            }
+            validateAddress(stop);
+            if (tooLong(stop.getRemark(), 200)) throw fail("flash.delivery.stop.remark.too.long");
+        }
+        List<FlashDeliveryV2StopRequest> pickups = stops.stream()
+                .filter(s -> "PICKUP".equals(s.getType())).toList();
+        List<FlashDeliveryV2StopRequest> deliveries = stops.stream()
+                .filter(s -> "DELIVERY".equals(s.getType())).toList();
+        if (pickups.isEmpty() || deliveries.isEmpty()
+                || pickups.size() > pickupStopLimit() || deliveries.size() > deliveryStopLimit()) {
+            throw fail("flash.delivery.stops.limit.exceeded");
+        }
+        if (pickups.size() > 1 && !dictionaryEnabled("flash_show_multi_pickup")) {
+            throw fail("flash.delivery.multi.pickup.disabled");
+        }
+        if (deliveries.size() > 1 && !dictionaryEnabled("flash_show_multi_dropoff")) {
+            throw fail("flash.delivery.multi.dropoff.disabled");
+        }
+        // 顺序即路线:先取完再送;交错取送属后续增量。
+        boolean deliverySeen = false;
+        for (FlashDeliveryV2StopRequest stop : stops) {
+            if ("DELIVERY".equals(stop.getType())) deliverySeen = true;
+            else if (deliverySeen) throw fail("flash.delivery.stops.order.invalid");
+        }
+        for (int i = 0; i < stops.size(); i++) {
+            for (int j = i + 1; j < stops.size(); j++) {
+                FlashDeliveryV2StopRequest a = stops.get(i);
+                FlashDeliveryV2StopRequest b = stops.get(j);
+                if (sameAddressText(a, b)
+                        || a.getLatitude().compareTo(b.getLatitude()) == 0
+                        && a.getLongitude().compareTo(b.getLongitude()) == 0) {
+                    throw fail("flash.delivery.address.same");
+                }
+            }
+        }
+        return stops;
+    }
+
+    /** v2 路线:经点为按用户顺序排列的全部站点;两点退化为单段计算。 */
+    private RouteDistance v2Route(List<FlashDeliveryV2StopRequest> stops, Integer vehicleType) {
+        List<GeoPoint> points = stops.stream()
+                .map(s -> new GeoPoint(s.getLatitude(), s.getLongitude())).toList();
+        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 FlashDeliveryQuoteRequest quoteAdapter(FlashDeliveryV2QuoteRequest request) {
+        FlashDeliveryQuoteRequest adapter = new FlashDeliveryQuoteRequest();
+        adapter.setServiceType(request.getServiceType());
+        adapter.setDeliveryType(request.getDeliveryType());
+        adapter.setVehicleType(request.getVehicleType());
+        adapter.setPackageType(request.getPackageType());
+        adapter.setQuantity(request.getQuantity());
+        adapter.setWeightRange(request.getWeightRange());
+        adapter.setSpecification(request.getSpecification());
+        adapter.setTipAmount(request.getTipAmount());
+        adapter.setDeliveryMode(request.getDeliveryMode());
+        adapter.setScheduledPickupStartAt(request.getScheduledPickupStartAt());
+        adapter.setScheduledPickupEndAt(request.getScheduledPickupEndAt());
+        adapter.setPayType(request.getPayType());
+        return adapter;
+    }
+
+    private boolean matchesQuotedPriceV2(FlashDeliveryV2CreateRequest request, FlashDeliveryQuoteView latest) {
+        return Objects.equals(request.getPricingId(), latest.getPricingId())
+                && Objects.equals(request.getPricingVersion(), latest.getPricingVersion())
+                && Objects.equals(request.getQuotedBaseDeliveryFee(), latest.getBaseDeliveryFee())
+                && Objects.equals(request.getQuotedDistanceFee(), latest.getDistanceFee())
+                && Objects.equals(request.getQuotedUrgentFee(), latest.getUrgentFee())
+                && Objects.equals(request.getQuotedAmount(), latest.getAmount());
+    }
+
+    /** 保存 v2 全部站点:按用户顺序落 stop_order,逐站写入备注、收件人与收货码。 */
+    private void saveStopsV2(FlashDeliveryOrder order, List<FlashDeliveryV2StopRequest> stops,
+                             boolean pinRequired, Date now) {
+        for (int i = 0; i < stops.size(); i++) {
+            FlashDeliveryV2StopRequest stop = stops.get(i);
+            FlashDeliveryOrderStop row = new FlashDeliveryOrderStop();
+            row.setName(stop.getName().trim()); row.setPhone(stop.getPhone().trim());
+            row.setAddress(stop.getAddress().trim()); row.setAddressDetail(trimToNull(stop.getAddressDetail()));
+            row.setHandoffMethod(trimToNull(stop.getHandoffMethod()));
+            row.setLongitude(stop.getLongitude()); row.setLatitude(stop.getLatitude());
+            row.setRemark(trimToNull(stop.getRemark()));
+            row.setOrderId(order.getId()); row.setStopType(stop.getType()); row.setStopOrder(i);
+            row.setStatus("PENDING"); row.setCreateTime(now);
+            if ("DELIVERY".equals(stop.getType())) {
+                row.setReceiverUserId(resolveReceiverUserId(stop.getPhone()));
+                if (pinRequired) {
+                    row.setDeliveryPinCode(String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)));
+                }
+            }
+            if (stopMapper.insert(row) != 1) throw fail("flash.delivery.state.changed");
+        }
+    }
+
     /** 平台运价配置列表,按时段开始时间排序。 */
     public List<FlashDeliveryPricing> adminPricing() {
         return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
@@ -1642,6 +1940,9 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
         BeanUtils.copyProperties(pickups.get(0), pickup);
         BeanUtils.copyProperties(request.getPickup(), pickup);
+        // 站点不再保存逐站物品;单点改址回退时物品取订单级摘要,与统一契约一致。
+        pickup.setPackageType(order.getPackageType()); pickup.setQuantity(order.getQuantity());
+        pickup.setWeightRange(order.getWeightRange()); pickup.setSpecification(order.getSpecification());
         return List.of(pickup);
     }
 
@@ -1712,11 +2013,20 @@ public class FlashDeliveryApplicationService {
 
     /** 点数配置缺失、非整数或越界时,有效上限回退为1。 */
     private int pickupStopLimit() {
-        String value = dictionaryValue("flash_pickup_stop_limit");
+        return stopLimit("flash_pickup_stop_limit");
+    }
+
+    /** v2 收货点上限与取货点同口径,配置缺失或越界回退为1。 */
+    private int deliveryStopLimit() {
+        return stopLimit("flash_delivery_stop_limit");
+    }
+
+    private int stopLimit(String dictKey) {
+        String value = dictionaryValue(dictKey);
         if (value == null || !value.matches("[0-9]+")) return 1;
         try {
             int limit = Integer.parseInt(value);
-            return limit >= 1 && limit <= 26 ? limit : 1;
+            return limit >= 1 && limit <= 10 ? limit : 1;
         } catch (NumberFormatException ignored) { return 1; }
     }
 
@@ -1750,8 +2060,7 @@ public class FlashDeliveryApplicationService {
         BeanUtils.copyProperties(address, stop);
         stop.setName(address.getName().trim()); stop.setPhone(address.getPhone().trim());
         stop.setAddress(address.getAddress().trim()); stop.setAddressDetail(trimToNull(address.getAddressDetail()));
-        stop.setCity(trimToNull(address.getCity())); stop.setArea(trimToNull(address.getArea()));
-        stop.setHandoffMethod(trimToNull(address.getHandoffMethod())); stop.setSpecification(trimToNull(stop.getSpecification()));
+        stop.setHandoffMethod(trimToNull(address.getHandoffMethod()));
         stop.setOrderId(orderId); stop.setStopType(type); stop.setStopOrder(index);
         stop.setStatus("PENDING"); stop.setCreateTime(now);
         if (stopMapper.insert(stop) != 1) throw fail("flash.delivery.state.changed");
@@ -1775,12 +2084,17 @@ public class FlashDeliveryApplicationService {
         List<FlashDeliveryOrderStop> rows = stops(order.getId());
         if (rows == null || pickupStops(rows).isEmpty()
                 || !Objects.equals(order.getPickupStopCount(), pickupStops(rows).size())
-                || rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() != 1) {
+                || rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() != expectedDeliveryStopCount(order)) {
             throw fail("flash.delivery.stops.invalid");
         }
         return rows;
     }
 
+    /** v2 多收货站订单携带 delivery_stop_count;历史订单与旧接口订单回退为单收货站。 */
+    private int expectedDeliveryStopCount(FlashDeliveryOrder order) {
+        return order.getDeliveryStopCount() == null ? 1 : order.getDeliveryStopCount();
+    }
+
     private List<FlashDeliveryOrderStop> pickupStops(List<FlashDeliveryOrderStop> rows) {
         return rows.stream().filter(s -> "PICKUP".equals(s.getStopType())).toList();
     }
@@ -1940,9 +2254,10 @@ public class FlashDeliveryApplicationService {
         if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus()) || order.getPickedUpAt() != null
                 || order.getPickedUpStopCount() != null && order.getPickedUpStopCount() > 0 || rows == null) return false;
         List<FlashDeliveryOrderStop> pickups = pickupStops(rows);
-        return !pickups.isEmpty() && Objects.equals(order.getPickupStopCount(), pickups.size())
-                && rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() == 1 && !anyPicked(rows)
-                && pickups.stream().allMatch(s -> "PENDING".equals(s.getStatus()));
+        if (pickups.isEmpty() || !Objects.equals(order.getPickupStopCount(), pickups.size())
+                || rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() != expectedDeliveryStopCount(order)
+                || rows.stream().anyMatch(s -> "DELIVERED".equals(s.getStatus()))) return false;
+        return !anyPicked(rows);
     }
 
 

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

@@ -436,6 +436,12 @@ no.pay.switch.offline.restricted=Tiền mặt / thanh toán khi nhận hàng ch
 no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau
 flash.delivery.billing.save.failed=Không thể ghi nhận thu nhập giao hàng nhanh, vui lòng thử lại
 flash.delivery.billing.income=Thu nhập giao hàng nhanh (thanh toán trực tiếp cho tài xế)
+flash.delivery.stops.required=Vui lòng gửi ít nhất một điểm nhận và một điểm giao
+flash.delivery.stop.type.invalid=Loại điểm dừng không hợp lệ
+flash.delivery.stop.remark.too.long=Ghi chú của điểm dừng không được vượt quá 200 ký tự
+flash.delivery.stops.limit.exceeded=Số điểm nhận hoặc điểm giao vượt quá giới hạn
+flash.delivery.multi.dropoff.disabled=Tạm thời chưa hỗ trợ nhiều điểm giao
+flash.delivery.stops.order.invalid=Điểm giao phải đứng sau tất cả các điểm nhận
 
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=Vui lòng tải lại trước khi lưu: thiếu phiên bản hồ sơ

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

@@ -422,6 +422,12 @@ no.pay.switch.offline.restricted=Cash / cash-on-delivery only for pickup or dine
 no.pay.switch.failed=Failed to change payment method, please try again later
 flash.delivery.billing.save.failed=Failed to record flash delivery income, please try again
 flash.delivery.billing.income=Flash delivery income (paid directly to rider)
+flash.delivery.stops.required=Submit at least one pickup stop and one delivery stop
+flash.delivery.stop.type.invalid=Invalid stop type
+flash.delivery.stop.remark.too.long=Stop remark cannot exceed 200 characters
+flash.delivery.stops.limit.exceeded=Too many pickup or delivery stops
+flash.delivery.multi.dropoff.disabled=Multiple delivery stops are not available yet
+flash.delivery.stops.order.invalid=Delivery stops must be placed after all pickup stops
 
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=Refresh before saving: material version is missing

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

@@ -423,6 +423,12 @@ no.pay.switch.offline.restricted=เงินสด / ชำระเงิน
 no.pay.switch.failed=เปลี่ยนวิธีชำระเงินไม่สำเร็จ กรุณาลองใหม่ภายหลัง
 flash.delivery.billing.save.failed=บันทึกรายได้จากการจัดส่งด่วนไม่สำเร็จ กรุณาลองอีกครั้ง
 flash.delivery.billing.income=รายได้จากการจัดส่งด่วน (ชำระให้ผู้ขับขี่โดยตรง)
+flash.delivery.stops.required=โปรดระบุจุดรับสินค้าและจุดส่งอย่างน้อยจุดละหนึ่งจุด
+flash.delivery.stop.type.invalid=ประเภทจุดแวะไม่ถูกต้อง
+flash.delivery.stop.remark.too.long=หมายเหตุของจุดแวะต้องไม่เกิน 200 ตัวอักษร
+flash.delivery.stops.limit.exceeded=จำนวนจุดรับหรือจุดส่งเกินขีดจำกัด
+flash.delivery.multi.dropoff.disabled=ยังไม่รองรับหลายจุดส่ง
+flash.delivery.stops.order.invalid=จุดส่งต้องอยู่หลังจุดรับทั้งหมด
 
 # 034 Stall qualification: common material rules and public images
 stall.qualification.version.required=โปรดรีเฟรชก่อนบันทึก เนื่องจากขาดเวอร์ชันของข้อมูล

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

@@ -424,6 +424,12 @@ no.pay.switch.offline.restricted=仅自取/堂食订单可更换为现金或货
 no.pay.switch.failed=更换支付方式失败,请稍后重试
 flash.delivery.billing.save.failed=闪送骑手账单生成失败,请重试
 flash.delivery.billing.income=闪送收入(款项直付骑手)
+flash.delivery.stops.required=请提交至少一个取货点和一个收货点
+flash.delivery.stop.type.invalid=站点类型不正确
+flash.delivery.stop.remark.too.long=站点备注不能超过200字
+flash.delivery.stops.limit.exceeded=取货点或收货点数量超出限制
+flash.delivery.multi.dropoff.disabled=暂不支持多个收货点
+flash.delivery.stops.order.invalid=收货点必须排在全部取货点之后
 
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=请刷新后再保存,缺少资料版本

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

@@ -423,6 +423,12 @@ no.pay.switch.offline.restricted=僅自取/堂食訂單可更換為現金或貨
 no.pay.switch.failed=更換支付方式失敗,請稍後重試
 flash.delivery.billing.save.failed=閃送騎手帳單產生失敗,請重試
 flash.delivery.billing.income=閃送收入(款項直付騎手)
+flash.delivery.stops.required=請提交至少一個取貨點和一個收貨點
+flash.delivery.stop.type.invalid=站點類型不正確
+flash.delivery.stop.remark.too.long=站點備註不能超過200字
+flash.delivery.stops.limit.exceeded=取貨點或收貨點數量超出限制
+flash.delivery.multi.dropoff.disabled=暫不支援多個收貨點
+flash.delivery.stops.order.invalid=收貨點必須排在全部取貨點之後
 
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=請重新整理後再儲存,缺少資料版本

+ 0 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -2271,7 +2271,6 @@ class FlashDeliveryApplicationServiceTest {
                 pickup.setId(101L); pickup.setOrderId(10L); pickup.setStopType("PICKUP"); pickup.setStopOrder(0);
                 pickup.setName("pickup contact"); pickup.setPhone("0911111111"); pickup.setAddress("pickup road");
                 pickup.setLatitude(new BigDecimal("25.0100")); pickup.setLongitude(new BigDecimal("121.5100"));
-                pickup.setPackageType("DOCUMENT"); pickup.setQuantity(1); pickup.setWeightRange("UP_TO_5_KG");
                 pickup.setStatus(current != null && List.of("PICKED_UP", "DELIVERED", "COMPLETED").contains(current.getStatus())
                         ? "PICKED_UP" : "PENDING");
                 var delivery = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();

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

@@ -214,7 +214,7 @@ class FlashDeliveryMultiPickupTest {
     @Test void disablingMultiPickupRejectsNewQuotesButDoesNotBlockExistingFulfilment() {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
-            dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("26")));
+            dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
             dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("0")));
             assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(2)));
             f.service.pickup(8L, 10L, proof(11L));
@@ -237,17 +237,17 @@ class FlashDeliveryMultiPickupTest {
     @Test void limitsFailBeforeMapsAndUrgentMultiPickupUsesOrderedFullRoute() {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
-            dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("26")));
+            dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
             dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
             FlashDeliveryQuoteRequest request = quoteRequest(2);
             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(), eq(1)); assertEquals(3, points.getValue().size());
-            for (int size : new int[] {0, 27}) {
+            for (int size : new int[] {0, 11}) {
                 assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(size)));
             }
-            assertEquals(26, f.service.home().getPickupStopLimit());
+            assertEquals(10, f.service.home().getPickupStopLimit());
             for (String invalid : List.of("", "0", "27", "-1", "1.5", "abc")) {
                 dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary(invalid)));
                 assertEquals(1, f.service.home().getPickupStopLimit());
@@ -260,7 +260,7 @@ class FlashDeliveryMultiPickupTest {
     @Test void arrayInputRejectsLegacyFieldsAndValidatesEachIndependentItem() {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
-            dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("26")));
+            dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
             dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
             FlashDeliveryQuoteRequest request = quoteRequest(2); request.setPackageType("DOCUMENT");
             assertThrows(ServiceException.class, () -> f.service.quote(request));
@@ -270,13 +270,13 @@ class FlashDeliveryMultiPickupTest {
         }
     }
 
-    @Test void twentySixPickupsAreAcceptedAndAllTwentySevenRoutePointsRemainOrdered() {
+    @Test void tenPickupsAreAcceptedAndAllElevenRoutePointsRemainOrdered() {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = multiEnabled()) {
             when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(39000, 1800, "ROUTE"));
-            assertEquals(39000, f.service.quote(quoteRequest(26)).getDistanceMeters());
+            assertEquals(39000, f.service.quote(quoteRequest(10)).getDistanceMeters());
             var points = org.mockito.ArgumentCaptor.forClass(List.class);
-            verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(27, points.getValue().size());
+            verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(11, points.getValue().size());
         }
     }
 
@@ -297,9 +297,6 @@ class FlashDeliveryMultiPickupTest {
             var saved = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrderStop.class);
             verify(f.stops, times(3)).insert(saved.capture());
             assertEquals(List.of("PICKUP", "PICKUP", "DELIVERY"), saved.getAllValues().stream().map(FlashDeliveryOrderStop::getStopType).toList());
-            assertEquals("DOCUMENT", saved.getAllValues().get(0).getPackageType());
-            assertEquals("GIFT", saved.getAllValues().get(1).getPackageType());
-            assertEquals(2, saved.getAllValues().get(1).getQuantity());
             var order = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrder.class);
             verify(f.orders).insert(order.capture()); assertEquals(2, order.getValue().getPickupStopCount());
             assertEquals("DOCUMENT", order.getValue().getPackageType()); assertEquals(0, order.getValue().getPickedUpStopCount());
@@ -366,7 +363,7 @@ class FlashDeliveryMultiPickupTest {
 
     private static MockedStatic<DictUtils> multiEnabled() {
         MockedStatic<DictUtils> dict = mockStatic(DictUtils.class);
-        dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("26")));
+        dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
         dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
         return dict;
     }
@@ -385,6 +382,7 @@ class FlashDeliveryMultiPickupTest {
         for (int i = 0; i < count; i++) {
             FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
             org.springframework.beans.BeanUtils.copyProperties(stop(20L + i, i, "PICKUP"), pickup);
+            pickup.setPackageType(i % 2 == 0 ? "DOCUMENT" : "GIFT"); pickup.setQuantity(i + 1); pickup.setWeightRange("UP_TO_5_KG");
             pickups.add(pickup);
         }
         request.setPickups(pickups);
@@ -404,7 +402,7 @@ class FlashDeliveryMultiPickupTest {
         FlashDeliveryOrderStop s = new FlashDeliveryOrderStop(); s.setId(id); s.setOrderId(10L); s.setStopOrder(index);
         s.setStopType(type); s.setStatus("PENDING"); s.setName("name"); s.setPhone("0912345678");
         s.setAddress("address " + index); s.setLatitude(BigDecimal.valueOf(25 + index * .001)); s.setLongitude(BigDecimal.valueOf(121));
-        s.setPackageType("DOCUMENT"); s.setQuantity(index + 1); s.setWeightRange("UP_TO_5_KG"); return s;
+        return s;
     }
     private static class Fixture {
         final FlashDeliveryOrderMapper orders = mock(FlashDeliveryOrderMapper.class);

+ 253 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryV2Test.java

@@ -0,0 +1,253 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
+import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.app.order.RiderDeliveryAcceptanceService;
+import com.ruoyi.app.order.RiderDeliveryLockService;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.flash.*;
+import org.junit.jupiter.api.*;
+import org.mockito.MockedStatic;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+
+import java.math.BigDecimal;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** v2 多点取送:统一 stops 数组、订单级物品、逐站送达与取消门禁。 */
+class FlashDeliveryV2Test {
+    private static ConfigurableListableBeanFactory original;
+    @BeforeAll static void messages() {
+        original = (ConfigurableListableBeanFactory) ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
+        StaticMessageSource source = new StaticMessageSource(); source.setUseCodeAsDefaultMessage(true);
+        factory.registerSingleton("messageSource", source); new SpringUtils().postProcessBeanFactory(factory);
+    }
+    @AfterAll static void restoreMessages() { new SpringUtils().postProcessBeanFactory(original); }
+
+    private static void multiStopDicts(MockedStatic<DictUtils> dict) {
+        dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("3")));
+        dict.when(() -> DictUtils.getDictCache("flash_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
+        dict.when(() -> DictUtils.getDictCache("flash_delivery_stop_limit")).thenReturn(List.of(dictionary("3")));
+        dict.when(() -> DictUtils.getDictCache("flash_show_multi_dropoff")).thenReturn(List.of(dictionary("1")));
+    }
+
+    @Test void v2CreateSavesUnifiedStopsWithOrderLevelItemAndPerStopReceivers() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryV2CreateRequest request = v2Create();
+            FlashDeliveryQuoteView quote = f.service.quoteV2(request);
+            request.setPricingId(quote.getPricingId()); request.setPricingVersion(quote.getPricingVersion());
+            request.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee()); request.setQuotedDistanceFee(quote.getDistanceFee());
+            request.setQuotedUrgentFee(quote.getUrgentFee()); request.setQuotedAmount(quote.getAmount());
+            f.service.createV2(7L, request);
+            var saved = argThatStops(f);
+            assertEquals(List.of("PICKUP", "PICKUP", "DELIVERY", "DELIVERY"),
+                    saved.stream().map(FlashDeliveryOrderStop::getStopType).toList());
+            assertEquals(List.of(0, 1, 2, 3), saved.stream().map(FlashDeliveryOrderStop::getStopOrder).toList());
+            assertEquals(List.of("取月餅", "取牛奶", "送管理室", "送前台"),
+                    saved.stream().map(FlashDeliveryOrderStop::getRemark).toList());
+            // 按站收件人:两个收货站按各自电话分别固化;取货站不固化。
+            assertEquals(Arrays.asList(null, null, 31L, 32L), saved.stream().map(FlashDeliveryOrderStop::getReceiverUserId).toList());
+            // 启用收货码时各收货站独立生成。
+            assertNotNull(saved.get(2).getDeliveryPinCode());
+            assertNotNull(saved.get(3).getDeliveryPinCode());
+            assertNotEquals(saved.get(2).getDeliveryPinCode(), saved.get(3).getDeliveryPinCode());
+            var order = argThatOrder(f);
+            assertEquals(2, order.getDeliveryStopCount());
+            assertEquals(0, order.getDeliveredStopCount());
+            // 物品主体整单一次,落在订单级摘要字段。
+            assertEquals("FOOD_INGREDIENTS", order.getPackageType());
+            assertEquals(8, order.getQuantity());
+        }
+    }
+
+    @Test void v2RejectsDeliveryBeforePickupLimitAndDisabledSwitch() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryV2CreateRequest wrongOrder = v2Create();
+            wrongOrder.getStops().add(0, stop("DELIVERY", "0911111112", "台北市松山區B街2號", "d1"));
+            assertThrows(ServiceException.class, () -> f.service.createV2(7L, wrongOrder));
+
+            FlashDeliveryV2CreateRequest tooMany = v2Create();
+            for (int i = 0; i < 2; i++) tooMany.getStops().add(stop("DELIVERY", "091111111" + (4 + i), "台北市中山區C街" + i + "號", "d" + i));
+            assertThrows(ServiceException.class, () -> f.service.createV2(7L, tooMany));
+            verify(f.orders, never()).insert(any(FlashDeliveryOrder.class));
+
+            dict.when(() -> DictUtils.getDictCache("flash_show_multi_dropoff")).thenReturn(List.of(dictionary("0")));
+            assertThrows(ServiceException.class, () -> f.service.quoteV2(v2Create()));
+        }
+    }
+
+    @Test void v2QuoteRoutesThroughAllStopsInUserOrder() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryV2QuoteRequest request = v2Create();
+            FlashDeliveryQuoteView view = f.service.quoteV2(request);
+            assertEquals(5000, view.getDistanceMeters());
+            var points = f.capturedPoints;
+            assertEquals(4, points.size());
+            verify(f.routes).calculate(anyList(), eq(1));
+        }
+    }
+
+    @Test void perStopDeliveryAdvancesOrderOnlyAfterLastStopAndBlocksCancel() {
+        Fixture f = new Fixture();
+        f.order.setStatus("PICKED_UP"); f.order.setPinRequired(false);
+        f.rows.forEach(row -> { if ("PICKUP".equals(row.getStopType())) row.setStatus("PICKED_UP"); });
+        when(f.orders.updateDeliveryFulfilment(any())).thenReturn(1);
+        FlashDeliveryDeliverRequest deliver = new FlashDeliveryDeliverRequest();
+        deliver.setImageUrls(List.of("/profile/d1.jpg"));
+        deliver.setStopId(13L);
+        f.service.deliverAtStop(8L, 10L, 13L, deliver);
+        assertEquals("DELIVERED", f.rows.get(2).getStatus());
+        assertEquals(1, f.order.getDeliveredStopCount());
+        assertEquals("PICKED_UP", f.order.getStatus());
+        // 部分送达后用户与平台都不能自助取消。
+        FlashDeliveryReasonRequest cancel = new FlashDeliveryReasonRequest(); cancel.setReason("changed mind");
+        assertThrows(ServiceException.class, () -> f.service.userCancel(7L, 10L, cancel));
+        verify(f.orders, never()).cancel(any(), any(), any(), any(), any(), any());
+        // 同站重试幂等:不重复写凭证、不重复推进。
+        f.service.deliverAtStop(8L, 10L, 13L, deliver);
+        verify(f.images, times(1)).insert(any(FlashDeliveryOrderImage.class));
+        // 末站完成后整单推进一次。
+        deliver.setStopId(14L);
+        f.service.deliverAtStop(8L, 10L, 14L, deliver);
+        assertEquals("DELIVERED", f.order.getStatus());
+        assertEquals(2, f.order.getDeliveredStopCount());
+        assertEquals(f.rows.get(3).getDeliveredAt(), f.order.getDeliveredAt());
+        verify(f.notifications, times(1)).notifyDelivered(any());
+    }
+
+    @Test void perStopPinIsValidatedAgainstThatStopOnly() {
+        Fixture f = new Fixture();
+        f.order.setStatus("PICKED_UP"); f.order.setPinRequired(true); f.order.setDeliveryPinCode("0000");
+        f.rows.forEach(row -> { if ("PICKUP".equals(row.getStopType())) row.setStatus("PICKED_UP"); });
+        f.rows.get(2).setDeliveryPinCode("1111");
+        f.rows.get(3).setDeliveryPinCode("2222");
+        when(f.orders.updateDeliveryFulfilment(any())).thenReturn(1);
+        FlashDeliveryDeliverRequest deliver = new FlashDeliveryDeliverRequest();
+        deliver.setImageUrls(List.of("/profile/d1.jpg"));
+        deliver.setStopId(13L);
+        deliver.setPinCode("2222");
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 13L, deliver));
+        verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+        deliver.setPinCode("1111");
+        f.service.deliverAtStop(8L, 10L, 13L, deliver);
+        assertEquals("DELIVERED", f.rows.get(2).getStatus());
+    }
+
+    private List<FlashDeliveryOrderStop> argThatStops(Fixture f) {
+        var captor = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrderStop.class);
+        verify(f.stops, atLeast(4)).insert(captor.capture());
+        return captor.getAllValues();
+    }
+
+    private FlashDeliveryOrder argThatOrder(Fixture f) {
+        var captor = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+        verify(f.orders).insert(captor.capture());
+        return captor.getValue();
+    }
+
+    private static FlashDeliveryV2StopRequest stop(String type, String phone, String address, String remark) {
+        FlashDeliveryV2StopRequest stop = new FlashDeliveryV2StopRequest();
+        stop.setType(type); stop.setName("聯絡人"); stop.setPhone(phone);
+        stop.setAddress(address); stop.setAddressDetail("3樓"); stop.setRemark(remark);
+        int seq = Math.abs(address.hashCode()) % 100;
+        stop.setLatitude(BigDecimal.valueOf(25.03 + seq * .0001));
+        stop.setLongitude(BigDecimal.valueOf(121.56 + seq * .0001));
+        return stop;
+    }
+
+    private static FlashDeliveryV2CreateRequest v2Create() {
+        FlashDeliveryV2CreateRequest request = new FlashDeliveryV2CreateRequest();
+        request.setServiceType("HELP_SEND"); request.setDeliveryType("NORMAL"); request.setVehicleType(1);
+        request.setTipAmount(0L); request.setPackageType("FOOD_INGREDIENTS"); request.setQuantity(8);
+        request.setClientRequestId("v2-" + System.nanoTime());
+        List<FlashDeliveryV2StopRequest> stops = new ArrayList<>(List.of(
+                stop("PICKUP", "0911111110", "台北市大安區信義路三段56號", "取月餅"),
+                stop("PICKUP", "0911111111", "台北市信義區松高路11號", "取牛奶"),
+                stop("DELIVERY", "0911111112", "台北市松山區南京東路五段1號", "送管理室"),
+                stop("DELIVERY", "0911111113", "台北市中山區中山北路二段1號", "送前台")));
+        request.setStops(stops);
+        return request;
+    }
+
+    private static SysDictData dictionary(String value) { SysDictData d = new SysDictData(); d.setDictValue(value); return d; }
+
+    private static class Fixture {
+        final FlashDeliveryOrderMapper orders = mock(FlashDeliveryOrderMapper.class);
+        final FlashDeliveryOrderStopMapper stops = mock(FlashDeliveryOrderStopMapper.class);
+        final FlashDeliveryTargetAdjustmentMapper adjustments = mock(FlashDeliveryTargetAdjustmentMapper.class);
+        final FlashDeliveryOrderImageMapper images = mock(FlashDeliveryOrderImageMapper.class);
+        final FlashDeliveryOrderLogMapper logs = mock(FlashDeliveryOrderLogMapper.class);
+        final FlashDeliveryPricingMapper prices = mock(FlashDeliveryPricingMapper.class);
+        final InfoUserMapper users = mock(InfoUserMapper.class);
+        final FlashDeliveryRouteService routes = mock(FlashDeliveryRouteService.class);
+        final FlashDeliveryNotificationService notifications = mock(FlashDeliveryNotificationService.class);
+        List<com.ruoyi.app.flashdelivery.route.GeoPoint> capturedPoints = new ArrayList<>();
+        final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(orders, prices, images, logs, users, mock(com.ruoyi.system.mapper.InfoBankCardMapper.class),
+                routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryAcceptanceService.class),
+                stops, adjustments, mock(com.ruoyi.system.service.PaymentMethodGateService.class),
+                notifications, mock(com.ruoyi.system.mapper.UserBillingMapper.class));
+        final FlashDeliveryOrder order = new FlashDeliveryOrder();
+        final List<FlashDeliveryOrderStop> rows = new ArrayList<>(List.of(
+                stop(11L, 0, "PICKUP", "0911111110"), stop(12L, 1, "PICKUP", "0911111111"),
+                stop(13L, 2, "DELIVERY", "0911111112"), stop(14L, 3, "DELIVERY", "0911111113")));
+        Fixture() {
+            order.setId(10L); order.setUserId(7L); order.setRiderId(8L); order.setStatus("ACCEPTED");
+            order.setVersion(0); order.setPickupStopCount(2); order.setPickedUpStopCount(0);
+            order.setDeliveryStopCount(2); order.setDeliveredStopCount(0);
+            order.setAmount(100L); order.setDeliveryMode("NOW"); order.setIsDisplay(true); order.setPayType("4");
+            InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); rider.setDeliveryType("FLASH");
+            when(users.selectInfoUserByUserId(anyLong())).thenReturn(rider);
+            when(orders.selectById(10L)).thenReturn(order); when(orders.selectByIdForUpdate(10L)).thenReturn(order);
+            when(orders.selectByUserRequestId(anyLong(), anyString())).thenReturn(null);
+            when(orders.insert(any(FlashDeliveryOrder.class))).thenAnswer(i -> { ((FlashDeliveryOrder) i.getArgument(0)).setId(10L); return 1; });
+            when(stops.selectList(any())).thenReturn(rows);
+            when(stops.insert(any(FlashDeliveryOrderStop.class))).thenReturn(1);
+            when(stops.updateById(any(FlashDeliveryOrderStop.class))).thenReturn(1);
+            when(images.insert(any(FlashDeliveryOrderImage.class))).thenReturn(1);
+            when(orders.updateFulfilment(any())).thenReturn(1);
+            when(orders.updateDeliveryFulfilment(any())).thenReturn(1);
+            when(users.selectOrdinaryUserIdsByNormalizedPhone("0911111112")).thenReturn(List.of(31L));
+            when(users.selectOrdinaryUserIdsByNormalizedPhone("0911111113")).thenReturn(List.of(32L));
+            when(users.selectOrdinaryUserIdsByNormalizedPhone("0911111110")).thenReturn(List.of());
+            when(users.selectOrdinaryUserIdsByNormalizedPhone("0911111111")).thenReturn(List.of());
+            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(), anyInt())).thenReturn(List.of(price));
+            when(routes.calculate(anyList(), anyInt())).thenAnswer(i -> {
+                capturedPoints.addAll(i.getArgument(0));
+                return new RouteDistance(5000, 600, "ROUTE");
+            });
+            when(routes.calculate(any(com.ruoyi.app.flashdelivery.route.GeoPoint.class),
+                    any(com.ruoyi.app.flashdelivery.route.GeoPoint.class), anyInt()))
+                    .thenReturn(new RouteDistance(5000, 600, "ROUTE"));
+        }
+    }
+
+    private static FlashDeliveryOrderStop stop(Long id, int index, String type, String phone) {
+        FlashDeliveryOrderStop s = new FlashDeliveryOrderStop(); s.setId(id); s.setOrderId(10L); s.setStopOrder(index);
+        s.setStopType(type); s.setStatus("PENDING"); s.setName("name"); s.setPhone(phone);
+        s.setAddress("address " + index); s.setLatitude(BigDecimal.valueOf(25.03 + index * .001)); s.setLongitude(BigDecimal.valueOf(121.56));
+        return s;
+    }
+}

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -142,6 +142,10 @@ public class FlashDeliveryOrder {
     private Integer pickupStopCount;
     /** 已确认取货站数,与站点同事务更新,供列表展示实际进度。 */
     private Integer pickedUpStopCount;
+    /** v2 多点取送的收货站总数;历史订单为空,按单收货站处理。 */
+    private Integer deliveryStopCount;
+    /** v2 已确认送达站数,与站点同事务更新。 */
+    private Integer deliveredStopCount;
     /** 骑手选择的前往目标;取货后清空,恢复用户原始顺序引导。 */
     private Long currentTargetStopId;
     /** 平台关闭异常订单的审计信息,不在此处理支付或退款。 */

+ 8 - 11
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderStop.java

@@ -14,28 +14,25 @@ public class FlashDeliveryOrderStop {
     @TableId(type = IdType.AUTO)
     private Long id;
     private Long orderId;
-    /** 站点类型:PICKUP取货、DELIVERY收货,每单仅一个收货站。 */
+    /** 站点类型:PICKUP取货、DELIVERY收货;v2多点取送允许多个收货站。 */
     private String stopType;
     /** 用户填写顺序,从0开始;骑手调整前往目标不改变此顺序。 */
     private Integer stopOrder;
     /** 接单后用于履约核对的地址快照,骑手不可改写。 */
     private String name;
     private String phone;
+    /** 完整地址文本,不做城市/区拆分,路线与计价以经纬度为准。 */
     private String address;
     private String addressDetail;
-    private String city;
-    private String area;
     private String handoffMethod;
     private BigDecimal longitude;
     private BigDecimal latitude;
-    /** 本站独立申报的物品类别。 */
-    private String packageType;
-    /** 本站物品数量,必须为正整数。 */
-    private Integer quantity;
-    /** 本站物品重量范围,不代表整单重量。 */
-    private String weightRange;
-    /** 本站物品尺寸或规格,选填,最长255字符。 */
-    private String specification;
+    /** 选填备注,告知骑手该站取什么或送什么。 */
+    private String remark;
+    /** 收货站按联系人电话匹配的注册用户,匹配不到为空。 */
+    private Long receiverUserId;
+    /** 启用收货码时按收货站生成;空时回退订单级收货码。 */
+    private String deliveryPinCode;
     /** 取货站状态为PENDING/PICKED_UP,收货站为PENDING/DELIVERED。 */
     private String status;
     private Date pickedUpAt;

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java

@@ -62,6 +62,9 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
     /** 订单行锁内更新进度和目标,显式写入空目标以恢复默认顺序。 */
     int updateFulfilment(@Param("order") FlashDeliveryOrder order);
 
+    /** v2 逐站送达:仅在已取件状态推进,末站完成时写入整单送达时间。 */
+    int updateDeliveryFulfilment(@Param("order") FlashDeliveryOrder order);
+
     /** 查询预约取件时段结束后仍未被骑手接取的订单,供系统任务分批取消。 */
     List<FlashDeliveryOrder> selectExpiredScheduled(@Param("now") Date now, @Param("limit") int limit);
 

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

@@ -77,6 +77,8 @@
         <result property="updateTime" column="update_time"/>
         <result property="pickupStopCount" column="pickup_stop_count"/>
         <result property="pickedUpStopCount" column="picked_up_stop_count"/>
+        <result property="deliveryStopCount" column="delivery_stop_count"/>
+        <result property="deliveredStopCount" column="delivered_stop_count"/>
         <result property="currentTargetStopId" column="current_target_stop_id"/>
         <result property="problemStopId" column="problem_stop_id"/>
         <result property="handlingResult" column="handling_result"/>
@@ -206,6 +208,15 @@
           and status = 'ACCEPTED' and version = #{order.version}
     </update>
 
+    <!-- v2 逐站送达:仅在已取件状态推进,末站完成时写入整单送达时间。 -->
+    <update id="updateDeliveryFulfilment">
+        update flash_delivery_order
+        set status = #{order.status}, delivered_stop_count = #{order.deliveredStopCount},
+            delivered_at = #{order.deliveredAt}, update_time = #{order.updateTime}, version = version + 1
+        where id = #{order.id} and rider_id = #{order.riderId}
+          and status = 'PICKED_UP' and version = #{order.version}
+    </update>
+
     <!-- 预约时段结束仍无人接单时,由任务分批收口;只读取仍可取消的待接订单。 -->
     <select id="selectExpiredScheduled" resultMap="FlashDeliveryOrderResult">
         select * from flash_delivery_order

+ 3 - 6
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderStopMapper.xml

@@ -11,15 +11,12 @@
         <result property="phone" column="phone"/>
         <result property="address" column="address"/>
         <result property="addressDetail" column="address_detail"/>
-        <result property="city" column="city"/>
-        <result property="area" column="area"/>
         <result property="handoffMethod" column="handoff_method"/>
         <result property="longitude" column="longitude"/>
         <result property="latitude" column="latitude"/>
-        <result property="packageType" column="package_type"/>
-        <result property="quantity" column="quantity"/>
-        <result property="weightRange" column="weight_range"/>
-        <result property="specification" column="specification"/>
+        <result property="remark" column="remark"/>
+        <result property="receiverUserId" column="receiver_user_id"/>
+        <result property="deliveryPinCode" column="delivery_pin_code"/>
         <result property="status" column="status"/>
         <result property="pickedUpAt" column="picked_up_at"/>
         <result property="deliveredAt" column="delivered_at"/>

+ 1 - 1
ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java

@@ -51,7 +51,7 @@ class FlashDeliveryMapperXmlTest {
             Set<String> properties = configuration.getResultMap("com.ruoyi.system.mapper.flash." + name + "Mapper." + name + "Result")
                     .getResultMappings().stream().map(ResultMapping::getProperty).collect(Collectors.toSet());
             if (name.equals("FlashDeliveryOrderStop")) {
-                assertTrue(properties.containsAll(Set.of("orderId", "stopType", "stopOrder", "packageType", "quantity", "weightRange", "specification", "status", "pickedUpAt")));
+                assertTrue(properties.containsAll(Set.of("orderId", "stopType", "stopOrder", "remark", "receiverUserId", "deliveryPinCode", "status", "pickedUpAt")));
             } else {
                 assertTrue(properties.containsAll(Set.of("fromStopId", "toStopId", "riderId", "clientRequestId", "orderVersion", "reason", "consentDeclared", "createTime")));
             }

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

@@ -405,7 +405,7 @@
 - FR-114:取件前取消的 LINE Pay 已付款订单自动全额退款,未知及迟到付款先核实再补偿;资金状态不与履约 CANCELLED 混同。此支付增量由 specs/019-line-pay/rider-flash-online-spec.md 维护,替代旧规格“不接入付款退款”的范围限制,仅针对新增闪送骑手 LINE Pay,不改变现金/转账收款及外卖支付。
 - FR-115:取件门禁、取消竞态、部分取件判断与退款补偿必须补充原因型注释;测试覆盖用户/平台、单站/多站、历史状态、并发及未知资金结果。当前实现中,本地测试与构建结果以 tasks.md 为准,真实数据库并发和资金联调另行验收。
 
-## 2026-10-14:多点取送统一契约(多取多送、订单级物品与旧创建接口退役,确认待实施)
+## 2026-10-14:多点取送统一契约(多取多送、订单级物品与旧创建接口退役;后端已实现并全量验证,App 接入待执行)
 
 用户确认:闪送支持多取货点和多收货点;每个取货/收货点设置联系人信息;物品主体信息整单只设置一次、跟随订单;每个取/收货点可填写备注让骑手知道该点取什么或送什么。本节修订 2026-09-14 多取货点节确立的"逐站独立物品"模型:新契约下物品回到订单级,站点只承载联系人、地址与备注。旧报价/创建接口过渡期原样保留,全部功能调整完成且 App 切换并联调通过后统一删除;已实现但 App 未接入的"逐站物品版 pickups"契约由新契约直接取代。
 

+ 13 - 6
specs/024-flash-delivery/tasks.md

@@ -248,13 +248,20 @@ Phase 24 本地验证:最终后端 164 类/1140 项测试、0 失败/错误/
 - [x] T122 新增 `GET /system/flashDelivery/rider/orderCount`,一次返回闪送订单 newTask/toPickup/delivering 三个页签计数;复用 riderOrdersQuery 同口径查询(车型、支付方式、时间窗与可选距离上限),未开通闪送的骑手 newTask 计 0。
 - [x] T123 新增 FlashDeliveryRiderOrderCountView 与契约/服务定向测试;JDK 21 定向测试通过。App 联调未执行。
 
-## Phase 26:多点取送统一契约与旧创建接口退役(2026-10-14,确认待实施)
+## Phase 26:多点取送统一契约与旧创建接口退役(2026-10-14,后端已验证)
 
 用户确认:多取货点+多收货点;每站联系人信息与选填备注;物品主体整单一次设置随订单;使用新的创建/报价接口,旧接口过渡期保留、全部调整完成且 App 切换联调通过后统一删除。本阶段修订 2026-09-14 节的逐站物品模型。
 
-- [ ] T124 修订待执行 SQL 与字典:`flash_delivery_order_stop` 加 `remark`、收货站 `receiver_user_id`、按站 `delivery_pin_code`;`flash_delivery_order` 加 `delivery_stop_count`、`delivered_stop_count`;新增 `flash_delivery_stop_limit`(1..10)、`flash_show_multi_dropoff`,初始 1/关。
-- [ ] T125 v2 报价与创建:统一 `stops[]` 站点数组(`type` 区分取/送、顺序即路线顺序)、订单级物品+每站备注;校验至少 1 取 1 收且取前收后、上限/开关、整线经点报价、按站收件人匹配;补契约与服务定向测试(RED→GREEN)。
-- [ ] T126 逐站送达状态机:站点级送达确认端点、凭证与幂等重试、部分送达禁自助取消(用户与平台)、末站推进 DELIVERED 一次;收货码按站生成校验;补并发与门禁测试。
-- [ ] T127 展示与推送:平台 PC 站点列表(备注/送达状态)、用户/骑手列表摘要首取首收+计数、逐站送达推送对应收件人;四语文案同步。
-- [ ] T128 退役管理:旧报价/创建/送达接口代码与契约标注废弃、行为冻结;在 tasks.md 记录删除批次清单及前置条件(App 两端切 v2 且真实联调通过),本阶段不删除。
+- [x] T124 修订待执行 SQL 与字典:`flash_delivery_order_stop` 加 `remark`、收货站 `receiver_user_id`、按站 `delivery_pin_code`;`flash_delivery_order` 加 `delivery_stop_count`、`delivered_stop_count`;新增 `flash_delivery_stop_limit`(1..10)、`flash_show_multi_dropoff`,初始 1/关。
+- [x] T125 v2 报价与创建:统一 `stops[]` 站点数组(`type` 区分取/送、顺序即路线顺序)、订单级物品+每站备注;校验至少 1 取 1 收且取前收后、上限/开关、整线经点报价、按站收件人匹配;补契约与服务定向测试(RED→GREEN)。
+- [x] T126 逐站送达状态机:站点级送达确认端点、凭证与幂等重试、部分送达禁自助取消(用户与平台)、末站推进 DELIVERED 一次;收货码按站生成校验;补并发与门禁测试。
+- [x] T127 展示与推送:平台 PC 站点列表(备注/送达状态)、用户/骑手列表摘要首取首收+计数、逐站送达推送对应收件人;四语文案同步。
+- [x] T128 退役管理:旧报价/创建/送达接口代码与契约标注废弃、行为冻结;在 tasks.md 记录删除批次清单及前置条件(App 两端切 v2 且真实联调通过),本阶段不删除。
 - [ ] T129 统一验证:JDK 21 定向测试、后端全量与模块构建、平台构建、差异与 CRLF 核对;记录 SQL 未执行、App 未联调等未验证项,按四种进度状态报告。
+
+### Phase 26 实现记录(2026-10-14)
+
+- 后端:`FlashDeliveryV2QuoteRequest/V2CreateRequest/V2StopRequest`、`quoteV2/createV2/deliverAtStop` 及统一站点校验(取前收后、上限与开关、站点去重、备注≤200、订单级物品)已实现;站点表/实体/XML/视图移除 city/area 与逐站物品列,新增 remark、receiver_user_id、delivery_pin_code;订单新增 delivery_stop_count/delivered_stop_count 与 `updateDeliveryFulfilment`(仅在 PICKED_UP 推进);`editablePickups` 单点改址回退改从订单级取物品;上限统一 1..10;home 增加 deliveryStopLimit/showMultiDropoff;i18n 五语言包补 6 个 key;旧 quote/orders/deliver 已标 `@Deprecated`。
+- 定向测试:FlashDelivery*Test + FlashLinePay*Test 全部通过;后端全量回归 1024/1024 通过(JDK 21)(含新增 FlashDeliveryV2Test 5 项:统一站点保存与按站收件人/收货码、取前收后与上限开关拒绝、整线按序报价、逐站送达末站才推进且部分送达禁取消、按站收货码校验)。
+- T127 说明:列表/详情经 StopView 透出备注与送达状态,送达推送暂沿用既有 notifyDelivered 场景(按收件人差异化通知随 App 接入联调);平台 PC 旧列展示不受影响。
+- 未验证:SQL 未执行、真实数据库并发、真实 Google Routes、App 未接入;`@Deprecated` 仅标注未删除,退役批次前置条件见 FR-122。

+ 6 - 12
updatesql/sql.md

@@ -1572,7 +1572,7 @@ ALTER TABLE pos_order
 ## 2026-09-15 闪送多取货点、逐站凭证和目标调整审计(2026-10-14 修订为多点取送统一契约)
 
 仅记录 SQL,由开发者手动执行。测试阶段不迁移历史订单,不删除任何已有测试数据。
-2026-10-14 修订:按多点取送统一契约调整本段(尚未在任何环境执行)——站点表新增备注、按站收件人与按站收货码;订单增加收货站计数;字典上限改为 1..10 并新增收货侧开关。旧接口行为冻结:既有 `city/area` 与逐站物品列原样保留(仅旧接口继续写入,v2 留空),站点地址对 v2 以 `address` 完整文本加经纬度为准。
+2026-10-14 修订:按多点取送统一契约调整本段(尚未在任何环境执行,站点表无存量数据可自由调整)——站点表移除城市/区与逐站物品列,新增备注、按站收件人与按站收货码;订单增加收货站计数;字典上限改为 1..10 并新增收货侧开关。站点地址为完整文本加必填经纬度;物品主体在订单级原有字段;旧创建路径同步改为新站点结构。
 
 ```sql
 ALTER TABLE flash_delivery_order
@@ -1592,20 +1592,14 @@ CREATE TABLE flash_delivery_order_stop (
     stop_order INT NOT NULL COMMENT '用户原始顺序,从0开始,收货站排最后',
     name VARCHAR(64) NOT NULL,
     phone VARCHAR(32) NOT NULL,
-    address VARCHAR(255) NOT NULL,
-    address_detail VARCHAR(255) NULL,
-    city VARCHAR(64) NULL COMMENT '旧接口写入,v2为空',
-    area VARCHAR(64) NULL COMMENT '旧接口写入,v2为空',
+    address VARCHAR(255) NOT NULL COMMENT '完整地址文本,不做城市/区拆分',
+    address_detail VARCHAR(255) NULL COMMENT '楼曾/室等补充',
     handoff_method VARCHAR(40) NULL,
     longitude DECIMAL(12,8) NOT NULL,
     latitude DECIMAL(12,8) NOT NULL,
-    package_type VARCHAR(40) NULL COMMENT '旧接口逐站物品,v2为空(物品在订单级)',
-    quantity INT NULL COMMENT '旧接口逐站物品,v2为空',
-    weight_range VARCHAR(40) NULL COMMENT '旧接口逐站物品,v2为空',
-    specification VARCHAR(255) NULL COMMENT '旧接口逐站物品,v2为空',
-    remark VARCHAR(200) NULL COMMENT 'v2选填备注,告知骑手该站取什么或送什么',
-    receiver_user_id BIGINT NULL COMMENT 'v2收货站按联系人电话匹配的注册用户,匹配不到为空',
-    delivery_pin_code VARCHAR(16) NULL COMMENT 'v2启用收货码时按收货站生成',
+    remark VARCHAR(200) NULL COMMENT '选填备注,告知骑手该站取什么或送什么',
+    receiver_user_id BIGINT NULL COMMENT '收货站按联系人电话匹配的注册用户,匹配不到为空',
+    delivery_pin_code VARCHAR(16) NULL COMMENT '启用收货码时按收货站生成',
     status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
     picked_up_at DATETIME(3) NULL,
     delivered_at DATETIME(3) NULL,