Browse Source

实现闪送多取货点履约与顺序调整

支持逐站物品、整线报价、取件凭证、目标调整审计和异常交接,补充订单锁与当前读保护。同步规格、接口文档、迁移 SQL 和验证记录。

定向测试 131 项及 JDK 21 打包通过;原有支付测试冲突单列,真实数据库并发与 App 联调待验证。
qmj 1 month ago
parent
commit
0ef2d13723
51 changed files with 1807 additions and 141 deletions
  1. 42 0
      docs/flash-delivery-app-api.md
  2. 19 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  3. 5 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressChangeRequest.java
  4. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAdminOrderDetailView.java
  5. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  6. 8 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  7. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  8. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPickupRequest.java
  9. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryProofRequest.java
  10. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuantityDeserializer.java
  11. 9 4
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  12. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java
  13. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  14. 36 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopView.java
  15. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTargetRequest.java
  16. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  17. 38 15
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  18. 31 7
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java
  19. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java
  20. 413 88
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  21. 14 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  22. 14 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  23. 14 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  24. 14 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  25. 14 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  26. 14 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  27. 13 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  28. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  29. 72 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java
  30. 31 6
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  31. 26 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupContractTest.java
  32. 444 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  33. 10 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  34. 3 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderImage.java
  35. 44 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderStop.java
  36. 26 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryTargetAdjustment.java
  37. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  38. 8 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderStopMapper.java
  39. 8 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryTargetAdjustmentMapper.java
  40. 24 1
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  41. 28 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderStopMapper.xml
  42. 17 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryTargetAdjustmentMapper.xml
  43. 40 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  44. 40 0
      specs/024-flash-delivery/contracts/api.md
  45. 9 0
      specs/024-flash-delivery/data-model.md
  46. 3 3
      specs/024-flash-delivery/plan.md
  47. 13 0
      specs/024-flash-delivery/quickstart.md
  48. 1 1
      specs/024-flash-delivery/spec.md
  49. 17 9
      specs/024-flash-delivery/tasks.md
  50. 43 0
      specs/024-flash-delivery/validation.md
  51. 80 0
      updatesql/sql.md

+ 42 - 0
docs/flash-delivery-app-api.md

@@ -1125,3 +1125,45 @@ POST /system/address/{id}/top
 - “我收的”按订单当前绑定的账号展示;创建或确认修改取送信息时匹配收件电话,清空或更换绑定后旧收件人不再有访问权;查询时不动态认领历史订单。
 - 发布成功页显示“发布后等待附近骑手接单”,不显示虚构的骑手数量或预计接单时长。
 - 骑手端金额文案统一为“订单金额”,不使用“收益”;联系骑手/用户通过 IM,不提供通话。
+
+## 2026-09-15:多取货点客户端接入
+
+本节覆盖上文单点输入、订单级取件和仅按主状态显示取消按钮的旧说明。完整字段与异常规则见 [API 契约多取货点增量](../specs/024-flash-delivery/contracts/api.md#2026-09-15-多取货点增量覆盖上文单点口径)。
+
+### 下单与编辑
+
+首页读取 `pickupStopLimit/showMultiPickup/showUrgentOption`。用户可新增取货点、填写每站独立物品,并在报价前排序;最终 `pickups` 数组顺序即报价顺序。每个数组项直接包含地址字段和 `packageType/quantity/weightRange/specification`。数组模式不再传旧 `pickup` 或订单级物品。唯一 `delivery` 保持原格式。
+
+```json
+{
+  "serviceType": "HELP_SEND", "deliveryType": "NORMAL", "deliveryMode": "NOW", "tipAmount": 0,
+  "pickups": [
+    {"name":"寄件人 A","phone":"0912345678","address":"台北市信义区 A 点","latitude":25.033,"longitude":121.5645,"packageType":"DOCUMENT","quantity":1,"weightRange":"UP_TO_5_KG"},
+    {"name":"寄件人 B","phone":"0922345678","address":"台北市大安区 B 点","latitude":25.026,"longitude":121.543,"packageType":"GIFT","quantity":2,"weightRange":"OVER_5_TO_10_KG"}
+  ],
+  "delivery":{"name":"收件人","phone":"0932345678","address":"台北市中山区收件点","latitude":25.052,"longitude":121.531}
+}
+```
+
+创建时再附加既有幂等号、报价版本和金额回传字段;待接单编辑使用完整数组,调整顺序同样先重报价并确认。下单页面提示“默认按此顺序取件,骑手如需调整将先与您沟通”。用户原顺序及金额不会因骑手调整访问先后而改变。
+
+### 骑手目标与实际取件
+
+1. 详情读取 `stops,currentTargetStopId,orderVersion`,默认引导至用户顺序中的最早未取站。站点 `id` 是服务端 ID,不是数组下标。
+2. 骑手需要先去 B 时,先联系下单人取得同意,再调用 `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/target`:
+
+```json
+{"orderVersion":3,"clientRequestId":"route-change-uuid","reason":"A 尚未备好,下单人同意先去 B","consentDeclared":true}
+```
+
+3. 请求成功后刷新详情,以服务端目标导航。重试沿用同一请求号;重新调整用新请求号及最新版本。声明仅表示骑手作出了声明,不展示成用户已在 App 审批。订单聊天可留作沟通依据。
+4. 导航与取件按钮分开。实际取到货后,调用 `POST /rider/orders/{id}/stops/{stopId}/pickup` 提交 `imageUrls`;也可使用原 `/pickup`,多点必须提供 body `stopId`,单点可省略。
+5. B 取件后默认回到 A;所有取货点完成前不能送达。某站未备好可以沟通后换顺序,但站点不能删除,也不能用虚假取件跳过;最终无法取货须联系平台处理。
+
+### 进度、取消与导航
+
+各端显示 `pickedUpStopCount/pickupStopCount`,保留原站点顺序展示实际进度;部分已取仍为 `ACCEPTED`,用户取消按钮使用 `canUserCancel` 且结合当前用户角色。任意取件、取消、调整或刷新后重新读取最新详情,不能仅按主状态判断按钮。
+
+导航目标由 `currentTargetStopId` 定位,全部取完后为唯一收货点。客户端生成 Google Maps URL,使用 `api=1&travelmode=two-wheeler&dir_action=navigate`,地址参数正确编码,起点取设备当前位置。没有定位或无法启动外部地图时,保留目标地址并允许重试;不得因此自动取件。后端不返回导航 URL。
+
+上线前用户、骑手端必须完成数组、逐站凭证、目标调整及状态按钮适配再开启多点开关。当前工作区未提供对应 App 页面实现,本节为客户端交付契约,不能作为 App 已联调证据。

+ 19 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.flashdelivery.controller;
 
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryTargetRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
@@ -55,7 +56,7 @@ public class FlashDeliveryRiderController extends BaseController {
         return success(service.accept(userId(token), id));
     }
 
-    /** 上传取件凭证并将已接单订单推进为已取件。 */
+    /** 上传指定取货站凭证;全部取货站完成后订单才进入已取件。 */
     @PostMapping("/orders/{id}/pickup")
     @Auth
     public AjaxResult pickup(@RequestHeader String token, @PathVariable Long id,
@@ -73,5 +74,22 @@ public class FlashDeliveryRiderController extends BaseController {
         return success();
     }
 
+    /** 只确认指定站点取货,不自动选择其他待取站点。 */
+    @PostMapping("/orders/{id}/stops/{stopId}/pickup")
+    @Auth
+    public AjaxResult pickupStop(@RequestHeader String token, @PathVariable Long id, @PathVariable Long stopId,
+                                 @RequestBody FlashDeliveryProofRequest request) {
+        service.pickupAtStop(userId(token), id, stopId, request);
+        return success();
+    }
+
+    /** 保存前往目标及必要的骑手同意声明,不推进取件状态。 */
+    @PostMapping("/orders/{id}/stops/{stopId}/target")
+    @Auth
+    public AjaxResult target(@RequestHeader String token, @PathVariable Long id, @PathVariable Long stopId,
+                             @RequestBody FlashDeliveryTargetRequest request) {
+        return success(service.selectTarget(userId(token), id, stopId, request));
+    }
+
     private Long userId(String token) { return Long.valueOf(new JwtUtil().getusid(token)); }
 }

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

@@ -1,14 +1,17 @@
 package com.ruoyi.app.flashdelivery.dto;
 
+import java.util.List;
 import lombok.Data;
 
-/** 待接单订单修改取送信息;两端均提交完整地址,未修改的一端原样回传。 */
+/** 待接单订单修改取送信息;多点提交全部pickups,单点可用旧pickup并保留该站物品。 */
 @Data
 public class FlashDeliveryAddressChangeRequest {
     /** 用户订单详情或地址报价返回的订单版本,必填。 */
     private Integer orderVersion;
-    /** 新取件地址,需完整提交,未修改时也原样回传。 */
+    /** 仅用于单点订单的新取件地址,与pickups数组二选一。 */
     private FlashDeliveryAddressRequest pickup;
     /** 新收件地址,需完整提交,未修改时也原样回传。 */
     private FlashDeliveryAddressRequest delivery;
+    /** 按用户顺序提交完整取货点及物品,不能与旧pickup字段同时出现。 */
+    private List<FlashDeliveryPickupRequest> pickups;
 }

+ 14 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAdminOrderDetailView.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.flashdelivery.dto;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrderImage;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrderLog;
+import com.ruoyi.system.domain.flash.FlashDeliveryTargetAdjustment;
 import lombok.Data;
 
 import java.util.List;
@@ -16,4 +17,17 @@ public class FlashDeliveryAdminOrderDetailView {
     private List<FlashDeliveryOrderImage> images;
     /** 状态变更日志。 */
     private List<FlashDeliveryOrderLog> logs;
+    /** 取货站总数及已实际确认的取货站数。 */
+    private Integer pickupStopCount;
+    private Integer pickedUpStopCount;
+    /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
+    private Boolean canUserCancel;
+    /** 按用户原始顺序返回站点、独立物品及本站凭证。 */
+    private List<FlashDeliveryStopView> stops = List.of();
+    /** 当前前往目标,全部取货后指向唯一收货站。 */
+    private Long currentTargetStopId;
+    /** 目标调整记录,骑手声明与实际取货凭证分别展示。 */
+    private List<FlashDeliveryTargetAdjustment> targetAdjustments = List.of();
+    /** 平台异常处理中的交接凭证,独立于骑手取货凭证。 */
+    private List<String> handoffImageUrls = List.of();
 }

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java

@@ -13,4 +13,10 @@ public class FlashDeliveryHomeView {
     private List<String> deliveryTypes;
     /** 当前时段各服务的运价摘要,无可用运价时为空列表。 */
     private List<FlashDeliveryServiceView> services;
+    /** 取货点有效上限,合法范围1至26,配置非法时为1。 */
+    private Integer pickupStopLimit;
+    /** 是否开放多取货点提交,不影响已有订单履约。 */
+    private Boolean showMultiPickup;
+    /** 仅控制加急选项展示,后端仍按deliveryType校验。 */
+    private Boolean showUrgentOption;
 }

+ 8 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java

@@ -1,6 +1,7 @@
 package com.ruoyi.app.flashdelivery.dto;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
+import com.ruoyi.system.domain.flash.FlashDeliveryTargetAdjustment;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
@@ -10,7 +11,7 @@ import java.util.List;
 @Data
 @EqualsAndHashCode(callSuper = true)
 public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
-    /** 用户编辑待接单订单使用的版本;骑手详情不返回。 */
+    /** 用户编辑及中单骑手选择目标使用的版本;接单前不返回。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private Integer orderVersion;
     /** 寄件图片 URL 列表,用户下单时上传,可为空。 */
@@ -25,4 +26,10 @@ public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
     /** 交付 PIN,仅启用 PIN 的用户详情返回,骑手端始终为空。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private String deliveryPinCode;
+    /** 按用户原始顺序返回站点、独立物品及本站凭证。 */
+    private List<FlashDeliveryStopView> stops = List.of();
+    /** 当前前往目标,全部取货后指向唯一收货站。 */
+    private Long currentTargetStopId;
+    /** 目标调整记录,骑手声明与实际取货凭证分别展示。 */
+    private List<FlashDeliveryTargetAdjustment> targetAdjustments = List.of();
 }

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java

@@ -75,4 +75,9 @@ public class FlashDeliveryOrderView {
     private Date createTime;
     /** 最后更新时间。 */
     private Date updateTime;
+    /** 取货站总数及已实际确认的取货站数。 */
+    private Integer pickupStopCount;
+    private Integer pickedUpStopCount;
+    /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
+    private Boolean canUserCancel;
 }

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPickupRequest.java

@@ -0,0 +1,20 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 完整取货地址及独立物品信息,数组顺序决定报价路线与默认取件顺序。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryPickupRequest extends FlashDeliveryAddressRequest {
+    /** 本站独立申报的物品类别。 */
+    private String packageType;
+    /** 本站物品数量,必须为正整数。 */
+    @JsonDeserialize(using = FlashDeliveryQuantityDeserializer.class)
+    private Integer quantity;
+    /** 本站物品重量范围,不代表整单重量。 */
+    private String weightRange;
+    /** 本站物品尺寸或规格,选填,最长255字符。 */
+    private String specification;
+}

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

@@ -12,4 +12,6 @@ public class FlashDeliveryProofRequest {
      * 每项最长 1000 个字符,允许相对路径。
      */
     private List<String> imageUrls;
+    /** 取货站点编号;仅单取货点订单允许省略。 */
+    private Long stopId;
 }

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuantityDeserializer.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+
+import java.io.IOException;
+import java.math.BigInteger;
+
+/** 防止站点数量的小数或超大值被截断;无效输入交由Service统一返回国际化业务校验。 */
+public class FlashDeliveryQuantityDeserializer extends JsonDeserializer<Integer> {
+    @Override
+    public Integer deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+        if (parser.currentToken() != JsonToken.VALUE_NUMBER_INT) {
+            // 无效数组或对象也要完整消耗,确保后续字段不会解析错位。
+            parser.skipChildren();
+            return null;
+        }
+        BigInteger quantity = parser.getBigIntegerValue();
+        return quantity.bitLength() < 32 ? quantity.intValue() : null;
+    }
+}

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

@@ -1,5 +1,7 @@
 package com.ruoyi.app.flashdelivery.dto;
 
+import java.util.List;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import lombok.Data;
 
 import java.util.Date;
@@ -20,17 +22,18 @@ public class FlashDeliveryQuoteRequest {
     private String deliveryType;
 
     /**
-     * 物品类别,必填。
+     * 旧单点输入的物品类别;使用pickups数组时不得提交。
      * 可选值:DOCUMENT、GIFT、CLOTHING、BEAUTY、DAILY_NECESSITIES、
      * FOOD_INGREDIENTS、ELECTRONICS、SMALL_APPLIANCE、OTHER。
      */
     private String packageType;
 
-    /** 物品总数量,必填,必须为大于 0 的整数。 */
+    /** 旧单点输入的物品数量,必须为大于0的整数;使用pickups时不得提交。 */
+    @JsonDeserialize(using = FlashDeliveryQuantityDeserializer.class)
     private Integer quantity;
 
     /**
-     * 物品重量范围,必填。
+     * 旧单点输入的物品重量范围;使用pickups时不得提交。
      * 可选值:UP_TO_5_KG、OVER_5_TO_10_KG、OVER_10_TO_15_KG、OVER_15_TO_20_KG。
      */
     private String weightRange;
@@ -59,9 +62,11 @@ public class FlashDeliveryQuoteRequest {
      */
     private Date scheduledPickupEndAt;
 
-    /** 取件联系人、电话、地址、交接方式和经纬度,必填。 */
+    /** 旧单点取件地址,与pickups数组二选一。 */
     private FlashDeliveryAddressRequest pickup;
 
     /** 收件联系人、电话、地址、交接方式和经纬度,必填。 */
     private FlashDeliveryAddressRequest delivery;
+    /** 按用户顺序提交完整取货点及物品,不能与旧pickup字段同时出现。 */
+    private List<FlashDeliveryPickupRequest> pickups;
 }

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.flashdelivery.dto;
 
+import java.util.List;
 import lombok.Data;
 
 /** 用户或平台取消闪送订单时提交的原因。 */
@@ -7,4 +8,12 @@ import lombok.Data;
 public class FlashDeliveryReasonRequest {
     /** 取消原因,必填,去除首尾空格后不能为空且最长 500 个字符。 */
     private String reason;
+    /** 问题站点编号,收件拒收时允许指定DELIVERY收货站。 */
+    private Long problemStopId;
+    /** 平台取消时必填的处理结果,最长500字符。 */
+    private String handlingResult;
+    /** 已取物品去向或接收人;已有取货时必填,最长500字符。 */
+    private String pickedGoodsDisposition;
+    /** 平台介入时的实际交接凭证;已有取货时至少1张、最多9张。 */
+    private List<String> handoffImageUrls;
 }

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java

@@ -63,4 +63,9 @@ public class FlashDeliveryRiderOrderListView {
     private String payType;
     /** 创建时间。 */
     private Date createTime;
+    /** 取货站总数及已实际确认的取货站数。 */
+    private Integer pickupStopCount;
+    private Integer pickedUpStopCount;
+    /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
+    private Boolean canUserCancel;
 }

+ 36 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopView.java

@@ -0,0 +1,36 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import java.util.List;
+import java.util.Date;
+import java.math.BigDecimal;
+import lombok.Data;
+
+/** 站点详情;骑手接单前逐站隐藏联系人、电话、坐标和凭证。 */
+@Data
+public class FlashDeliveryStopView {
+    private Long id;
+    private String stopType;
+    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 status;
+    private Date pickedUpAt;
+    private Date deliveredAt;
+    /** 本站实际取货或送达凭证,接单前不向骑手返回。 */
+    private List<String> imageUrls = List.of();
+}

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTargetRequest.java

@@ -0,0 +1,16 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 选择前往取货站点,不推进取件状态或改变报价路线。 */
+@Data
+public class FlashDeliveryTargetRequest {
+    /** 订单详情版本,必填,用于拒绝过期的目标选择请求。 */
+    private Integer orderVersion;
+    /** 本次骑手操作的幂等编号,最长64字符;重试保持编号和内容一致。 */
+    private String clientRequestId;
+    /** 改变当前目标的原因,最长500字符。 */
+    private String reason;
+    /** 骑手声明已取得下单人同意,不代表用户App内审批。 */
+    private Boolean consentDeclared;
+}

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java

@@ -55,4 +55,9 @@ public class FlashDeliveryUserOrderListView {
     private Date deliveredAt;
     /** 创建时间。 */
     private Date createTime;
+    /** 取货站总数及已实际确认的取货站数。 */
+    private Integer pickupStopCount;
+    private Integer pickedUpStopCount;
+    /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
+    private Boolean canUserCancel;
 }

+ 38 - 15
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java

@@ -2,9 +2,31 @@ package com.ruoyi.app.flashdelivery.route;
 
 import org.springframework.stereotype.Service;
 
+import java.util.Arrays;
+import java.util.List;
+
 /** 路线距离服务:优先请求地图路线,异常时使用球面直线距离保证报价仍然可用。 */
 @Service
 public class FlashDeliveryRouteService {
+    /** 按用户提供的完整站点序列计算路线,最后一点为收货点。 */
+    public RouteDistance calculate(java.util.List<GeoPoint> points) {
+        // 数量和所有坐标必须先通过校验,输入错误不能伪装成地图故障降级。
+        validatePoints(points);
+        List<GeoPoint> ordered = List.copyOf(points);
+        try {
+            RouteDistance route = provider.route(ordered);
+            if (route != null && route.distanceMeters() > 0
+                    && (route.durationSeconds() == null || route.durationSeconds() >= 0)) return route;
+        } catch (RuntimeException ignored) {
+            // 任一地图请求失败时整线降级,不混用局部路线或遗漏未返回的站点。
+        }
+        double meters = 0;
+        for (int i = 1; i < ordered.size(); i++) {
+            meters += straightLineMeters(ordered.get(i - 1), ordered.get(i));
+        }
+        // 同坐标相邻站贡献零距离,只在整线汇总后取整并保留既有最小距离。
+        return new RouteDistance(Math.max(1, Math.toIntExact(Math.round(meters))), null, "STRAIGHT_LINE");
+    }
     private static final double EARTH_RADIUS_METERS = 6_371_000D;
     private final RouteDistanceProvider provider;
 
@@ -14,18 +36,11 @@ public class FlashDeliveryRouteService {
 
     /** 计算取送两点路线距离:优先地图路线,异常或无结果时降级球面直线距离。 */
     public RouteDistance calculate(GeoPoint origin, GeoPoint destination) {
-        validate(origin);
-        validate(destination);
-        try {
-            RouteDistance route = provider.route(origin.latitude(), origin.longitude(),
-                    destination.latitude(), destination.longitude());
-            if (route != null && route.distanceMeters() > 0) {
-                return route;
-            }
-        } catch (RuntimeException ignored) {
-            // 地图超时、限流或配置缺失不阻断下单,统一进入本地 Haversine 降级。
-        }
-        // Haversine 只作为降级结果,并通过 source=STRAIGHT_LINE 明确告知调用方。
+        return calculate(Arrays.asList(origin, destination));
+    }
+
+    /** Haversine 分段距离只用于地图不可用时的整线降级。 */
+    private double straightLineMeters(GeoPoint origin, GeoPoint destination) {
         double lat1 = Math.toRadians(origin.latitude().doubleValue());
         double lat2 = Math.toRadians(destination.latitude().doubleValue());
         double deltaLat = lat2 - lat1;
@@ -34,11 +49,19 @@ public class FlashDeliveryRouteService {
         double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2)
                 + Math.cos(lat1) * Math.cos(lat2)
                 * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
-        int meters = (int) Math.round(EARTH_RADIUS_METERS * 2D * Math.atan2(Math.sqrt(a), Math.sqrt(1D - a)));
-        return new RouteDistance(Math.max(1, meters), null, "STRAIGHT_LINE");
+        a = Math.max(0D, Math.min(1D, a));
+        return EARTH_RADIUS_METERS * 2D * Math.atan2(Math.sqrt(a), Math.sqrt(1D - a));
+    }
+
+    /** 26 个取货点加唯一收货点,保持与业务字典上限一致。 */
+    static void validatePoints(List<GeoPoint> points) {
+        if (points == null || points.size() < 2 || points.size() > 27) {
+            throw new IllegalArgumentException("invalid route point count");
+        }
+        points.forEach(FlashDeliveryRouteService::validate);
     }
 
-    private void validate(GeoPoint point) {
+    private static void validate(GeoPoint point) {
         if (point == null || point.latitude() == null || point.longitude() == null
                 || point.latitude().doubleValue() < -90D || point.latitude().doubleValue() > 90D
                 || point.longitude().doubleValue() < -180D || point.longitude().doubleValue() > 180D) {

+ 31 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java

@@ -30,19 +30,20 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
     @Override
     public RouteDistance route(BigDecimal originLatitude, BigDecimal originLongitude,
                                BigDecimal destinationLatitude, BigDecimal destinationLongitude) {
+        return route(List.of(new GeoPoint(originLatitude, originLongitude),
+                new GeoPoint(destinationLatitude, destinationLongitude)));
+    }
+
+    /** 一次请求完整路线,按原始顺序传中途点;不调用路线优化接口。 */
+    @Override
+    public RouteDistance route(List<GeoPoint> points) {
+        JSONObject body = requestBody(points);
         // 不复用可能被浏览器端读取的 sys_googlemap_key,防止服务端 API 权限泄露。
         List<SysDictData> keys = DictUtils.getDictCache("sys_google_routes_key");
         if (keys == null || keys.isEmpty() || keys.get(0).getDictValue() == null
                 || keys.get(0).getDictValue().isBlank()) {
             throw new IllegalStateException("google routes key unavailable");
         }
-        JSONObject body = new JSONObject();
-        body.put("origin", waypoint(originLatitude, originLongitude));
-        body.put("destination", waypoint(destinationLatitude, destinationLongitude));
-        body.put("travelMode", "DRIVE");
-        body.put("routingPreference", "TRAFFIC_AWARE");
-        body.put("computeAlternativeRoutes", false);
-        body.put("units", "METRIC");
         HttpPost request = new HttpPost(ENDPOINT);
         request.setConfig(TIMEOUTS);
         request.setHeader("X-Goog-Api-Key", keys.get(0).getDictValue());
@@ -70,6 +71,29 @@ public class GoogleRoutesDistanceProvider implements RouteDistanceProvider {
         }
     }
 
+    /** 两点和多点共用的地图请求体,保留重复坐标对应的独立站点。 */
+    JSONObject requestBody(List<GeoPoint> points) {
+        FlashDeliveryRouteService.validatePoints(points);
+        JSONObject body = new JSONObject();
+        GeoPoint origin = points.get(0);
+        GeoPoint destination = points.get(points.size() - 1);
+        body.put("origin", waypoint(origin.latitude(), origin.longitude()));
+        body.put("destination", waypoint(destination.latitude(), destination.longitude()));
+        if (points.size() > 2) {
+            JSONArray intermediates = new JSONArray();
+            for (GeoPoint point : points.subList(1, points.size() - 1)) {
+                intermediates.add(waypoint(point.latitude(), point.longitude()));
+            }
+            body.put("intermediates", intermediates);
+        }
+        body.put("optimizeWaypointOrder", false);
+        body.put("travelMode", "DRIVE");
+        body.put("routingPreference", "TRAFFIC_AWARE");
+        body.put("computeAlternativeRoutes", false);
+        body.put("units", "METRIC");
+        return body;
+    }
+
     private JSONObject waypoint(BigDecimal latitude, BigDecimal longitude) {
         JSONObject latLng = new JSONObject();
         latLng.put("latitude", latitude);

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java

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

+ 413 - 88
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -20,7 +20,9 @@ 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.springframework.beans.BeanUtils;
 import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -62,6 +64,8 @@ public class FlashDeliveryApplicationService {
     private static final long MAX_SCHEDULE_DELAY_MILLIS = 3L * 24L * 60L * 60L * 1000L;
     private static final SecureRandom PIN_RANDOM = new SecureRandom();
     private final FlashDeliveryOrderMapper orderMapper;
+    private final FlashDeliveryOrderStopMapper stopMapper;
+    private final FlashDeliveryTargetAdjustmentMapper adjustmentMapper;
     private final FlashDeliveryPricingMapper pricingMapper;
     private final FlashDeliveryOrderImageMapper imageMapper;
     private final FlashDeliveryOrderLogMapper logMapper;
@@ -79,8 +83,12 @@ public class FlashDeliveryApplicationService {
                                            FlashDeliveryRouteService routeService,
                                            FlashDeliveryPricingCalculator pricingCalculator,
                                            RiderDeliveryLockService riderDeliveryLockService,
-                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
+                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService,
+                                           FlashDeliveryOrderStopMapper stopMapper,
+                                           FlashDeliveryTargetAdjustmentMapper adjustmentMapper) {
         this.orderMapper = orderMapper;
+        this.stopMapper = stopMapper;
+        this.adjustmentMapper = adjustmentMapper;
         this.pricingMapper = pricingMapper;
         this.imageMapper = imageMapper;
         this.logMapper = logMapper;
@@ -94,6 +102,9 @@ public class FlashDeliveryApplicationService {
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
     public FlashDeliveryHomeView home() {
         FlashDeliveryHomeView view = new FlashDeliveryHomeView();
+        view.setPickupStopLimit(pickupStopLimit());
+        view.setShowMultiPickup(dictionaryEnabled("flash_show_multi_pickup"));
+        view.setShowUrgentOption(dictionaryEnabled("flash_show_urgent_option"));
         view.setServiceTypes(SERVICE_TYPES);
         view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
         FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()));
@@ -150,7 +161,7 @@ public class FlashDeliveryApplicationService {
         String requestId = request.getClientRequestId().trim();
         // 用户 ID + clientRequestId 构成幂等键;重复请求直接返回第一次创建的订单。
         FlashDeliveryOrder existing = orderMapper.selectByUserRequestId(userId, requestId);
-        if (existing != null) return participantDetail(existing, true, false);
+        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");
         }
@@ -172,16 +183,14 @@ public class FlashDeliveryApplicationService {
         order.setServiceType(request.getServiceType());
         order.setDeliveryType(request.getDeliveryType());
         order.setStatus(WAITING_ACCEPTANCE);
-        order.setPackageType(request.getPackageType());
-        order.setQuantity(request.getQuantity());
-        order.setWeightRange(request.getWeightRange());
-        order.setSpecification(trimToNull(request.getSpecification()));
+        copyPickupSummary(order, quote.pickups());
+        order.setPickedUpStopCount(0);
         order.setDeliveryMode(deliveryMode);
         order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
         order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
         order.setPinRequired(pinRequired);
         order.setDeliveryPinCode(pinRequired ? String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000)) : null);
-        copyAddress(request.getPickup(), order, true);
+
         copyAddress(request.getDelivery(), order, false);
         order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
         order.setDistanceMeters(quote.route().distanceMeters());
@@ -213,10 +222,12 @@ public class FlashDeliveryApplicationService {
             orderMapper.insert(order);
         } catch (DataIntegrityViolationException duplicate) {
             // 并发重复请求可能同时通过前置查询,最终由数据库唯一键完成幂等收口。
-            FlashDeliveryOrder concurrent = orderMapper.selectByUserRequestId(userId, requestId);
-            if (concurrent != null) return participantDetail(concurrent, true, false);
+            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;
         }
+        saveStops(order, quote.pickups(), request.getDelivery(), now);
         saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
         writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
         return participantDetail(order, true, false);
@@ -231,14 +242,20 @@ public class FlashDeliveryApplicationService {
         String participantColumn = "receiver".equals(selectedRole) ? "receiver_user_id" : "user_id";
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
                 .eq(participantColumn, userId).orderByDesc("create_time");
-        return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), this::userOrderListView);
+        return mapPage(orderMapper.selectPage(page(pageNum, pageSize), query), order -> {
+            FlashDeliveryUserOrderListView view = userOrderListView(order);
+            view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && canUserCancel(order));
+            return view;
+        });
     }
 
     /** 查询本人订单详情,仅寄件人和匹配到的收件人可见。 */
     public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
         FlashDeliveryOrder order = requireOrder(orderId);
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
-        return participantDetail(order, true, false);
+        FlashDeliveryOrderDetailView view = participantDetail(order, true, false);
+        view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && canUserCancel(order));
+        return view;
     }
 
     /** 地址修改先报价,用户确认前不改动订单。 */
@@ -263,13 +280,14 @@ public class FlashDeliveryApplicationService {
                 || !Objects.equals(request.getQuotedAmount(), quote.getAmount())) {
             throw new FlashDeliveryQuoteChangedException(quote);
         }
-        copyAddress(request.getPickup(), order, true);
+        List<FlashDeliveryPickupRequest> pickups = editablePickups(order, request);
+        copyPickupSummary(order, pickups);
         copyAddress(request.getDelivery(), order, false);
         order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
         order.setDistanceMeters(quote.getDistanceMeters());
         order.setDistanceSource(quote.getDistanceSource());
         order.setEstimatedDurationSeconds(quote.getEstimatedDurationSeconds());
-        // 运价快照与金额同事务刷新为本次计价依据(快照=金额的最后一次计算依据,而非最初依据)
+        // 改址报价携带原运价快照,金额与完整站点同事务保存,不引入最新运价。
         order.setPricingId(quote.getPricingId());
         order.setPricingVersion(quote.getPricingVersion());
         order.setPricingStartTime(quote.getStartTime());
@@ -290,6 +308,9 @@ public class FlashDeliveryApplicationService {
         if (orderMapper.updateWaitingAddress(order, request.getOrderVersion()) != 1) {
             throw fail("flash.delivery.state.changed");
         }
+        // 条件更新成功后持有订单行锁至提交,抢单不会读到仅修改一半的地址。
+        stopMapper.delete(new QueryWrapper<FlashDeliveryOrderStop>().eq("order_id", orderId));
+        saveStops(order, pickups, request.getDelivery(), now);
         order.setVersion(order.getVersion() + 1);
         writeLog(orderId, WAITING_ACCEPTANCE, WAITING_ACCEPTANCE, "USER", userId, "ADDRESS_UPDATED", now);
         return participantDetail(order, true, false);
@@ -329,7 +350,9 @@ public class FlashDeliveryApplicationService {
     private FlashDeliveryOrder requireEditableOrder(Long userId, Long orderId, Integer version) {
         requireUserId(userId);
         if (orderId == null || orderId <= 0) throw fail("flash.delivery.order.not.found");
-        FlashDeliveryOrder order = requireOrder(orderId);
+        // 写入路径先锁订单再读取站点,保持与抢单、取货和取消一致的锁顺序。
+        FlashDeliveryOrder order = TransactionSynchronizationManager.isActualTransactionActive()
+                ? lockOrder(orderId) : requireOrder(orderId);
         if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
         if (!WAITING_ACCEPTANCE.equals(order.getStatus()) || order.getRiderId() != null) {
             throw fail("flash.delivery.edit.not.allowed");
@@ -342,22 +365,16 @@ public class FlashDeliveryApplicationService {
     /** 地址修改报价:使用订单保存的运价快照重算,不采用当前运价,避免改地址顺带变价。 */
     private FlashDeliveryQuoteView calculateAddressQuote(FlashDeliveryOrder order,
                                                          FlashDeliveryAddressChangeRequest request) {
-        validateAddress(request.getPickup());
-        validateAddress(request.getDelivery());
-        if (sameAddressText(request.getPickup(), request.getDelivery())
-                || request.getPickup().getLatitude().compareTo(request.getDelivery().getLatitude()) == 0
-                && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
-            throw fail("flash.delivery.address.same");
-        }
-        RouteDistance route = routeService.calculate(
-                new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
-                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
-        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));
+        List<FlashDeliveryPickupRequest> pickups = editablePickups(order, request);
+        validatePickups(pickups, request.getDelivery());
+        RouteDistance route = pickupRoute(pickups, request.getDelivery());
+        // FR-074/093:改址沿用下单快照,只重算整条路线,平台后续调价不影响此单。
+        FlashDeliveryPricing pricing = new FlashDeliveryPricing();
+        pricing.setId(order.getPricingId()); pricing.setConfigVersion(order.getPricingVersion());
+        pricing.setStartTime(order.getPricingStartTime()); pricing.setEndTime(order.getPricingEndTime());
+        pricing.setStartingDistance(order.getStartingDistance()); pricing.setStartingFare(order.getStartingFare());
+        pricing.setDistance(order.getDistance()); pricing.setFreight(order.getFreight());
+        pricing.setUrgentRate(order.getUrgentRate()); pricing.setMinimumUrgentFee(order.getMinimumUrgentFee());
         FlashDeliveryPriceBreakdown breakdown;
         try {
             breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
@@ -371,7 +388,7 @@ public class FlashDeliveryApplicationService {
         quoteRequest.setDeliveryMode(order.getDeliveryMode());
         quoteRequest.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         quoteRequest.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
-        FlashDeliveryQuoteView view = quoteView(quoteRequest, new QuoteContext(pricing, route, breakdown));
+        FlashDeliveryQuoteView view = quoteView(quoteRequest, new QuoteContext(pricing, route, breakdown, pickups));
         view.setOrderVersion(order.getVersion());
         return view;
     }
@@ -379,9 +396,9 @@ public class FlashDeliveryApplicationService {
     /** 用户取消:仅待接单或已接单(实际取件前)允许。 */
     @Transactional
     public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
-        FlashDeliveryOrder order = requireOrder(orderId);
+        FlashDeliveryOrder order = lockOrder(orderId);
         if (!Objects.equals(userId, order.getUserId())) throw fail("flash.delivery.order.not.found");
-        if (!FlashDeliveryStateMachine.canUserCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
+        if (anyPicked(requireStops(order)) || !FlashDeliveryStateMachine.canUserCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
         String reason = requiredReason(request);
         Date now = new Date();
         if (orderMapper.cancel(orderId, order.getStatus(), "USER", userId, reason, now) != 1) {
@@ -393,8 +410,9 @@ public class FlashDeliveryApplicationService {
     /** 用户(寄件人或收件人)确认收货:已送达推进为已完成。 */
     @Transactional
     public void confirmReceipt(Long userId, Long orderId) {
-        FlashDeliveryOrder order = requireOrder(orderId);
+        FlashDeliveryOrder order = lockOrder(orderId);
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
+        if (!allPicked(requireStops(order))) throw fail("flash.delivery.complete.not.allowed");
         Date now = new Date();
         if (orderMapper.transitionByParticipant(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
             throw fail("flash.delivery.complete.not.allowed");
@@ -474,25 +492,37 @@ public class FlashDeliveryApplicationService {
         });
     }
 
-    /** 骑手取件:提交取件凭证,订单由已接单推进为已取件。 */
+    /** 骑手逐站取件:保存本站凭证,仅在全部取货站完成后推进订单为已取件。 */
     @Transactional
     public void pickup(Long riderId, Long orderId, FlashDeliveryProofRequest request) {
-        transitionWithProof(riderId, orderId, request, ACCEPTED, PICKED_UP, "PICKUP", "picked_up_at");
+        pickupStation(riderId, orderId, request == null ? null : request.getStopId(), request);
     }
 
     /** 骑手送达:校验交付 PIN(启用时)并提交送达凭证,订单由已取件推进为已送达。 */
     @Transactional
     public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) {
         requireRider(riderId);
-        FlashDeliveryOrder order = requireOrder(orderId);
+        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");
         if (Boolean.TRUE.equals(order.getPinRequired())
                 && (request == null || !Objects.equals(order.getDeliveryPinCode(), trimToNull(request.getPinCode())))) {
             throw fail("flash.delivery.pin.invalid");
         }
-        transitionWithProof(riderId, orderId, request, PICKED_UP, DELIVERED, "DELIVERY", "delivered_at");
+        FlashDeliveryOrderStop delivery = rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).findFirst().orElseThrow();
+        if (request != null && request.getStopId() != null && !Objects.equals(request.getStopId(), delivery.getId())) throw fail("flash.delivery.stop.invalid");
+        List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
+        Date now = new Date();
+        if (orderMapper.transitionByRider(orderId, riderId, PICKED_UP, DELIVERED, "delivered_at", now) != 1) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
+        delivery.setStatus("DELIVERED"); delivery.setDeliveredAt(now);
+        if (stopMapper.updateById(delivery) != 1) throw fail("flash.delivery.state.changed");
+        saveStationImages(orderId, delivery.getId(), "DELIVERY", "RIDER", riderId, urls, now);
+        writeLog(orderId, PICKED_UP, DELIVERED, "RIDER", riderId, null, now);
     }
 
     /** 平台运价配置列表,按时段开始时间排序。 */
@@ -587,8 +617,16 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryOrder order = requireOrder(orderId);
         FlashDeliveryAdminOrderDetailView view = new FlashDeliveryAdminOrderDetailView();
         view.setOrder(order);
+        List<FlashDeliveryOrderStop> stationRows = stops(orderId);
+        view.setPickupStopCount(order.getPickupStopCount()); view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setCanUserCancel(canUserCancel(order)); view.setCurrentTargetStopId(targetStopId(order, stationRows));
+        view.setTargetAdjustments(adjustmentMapper.selectList(new QueryWrapper<FlashDeliveryTargetAdjustment>()
+                .eq("order_id", orderId).orderByAsc("create_time", "id")));
         view.setImages(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
                 .eq("order_id", orderId).orderByAsc("proof_type", "sort_order")));
+        view.setStops(stopViews(stationRows, true, view.getImages()));
+        view.setHandoffImageUrls(view.getImages().stream().filter(i -> "HANDOFF".equals(i.getProofType()))
+                .map(FlashDeliveryOrderImage::getImageUrl).toList());
         view.setLogs(logMapper.selectList(new QueryWrapper<FlashDeliveryOrderLog>()
                 .eq("order_id", orderId).orderByAsc("create_time", "id")));
         return view;
@@ -597,19 +635,35 @@ public class FlashDeliveryApplicationService {
     /** 平台取消:任何非终态订单可取消,记录操作人与原因,用于异常履约人工收口。 */
     @Transactional
     public void adminCancel(Long adminId, Long orderId, FlashDeliveryReasonRequest request) {
-        FlashDeliveryOrder order = requireOrder(orderId);
+        FlashDeliveryOrder order = lockOrder(orderId);
         if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus())) throw fail("flash.delivery.cancel.not.allowed");
         String reason = requiredReason(request);
-        Date now = new Date();
-        if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) {
-            throw fail("flash.delivery.state.changed");
+        List<FlashDeliveryOrderStop> rows = requireStops(order);
+        if (request.getProblemStopId() == null || !hasText(request.getHandlingResult()) || tooLong(request.getHandlingResult(), 500)) {
+            throw fail("flash.delivery.intervention.required");
+        }
+        requireStop(rows, request.getProblemStopId());
+        boolean collected = anyPicked(rows);
+        if ((collected && !hasText(request.getPickedGoodsDisposition())) || tooLong(request.getPickedGoodsDisposition(), 500)) {
+            throw fail("flash.delivery.handoff.required");
         }
+        List<String> proofs = validateProofUrls(request.getHandoffImageUrls(), collected);
+        Date now = new Date();
+        // 关闭异常订单前保存介入结果和物品去向;凭证写入失败时整笔事务回滚。
+        UpdateWrapper<FlashDeliveryOrder> audit = new UpdateWrapper<FlashDeliveryOrder>().eq("id", orderId)
+                .set("problem_stop_id", request.getProblemStopId()).set("handling_result", request.getHandlingResult().trim())
+                .set("picked_goods_disposition", trimToNull(request.getPickedGoodsDisposition()));
+        if (orderMapper.update(null, audit) != 1) throw fail("flash.delivery.state.changed");
+        saveStationImages(orderId, request.getProblemStopId(), "HANDOFF", "ADMIN", adminId, proofs, now);
+        if (orderMapper.cancel(orderId, order.getStatus(), "ADMIN", adminId, reason, now) != 1) throw fail("flash.delivery.state.changed");
         writeLog(orderId, order.getStatus(), CANCELLED, "ADMIN", adminId, reason, now);
     }
 
     /** 将已送达订单推进为已完成;平台手动与系统自动完成共用此入口,条件更新失败返回 false。 */
     @Transactional
     public boolean complete(Long operatorId, String operatorType, Long orderId) {
+        FlashDeliveryOrder order = lockOrder(orderId);
+        if (!DELIVERED.equals(order.getStatus()) || !allPicked(requireStops(order))) return false;
         Date now = new Date();
         if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false;
         writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now);
@@ -626,30 +680,18 @@ public class FlashDeliveryApplicationService {
         if (request == null) throw fail("flash.delivery.request.required");
         validateServiceType(request.getServiceType());
         validateDeliveryType(request.getDeliveryType());
-        if (!hasText(request.getPackageType()) || !PACKAGE_TYPES.contains(request.getPackageType())) {
-            throw fail("flash.delivery.package.type.invalid");
-        }
-        validateItem(request);
+        List<FlashDeliveryPickupRequest> pickups = normalizePickups(request);
+        validatePickups(pickups, request.getDelivery());
+        if (request.getTipAmount() == null || request.getTipAmount() < 0) throw fail("flash.delivery.tip.invalid");
         Date now = new Date();
         String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
         validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
-        validateAddress(request.getPickup());
-        validateAddress(request.getDelivery());
-        if (sameAddressText(request.getPickup(), request.getDelivery())
-                || request.getPickup().getLatitude().compareTo(request.getDelivery().getLatitude()) == 0
-                && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
-            throw fail("flash.delivery.address.same");
-        }
         Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
         FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
-        // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
-        RouteDistance route = routeService.calculate(
-                new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
-                new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
-        if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
+        RouteDistance route = pickupRoute(pickups, request.getDelivery());
         FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
                 route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
-        return new QuoteContext(pricing, route, breakdown);
+        return new QuoteContext(pricing, route, breakdown, pickups);
     }
 
     /** 查询指定时刻命中的运价配置,未配置时直接报错。 */
@@ -735,19 +777,6 @@ public class FlashDeliveryApplicationService {
         return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
     }
 
-    /** 骑手带凭证的状态推进:校验凭证后按骑手归属条件更新,再落图片与日志。 */
-    private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request,
-                                     String expected, String next, String proofType, String timeColumn) {
-        List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
-        requireRider(riderId);
-        Date now = new Date();
-        if (orderMapper.transitionByRider(orderId, riderId, expected, next, timeColumn, now) != 1) {
-            throw fail("flash.delivery.transition.not.allowed");
-        }
-        saveImages(orderId, proofType, "RIDER", riderId, urls, now);
-        writeLog(orderId, expected, next, "RIDER", riderId, null, now);
-    }
-
     /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */
     private void saveImages(Long orderId, String proofType, String operatorType,
                             Long operatorId, List<String> urls, Date now) {
@@ -792,14 +821,22 @@ public class FlashDeliveryApplicationService {
                                                            boolean userView,
                                                            boolean hideSensitiveFields) {
         FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields);
+        if (!hideSensitiveFields) view.setOrderVersion(order.getVersion());
+        List<FlashDeliveryOrderStop> stationRows = stops(order.getId());
+        List<FlashDeliveryOrderImage> stationImages = hideSensitiveFields ? List.of() : imageMapper.selectList(currentRead(
+                new QueryWrapper<FlashDeliveryOrderImage>().eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
+        view.setStops(stopViews(stationRows, !hideSensitiveFields, stationImages));
+        view.setCurrentTargetStopId(targetStopId(order, stationRows));
+        if (!hideSensitiveFields) {
+            view.setTargetAdjustments(adjustmentMapper.selectList(currentRead(new QueryWrapper<FlashDeliveryTargetAdjustment>()
+                    .eq("order_id", order.getId()).orderByAsc("create_time", "id"))));
+        }
         if (userView) {
-            view.setOrderVersion(order.getVersion());
             view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
             view.setRider(riderSummary(order));
         }
         if (!hideSensitiveFields && order.getId() != null) {
-            groupImageUrls(view, imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
-                    .eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
+            groupImageUrls(view, stationImages);
         }
         return view;
     }
@@ -812,6 +849,9 @@ public class FlashDeliveryApplicationService {
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
         view.setStatus(order.getStatus());
+        view.setPickupStopCount(order.getPickupStopCount());
+        view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setCanUserCancel(canUserCancel(order));
         view.setOrderVersion(order.getVersion());
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
@@ -843,6 +883,9 @@ public class FlashDeliveryApplicationService {
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
         view.setStatus(order.getStatus());
+        view.setPickupStopCount(order.getPickupStopCount());
+        view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setCanUserCancel(canUserCancel(order));
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
         view.setWeightRange(order.getWeightRange());
@@ -918,6 +961,9 @@ public class FlashDeliveryApplicationService {
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
         view.setStatus(order.getStatus());
+        view.setPickupStopCount(order.getPickupStopCount());
+        view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setCanUserCancel(canUserCancel(order));
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
         view.setWeightRange(order.getWeightRange());
@@ -1099,18 +1145,6 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    /** 校验物品信息:数量、重量范围、规格长度和小费。 */
-    private void validateItem(FlashDeliveryQuoteRequest request) {
-        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");
-        }
-        if (request.getTipAmount() == null || request.getTipAmount() < 0) {
-            throw fail("flash.delivery.tip.invalid");
-        }
-    }
-
     /** 支付方式与主订单系统同域(4=现金、6=线下转账);闪送款项直达骑手,仅允许线下方式,缺省现金,下单后锁定。 */
     private String normalizePayType(String payType) {
         if (!hasText(payType)) return PAY_TYPE_CASH;
@@ -1292,7 +1326,298 @@ public class FlashDeliveryApplicationService {
     private String normalized(String value) { return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); }
     private ServiceException fail(String key) { return new ServiceException(MessageUtils.message(key)); }
 
+    /** 将旧单点输入适配为完整取货站;多点请求必须逐站申报物品。 */
+    private List<FlashDeliveryPickupRequest> normalizePickups(FlashDeliveryQuoteRequest request) {
+        if (request.getPickups() != null) {
+            if (request.getPickup() != null || request.getPackageType() != null || request.getQuantity() != null
+                    || request.getWeightRange() != null || request.getSpecification() != null) {
+                throw fail("flash.delivery.pickup.input.conflict");
+            }
+            return request.getPickups();
+        }
+        FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
+        if (request.getPickup() == null) throw fail("flash.delivery.address.invalid");
+        BeanUtils.copyProperties(request.getPickup(), pickup);
+        pickup.setPackageType(request.getPackageType());
+        pickup.setQuantity(request.getQuantity());
+        pickup.setWeightRange(request.getWeightRange());
+        pickup.setSpecification(request.getSpecification());
+        return List.of(pickup);
+    }
+
+    /** 编辑提交完整站点;旧单点地址编辑保留该站原有物品信息。 */
+    private List<FlashDeliveryPickupRequest> editablePickups(FlashDeliveryOrder order,
+                                                           FlashDeliveryAddressChangeRequest request) {
+        List<FlashDeliveryOrderStop> existing = requireStops(order);
+        if (request.getPickups() != null) {
+            if (request.getPickup() != null) throw fail("flash.delivery.pickup.input.conflict");
+            return request.getPickups();
+        }
+        List<FlashDeliveryOrderStop> pickups = pickupStops(existing);
+        if (pickups.size() != 1) throw fail("flash.delivery.pickups.required");
+        if (request.getPickup() == null) throw fail("flash.delivery.address.invalid");
+        FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
+        BeanUtils.copyProperties(pickups.get(0), pickup);
+        BeanUtils.copyProperties(request.getPickup(), pickup);
+        return List.of(pickup);
+    }
+
+    /** 地图调用前先校验有效点数、开关及每站地址和物品。 */
+    private void validatePickups(List<FlashDeliveryPickupRequest> pickups, FlashDeliveryAddressRequest delivery) {
+        if (pickups == null || pickups.isEmpty() || pickups.size() > pickupStopLimit()) {
+            throw fail("flash.delivery.pickup.limit.exceeded");
+        }
+        if (pickups.size() > 1 && !dictionaryEnabled("flash_show_multi_pickup")) {
+            throw fail("flash.delivery.multi.pickup.disabled");
+        }
+        validateAddress(delivery);
+        for (FlashDeliveryPickupRequest pickup : pickups) {
+            validateAddress(pickup);
+            if (!PACKAGE_TYPES.contains(Objects.toString(pickup.getPackageType(), ""))) {
+                throw fail("flash.delivery.package.type.invalid");
+            }
+            if (pickup.getQuantity() == null || pickup.getQuantity() <= 0
+                    || !WEIGHT_RANGES.contains(Objects.toString(pickup.getWeightRange(), ""))
+                    || tooLong(pickup.getSpecification(), 255)) throw fail("flash.delivery.item.invalid");
+            if (sameAddressText(pickup, delivery)
+                    || pickup.getLatitude().compareTo(delivery.getLatitude()) == 0
+                    && pickup.getLongitude().compareTo(delivery.getLongitude()) == 0) {
+                throw fail("flash.delivery.address.same");
+            }
+        }
+    }
+
+    private RouteDistance pickupRoute(List<FlashDeliveryPickupRequest> pickups, FlashDeliveryAddressRequest delivery) {
+        List<GeoPoint> points = new ArrayList<>();
+        for (FlashDeliveryPickupRequest pickup : pickups) points.add(new GeoPoint(pickup.getLatitude(), pickup.getLongitude()));
+        points.add(new GeoPoint(delivery.getLatitude(), delivery.getLongitude()));
+        RouteDistance route = points.size() == 2 ? routeService.calculate(points.get(0), points.get(1)) : routeService.calculate(points);
+        if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
+        return route;
+    }
+
+    /** 点数配置缺失、非整数或越界时,有效上限回退为1。 */
+    private int pickupStopLimit() {
+        String value = dictionaryValue("flash_pickup_stop_limit");
+        if (value == null || !value.matches("[0-9]+")) return 1;
+        try {
+            int limit = Integer.parseInt(value);
+            return limit >= 1 && limit <= 26 ? limit : 1;
+        } catch (NumberFormatException ignored) { return 1; }
+    }
+
+    private boolean dictionaryEnabled(String key) { return "1".equals(dictionaryValue(key)); }
+
+    private String dictionaryValue(String key) {
+        try {
+            List<SysDictData> values = DictUtils.getDictCache(key);
+            return values == null || values.isEmpty() ? null : values.get(0).getDictValue();
+        } catch (Exception ignored) { return null; }
+    }
+
+    /** 订单旧字段仅维护首站兼容摘要,不代表全部站点物品。 */
+    private void copyPickupSummary(FlashDeliveryOrder order, List<FlashDeliveryPickupRequest> pickups) {
+        FlashDeliveryPickupRequest first = pickups.get(0);
+        copyAddress(first, order, true);
+        order.setPackageType(first.getPackageType()); order.setQuantity(first.getQuantity());
+        order.setWeightRange(first.getWeightRange()); order.setSpecification(trimToNull(first.getSpecification()));
+        order.setPickupStopCount(pickups.size());
+    }
+
+    /** 调用方已创建订单或持有订单行锁,全部站点与订单在同一事务保存。 */
+    private void saveStops(FlashDeliveryOrder order, List<FlashDeliveryPickupRequest> pickups,
+                           FlashDeliveryAddressRequest delivery, Date now) {
+        for (int i = 0; i < pickups.size(); i++) insertStop(order.getId(), pickups.get(i), "PICKUP", i, now);
+        insertStop(order.getId(), delivery, "DELIVERY", pickups.size(), now);
+    }
+
+    private void insertStop(Long orderId, FlashDeliveryAddressRequest address, String type, int index, Date now) {
+        FlashDeliveryOrderStop stop = new FlashDeliveryOrderStop();
+        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.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");
+    }
+
+    /** 事务内使用当前读,避免骑手资料查询建立的旧快照遮蔽锁等待期间的履约变化;GET 不加锁。 */
+    private <T> QueryWrapper<T> currentRead(QueryWrapper<T> query) {
+        if (TransactionSynchronizationManager.isActualTransactionActive()) {
+            query.last("FOR UPDATE");
+        }
+        return query;
+    }
+
+    private List<FlashDeliveryOrderStop> stops(Long orderId) {
+        return stopMapper.selectList(currentRead(new QueryWrapper<FlashDeliveryOrderStop>()
+                .eq("order_id", orderId).orderByAsc("stop_order", "id")));
+    }
+
+    /** 履约必须有真实站点记录,不从旧测试订单的摘要字段合成站点。 */
+    private List<FlashDeliveryOrderStop> requireStops(FlashDeliveryOrder order) {
+        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) {
+            throw fail("flash.delivery.stops.invalid");
+        }
+        return rows;
+    }
+
+    private List<FlashDeliveryOrderStop> pickupStops(List<FlashDeliveryOrderStop> rows) {
+        return rows.stream().filter(s -> "PICKUP".equals(s.getStopType())).toList();
+    }
+
+    private boolean anyPicked(List<FlashDeliveryOrderStop> rows) {
+        return rows.stream().anyMatch(s -> "PICKUP".equals(s.getStopType()) && "PICKED_UP".equals(s.getStatus()));
+    }
+
+    private boolean allPicked(List<FlashDeliveryOrderStop> rows) {
+        List<FlashDeliveryOrderStop> pickups = pickupStops(rows);
+        return !pickups.isEmpty() && pickups.stream().allMatch(s -> "PICKED_UP".equals(s.getStatus()));
+    }
+
+    /** 选择的目标在刷新后保留;该站取货后回到原顺序最早的待取站点。 */
+    private Long targetStopId(FlashDeliveryOrder order, List<FlashDeliveryOrderStop> rows) {
+        if (CANCELLED.equals(order.getStatus()) || COMPLETED.equals(order.getStatus()) || DELIVERED.equals(order.getStatus())) return null;
+        if (order.getCurrentTargetStopId() != null && rows.stream().anyMatch(s ->
+                Objects.equals(s.getId(), order.getCurrentTargetStopId()) && "PICKUP".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))) {
+            return order.getCurrentTargetStopId();
+        }
+        return rows.stream().filter(s -> "PICKUP".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))
+                .map(FlashDeliveryOrderStop::getId).findFirst().orElseGet(() -> rows.stream()
+                        .filter(s -> "DELIVERY".equals(s.getStopType())).map(FlashDeliveryOrderStop::getId).findFirst().orElse(null));
+    }
+
+    /** 事务内先锁订单并读取最新状态,再查询站点,统一取货、调整与取消的并发边界。 */
+    private FlashDeliveryOrder lockOrder(Long orderId) {
+        FlashDeliveryOrder order = orderId == null ? null : orderMapper.selectByIdForUpdate(orderId);
+        if (order == null) throw fail("flash.delivery.order.not.found");
+        return order;
+    }
+
+    private FlashDeliveryOrderStop requireStop(List<FlashDeliveryOrderStop> rows, Long stopId) {
+        return rows.stream().filter(s -> Objects.equals(s.getId(), stopId)).findFirst()
+                .orElseThrow(() -> fail("flash.delivery.stop.invalid"));
+    }
+
+    /** 选择前往目标只记录履约调整,不修改取件进度、金额或用户站点顺序。 */
+    @Transactional
+    public FlashDeliveryOrderDetailView selectTarget(Long riderId, Long orderId, Long stopId,
+                                                     FlashDeliveryTargetRequest request) {
+        requireRider(riderId);
+        FlashDeliveryOrder order = lockOrder(orderId);
+        if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
+        if (request == null || !hasText(request.getClientRequestId()) || tooLong(request.getClientRequestId(), 64)) {
+            throw fail("flash.delivery.client.request.id.invalid");
+        }
+        String requestId = request.getClientRequestId().trim();
+        FlashDeliveryTargetAdjustment previous = adjustmentMapper.selectOne(new QueryWrapper<FlashDeliveryTargetAdjustment>()
+                .eq("order_id", orderId).eq("rider_id", riderId).eq("client_request_id", requestId).last("FOR UPDATE"));
+        if (previous != null) {
+            if (!Objects.equals(previous.getToStopId(), stopId)
+                    || !Objects.equals(previous.getOrderVersion(), request.getOrderVersion())
+                    || !Objects.equals(previous.getReason(), trimToNull(request.getReason()))
+                    || !Objects.equals(previous.getConsentDeclared(), Boolean.TRUE.equals(request.getConsentDeclared()))) {
+                throw fail("flash.delivery.target.request.conflict");
+            }
+            // 重试返回当前进度,不把已过期的历史目标重新写回订单。
+            return participantDetail(order, false, false);
+        }
+        if (!ACCEPTED.equals(order.getStatus())) throw fail("flash.delivery.transition.not.allowed");
+        if (request.getOrderVersion() == null || !Objects.equals(request.getOrderVersion(), order.getVersion())) {
+            throw fail("flash.delivery.state.changed");
+        }
+        List<FlashDeliveryOrderStop> rows = requireStops(order);
+        FlashDeliveryOrderStop target = requireStop(rows, stopId);
+        if (!"PICKUP".equals(target.getStopType()) || !"PENDING".equals(target.getStatus())) throw fail("flash.delivery.stop.invalid");
+        Long before = targetStopId(order, rows);
+        if (!Objects.equals(before, stopId) && (!hasText(request.getReason()) || tooLong(request.getReason(), 500)
+                || !Boolean.TRUE.equals(request.getConsentDeclared()))) throw fail("flash.delivery.target.consent.required");
+        if (tooLong(request.getReason(), 500)) throw fail("flash.delivery.target.consent.required");
+        Date now = new Date();
+        FlashDeliveryTargetAdjustment adjustment = new FlashDeliveryTargetAdjustment();
+        adjustment.setOrderId(orderId); adjustment.setFromStopId(before); adjustment.setToStopId(stopId);
+        adjustment.setRiderId(riderId); adjustment.setClientRequestId(requestId); adjustment.setOrderVersion(request.getOrderVersion());
+        adjustment.setReason(trimToNull(request.getReason())); adjustment.setConsentDeclared(Boolean.TRUE.equals(request.getConsentDeclared())); adjustment.setCreateTime(now);
+        if (adjustmentMapper.insert(adjustment) != 1) throw fail("flash.delivery.state.changed");
+        order.setCurrentTargetStopId(stopId); order.setUpdateTime(now);
+        if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
+        order.setVersion(order.getVersion() + 1);
+        return participantDetail(order, false, false);
+    }
+
+    /** 两种取货接口共用同一加锁流程,路径与请求体中的站点编号必须一致。 */
+    @Transactional
+    public void pickupAtStop(Long riderId, Long orderId, Long stopId, FlashDeliveryProofRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        if (request.getStopId() != null && !Objects.equals(request.getStopId(), stopId)) throw fail("flash.delivery.stop.invalid");
+        pickupStation(riderId, orderId, stopId, request);
+    }
+
+    private void pickupStation(Long riderId, Long orderId, Long requestedStopId, FlashDeliveryProofRequest request) {
+        requireRider(riderId);
+        FlashDeliveryOrder order = lockOrder(orderId);
+        if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
+        List<FlashDeliveryOrderStop> rows = requireStops(order);
+        List<FlashDeliveryOrderStop> pickups = pickupStops(rows);
+        Long stopId = requestedStopId;
+        if (stopId == null) {
+            if (pickups.size() != 1) throw fail("flash.delivery.stop.id.required");
+            stopId = pickups.get(0).getId();
+        }
+        FlashDeliveryOrderStop stop = requireStop(rows, stopId);
+        if (!"PICKUP".equals(stop.getStopType())) throw fail("flash.delivery.stop.invalid");
+        // 已确认站点的重试直接成功,不因当前目标或主状态已变化而重复写凭证。
+        if ("PICKED_UP".equals(stop.getStatus())) return;
+        if (!ACCEPTED.equals(order.getStatus())) throw fail("flash.delivery.transition.not.allowed");
+        if (!Objects.equals(stopId, targetStopId(order, rows))) throw fail("flash.delivery.target.mismatch");
+        List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
+        Date now = new Date();
+        stop.setStatus("PICKED_UP"); stop.setPickedUpAt(now);
+        if (stopMapper.updateById(stop) != 1) throw fail("flash.delivery.state.changed");
+        saveStationImages(orderId, stopId, "PICKUP", "RIDER", riderId, urls, now);
+        order.setPickedUpStopCount((int) pickups.stream().filter(s -> "PICKED_UP".equals(s.getStatus())).count());
+        order.setCurrentTargetStopId(null); order.setUpdateTime(now);
+        if (allPicked(rows)) { order.setStatus(PICKED_UP); order.setPickedUpAt(now); }
+        if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
+        writeLog(orderId, ACCEPTED, order.getStatus(), "RIDER", riderId, "PICKUP_STOP:" + stopId, now);
+    }
+
+    private void saveStationImages(Long orderId, Long stopId, String proofType, String operatorType,
+                                   Long operatorId, List<String> urls, Date now) {
+        for (int index = 0; index < urls.size(); index++) {
+            FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
+            image.setOrderId(orderId); image.setStopId(stopId); image.setProofType(proofType);
+            image.setOperatorType(operatorType); image.setOperatorId(operatorId); image.setImageUrl(urls.get(index));
+            image.setSortOrder(index); image.setCreateTime(now);
+            if (imageMapper.insert(image) != 1) throw fail("flash.delivery.state.changed");
+        }
+    }
+
+    private List<FlashDeliveryStopView> stopViews(List<FlashDeliveryOrderStop> rows, boolean sensitive,
+                                                 List<FlashDeliveryOrderImage> images) {
+        return rows.stream().map(stop -> {
+            FlashDeliveryStopView view = new FlashDeliveryStopView();
+            BeanUtils.copyProperties(stop, view);
+            if (sensitive) view.setImageUrls(images.stream().filter(i -> Objects.equals(i.getStopId(), stop.getId())
+                            && Objects.equals(i.getProofType(), stop.getStopType()))
+                    .map(FlashDeliveryOrderImage::getImageUrl).toList());
+            else { view.setName(null); view.setPhone(null); view.setLongitude(null); view.setLatitude(null); }
+            return view;
+        }).toList();
+    }
+
+    private boolean canUserCancel(FlashDeliveryOrder order) {
+        return FlashDeliveryStateMachine.canUserCancel(order.getStatus())
+                && Integer.valueOf(0).equals(order.getPickedUpStopCount());
+    }
+
+
     /** 报价中间结果:命中的运价配置、路线距离和费用明细。 */
     private record QuoteContext(FlashDeliveryPricing pricing, RouteDistance route,
-                                FlashDeliveryPriceBreakdown breakdown) { }
+                                FlashDeliveryPriceBreakdown breakdown, List<FlashDeliveryPickupRequest> pickups) { }
 }

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

@@ -336,3 +336,17 @@ flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến
+
+# Multi-pickup station validation and custody audit
+flash.delivery.pickup.input.conflict=取货点格式或旧物品字段不可同时提交。
+flash.delivery.pickups.required=请提交全部取货点及各站物品信息。
+flash.delivery.pickup.limit.exceeded=取货点数量超过有效上限。
+flash.delivery.multi.pickup.disabled=暂未开放多个取货点。
+flash.delivery.stops.invalid=订单站点资料不完整。
+flash.delivery.stop.invalid=该站点不属于本订单或不适用于此操作。
+flash.delivery.target.request.conflict=该请求编号已用于不同的目标调整。
+flash.delivery.target.consent.required=请填写调整原因,并声明已取得下单人同意。
+flash.delivery.target.mismatch=请在当前目标站取货;变更目标需先保存沟通同意记录。
+flash.delivery.stop.id.required=多取货点订单必须指定取货站点编号。
+flash.delivery.intervention.required=请选择问题站点并记录平台处理结果。
+flash.delivery.handoff.required=请记录已取物品去向及交接凭证。

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

@@ -339,3 +339,17 @@ flash.delivery.edit.not.allowed=Only orders awaiting acceptance with no assigned
 flash.delivery.order.version.invalid=Please provide a valid order version
 flash.delivery.tip.additional.invalid=Additional tip must be a positive integer within the supported amount range
 flash.delivery.pay.type.invalid=Payment method must be cash or offline transfer
+
+# Multi-pickup station validation and custody audit
+flash.delivery.pickup.input.conflict=Do not combine pickup formats or legacy item fields.
+flash.delivery.pickups.required=Submit every pickup with its item details.
+flash.delivery.pickup.limit.exceeded=Pickup count exceeds the configured limit.
+flash.delivery.multi.pickup.disabled=Multiple pickups are not enabled.
+flash.delivery.stops.invalid=Order station data is incomplete.
+flash.delivery.stop.invalid=This station is not a valid target for this order.
+flash.delivery.target.request.conflict=This request ID was already used with different target details.
+flash.delivery.target.consent.required=Enter a reason and declare that the sender agreed before changing the target.
+flash.delivery.target.mismatch=Confirm pickup at the current target; save an agreed target change first.
+flash.delivery.stop.id.required=A pickup station ID is required for this order.
+flash.delivery.intervention.required=Select the problem station and record the intervention result.
+flash.delivery.handoff.required=Record the destination of collected goods and handoff evidence.

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

@@ -340,3 +340,17 @@ flash.delivery.edit.not.allowed=แก้ไขได้เฉพาะคำส
 flash.delivery.order.version.invalid=กรุณาระบุเวอร์ชันคำสั่งซื้อที่ถูกต้อง
 flash.delivery.tip.additional.invalid=ทิปเพิ่มเติมต้องเป็นจำนวนเต็มบวกและไม่เกินวงเงินที่รองรับ
 flash.delivery.pay.type.invalid=รองรับเฉพาะการชำระเงินสดหรือโอนแบบออฟไลน์
+
+# Multi-pickup station validation and custody audit
+flash.delivery.pickup.input.conflict=ห้ามส่งรูปแบบจุดรับหรือข้อมูลสิ่งของแบบเดิมพร้อมกัน
+flash.delivery.pickups.required=กรุณาส่งจุดรับทั้งหมดพร้อมข้อมูลสิ่งของแต่ละจุด
+flash.delivery.pickup.limit.exceeded=จำนวนจุดรับเกินขีดจำกัดที่กำหนด
+flash.delivery.multi.pickup.disabled=ยังไม่เปิดใช้การรับสินค้าหลายจุด
+flash.delivery.stops.invalid=ข้อมูลจุดแวะของคำสั่งซื้อไม่ครบถ้วน
+flash.delivery.stop.invalid=จุดนี้ไม่อยู่ในคำสั่งซื้อหรือใช้กับการดำเนินการนี้ไม่ได้
+flash.delivery.target.request.conflict=รหัสคำขอนี้ถูกใช้กับรายละเอียดการเปลี่ยนเป้าหมายอื่นแล้ว
+flash.delivery.target.consent.required=กรอกเหตุผลและยืนยันว่าได้รับความยินยอมจากผู้สั่งก่อนเปลี่ยนเป้าหมาย
+flash.delivery.target.mismatch=ยืนยันรับสินค้าที่เป้าหมายปัจจุบัน โดยบันทึกการเปลี่ยนที่ได้รับความยินยอมก่อน
+flash.delivery.stop.id.required=คำสั่งซื้อหลายจุดต้องระบุรหัสจุดรับสินค้า
+flash.delivery.intervention.required=เลือกจุดที่มีปัญหาและบันทึกผลการจัดการของแพลตฟอร์ม
+flash.delivery.handoff.required=บันทึกปลายทางของสินค้าที่รับแล้วและหลักฐานส่งมอบ

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

@@ -339,3 +339,17 @@ flash.delivery.edit.not.allowed=Chỉ có thể sửa đơn đang chờ nhận v
 flash.delivery.order.version.invalid=Vui lòng cung cấp phiên bản đơn hàng hợp lệ
 flash.delivery.tip.additional.invalid=Tiền boa thêm phải là số nguyên dương trong phạm vi số tiền được hỗ trợ
 flash.delivery.pay.type.invalid=Chiỉ hỗ trợ thanh toán bằng tiền mặt hoặc chuyển khoản ngoại tuyến
+
+# Multi-pickup station validation and custody audit
+flash.delivery.pickup.input.conflict=Không gửi đồng thời các định dạng điểm lấy hàng hoặc trường hàng hóa cũ.
+flash.delivery.pickups.required=Vui lòng gửi toàn bộ điểm lấy hàng và thông tin hàng hóa từng điểm.
+flash.delivery.pickup.limit.exceeded=Số điểm lấy hàng vượt giới hạn được cấu hình.
+flash.delivery.multi.pickup.disabled=Chức năng nhiều điểm lấy hàng chưa được bật.
+flash.delivery.stops.invalid=Dữ liệu điểm dừng của đơn hàng chưa đầy đủ.
+flash.delivery.stop.invalid=Điểm dừng không thuộc đơn hàng hoặc không phù hợp với thao tác này.
+flash.delivery.target.request.conflict=Mã yêu cầu này đã được dùng với nội dung đổi điểm khác.
+flash.delivery.target.consent.required=Nhập lý do và xác nhận đã được người đặt hàng đồng ý trước khi đổi điểm.
+flash.delivery.target.mismatch=Chỉ xác nhận lấy hàng tại điểm đích hiện tại; cần lưu việc đổi điểm đã được đồng ý trước.
+flash.delivery.stop.id.required=Đơn nhiều điểm lấy hàng phải chỉ định mã điểm lấy hàng.
+flash.delivery.intervention.required=Chọn điểm gặp sự cố và ghi kết quả xử lý của nền tảng.
+flash.delivery.handoff.required=Ghi nơi chuyển hàng đã lấy và bằng chứng bàn giao.

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

@@ -340,3 +340,17 @@ flash.delivery.edit.not.allowed=仅待接单且未分配骑手的订单可以修
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=支付方式仅支持现金或线下转账
+
+# Multi-pickup station validation and custody audit
+flash.delivery.pickup.input.conflict=取货点格式或旧物品字段不可同时提交。
+flash.delivery.pickups.required=请提交全部取货点及各站物品信息。
+flash.delivery.pickup.limit.exceeded=取货点数量超过有效上限。
+flash.delivery.multi.pickup.disabled=暂未开放多个取货点。
+flash.delivery.stops.invalid=订单站点资料不完整。
+flash.delivery.stop.invalid=该站点不属于本订单或不适用于此操作。
+flash.delivery.target.request.conflict=该请求编号已用于不同的目标调整。
+flash.delivery.target.consent.required=请填写调整原因,并声明已取得下单人同意。
+flash.delivery.target.mismatch=请在当前目标站取货;变更目标需先保存沟通同意记录。
+flash.delivery.stop.id.required=多取货点订单必须指定取货站点编号。
+flash.delivery.intervention.required=请选择问题站点并记录平台处理结果。
+flash.delivery.handoff.required=请记录已取物品去向及交接凭证。

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

@@ -340,3 +340,17 @@ flash.delivery.edit.not.allowed=僅待接單且未分配騎手的訂單可以修
 flash.delivery.order.version.invalid=請提供有效的訂單版本
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍
 flash.delivery.pay.type.invalid=支付方式僅支援現金或線下轉帳
+
+# Multi-pickup station validation and custody audit
+flash.delivery.pickup.input.conflict=取貨點格式或舊物品欄位不可同時提交。
+flash.delivery.pickups.required=請提交全部取貨點及各站物品資訊。
+flash.delivery.pickup.limit.exceeded=取貨點數量超過有效上限。
+flash.delivery.multi.pickup.disabled=暫未開放多個取貨點。
+flash.delivery.stops.invalid=訂單站點資料不完整。
+flash.delivery.stop.invalid=該站點不屬於本訂單或不適用於此操作。
+flash.delivery.target.request.conflict=該請求編號已用於不同的目標調整。
+flash.delivery.target.consent.required=請填寫調整原因,並聲明已取得下單人同意。
+flash.delivery.target.mismatch=請在目前目標站取貨;變更目標須先儲存溝通同意紀錄。
+flash.delivery.stop.id.required=多取貨點訂單必須指定取貨站點編號。
+flash.delivery.intervention.required=請選擇問題站點並記錄平台處理結果。
+flash.delivery.handoff.required=請記錄已取物品去向及交接憑證。

+ 13 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java

@@ -37,6 +37,19 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 class FlashDeliveryControllerContractTest {
+    @Test
+    void perStopFulfilmentUsesTokenExplicitPathsAndBodyDtos() throws Exception {
+        for (Method method : java.util.List.of(
+                FlashDeliveryRiderController.class.getMethod("pickupStop", String.class, Long.class, Long.class, FlashDeliveryProofRequest.class),
+                FlashDeliveryRiderController.class.getMethod("target", String.class, Long.class, Long.class,
+                        com.ruoyi.app.flashdelivery.dto.FlashDeliveryTargetRequest.class))) {
+            assertNotNull(method.getAnnotation(Auth.class));
+            assertNotNull(method.getParameters()[0].getAnnotation(RequestHeader.class));
+            assertNotNull(method.getParameters()[1].getAnnotation(org.springframework.web.bind.annotation.PathVariable.class));
+            assertNotNull(method.getParameters()[2].getAnnotation(org.springframework.web.bind.annotation.PathVariable.class));
+            assertNotNull(method.getParameters()[3].getAnnotation(RequestBody.class));
+        }
+    }
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
     @BeforeAll

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

@@ -15,7 +15,7 @@ class FlashDeliveryRiderOrderListViewTest {
                 "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
                 "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
                 "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
-                "estimatedDurationSeconds", "amount", "currency", "deliveredAt", "createTime");
+                "estimatedDurationSeconds", "amount", "currency", "deliveredAt", "createTime", "orderVersion", "pickupStopCount", "pickedUpStopCount", "canUserCancel");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
 
@@ -29,7 +29,7 @@ class FlashDeliveryRiderOrderListViewTest {
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",
                 "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",
                 "estimatedDurationSeconds", "baseDeliveryFee", "distanceFee", "urgentFee",
-                "tipAmount", "amount", "currency", "createTime");
+                "tipAmount", "amount", "currency", "createTime", "payType", "pickupStopCount", "pickedUpStopCount", "canUserCancel");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
 

+ 72 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java

@@ -3,12 +3,84 @@ package com.ruoyi.app.flashdelivery.route;
 import org.junit.jupiter.api.Test;
 
 import java.math.BigDecimal;
+import java.util.List;
+import java.util.Collections;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 class FlashDeliveryRouteServiceTest {
 
+    @Test
+    void mapReceivesTheCompleteOrderedRouteInOneCall() {
+        List<GeoPoint> points = List.of(point("25", "121"), point("25.1", "121.1"),
+                point("25.2", "121.2"));
+        RouteDistanceProvider provider = mock(RouteDistanceProvider.class);
+        when(provider.route(points)).thenReturn(new RouteDistance(32100, 2200, "ROUTE"));
+        assertEquals(new RouteDistance(32100, 2200, "ROUTE"),
+                new FlashDeliveryRouteService(provider).calculate(points));
+        verify(provider).route(points);
+        verifyNoMoreInteractions(provider);
+    }
+
+    @Test
+    void googleRequestPreservesUserOrderAndTwentyFiveIntermediates() {
+        List<GeoPoint> points = new java.util.ArrayList<>();
+        for (int i = 0; i < 27; i++) points.add(point("25", "121." + String.format("%03d", i)));
+        var body = new GoogleRoutesDistanceProvider().requestBody(points);
+        assertEquals(false, body.getBoolean("optimizeWaypointOrder"));
+        assertEquals(25, body.getJSONArray("intermediates").size());
+        for (int i = 0; i < 25; i++) {
+            assertEquals(points.get(i + 1).longitude(), body.getJSONArray("intermediates")
+                    .getJSONObject(i).getJSONObject("location").getJSONObject("latLng").getBigDecimal("longitude"));
+        }
+        assertEquals(points.get(26).longitude(), body.getJSONObject("destination")
+                .getJSONObject("location").getJSONObject("latLng").getBigDecimal("longitude"));
+        var single = new GoogleRoutesDistanceProvider().requestBody(points.subList(0, 2));
+        assertEquals(null, single.get("intermediates"));
+        assertThrows(IllegalArgumentException.class,
+                () -> new GoogleRoutesDistanceProvider().requestBody(Collections.nCopies(28, points.get(0))));
+    }
+
+    @Test
+    void multiStopFallbackIncludesTheMiddleStopInsteadOfTakingAShortcut() {
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService((a, b, c, d) -> {
+            throw new IllegalStateException("offline");
+        });
+        GeoPoint a = point("25.0", "121.0");
+        GeoPoint b = point("25.1", "121.0");
+        GeoPoint c = point("25.0", "121.001");
+        RouteDistance route = service.calculate(List.of(a, b, c));
+        assertEquals("STRAIGHT_LINE", route.source());
+        assertTrue(route.distanceMeters() > 22000 && route.distanceMeters() < 22400);
+    }
+
+    @Test
+    void multiStopRejectsInvalidCountsAndMiddleCoordinatesBeforeCallingMap() {
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService((a, b, c, d) -> {
+            throw new AssertionError("invalid request reached map");
+        });
+        GeoPoint point = point("25", "121");
+        assertThrows(IllegalArgumentException.class, () -> service.calculate(List.of(point)));
+        assertThrows(IllegalArgumentException.class, () -> service.calculate(Collections.nCopies(28, point)));
+        assertThrows(IllegalArgumentException.class,
+                () -> service.calculate(List.of(point, point("91", "121"), point)));
+    }
+
+    @Test
+    void twentySevenPointsAndDuplicatePickupCoordinatesAreAccepted() {
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService((a, b, c, d) -> {
+            throw new IllegalStateException("offline");
+        });
+        RouteDistance route = service.calculate(Collections.nCopies(27, point("25", "121")));
+        assertEquals(1, route.distanceMeters());
+    }
+
     @Test
     void keepsDrivingRouteWhenProviderSucceeds() {
         RouteDistanceProvider provider = (a, b, c, d) -> new RouteDistance(5210, 780, "ROUTE");

+ 31 - 6
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -1046,6 +1046,7 @@ class FlashDeliveryApplicationServiceTest {
     private FlashDeliveryOrder order(Long id, String status) {
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         order.setId(id);
+        order.setPickupStopCount(1); order.setPickedUpStopCount(0); order.setVersion(0);
         order.setOrderNo("FL-20260813-021");
         order.setUserId(7L);
         order.setServiceType("HELP_SEND");
@@ -1111,10 +1112,10 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void addressQuoteUsesLatestPricingForSlotTimeAndDoesNotWrite() {
+    void addressQuoteUsesOriginalPricingSnapshotAndDoesNotWrite() {
         Fixture fixture = new Fixture();
         FlashDeliveryOrder order = editableOrder(fixture);
-        // 已到预约时间也允许待接单订单改地址;报价读取该时点的最新运价而非订单旧快照。
+        // FR-074/093:预约单也保留原运价快照,平台调价不得改变旧单的计价参数。
         order.setDeliveryMode("SCHEDULED");
         order.setScheduledPickupStartAt(new Date(1));
         order.setScheduledPickupEndAt(new Date(1800001));
@@ -1128,8 +1129,8 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(2, quote.getOrderVersion());
         assertEquals(1, quote.getPricingVersion());
         assertEquals("pickup road", order.getPickupAddress());
-        // 改址报价必须查询运价表(按定价时点取最新),不再沿用订单快照
-        verify(fixture.pricingMapper).selectAtTime(anyString());
+        // 地址变化只重算路线,不查询最新运价。
+        verify(fixture.pricingMapper, never()).selectAtTime(anyString());
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
         verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
     }
@@ -1360,7 +1361,7 @@ class FlashDeliveryApplicationServiceTest {
         order.setFreight(15L);
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
-        // 031 后改址按订单定价时点读最新运价:stub 与订单快照同值,金额断言维持不变
+        // 后台现价与快照不同;改址仍以订单保存的价格计算。
         when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         return order;
     }
@@ -1396,6 +1397,8 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     private static class Fixture {
+        final com.ruoyi.system.mapper.flash.FlashDeliveryOrderStopMapper stopMapper = mock(com.ruoyi.system.mapper.flash.FlashDeliveryOrderStopMapper.class);
+        final com.ruoyi.system.mapper.flash.FlashDeliveryTargetAdjustmentMapper adjustmentMapper = mock(com.ruoyi.system.mapper.flash.FlashDeliveryTargetAdjustmentMapper.class);
         final FlashDeliveryOrderMapper orderMapper = mock(FlashDeliveryOrderMapper.class);
         final FlashDeliveryPricingMapper pricingMapper = mock(FlashDeliveryPricingMapper.class);
         final FlashDeliveryOrderImageMapper imageMapper = mock(FlashDeliveryOrderImageMapper.class);
@@ -1406,9 +1409,31 @@ class FlashDeliveryApplicationServiceTest {
         final RiderDeliveryExclusivityService exclusivityService = mock(RiderDeliveryExclusivityService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
-                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService);
+                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService, stopMapper, adjustmentMapper);
 
         Fixture() {
+            // Existing single-stop scenarios now explicitly provide station rows; production never synthesizes legacy stops.
+            when(orderMapper.selectByIdForUpdate(any())).thenAnswer(i -> {
+                FlashDeliveryOrder current = orderMapper.selectById((Long) i.getArgument(0));
+                if (current != null && current.getPickupStopCount() == null) current.setPickupStopCount(1);
+                return current;
+            });
+            when(stopMapper.insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderStop.class))).thenReturn(1);
+            when(stopMapper.updateById(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderStop.class))).thenReturn(1);
+            when(imageMapper.insert(any(FlashDeliveryOrderImage.class))).thenReturn(1);
+            when(stopMapper.selectList(any())).thenAnswer(i -> {
+                FlashDeliveryOrder current = orderMapper.selectById(10L);
+                var pickup = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();
+                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();
+                delivery.setId(102L); delivery.setOrderId(10L); delivery.setStopType("DELIVERY"); delivery.setStopOrder(1); delivery.setStatus("PENDING");
+                return List.of(pickup, delivery);
+            });
             when(lockService.withLock(any(), any())).thenAnswer(invocation -> {
                 RiderDeliveryLockService.LockedCall<?> call = invocation.getArgument(1);
                 return call.call();

+ 26 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupContractTest.java

@@ -0,0 +1,26 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Guards the additive input contract and mandatory order lock before fulfilment changes. */
+class FlashDeliveryMultiPickupContractTest {
+    @Test
+    void quoteSupportsIndependentPickupItems() throws Exception {
+        assertEquals(java.util.List.class, FlashDeliveryQuoteRequest.class.getMethod("getPickups").getReturnType());
+        Class<?> pickup = Class.forName("com.ruoyi.app.flashdelivery.dto.FlashDeliveryPickupRequest");
+        assertNotNull(pickup.getMethod("getPackageType"));
+        assertNotNull(pickup.getMethod("getQuantity"));
+        assertNotNull(pickup.getMethod("getAddress"));
+    }
+
+    @Test
+    void explicitStopIdentityAndDatabaseLockAreAvailable() throws Exception {
+        assertEquals(Long.class, FlashDeliveryProofRequest.class.getMethod("getStopId").getReturnType());
+        assertNotNull(FlashDeliveryOrderMapper.class.getMethod("selectByIdForUpdate", Long.class));
+    }
+}

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

@@ -0,0 +1,444 @@
+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.RiderDeliveryExclusivityService;
+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.*;
+
+/** Business-level tests for ordering, retry identity, proof ownership and fail-closed submission. */
+class FlashDeliveryMultiPickupTest {
+    @Test void fractionalPickupQuantityNeverBecomesAValidInteger() throws Exception {
+        var json = new com.fasterxml.jackson.databind.ObjectMapper();
+        assertEquals(2, json.readValue("{\"quantity\":2}", FlashDeliveryPickupRequest.class).getQuantity());
+        for (String value : List.of("1.9", "1.0", "2147483648", "{\"value\":1}", "[1]", "\"1\"")) {
+            var pickup = json.readValue("{\"quantity\":" + value + ",\"packageType\":\"GIFT\"}", FlashDeliveryPickupRequest.class);
+            assertNull(pickup.getQuantity(), value);
+            assertEquals("GIFT", pickup.getPackageType());
+        }
+        assertNull(json.readValue("{\"quantity\":1.9}", FlashDeliveryQuoteRequest.class).getQuantity());
+    }
+
+    @Test void transactionalTipAndCreateRetryLockOrderBeforeReadingStations() {
+        Fixture f = new Fixture(); waiting(f);
+        org.springframework.transaction.support.TransactionSynchronizationManager.setActualTransactionActive(true);
+        try {
+            FlashDeliveryTipAddRequest tip = new FlashDeliveryTipAddRequest(); tip.setOrderVersion(0); tip.setAdditionalTipAmount(BigDecimal.TEN);
+            when(f.orders.updateWaitingTip(any(), eq(0))).thenReturn(1);
+            f.service.addTip(7L, 10L, tip);
+            var sequence = inOrder(f.orders, f.stops);
+            sequence.verify(f.orders).selectByIdForUpdate(10L);
+            sequence.verify(f.orders).updateWaitingTip(any(), eq(0));
+            sequence.verify(f.stops).selectList(any());
+            clearInvocations(f.orders, f.stops);
+            FlashDeliveryCreateRequest retry = new FlashDeliveryCreateRequest(); retry.setClientRequestId("existing");
+            when(f.orders.selectByUserRequestId(7L, "existing")).thenReturn(f.order);
+            f.service.create(7L, retry);
+            sequence = inOrder(f.orders, f.stops);
+            sequence.verify(f.orders).selectByIdForUpdate(10L); sequence.verify(f.stops).selectList(any());
+            verify(f.orders, never()).insert(any(FlashDeliveryOrder.class));
+        } finally {
+            org.springframework.transaction.support.TransactionSynchronizationManager.setActualTransactionActive(false);
+        }
+    }
+    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); }
+
+    @Test void changedTargetRequiresDeclarationAndCannotBeBypassedByPickup() {
+        Fixture f = new Fixture();
+        assertEquals(11L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        FlashDeliveryTargetRequest target = target(0, "switch-b", null, false);
+        assertThrows(ServiceException.class, () -> f.service.selectTarget(8L, 10L, 12L, target));
+        assertThrows(ServiceException.class, () -> f.service.pickup(8L, 10L, proof(12L)));
+        verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+        verify(f.stops, never()).updateById(any(FlashDeliveryOrderStop.class));
+    }
+
+    @Test void selectingBDoesNotCollectThenPickupReturnsToAAndDisablesCancellation() {
+        Fixture f = new Fixture();
+        var selected = f.service.selectTarget(8L, 10L, 12L, target(0, "b", "A is not ready", true));
+        assertEquals(12L, selected.getCurrentTargetStopId());
+        assertEquals(0, selected.getPickedUpStopCount());
+        assertEquals("ACCEPTED", f.order.getStatus());
+        assertEquals("PENDING", f.rows.get(1).getStatus());
+        verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+        f.service.pickup(8L, 10L, proof(12L));
+        var progress = f.service.riderDetail(8L, 10L);
+        assertEquals(11L, progress.getCurrentTargetStopId());
+        assertEquals(1, progress.getPickedUpStopCount());
+        assertFalse(progress.getCanUserCancel());
+        assertEquals("ACCEPTED", f.order.getStatus());
+        assertEquals(0, f.rows.get(0).getStopOrder());
+        assertEquals(1, f.rows.get(1).getStopOrder());
+        assertEquals(100L, f.order.getAmount());
+        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());
+    }
+
+    @Test void repeatedPickupNeverWritesAnotherProofOrAdvancesAnotherStation() {
+        Fixture f = new Fixture();
+        f.service.pickup(8L, 10L, proof(11L));
+        Date first = f.rows.get(0).getPickedUpAt();
+        f.service.pickup(8L, 10L, proof(11L));
+        assertEquals(first, f.rows.get(0).getPickedUpAt());
+        assertEquals("PENDING", f.rows.get(1).getStatus());
+        verify(f.images, times(1)).insert(any(FlashDeliveryOrderImage.class));
+        verify(f.logs, times(1)).insert(any(FlashDeliveryOrderLog.class));
+        f.service.pickup(8L, 10L, proof(12L));
+        assertEquals("PICKED_UP", f.order.getStatus());
+        assertEquals(f.rows.get(1).getPickedUpAt(), f.order.getPickedUpAt());
+        assertEquals(13L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+    }
+
+    @Test void missingOrForeignOrDeliveryStopAndWrongRiderAreRejected() {
+        Fixture f = new Fixture();
+        for (Long id : Arrays.asList(null, 13L, 99L)) {
+            assertThrows(ServiceException.class, () -> f.service.pickup(8L, 10L, proof(id)));
+        }
+        assertThrows(ServiceException.class, () -> f.service.pickup(9L, 10L, proof(11L)));
+        assertThrows(ServiceException.class, () -> f.service.pickupAtStop(8L, 10L, 11L, proof(12L)));
+        verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+    }
+
+    @Test void singleStopCanOmitIdButMissingRowsAreNeverSynthesised() {
+        Fixture f = new Fixture(); f.rows.remove(1); f.order.setPickupStopCount(1);
+        f.service.pickup(8L, 10L, proof(null));
+        assertEquals("PICKED_UP", f.order.getStatus());
+        Fixture missing = new Fixture(); missing.rows.clear();
+        assertThrows(ServiceException.class, () -> missing.service.pickup(8L, 10L, proof(null)));
+    }
+
+    @Test void targetRetryIsPayloadSafeAndDoesNotRestoreAnOldTarget() {
+        Fixture f = new Fixture();
+        FlashDeliveryTargetRequest input = target(0, "b", "A not ready", true);
+        f.service.selectTarget(8L, 10L, 12L, input);
+        f.service.pickup(8L, 10L, proof(12L));
+        assertEquals(11L, f.service.selectTarget(8L, 10L, 12L, input).getCurrentTargetStopId());
+        verify(f.adjustments, times(1)).insert(any(FlashDeliveryTargetAdjustment.class));
+        assertThrows(ServiceException.class, () -> f.service.selectTarget(8L, 10L, 11L, input));
+        assertThrows(ServiceException.class, () -> f.service.selectTarget(8L, 10L, 11L, target(0, "stale", "back", true)));
+    }
+
+    @Test void sameTargetWithOmittedDeclarationCanRetryWithoutCreatingAnotherRecord() {
+        Fixture f = new Fixture(); FlashDeliveryTargetRequest request = target(0, "same", null, false);
+        request.setConsentDeclared(null);
+        f.service.selectTarget(8L, 10L, 11L, request);
+        assertEquals(11L, f.service.selectTarget(8L, 10L, 11L, request).getCurrentTargetStopId());
+        verify(f.adjustments, times(1)).insert(any(FlashDeliveryTargetAdjustment.class));
+        assertFalse(f.records.get("same").getConsentDeclared());
+    }
+
+    @Test void handoffAndMismatchedStageProofNeverAppearAsPickupEvidence() {
+        Fixture f = new Fixture(); List<FlashDeliveryOrderImage> images = new ArrayList<>();
+        for (String type : List.of("PICKUP", "DELIVERY", "HANDOFF")) {
+            FlashDeliveryOrderImage image = new FlashDeliveryOrderImage(); image.setStopId(11L);
+            image.setProofType(type); image.setImageUrl("/profile/" + type + ".jpg"); images.add(image);
+        }
+        when(f.images.selectList(any())).thenReturn(images);
+        assertEquals(List.of("/profile/PICKUP.jpg"), f.service.riderDetail(8L, 10L).getStops().get(0).getImageUrls());
+        assertEquals(List.of("/profile/HANDOFF.jpg"), f.service.adminDetail(10L).getHandoffImageUrls());
+    }
+
+    @Test void cancellationLocksBeforeReadingStationsAndCancelledOrderCannotCollect() {
+        Fixture f = new Fixture(); f.order.setStatus("CANCELLED");
+        assertThrows(ServiceException.class, () -> f.service.pickup(8L, 10L, proof(11L)));
+        var order = inOrder(f.orders, f.stops);
+        order.verify(f.orders).selectByIdForUpdate(10L);
+        order.verify(f.stops).selectList(any());
+        verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+        assertThrows(ServiceException.class, () -> f.service.selectTarget(8L, 10L, 12L, target(0, "b", "reason", true)));
+    }
+
+    @Test void platformRequiresCustodyProofBeforeCancellingPartiallyPickedOrder() {
+        Fixture f = new Fixture(); f.service.pickup(8L, 10L, proof(11L));
+        FlashDeliveryReasonRequest request = new FlashDeliveryReasonRequest();
+        request.setReason("missing goods"); request.setProblemStopId(12L); request.setHandlingResult("closed after return");
+        assertThrows(ServiceException.class, () -> f.service.adminCancel(1L, 10L, request));
+        request.setPickedGoodsDisposition("returned to sender");
+        assertThrows(ServiceException.class, () -> f.service.adminCancel(1L, 10L, request));
+        request.setHandoffImageUrls(List.of("/profile/handoff.jpg"));
+        when(f.orders.update(isNull(), any())).thenReturn(1);
+        when(f.orders.cancel(any(), any(), any(), any(), any(), any())).thenReturn(1);
+        f.service.adminCancel(1L, 10L, request);
+        verify(f.orders).cancel(eq(10L), eq("ACCEPTED"), eq("ADMIN"), eq(1L), eq("missing goods"), any());
+    }
+
+    @Test void platformCompleteCannotBypassPendingPickupsEvenIfMainStateIsCorrupt() {
+        Fixture f = new Fixture(); f.order.setStatus("DELIVERED");
+        assertFalse(f.service.complete(1L, "ADMIN", 10L));
+        verify(f.orders, never()).transitionByStatus(any(), any(), any(), any(), any());
+    }
+
+    @Test void failedProofPropagatesForTransactionRollbackBeforeOrderProgressOrLog() {
+        Fixture f = new Fixture();
+        doThrow(new IllegalStateException("proof storage failed")).when(f.images).insert(any(FlashDeliveryOrderImage.class));
+        assertThrows(IllegalStateException.class, () -> f.service.pickup(8L, 10L, proof(11L)));
+        verify(f.orders, never()).updateFulfilment(any());
+        verify(f.logs, never()).insert(any(FlashDeliveryOrderLog.class));
+        // 这里只验证异常传播和写入顺序;真实数据库回滚仍需通过MySQL验证。
+        assertNotNull(assertDoesNotThrow(() -> FlashDeliveryApplicationService.class
+                .getMethod("pickup", Long.class, Long.class, FlashDeliveryProofRequest.class))
+                .getAnnotation(org.springframework.transaction.annotation.Transactional.class));
+    }
+
+    @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_show_multi_pickup")).thenReturn(List.of(dictionary("0")));
+            assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(2)));
+            f.service.pickup(8L, 10L, proof(11L));
+            assertEquals(1, f.order.getPickedUpStopCount());
+            verify(f.routes, never()).calculate(anyList());
+        }
+    }
+
+    @Test void preAcceptanceDetailsRedactEveryStationAndProof() {
+        Fixture f = new Fixture(); f.order.setStatus("WAITING_ACCEPTANCE"); f.order.setRiderId(null);
+        var view = f.service.riderDetail(8L, 10L);
+        for (var stop : view.getStops()) {
+            assertNull(stop.getName()); assertNull(stop.getPhone()); assertNull(stop.getLatitude());
+            assertNull(stop.getLongitude()); assertTrue(stop.getImageUrls().isEmpty());
+            assertNotNull(stop.getAddress());
+        }
+        assertNull(view.getOrderVersion()); assertTrue(view.getTargetAdjustments().isEmpty());
+    }
+
+    @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_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
+            FlashDeliveryQuoteRequest request = quoteRequest(2);
+            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            assertEquals(4000, f.service.quote(request).getDistanceMeters());
+            var points = org.mockito.ArgumentCaptor.forClass(List.class);
+            verify(f.routes).calculate(points.capture()); assertEquals(3, points.getValue().size());
+            for (int size : new int[] {0, 27}) {
+                assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(size)));
+            }
+            assertEquals(26, 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());
+                assertThrows(ServiceException.class, () -> f.service.quote(request));
+            }
+            verify(f.routes, times(1)).calculate(anyList());
+        }
+    }
+
+    @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_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
+            FlashDeliveryQuoteRequest request = quoteRequest(2); request.setPackageType("DOCUMENT");
+            assertThrows(ServiceException.class, () -> f.service.quote(request));
+            request.setPackageType(null); request.getPickups().get(1).setQuantity(0);
+            assertThrows(ServiceException.class, () -> f.service.quote(request));
+            verify(f.routes, never()).calculate(anyList());
+        }
+    }
+
+    @Test void twentySixPickupsAreAcceptedAndAllTwentySevenRoutePointsRemainOrdered() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = multiEnabled()) {
+            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(39000, 1800, "ROUTE"));
+            assertEquals(39000, f.service.quote(quoteRequest(26)).getDistanceMeters());
+            var points = org.mockito.ArgumentCaptor.forClass(List.class);
+            verify(f.routes).calculate(points.capture()); assertEquals(27, points.getValue().size());
+        }
+    }
+
+    @Test void multiCreatePersistsEveryItemAndOnlyFirstItemAsOrderSummary() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = multiEnabled()) {
+            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            FlashDeliveryCreateRequest request = new FlashDeliveryCreateRequest();
+            org.springframework.beans.BeanUtils.copyProperties(quoteRequest(2), request);
+            request.getPickups().get(1).setPackageType("GIFT");
+            var quote = f.service.quote(request);
+            request.setClientRequestId("new-order"); request.setPricingId(quote.getPricingId());
+            request.setPricingVersion(quote.getPricingVersion()); request.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee());
+            request.setQuotedDistanceFee(quote.getDistanceFee()); request.setQuotedUrgentFee(quote.getUrgentFee()); request.setQuotedAmount(quote.getAmount());
+            when(f.orders.insert(any(FlashDeliveryOrder.class))).thenAnswer(i -> { ((FlashDeliveryOrder)i.getArgument(0)).setId(10L); return 1; });
+            when(f.stops.insert(any(FlashDeliveryOrderStop.class))).thenReturn(1);
+            f.service.create(7L, request);
+            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());
+        }
+    }
+
+    @Test void multiAddressEditRequiresFullListAndUsesOriginalSnapshotAfterReordering() {
+        Fixture f = new Fixture(); waiting(f);
+        try (MockedStatic<DictUtils> dict = multiEnabled()) {
+            FlashDeliveryAddressChangeRequest request = new FlashDeliveryAddressChangeRequest(); request.setOrderVersion(0);
+            var data = quoteRequest(2); request.setDelivery(data.getDelivery()); request.setPickup(data.getPickups().get(0));
+            assertThrows(ServiceException.class, () -> f.service.quoteAddress(7L, 10L, request));
+            request.setPickup(null); request.setPickups(new ArrayList<>(data.getPickups()));
+            Collections.reverse(request.getPickups());
+            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            var quote = f.service.quoteAddress(7L, 10L, request);
+            assertEquals(100L, quote.getStartingFare()); assertEquals(110L, quote.getAmount());
+            verify(f.prices, never()).selectAtTime(anyString());
+            FlashDeliveryAddressConfirmRequest confirmation = new FlashDeliveryAddressConfirmRequest();
+            org.springframework.beans.BeanUtils.copyProperties(request, confirmation);
+            confirmation.setQuotedDistanceMeters(quote.getDistanceMeters()); confirmation.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee());
+            confirmation.setQuotedDistanceFee(quote.getDistanceFee()); confirmation.setQuotedUrgentFee(quote.getUrgentFee()); confirmation.setQuotedAmount(quote.getAmount());
+            when(f.orders.updateWaitingAddress(any(), eq(0))).thenReturn(1);
+            when(f.stops.insert(any(FlashDeliveryOrderStop.class))).thenReturn(1);
+            f.service.updateAddress(7L, 10L, confirmation);
+            assertEquals(2, f.order.getQuantity());
+            var sequence = inOrder(f.orders, f.stops);
+            sequence.verify(f.orders).updateWaitingAddress(any(), eq(0));
+            sequence.verify(f.stops).delete(any()); sequence.verify(f.stops, times(3)).insert(any(FlashDeliveryOrderStop.class));
+        }
+    }
+
+    @Test void editVersionFailureCannotDeleteOrReplaceStationRows() {
+        Fixture f = new Fixture(); waiting(f);
+        try (MockedStatic<DictUtils> dict = multiEnabled()) {
+            var data = quoteRequest(2); FlashDeliveryAddressConfirmRequest request = new FlashDeliveryAddressConfirmRequest();
+            request.setOrderVersion(0); request.setPickups(data.getPickups()); request.setDelivery(data.getDelivery());
+            when(f.routes.calculate(anyList())).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            var quote = f.service.quoteAddress(7L, 10L, request);
+            request.setQuotedDistanceMeters(quote.getDistanceMeters()); request.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee());
+            request.setQuotedDistanceFee(quote.getDistanceFee()); request.setQuotedUrgentFee(quote.getUrgentFee()); request.setQuotedAmount(quote.getAmount());
+            assertThrows(ServiceException.class, () -> f.service.updateAddress(7L, 10L, request));
+            verify(f.stops, never()).delete(any()); verify(f.stops, never()).insert(any(FlashDeliveryOrderStop.class));
+        }
+    }
+
+    @Test void transactionalDetailUsesCurrentReadsButOrdinaryGetDoesNotLockStations() {
+        Fixture f = new Fixture();
+        f.service.riderDetail(8L, 10L);
+        var stationQuery = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(f.stops).selectList(stationQuery.capture()); assertFalse(stationQuery.getValue().getSqlSegment().contains("FOR UPDATE"));
+        clearInvocations(f.stops);
+        org.springframework.transaction.support.TransactionSynchronizationManager.setActualTransactionActive(true);
+        try {
+            f.service.riderDetail(8L, 10L);
+            verify(f.stops).selectList(stationQuery.capture()); assertTrue(stationQuery.getValue().getSqlSegment().contains("FOR UPDATE"));
+            var imageQuery = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+            verify(f.images, times(2)).selectList(imageQuery.capture());
+            assertTrue(imageQuery.getValue().getSqlSegment().contains("FOR UPDATE"));
+        } finally {
+            org.springframework.transaction.support.TransactionSynchronizationManager.setActualTransactionActive(false);
+        }
+    }
+
+    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_show_multi_pickup")).thenReturn(List.of(dictionary("1")));
+        return dict;
+    }
+
+    private static void waiting(Fixture f) {
+        f.order.setStatus("WAITING_ACCEPTANCE"); f.order.setRiderId(null); f.order.setServiceType("HELP_SEND");
+        f.order.setDeliveryType("NORMAL"); f.order.setTipAmount(0L); f.order.setPricingId(1L); f.order.setPricingVersion(1);
+        f.order.setStartingFare(100L); f.order.setStartingDistance(BigDecimal.valueOf(3));
+        f.order.setDistance(BigDecimal.ONE); f.order.setFreight(10L); f.order.setUrgentRate(BigDecimal.ZERO); f.order.setMinimumUrgentFee(0L);
+    }
+
+    private static FlashDeliveryQuoteRequest quoteRequest(int count) {
+        FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
+        request.setServiceType("HELP_SEND"); request.setDeliveryType("URGENT"); request.setTipAmount(0L);
+        List<FlashDeliveryPickupRequest> pickups = new ArrayList<>();
+        for (int i = 0; i < count; i++) {
+            FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
+            org.springframework.beans.BeanUtils.copyProperties(stop(20L + i, i, "PICKUP"), pickup);
+            pickups.add(pickup);
+        }
+        request.setPickups(pickups);
+        FlashDeliveryAddressRequest delivery = new FlashDeliveryAddressRequest();
+        org.springframework.beans.BeanUtils.copyProperties(stop(99L, 99, "DELIVERY"), delivery);
+        request.setDelivery(delivery); return request;
+    }
+    private static SysDictData dictionary(String value) { SysDictData d = new SysDictData(); d.setDictValue(value); return d; }
+    private static FlashDeliveryTargetRequest target(int version, String key, String reason, boolean consent) {
+        FlashDeliveryTargetRequest r = new FlashDeliveryTargetRequest(); r.setOrderVersion(version); r.setClientRequestId(key);
+        r.setReason(reason); r.setConsentDeclared(consent); return r;
+    }
+    private static FlashDeliveryProofRequest proof(Long id) {
+        FlashDeliveryProofRequest r = new FlashDeliveryProofRequest(); r.setStopId(id); r.setImageUrls(List.of("/profile/pickup.jpg")); return r;
+    }
+    private static FlashDeliveryOrderStop stop(Long id, int index, String type) {
+        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;
+    }
+    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 FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(orders, prices, images, logs, users,
+                routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryExclusivityService.class), stops, adjustments);
+        final FlashDeliveryOrder order = new FlashDeliveryOrder();
+        final List<FlashDeliveryOrderStop> rows = new ArrayList<>(List.of(stop(11L, 0, "PICKUP"), stop(12L, 1, "PICKUP"), stop(13L, 2, "DELIVERY")));
+        final Map<String, FlashDeliveryTargetAdjustment> records = new HashMap<>();
+        Fixture() {
+            order.setId(10L); order.setUserId(7L); order.setRiderId(8L); order.setStatus("ACCEPTED");
+            order.setVersion(0); order.setPickupStopCount(2); order.setPickedUpStopCount(0); order.setAmount(100L); order.setDeliveryMode("NOW");
+            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(stops.selectList(any())).thenReturn(rows); when(stops.updateById(any(FlashDeliveryOrderStop.class))).thenReturn(1);
+            when(images.insert(any(FlashDeliveryOrderImage.class))).thenReturn(1);
+            when(orders.updateFulfilment(any())).thenReturn(1);
+            when(adjustments.insert(any(FlashDeliveryTargetAdjustment.class))).thenAnswer(i -> {
+                FlashDeliveryTargetAdjustment value = i.getArgument(0); records.put(value.getClientRequestId(), value); return 1;
+            });
+            when(adjustments.selectOne(any())).thenAnswer(i -> {
+                com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<?> q = i.getArgument(0);
+                q.getSqlSegment();
+                return records.values().stream().filter(r -> q.getParamNameValuePairs().containsValue(r.getClientRequestId())).findFirst().orElse(null);
+            });
+            FlashDeliveryPricing price = new FlashDeliveryPricing(); price.setId(1L); price.setConfigVersion(1);
+            price.setStartingDistance(BigDecimal.valueOf(3)); price.setStartingFare(80L); price.setDistance(BigDecimal.ONE);
+            price.setFreight(10L); price.setUrgentRate(BigDecimal.ZERO); price.setMinimumUrgentFee(0L);
+            when(prices.selectAtTime(anyString())).thenReturn(List.of(price));
+        }
+    }
+}

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

@@ -126,4 +126,14 @@ public class FlashDeliveryOrder {
     private Long cancelOperatorId;
     private Date createTime;
     private Date updateTime;
+    /** 取货点总数;上述地址和物品旧字段仅为首站兼容摘要。 */
+    private Integer pickupStopCount;
+    /** 已确认取货站数,与站点同事务更新,供列表展示实际进度。 */
+    private Integer pickedUpStopCount;
+    /** 骑手选择的前往目标;取货后清空,恢复用户原始顺序引导。 */
+    private Long currentTargetStopId;
+    /** 平台关闭异常订单的审计信息,不在此处理支付或退款。 */
+    private Long problemStopId;
+    private String handlingResult;
+    private String pickedGoodsDisposition;
 }

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

@@ -16,7 +16,7 @@ public class FlashDeliveryOrderImage {
     private Long id;
     /** 关联闪送订单 ID。 */
     private Long orderId;
-    /** 凭证类型:SENDER(寄件)、PICKUP(取件)或 DELIVERY(送达)。 */
+    /** 凭证类型:SENDER(寄件)、PICKUP(取件)、DELIVERY(送达)或 HANDOFF(平台异常交接)。 */
     private String proofType;
     /** 图片 URL,最长 1000 个字符,允许相对路径。 */
     private String imageUrl;
@@ -28,4 +28,6 @@ public class FlashDeliveryOrderImage {
     private Long operatorId;
     /** 上传时间。 */
     private Date createTime;
+    /** 凭证所属实际站点;订单级寄件图片不关联站点。 */
+    private Long stopId;
 }

+ 44 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderStop.java

@@ -0,0 +1,44 @@
+package com.ruoyi.system.domain.flash;
+
+import java.util.Date;
+import java.math.BigDecimal;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.IdType;
+import lombok.Data;
+
+/** 所有新订单的站点唯一真值,单取货点订单也保存取货和收货站。 */
+@Data
+@TableName("flash_delivery_order_stop")
+public class FlashDeliveryOrderStop {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long orderId;
+    /** 站点类型:PICKUP取货、DELIVERY收货,每单仅一个收货站。 */
+    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;
+    /** 取货站状态为PENDING/PICKED_UP,收货站为PENDING/DELIVERED。 */
+    private String status;
+    private Date pickedUpAt;
+    private Date deliveredAt;
+    private Date createTime;
+}

+ 26 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryTargetAdjustment.java

@@ -0,0 +1,26 @@
+package com.ruoyi.system.domain.flash;
+
+import java.util.Date;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.IdType;
+import lombok.Data;
+
+/** 目标调整只追加审计,订单、骑手、请求编号唯一键防止重复记录。 */
+@Data
+@TableName("flash_delivery_target_adjustment")
+public class FlashDeliveryTargetAdjustment {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long orderId;
+    private Long fromStopId;
+    private Long toStopId;
+    private Long riderId;
+    private String clientRequestId;
+    /** 原请求版本,用于重试时核对是否为同一业务请求。 */
+    private Integer orderVersion;
+    private String reason;
+    /** 仅表示骑手作出同意声明,不代表用户在App内审批。 */
+    private Boolean consentDeclared;
+    private Date createTime;
+}

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

@@ -52,4 +52,9 @@ public interface FlashDeliveryOrderMapper extends BaseMapper<FlashDeliveryOrder>
     /** 分批查询送达时间早于截止点且仍未完成的订单。 */
     List<FlashDeliveryOrder> selectAutoCompletable(@Param("deadline") Date deadline,
                                                    @Param("limit") int limit);
+    /** 读取最新站点前锁定订单,串行保护目标选择、取货和取消。 */
+    FlashDeliveryOrder selectByIdForUpdate(@Param("id") Long id);
+
+    /** 订单行锁内更新进度和目标,显式写入空目标以恢复默认顺序。 */
+    int updateFulfilment(@Param("order") FlashDeliveryOrder order);
 }

+ 8 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderStopMapper.java

@@ -0,0 +1,8 @@
+package com.ruoyi.system.mapper.flash;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
+
+/** 履约写入由业务层先取得所属订单行锁,站点和审计记录随订单事务提交。 */
+public interface FlashDeliveryOrderStopMapper extends BaseMapper<FlashDeliveryOrderStop> {
+}

+ 8 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryTargetAdjustmentMapper.java

@@ -0,0 +1,8 @@
+package com.ruoyi.system.mapper.flash;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.flash.FlashDeliveryTargetAdjustment;
+
+/** 履约写入由业务层先取得所属订单行锁,站点和审计记录随订单事务提交。 */
+public interface FlashDeliveryTargetAdjustmentMapper extends BaseMapper<FlashDeliveryTargetAdjustment> {
+}

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

@@ -70,6 +70,12 @@
         <result property="cancelOperatorId" column="cancel_operator_id"/>
         <result property="createTime" column="create_time"/>
         <result property="updateTime" column="update_time"/>
+        <result property="pickupStopCount" column="pickup_stop_count"/>
+        <result property="pickedUpStopCount" column="picked_up_stop_count"/>
+        <result property="currentTargetStopId" column="current_target_stop_id"/>
+        <result property="problemStopId" column="problem_stop_id"/>
+        <result property="handlingResult" column="handling_result"/>
+        <result property="pickedGoodsDisposition" column="picked_goods_disposition"/>
     </resultMap>
 
     <select id="selectByUserRequestId" resultMap="FlashDeliveryOrderResult">
@@ -87,7 +93,10 @@
     <!-- 编辑与抢单竞争时,由同一订单行的原子条件更新决定谁先成功。 -->
     <update id="updateWaitingAddress">
         update flash_delivery_order
-        set pickup_name = #{order.pickupName}, pickup_phone = #{order.pickupPhone},
+        set pickup_stop_count = #{order.pickupStopCount}, picked_up_stop_count = 0, current_target_stop_id = null,
+            package_type = #{order.packageType}, quantity = #{order.quantity},
+            weight_range = #{order.weightRange}, specification = #{order.specification},
+            pickup_name = #{order.pickupName}, pickup_phone = #{order.pickupPhone},
             pickup_address = #{order.pickupAddress}, pickup_address_detail = #{order.pickupAddressDetail},
             pickup_city = #{order.pickupCity}, pickup_area = #{order.pickupArea},
             pickup_handoff_method = #{order.pickupHandoffMethod},
@@ -158,4 +167,18 @@
         where status='DELIVERED' and delivered_at &lt;= #{deadline}
         order by delivered_at asc limit #{limit}
     </select>
+    <!-- 先锁订单再读站点,凭证、日志和状态写入全部完成后才释放锁。 -->
+    <select id="selectByIdForUpdate" resultMap="FlashDeliveryOrderResult">
+        select * from flash_delivery_order where id = #{id} for update
+    </select>
+
+    <!-- 显式写入空目标恢复默认顺序,避免通用updateById跳过null字段。 -->
+    <update id="updateFulfilment">
+        update flash_delivery_order
+        set status = #{order.status}, picked_up_stop_count = #{order.pickedUpStopCount},
+            current_target_stop_id = #{order.currentTargetStopId}, picked_up_at = #{order.pickedUpAt},
+            update_time = #{order.updateTime}, version = version + 1
+        where id = #{order.id} and rider_id = #{order.riderId}
+          and status = 'ACCEPTED' and version = #{order.version}
+    </update>
 </mapper>

+ 28 - 0
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderStopMapper.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.flash.FlashDeliveryOrderStopMapper">
+    <!-- 显式映射快照字段;MyBatis Plus写入与所属订单处于同一事务。 -->
+    <resultMap id="FlashDeliveryOrderStopResult" type="com.ruoyi.system.domain.flash.FlashDeliveryOrderStop">
+        <id property="id" column="id"/>
+        <result property="orderId" column="order_id"/>
+        <result property="stopType" column="stop_type"/>
+        <result property="stopOrder" column="stop_order"/>
+        <result property="name" column="name"/>
+        <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="status" column="status"/>
+        <result property="pickedUpAt" column="picked_up_at"/>
+        <result property="deliveredAt" column="delivered_at"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+</mapper>

+ 17 - 0
ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryTargetAdjustmentMapper.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.flash.FlashDeliveryTargetAdjustmentMapper">
+    <!-- 显式映射快照字段;MyBatis Plus写入与所属订单处于同一事务。 -->
+    <resultMap id="FlashDeliveryTargetAdjustmentResult" type="com.ruoyi.system.domain.flash.FlashDeliveryTargetAdjustment">
+        <id property="id" column="id"/>
+        <result property="orderId" column="order_id"/>
+        <result property="fromStopId" column="from_stop_id"/>
+        <result property="toStopId" column="to_stop_id"/>
+        <result property="riderId" column="rider_id"/>
+        <result property="clientRequestId" column="client_request_id"/>
+        <result property="orderVersion" column="order_version"/>
+        <result property="reason" column="reason"/>
+        <result property="consentDeclared" column="consent_declared"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+</mapper>

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

@@ -18,6 +18,46 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class FlashDeliveryMapperXmlTest {
 
+    @Test
+    void stationFulfilmentLocksOrderAndPersistsExplicitTargetReset() throws Exception {
+        String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";
+        Configuration configuration = new Configuration();
+        configuration.getTypeAliasRegistry().registerAlias("FlashDeliveryOrder", FlashDeliveryOrder.class);
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        String namespace = "com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.";
+        String lock = configuration.getMappedStatement(namespace + "selectByIdForUpdate")
+                .getBoundSql(java.util.Map.of("id", 10L)).getSql().replaceAll("\\s+", " ").toLowerCase();
+        assertTrue(lock.endsWith("for update"));
+        FlashDeliveryOrder order = new FlashDeliveryOrder(); order.setId(10L); order.setVersion(3);
+        var bound = configuration.getMappedStatement(namespace + "updateFulfilment").getBoundSql(java.util.Map.of("order", order));
+        String sql = bound.getSql().replaceAll("\\s+", " ").toLowerCase();
+        assertTrue(sql.contains("current_target_stop_id = ?"));
+        assertTrue(sql.contains("picked_up_stop_count = ?"));
+        assertTrue(sql.contains("status = 'accepted' and version = ?"));
+        assertTrue(sql.contains("version = version + 1"));
+        assertTrue(bound.getParameterMappings().stream().anyMatch(p -> p.getProperty().equals("order.currentTargetStopId")));
+    }
+
+    @Test
+    void stationAndAdjustmentMappingsPreserveItemsAndDeclarationIdentity() throws Exception {
+        for (String name : java.util.List.of("FlashDeliveryOrderStop", "FlashDeliveryTargetAdjustment")) {
+            String resource = "mapper/flash/" + name + "Mapper.xml";
+            Configuration configuration = new Configuration();
+            try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+                new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+            }
+            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")));
+            } else {
+                assertTrue(properties.containsAll(Set.of("fromStopId", "toStopId", "riderId", "clientRequestId", "orderVersion", "reason", "consentDeclared", "createTime")));
+            }
+        }
+    }
+
     @Test
     void waitingOrderEditsGuardOwnerStatusRiderAndVersionInOneUpdate() throws Exception {
         String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";

+ 40 - 0
specs/024-flash-delivery/contracts/api.md

@@ -158,3 +158,43 @@
 - 同一小费请求成功后重发旧版本会被拒绝,不会重复加款。网络超时应刷新详情核对小费和总金额,不应自动换新版本重复追加。
 - 地址报价后如追加过小费,旧地址确认失效;刷新详情、重新报价并让用户确认。
 - 当前取消规则、支付和退款范围不变;无新增数据库字段或迁移 SQL。
+
+## 2026-09-15 多取货点增量(覆盖上文单点口径)
+
+### 下单、报价与待接单编辑
+
+- `home` 增加 `pickupStopLimit`、`showMultiPickup`、`showUrgentOption`。上限来自 `flash_pickup_stop_limit`,有效值 1..26,缺失或非法回退 1;`flash_show_multi_pickup` 关闭时只能提交一个取货点。
+- 报价、创建新增 `pickups` 数组。每项直接包含地址字段(`name,phone,address,addressDetail,city,area,handoffMethod,latitude,longitude`)及独立物品字段(`packageType,quantity,weightRange,specification`),不再提交订单级物品。
+- 原 `pickup` 加订单级物品的单点请求仍可提交;与 `pickups` 同传或多点数组与订单级物品混传均拒绝。新订单一律保存取货及收货站点,不提供缺失站点历史订单回退。
+- 地址报价/确认使用完整 `pickups` 和唯一 `delivery`。多点不得仅传旧 `pickup`;旧单点编辑保留原物品。待接单调整站点顺序必须重新报价确认;接单后访问顺序调整不走改址,不重算金额。
+- 用户顺序用于整线地图报价,最多 26 个取货点及 1 个收货点,不优化或省略中途点;地图不可用时逐段直线求和,整线限制 40 公里。仅追加小费不调用路线、不重新校验开关及点数上限。
+
+### 逐站履约
+
+| 方法 | 路径 | 请求体及语义 |
+|---|---|---|
+| POST | `/system/flashDelivery/rider/orders/{id}/pickup` | `stopId?,imageUrls`;单点可省略站点 ID,多点必传;实际交接后提交至少 1 张图片 |
+| POST | `/system/flashDelivery/rider/orders/{id}/stops/{stopId}/pickup` | `imageUrls`,若 body 也传 `stopId` 必须与路径一致;与原取件端点共用实现 |
+| POST | `/system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` | `orderVersion,clientRequestId,reason,consentDeclared`;切换目标前先沟通取得下单人同意,原因非空、声明为 true |
+
+目标调整使用订单版本及客户端幂等号。切换到不同目标时缺少原因/声明,以及他人订单、收货站、已取站、过期版本及取消订单均拒绝新调整;同一请求重试不能重复写调整记录。选择当前目标可以省略原因和声明,仍以请求号记录一次选择并增加版本;省略声明按 false 处理。原目标未完成前保持所选站点;完成后回到原顺序中最早未取站。目标选择不代表取货,不产生取件凭证;直接上传非当前目标的取件凭证不能绕过调整记录。已确认站点由原骑手重试时幂等返回,不受目标已改变影响。
+
+全部取货点完成才进入 `PICKED_UP`;`pickedUpAt` 是最后一个实际完成站点的时间。仅部分已取时订单仍为 `ACCEPTED`,用户取消必须拒绝。取件、目标调整和用户/平台取消先锁订单行后读取最新站点,事务内统一保存状态、版本、凭证及日志。
+
+### 返回视图与脱敏
+
+- 用户/骑手列表新增 `pickupStopCount,pickedUpStopCount`,原地址和物品字段表示用户首个取货点摘要。
+- 详情新增 `stops,pickupStopCount,pickedUpStopCount,currentTargetStopId,canUserCancel,targetAdjustments`;骑手详情返回 `orderVersion` 用于目标选择。
+- `stops` 每项包含 `id,stopType,stopOrder`、平铺地址及物品字段、`status,pickedUpAt,imageUrls`。取货站状态为 `PENDING/PICKED_UP`;唯一收货站 `PENDING/DELIVERED`。
+- `targetAdjustments` 含 `fromStopId,toStopId,reason,riderId,createTime,consentDeclared`。骑手声明不等于下单人 App 审批;只能说明骑手声明已经取得同意。
+- 接单前逐站隐藏联系人、电话、精确坐标、图片和 PIN;完整文字地址及物品信息用于判断能否履约。各角色视图继续遵循订单参与人权限。
+- 平台详情保留 `order,images,logs`,另返回 `stops,targetAdjustments,pickedUpStopCount,currentTargetStopId,handoffImageUrls`。凭证独立关联 `stopId`;聚合图片不能替代逐站证据。
+
+### 平台异常取消
+
+现有 `/system/flashDelivery/admin/orders/{id}/cancel` 请求增加 `problemStopId,handlingResult,pickedGoodsDisposition,handoffImageUrls`。
+
+- `reason`、本单问题站 `problemStopId`、非空 `handlingResult` 必填。
+- 任一取货点已取时,非空 `pickedGoodsDisposition` 及至少一张 `handoffImageUrls` 必填;先记录货物去向和交接,再关闭订单。
+- 原因、处理结果及物品去向各最多 500 字符;交接图片最多 9 张,单 URL 最多 1000 字符。问题站可为取货站或拒收涉及的收货站。
+- 交接凭证使用 `proofType=HANDOFF`,平台详情单独展示;不代表完成配送,也不触发支付退款。

+ 9 - 0
specs/024-flash-delivery/data-model.md

@@ -36,3 +36,12 @@
 | DELIVERED | 异常取消/ADMIN | CANCELLED |
 
 `COMPLETED`、`CANCELLED` 是终态。
+
+## 2026-09-15 多站点数据增量
+
+- 新增 `flash_delivery_order_stop`:订单 ID、类型、用户顺序、完整联系人地址、逐站物品、状态及实际确认时间。`(order_id,stop_order)` 唯一;新订单包含全部取货站和唯一收货站。用户顺序不会因骑手改变访问顺序而重排。
+- 新增目标调整审计记录:订单、幂等请求号、原/新目标、原因、骑手 ID、同意声明和操作时间;同意字段表示骑手声明,不表示已核实的用户审批。
+- `flash_delivery_order` 增加 `pickup_stop_count`、`picked_up_stop_count`、`current_target_stop_id` 及 `problem_stop_id/handling_result/picked_goods_disposition`;首点地址及物品仅作列表摘要,站点表为履约依据。
+- `flash_delivery_order_image` 增加 `stop_id`;`PICKUP` 凭证属于实际取货站,`HANDOFF` 为异常关闭前的物品交接凭证。SENDER 仍为订单级图片,不强制分站。
+- 订单行锁统一串行化取件、目标调整及取消。部分取件不改变主状态但增加版本;所有站点完成才推进 `PICKED_UP`。站点、订单、图片和日志共享事务。
+- 迁移及字典初始化只记录于 `updatesql/sql.md`,由开发者手动执行;没有历史订单站点迁移、兼容回退或自动清理。

+ 3 - 3
specs/024-flash-delivery/plan.md

@@ -703,9 +703,9 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 按用户要求,仅调整确认修改地址和追加小费:在 FlashDeliveryAddressConfirmRequest、FlashDeliveryTipAddRequest 添加 orderId;Controller 两个方法只接 token 与 RequestBody DTO,通过 DTO 将订单 ID 传给既有 Service。requireEditableOrder 在查询前拒绝空/非正订单 ID。同步端点契约、JSON 绑定/身份传递测试、App 文档及定向验证,原有费用和并发业务规则保持不变。
 ## 2026-09-14:多取货点实施计划(2026-09-15 修订)
 
-**目标**:每站独立联系人及物品,默认按用户顺序引导、取得下单人同意并记录骑手声明后允许调整,单点可省略 stopId/多点必传,重复取件幂等,任意站取货后禁止用户取消。本节替换原多点增量方案,生产代码仍待实施。
+**目标**:每站独立联系人及物品,默认按用户顺序引导、取得下单人同意并记录骑手声明后允许调整,单点可省略 stopId/多点必传,重复取件幂等,任意站取货后禁止用户取消。本节替换原多点增量方案;后端与平台 PC 已按此实现,实际验证与待联调项见 validation.md。
 **实现**:新订单统一写站点表,订单表维护首点地址/物品摘要与 pickup_stop_count;不做历史迁移、无站点回退或测试数据清理。用户顺序用于整线报价、展示和默认引导;骑手沟通同意后调整实际取件顺序,记录原因及声明,保留原顺序且不变价,前往与取件分离;追加小费不重算路线。
-**约束**:JDK 21;ruoyi-admin -> ruoyi-system;明确 DTO、token 请求头、Service 国际化校验;SQL 只记录 updatesql/sql.md;保留原编码换行。本轮仅更新现有 spec.md、plan.md、tasks.md,不执行生产实现、SQL、测试或提交。
+**约束**:JDK 21;ruoyi-admin -> ruoyi-system;明确 DTO、token 请求头、Service 国际化校验;SQL 只记录 updatesql/sql.md;保留原编码换行。本轮按用户授权在 test-202609v1 实现后端、平台 PC 并同步关联文档,运行定向测试及构建;SQL 仅记录不执行。商家 PC 与现有 App 目录无闪送页面源码,客户端接入任务保留待办。
 
 1. **数据层(T089、T090)**:在 ruoyi-system 的 domain/flash 新增 FlashDeliveryOrderStop,mapper/flash 新增 Mapper 与 XML;存每站联系人、地址、packageType/quantity/weightRange/specification、状态和确认时间,关联站点凭证。订单实体与 FlashDeliveryOrderMapper.xml 的 resultMap、select/insert/update 同步 pickup_stop_count、首点地址及物品摘要;站点按 order_id 查询并建立同单 stop_order 唯一约束。updatesql/sql.md 记录建表、订单加列、凭证关联所需 SQL 及三个字典项,上限初始化为 1;不执行 SQL。
 2. **配置及路线(T090、T091)**:RouteDistanceProvider 新增多经点方法,GoogleRoutesDistanceProvider 以首取点为 origin、其余 intermediates、收货点 destination,不开启 optimizeWaypointOrder;FlashDeliveryRouteService 降级逐段直线求和。数量有效范围 1..26,字典缺失/非法回退 1,超限在地图调用前拒绝;路线与降级均校验整线 40 公里。FlashDeliveryHomeView 下发 pickupStopLimit、showMultiPickup、showUrgentOption。
@@ -717,7 +717,7 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 8. **验证交付(T095)**:实现后临时设置本机 JDK 21,运行受影响闪送定向测试及 mvn -pl ruoyi-admin -am -DskipTests package,再执行 git diff --check、变更范围和编码换行检查。需要 MySQL 的事务/并发检查独立执行记录,单元和 Mapper 文本检查不能替代数据库验证。真实地图、数据库、App 联调及未执行回归明确列出;核对暂存仅含本功能文件后中文提交。
 
 9. **顺序调整记录(T098)**:按 FR-095、FR-096 设计独立的履约目标选择/调整操作,使用明确 RequestBody DTO 和 token 请求头;服务端保存当前目标及订单、调整前后目标、原因、声明、骑手和时间,不修改 stop_order、站点物品或报价。采用订单行锁与取件/取消串行校验,防止重复请求追加同一次记录或过期请求覆盖目标;已取站及已取消订单拒绝新调整。目标完成后回到用户顺序中最早未取站。同步实体、Mapper/XML、视图及契约;必要 SQL 在实现时写入 updatesql/sql.md,不执行迁移。此操作保存履约选择,不生成导航 URL,也不推进取件状态。
-10. **客户端与异常核对(T099)**:用户下单前可排序,待接单调整需重报价确认,显示 FR-094 提示。骑手默认突出下一站,可查看其他站;选择非默认站时先提示联系下单人,要求原因及已取得同意声明,保存成功后再导航。区分“前往”与“已取货”,声明不显示为用户审批;订单聊天可作为依据,首期不新增用户 App 审批流程。各端保留原顺序,展示实际进度及取消条件,平台展示调整记录、异常处理结果和物品交接信息;可见文本按各端既有国际化要求实现。关联文档在 T094 同步,当前三件套为待实施增量依据。
+10. **客户端与异常核对(T099)**:用户下单前可排序,待接单调整需重报价确认,显示 FR-094 提示。骑手默认突出下一站,可查看其他站;选择非默认站时先提示联系下单人,要求原因及已取得同意声明,保存成功后再导航。区分“前往”与“已取货”,声明不显示为用户审批;订单聊天可作为依据,首期不新增用户 App 审批流程。各端保留原顺序,展示实际进度及取消条件,平台展示调整记录、异常处理结果和物品交接信息;可见文本按各端既有国际化要求实现。关联文档在 T094 同步,后端契约与平台页面已落实;用户/骑手 App 的排序、目标选择及导航页面仍需客户端接入。
 11. **补充验收(T100)**:覆盖默认 A、沟通并保存调整后前往 B、仅导航状态不变、缺原因/声明或直接绕过调整操作拒绝、B 取件后默认回 A、原顺序/物品/金额不变、部分取件进度及取消入口、平台明确标注骑手声明、无法取货的介入与交接记录;覆盖调整请求重复、与取件/取消竞争、目标变化后的取件幂等重试。联调验证前不得将 App 行为标记为已验证,骑手声明不能作为已验证用户同意真实性的依据。
 
 外部约束:Google Routes 单次最多 25 个中途点,Maps URL 使用 two-wheeler 且途经点数量受平台限制;本期采用字典有效上限最多 26 个取货点与单站导航,不做无限站点分段请求。

+ 13 - 0
specs/024-flash-delivery/quickstart.md

@@ -22,3 +22,16 @@
 8. 后台调价后编辑既有订单,核对使用原订单运价快照;预约时间已到但仍待接单的订单也可编辑。
 
 以上涉及真实数据库、地图与 App 的接口冒烟需在开发联调环境执行,自动化测试使用模拟外部依赖,不代替实际联调。
+
+## 多取货点验收(2026-09-15)
+
+1. 开发者手动执行多站点 SQL,确认上限默认 1;完成所需客户端适配后,手动开启 `flash_show_multi_pickup` 并将 `flash_pickup_stop_limit` 设置为测试值。单点新订单同样必须有取送站点。
+2. 创建 A → B → C → 收货点订单,每站提供不同物品。检查顺序、整线距离和金额,逐站资料独立;26 个取货点可配置,27 个取货点应在地图调用前拒绝。关闭开关及非法上限回退 1 后,多点报价/创建/改址拒绝。
+3. 待接单编辑完整数组,调整顺序后先报价再确认;故意保留旧报价应拒绝保存。收件人绑定、首点摘要、物品、站点数及费用同事务变更;骑手抢单或用户取消竞争时不得部分保存。
+4. 未接单骑手查看详情,逐站联系人、电话、坐标和凭证均不可见;原始交付 PIN 始终不能对骑手返回。接单后详情默认目标为 A。
+5. 上传 B 的图片直接确认取件应拒绝;先向下单人沟通,再以原因、声明、版本和幂等号选择 B。重复目标请求只保留一条记录;缺少原因/声明、过期版本或外单站点拒绝。
+6. 打开/退出导航后 B 仍待取。实际取到货上传图片确认 B,订单显示已取 1/3 且主状态仍 ACCEPTED,下一站回 A,金额及原顺序不变;用户取消拒绝。重试 B 不重复凭证、不改变确认时间或推进 A。
+7. 依次确认 A/C,只有最后一次实际取件才进入 PICKED_UP;按原 PIN 和整单送达凭证完成。平台不能在缺少取件时绕过履约直接完成。
+8. B 无法取货或收货人拒收:平台取消须选择问题站、填写处理结果;已有已取物品时,填写去向并上传交接凭证后关闭,详情可查证据。界面明确标记骑手声明,不能显示为用户审批。
+9. 在独立联调环境并发请求取件/用户取消、取件/平台取消、最后两站取件、重复目标及目标/取件竞争;核对单次推进、日志凭证幂等及异常全量回滚。单元测试不替代真实 MySQL 事务验证。
+10. 自建地图已完成的多点路由封装与本服务切换为不同步骤;当前路由按 Google 提供器执行,真实地图及 App 骑手导航仍须联调。

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

@@ -297,7 +297,7 @@
 - FR-078(2026-09-09 入参调整):确认修改地址与追加小费使用 POST /orders/address 和 POST /orders/tip;订单 ID 通过 JSON 请求体 orderId 传入,必须是有效正整数,token 仍放请求头。
 ## 2026-09-14:闪送多取货点(多取一送,2026-09-15 修订)
 
-本节按 2026-09-15 用户确认的方式 2 更新:默认按用户顺序引导,取得下单人同意后允许调整实际取件顺序;替换此前无条件自由取件的规则,生产功能仍待实施。单笔订单包含 1 至 N 个取货点和 1 个收货点,每个取货点独立关联联系人和物品信息。用户填写顺序用于初始路线报价、展示和默认取件引导;调整实际取件顺序须遵守 FR-094 至 FR-098。按整条路线距离计价,不新增每取货点附加费;点间不设单独距离限制,整条报价路线仍不得超过 40 公里。
+本节按 2026-09-15 用户确认的方式 2 更新:默认按用户顺序引导,取得下单人同意后允许调整实际取件顺序;替换此前无条件自由取件的规则。2026-09-15 已落实后端接口与平台 PC;用户/骑手 App 接入和真实数据库联调仍待执行,验证范围见 validation.md。单笔订单包含 1 至 N 个取货点和 1 个收货点,每个取货点独立关联联系人和物品信息。用户填写顺序用于初始路线报价、展示和默认取件引导;调整实际取件顺序须遵守 FR-094 至 FR-098。按整条路线距离计价,不新增每取货点附加费;点间不设单独距离限制,整条报价路线仍不得超过 40 公里。
 
 ### 用户故事 8:用户发布多取货点订单并由骑手逐点取件(优先级:P1)
 

+ 17 - 9
specs/024-flash-delivery/tasks.md

@@ -162,20 +162,28 @@
 - 本轮 URL/入参调整后未重跑完整回归,前一阶段完整回归为 474 项通过;真实地图、数据库及 App 联调仍未执行。
 ## Phase 18:多取货点(多取一送)
 
-本阶段按 2026-09-15 用户确认的方式 2 更新,以 FR-094 至 FR-098 替换原无条件自由取件规则。T088 至 T095、T098 至 T100 仍待实施;T097 仅记录本次文档修订,不表示生产功能完成,未运行代码测试或构建。
+本阶段按 2026-09-15 用户确认的方式 2 更新,以 FR-094 至 FR-098 替换原无条件自由取件规则。后端、平台 PC 和关联文档已实现并执行本地验证;T097 仅记录需求修订。T095、T099、T100 的真实 MySQL 并发/回滚及 App 联调部分仍待执行,不能用 Mock 测试替代,详细结果见 validation.md。
 
 - [x] T087 记录多取货点规格、计划与任务:多取一送、通用站点表、整线距离计价、每站独立联系人及物品、原取件顺序设计(已由 T097 修订)、用户顺序用于报价展示、加急允许多点、点间无独立距离限制、双开关与数量上限走数据字典
-- [ ] T088 编写并运行多点定向测试确认旧实现缺失:独立物品、输入兼容/冲突、单点可省略及多点必传 stopId、默认按序及记录同意声明后调整、前往与取件分离、重试/并发幂等、取消与取件竞争、末站并发及事务回滚、字典 1..26/非法回退;数据库并发验证与模拟测试分开记录
-- [ ] T089 在 updatesql/sql.md 记录站点表(独立联系人、地址、物品、履约信息及索引)、订单 pickup_stop_count、站点凭证关联及三个字典项 SQL;上限初始值 1,只记录不执行,不做历史迁移/回退或测试数据清理
-- [ ] T090 新增 FlashDeliveryOrderStop 实体/Mapper/XML,扩展订单映射、首点地址/物品摘要及订单行锁查询;实现多经点请求和逐段直线降级,最多 26 取点/25 中途点及整线 40 公里校验
-- [ ] T091 实现逐站物品 pickups[] 报价/创建、旧 pickup 单点适配及冲突拒绝;统一开关/上限/地址/物品校验、站点写入和复算;地址编辑同步完整站点、物品、摘要及收件人绑定,同事务版本保护;小费仅增金额、不查路线
-- [ ] T092 实现原 pickup 与路径 stopId 接口:单点可省略、多点必传、两处 ID 一致性、按 FR-096 校验当前目标及必要的调整记录、每站凭证、重试无副作用、全部取完才推进 PICKED_UP;多点缺少 stopId 不得动态选择下一未取站,已确认站点重试不受当前目标变化影响
-- [ ] T093 实现取件与用户/平台取消共用订单行锁及事务,任意站已取禁止用户自助取消,取消后禁止新取件;补 stops 独立物品/凭证、列表首点摘要及 pickupStopCount、已取数量/总数量与取消条件、逐站脱敏、平台详情及首页有效 pickupStopLimit/双开关
-- [ ] T094 更新 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源,平台改动同步四语言;明确新旧输入、站点物品、地址编辑及顺序重报价、幂等及取消、默认下一站、调整记录/骑手声明和异常交接;导航到默认或沟通后调整的单站并使用 travelmode=two-wheeler,前往与取件分离,不将本项目方案写成 Lalamove 已核实流程
+- [x] T088 编写并运行多点定向测试确认旧实现缺失:独立物品、输入兼容/冲突、单点可省略及多点必传 stopId、默认按序及记录同意声明后调整、前往与取件分离、重试/并发幂等、取消与取件竞争、末站并发及事务回滚、字典 1..26/非法回退;数据库并发验证与模拟测试分开记录
+- [x] T089 在 updatesql/sql.md 记录站点表(独立联系人、地址、物品、履约信息及索引)、订单 pickup_stop_count、站点凭证关联及三个字典项 SQL;上限初始值 1,只记录不执行,不做历史迁移/回退或测试数据清理
+- [x] T090 新增 FlashDeliveryOrderStop 实体/Mapper/XML,扩展订单映射、首点地址/物品摘要及订单行锁查询;实现多经点请求和逐段直线降级,最多 26 取点/25 中途点及整线 40 公里校验
+- [x] T091 实现逐站物品 pickups[] 报价/创建、旧 pickup 单点适配及冲突拒绝;统一开关/上限/地址/物品校验、站点写入和复算;地址编辑同步完整站点、物品、摘要及收件人绑定,同事务版本保护;小费仅增金额、不查路线
+- [x] T092 实现原 pickup 与路径 stopId 接口:单点可省略、多点必传、两处 ID 一致性、按 FR-096 校验当前目标及必要的调整记录、每站凭证、重试无副作用、全部取完才推进 PICKED_UP;多点缺少 stopId 不得动态选择下一未取站,已确认站点重试不受当前目标变化影响
+- [x] T093 实现取件与用户/平台取消共用订单行锁及事务,任意站已取禁止用户自助取消,取消后禁止新取件;补 stops 独立物品/凭证、列表首点摘要及 pickupStopCount、已取数量/总数量与取消条件、逐站脱敏、平台详情及首页有效 pickupStopLimit/双开关
+- [x] T094 更新 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源,平台改动同步四语言;明确新旧输入、站点物品、地址编辑及顺序重报价、幂等及取消、默认下一站、调整记录/骑手声明和异常交接;导航到默认或沟通后调整的单站并使用 travelmode=two-wheeler,前往与取件分离,不将本项目方案写成 Lalamove 已核实流程
 - [ ] T095 JDK 21 运行闪送定向测试、模块构建、git diff --check,检查变更范围及编码换行;实际验证取消/取件与末站并发事务,明确未执行的数据库/地图/App 联调及回归,核对暂存范围后中文提交
 - [x] T096 按用户逐项确认修订本三件套:每站独立物品、单点可省略/多点必传 stopId、原自由取件规则(已由 T097 替换)、任意站取货后禁止用户取消及并发保护、不考虑历史订单、字典限制 1..26/非法回退 1;其余采用审核建议,保留旧单点输入、小费不重算和修正导航参数
 
 - [x] T097 按 2026-09-15 会话确认的方式 2 修订 spec.md、plan.md、tasks.md:用户可排序并确认报价、默认下一站、沟通同意后调整及记录原因/骑手/时间/声明、原顺序与金额保留、实际进度、前往与取件分离、不能漏取及异常交接边界;首期无用户 App 审批,骑手声明不等同用户同意证明
-- [ ] T098 实现 FR-095、FR-096 当前目标及调整记录保存、DTO/实体/Mapper/视图/必要 SQL、原因与声明校验、订单权限和状态保护、重复请求与并发保护;取件校验有效目标及必要记录,目标完成后回到原顺序最早未取站,不修改原顺序/物品/金额
+- [x] T098 实现 FR-095、FR-096 当前目标及调整记录保存、DTO/实体/Mapper/视图/必要 SQL、原因与声明校验、订单权限和状态保护、重复请求与并发保护;取件校验有效目标及必要记录,目标完成后回到原顺序最早未取站,不修改原顺序/物品/金额
 - [ ] T099 落实用户端排序与提示、骑手端默认下一站/沟通后选择目标/前往与取件分离、各端实际取件进度及取消入口;平台可核对调整声明、异常处理结果和已取物品去向/交接凭证,用户可见文案国际化,聊天可作为沟通依据
 - [ ] T100 补充并执行顺序调整验收:有无原因/声明、直接取件绕过、导航不推进状态、B 取件后回 A、原顺序与金额不变、进度/取消按钮、声明展示、异常交接、调整重复及与取件/取消并发、已取站重试;配合 T095 记录实际测试与 App/数据库联调结果
+
+### Phase 18 本地交付记录(2026-09-15)
+
+- T088:已完成单元、契约及 Mapper 检查;Mock 只验证锁调用顺序、异常传播和事务边界,真实数据库并发与回滚归入 T095 未验收部分。
+- T095:后端定向 131 项通过、JDK 21 模块打包通过;原有支付测试冲突保留且单列,尚未执行真实 MySQL、Google 地图及 App 联调,未跑全仓回归。
+- T099:平台站点、进度、目标声明和异常交接页面已实现并通过浏览器模拟接口检查;当前工作区没有用户/骑手闪送页面源码,客户端部分保留待办,商家 PC 未新增入口。
+- T100:默认目标、同意声明、目标调整重试、逐站取件幂等、取完 B 回 A、金额不变、取消限制和平台异常交接已覆盖本地测试;真实并发、数据库回滚与 App 操作链路未验收。
+- 地图计价继续使用现有 Google provider,扩展有序多经点和整线直线降级;Foodie_HCM_Map 部署与后端切换不在本次交付内。

+ 43 - 0
specs/024-flash-delivery/validation.md

@@ -0,0 +1,43 @@
+# 闪送多取货点验证记录
+
+日期:2026-09-15。后端、平台 PC 在 `test-202609v1` 实现;商家 PC 同名分支没有闪送页面改动。
+
+## 本次范围
+
+- 多点独立物品、整线报价、站点持久化、原运价快照改址、逐站凭证和进度。
+- 默认用户顺序,沟通后记录原因及骑手同意声明再调整目标;导航与取件分离、幂等与订单行锁保护。
+- 部分取件禁止用户取消,平台异常关闭记录问题站、处理结果和已取物品交接;接单前逐站脱敏。
+- 平台多点详情、实际进度、声明审计和异常取消,四语言文案;接口和 SQL 文档同步。
+
+## 后端验证
+
+临时设置 `JAVA_HOME=C:\Users\qmj\.jdks\graalvm-jdk-21.0.7` 并将其 bin 加入当前命令的 PATH,不修改全局配置。
+
+1. 新契约测试先复现缺少 pickups/stopId;严格数量测试先复现 Jackson 将 `1.9` 转为 `1`,再实现字段级整数解析。
+2. `mvn -pl ruoyi-admin -am '-Dtest=FlashDelivery*Test' '-Dsurefire.failIfNoSpecifiedTests=false' test`:system 7 项通过;admin 123 项中 122 项通过,唯一失败为原有 `FlashDeliveryApplicationServiceTest.createRejectsUnsupportedPayType`。
+3. 最终定向命令:`mvn -pl ruoyi-admin -am '-Dtest=FlashDelivery*Test,GoogleRoutesDistanceProviderTest,!FlashDeliveryApplicationServiceTest#createRejectsUnsupportedPayType' '-Dsurefire.failIfNoSpecifiedTests=false' test`:system 7 + admin 124 = **131 项通过**。其中多取货点业务测试 23 项,路线测试 7 项。额外 Google 类名模式未匹配测试类,不计入数量。
+4. `mvn -pl ruoyi-admin -am -DskipTests package`:**BUILD SUCCESS**,全部参与模块打包通过。
+
+原支付失败在实现前 HEAD 已存在:`normalizePayType` 直接接受非空字符串,允许值校验被注释,而旧测试要求拒绝 `2`。本次未修改该支付行为或禁用该测试,只在最终定向命令排除该方法;不能据此声称闪送完整测试全部通过。
+
+Mock 测试覆盖业务分支、事务注解、订单锁及当前读调用顺序、重试与异常传播,不能证明真实数据库事务回滚或并发结果。
+
+## 平台验证
+
+- `node --test tests/flash-delivery.test.cjs tests/flash-multi-pickup.test.cjs`:**11/11 通过**。
+- `node node_modules/eslint/bin/eslint.js --no-ignore src/views/flashDelivery/multiPickup.js src/views/flashDelivery/orders/MultiPickupDetail.vue src/views/flashDelivery/orders/OrderDetail.vue src/views/flashDelivery/orders/index.vue src/views/flashDelivery/shared.js`:通过。
+- 当前命令设置 `NODE_OPTIONS=--openssl-legacy-provider` 后,执行 `node node_modules/@vue/cli-service/bin/vue-cli-service.js build --mode staging`:通过;保留仓库原有备份组件及资源大小警告。
+- Chrome 中挂载真实 Vue/Element 组件:检查原站点顺序、已取 1/2、当前目标、骑手声明和交接展示;异常取消检查缺项阻止提交、真实 ImageUpload 选择图片及提交完整取消参数,均通过,无页面运行错误。接口和上传响应由本地测试夹具模拟,没有访问真实后端。
+- 页面截图与浏览器夹具保存在本地 `.superpowers/sdd/flash-multi/`,不纳入产品代码提交。
+
+## 审查
+
+后端及路线/平台分别经过独立静态审查,未发现新增高、中严重度问题;补齐新增业务注释。提交前检查差异、范围与换行,保留现有编码风格。
+
+## 未验证及上线前事项
+
+- `updatesql/sql.md` 的新表、字段、索引和字典由开发者手动执行,本次未执行任何数据库迁移。上限初始为 1;App 适配及联调后再配置多点开关和上限。
+- 真实 MySQL 中的取件/取消双向竞争、末站并发、锁等待后的当前读可见性、失败后的完整回滚与 SQL 应用结果未验证;按 quickstart.md 执行。
+- 用户/骑手闪送 App 页面源码不在本次工作区,排序、目标选择、导航、进度与取消入口仍需客户端接入和联调;T099、T100 对应部分继续保留待办。
+- 未调用真实 Google Routes,未部署或切换 Foodie_HCM_Map,未验证真机导航。当前报价扩展既有 Google provider 的有序多经点与整线直线降级。
+- 未运行全仓完整回归,未部署上线。

+ 80 - 0
updatesql/sql.md

@@ -1539,3 +1539,83 @@ ALTER TABLE flash_delivery_order
 ALTER TABLE info_user
   MODIFY COLUMN avatar VARCHAR(1000) DEFAULT '' COMMENT '头像地址';
 ```
+
+
+## 2026-09-15 闪送多取货点、逐站凭证和目标调整审计
+
+仅记录 SQL,由开发者手动执行。测试阶段不迁移历史订单,不删除任何已有测试数据。
+
+```sql
+ALTER TABLE flash_delivery_order
+    ADD COLUMN pickup_stop_count INT NULL COMMENT '完整取货站数量,新订单必填',
+    ADD COLUMN picked_up_stop_count INT NOT NULL DEFAULT 0 COMMENT '已确认取货站数量',
+    ADD COLUMN current_target_stop_id BIGINT NULL COMMENT '骑手当前选择目标,取货后清空恢复原顺序',
+    ADD COLUMN problem_stop_id BIGINT NULL COMMENT '平台介入问题站,允许收货站',
+    ADD COLUMN handling_result VARCHAR(500) NULL COMMENT '平台异常处理结果',
+    ADD COLUMN picked_goods_disposition VARCHAR(500) NULL COMMENT '已取物品去向';
+
+CREATE TABLE flash_delivery_order_stop (
+    id BIGINT NOT NULL AUTO_INCREMENT,
+    order_id BIGINT NOT NULL,
+    stop_type VARCHAR(16) NOT NULL COMMENT 'PICKUP或DELIVERY',
+    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,
+    area VARCHAR(64) NULL,
+    handoff_method VARCHAR(40) NULL,
+    longitude DECIMAL(12,8) NOT NULL,
+    latitude DECIMAL(12,8) NOT NULL,
+    package_type VARCHAR(40) NULL,
+    quantity INT NULL,
+    weight_range VARCHAR(40) NULL,
+    specification VARCHAR(255) NULL,
+    status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
+    picked_up_at DATETIME(3) NULL,
+    delivered_at DATETIME(3) NULL,
+    create_time DATETIME(3) NOT NULL,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_flash_stop_order (order_id, stop_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闪送新订单站点唯一真值';
+
+ALTER TABLE flash_delivery_order_image
+    ADD COLUMN stop_id BIGINT NULL COMMENT '实际凭证站点,寄件图片保持订单级',
+    ADD INDEX idx_flash_image_stop (order_id, stop_id, proof_type);
+
+CREATE TABLE flash_delivery_target_adjustment (
+    id BIGINT NOT NULL AUTO_INCREMENT,
+    order_id BIGINT NOT NULL,
+    from_stop_id BIGINT NOT NULL,
+    to_stop_id BIGINT NOT NULL,
+    rider_id BIGINT NOT NULL,
+    client_request_id VARCHAR(64) NOT NULL,
+    order_version INT NOT NULL COMMENT '原请求版本,供幂等重试核对',
+    reason VARCHAR(500) NULL,
+    consent_declared TINYINT(1) NULL COMMENT '仅骑手声明,不等于用户App审批',
+    create_time DATETIME(3) NOT NULL,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_flash_target_request (order_id, rider_id, client_request_id),
+    KEY idx_flash_target_order (order_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闪送目标选择与骑手声明审计';
+
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '闪送取货点上限', 'flash_pickup_stop_limit', '0', 'admin', NOW(), '整数1至26,缺失或非法回退1'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_pickup_stop_limit');
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '闪送多取货点开关', 'flash_show_multi_pickup', '0', 'admin', NOW(), '1开启;仅约束报价、创建和地址编辑'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_show_multi_pickup');
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '闪送加急选项展示', 'flash_show_urgent_option', '0', 'admin', NOW(), '1展示;仅控制客户端展示'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='flash_show_urgent_option');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '取货点上限', '1', 'flash_pickup_stop_limit', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_pickup_stop_limit');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '多取货点开关', '0', 'flash_show_multi_pickup', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_show_multi_pickup');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '加急选项展示', '1', 'flash_show_urgent_option', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='flash_show_urgent_option');
+```