Selaa lähdekoodia

merge: 融合闪送履约与支付配置

qmj 23 tuntia sitten
vanhempi
sitoutus
9efb3e7d3f
78 muutettua tiedostoa jossa 2911 lisäystä ja 605 poistoa
  1. 47 1
      docs/flash-delivery-app-api.md
  2. 66 0
      docs/superpowers/plans/2026-09-17-flash-delivery-v1-v2-merge.md
  3. 20 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  4. 5 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAddressChangeRequest.java
  5. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryAdminOrderDetailView.java
  6. 7 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  7. 8 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  8. 8 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  9. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPickupRequest.java
  10. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java
  11. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java
  12. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryProofRequest.java
  13. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuantityDeserializer.java
  14. 13 8
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  15. 5 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  16. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryReasonRequest.java
  17. 8 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  18. 36 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopView.java
  19. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTargetRequest.java
  20. 8 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  21. 70 15
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteService.java
  22. 60 14
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java
  23. 13 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/RouteDistanceProvider.java
  24. 493 147
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  25. 7 5
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java
  26. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/InfoAddressController.java
  27. 5 4
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  28. 0 16
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  29. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  30. 156 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryAcceptanceService.java
  31. 0 61
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java
  32. 19 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  33. 19 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  34. 19 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  35. 19 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  36. 19 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  37. 13 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  38. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  39. 4 5
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  40. 90 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java
  41. 21 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProviderTest.java
  42. 155 115
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  43. 2 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  44. 26 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupContractTest.java
  45. 445 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  46. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java
  47. 8 8
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
  48. 31 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderListPaymentFilterTest.java
  49. 226 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryAcceptanceServiceTest.java
  50. 0 126
      ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java
  51. 13 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  52. 3 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderImage.java
  53. 44 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderStop.java
  54. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java
  55. 26 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryTargetAdjustment.java
  56. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryVehicleType.java
  57. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  58. 8 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderStopMapper.java
  59. 8 4
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java
  60. 8 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryTargetAdjustmentMapper.java
  61. 25 1
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  62. 28 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderStopMapper.xml
  63. 17 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryTargetAdjustmentMapper.xml
  64. 40 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  65. 4 2
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java
  66. 6 0
      specs/006-orderstate/plan.md
  67. 6 0
      specs/006-orderstate/spec.md
  68. 6 0
      specs/006-orderstate/tasks.md
  69. 58 10
      specs/024-flash-delivery/contracts/api.md
  70. 11 2
      specs/024-flash-delivery/data-model.md
  71. 1 1
      specs/024-flash-delivery/design.md
  72. 52 8
      specs/024-flash-delivery/plan.md
  73. 14 1
      specs/024-flash-delivery/quickstart.md
  74. 2 2
      specs/024-flash-delivery/research.md
  75. 50 14
      specs/024-flash-delivery/spec.md
  76. 51 11
      specs/024-flash-delivery/tasks.md
  77. 43 0
      specs/024-flash-delivery/validation.md
  78. 120 0
      updatesql/sql.md

+ 47 - 1
docs/flash-delivery-app-api.md

@@ -953,7 +953,11 @@ GET /system/flashDelivery/rider/orders/{id}
 POST /system/flashDelivery/rider/orders/{id}/accept
 ```
 
-无请求体。骑手账号必须已开通闪送配送。抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段;订单已被其他骑手抢走时返回“订单已被其他骑手接走”。违反急送独占规则时返回统一的独占冲突提示,App 应保留在列表页并刷新可接任务。
+无请求体。服务端取得骑手锁后重新校验,不信任待抢列表的过滤结果。骑手必须已审核、账号启用、`userType=2`、`offline=0` 并开通闪送配送;同时校验进行中闪送容量、外卖与闪送合计容量及急送独占。容量分别由 `sys_rider_flash_active_order_limit`、`sys_rider_total_active_order_limit` 控制,外卖接单另使用 `sys_rider_food_active_order_limit`;配置只接受 1..20,缺失、空值、非法、越界或读取异常一律按 1 执行,不会变成不限单。
+
+抢单成功后状态变为 `ACCEPTED`,响应 `data` 直接返回完整订单字段。失败会返回明确原因,包括账号未审核或停用、骑手离线、未开通闪送、闪送任务已满、配送任务总量已满、急送独占冲突或订单已被其他骑手接走;App 应展示 `msg`、保留在列表页并刷新可接任务。
+
+当前版本尚未把容量规则用于待抢列表,也未在接单时校验服务端位置更新时间或接单距离,因此列表可见不代表一定能接;最终以本接口复核为准。
 
 ### 8.4 确认取件
 
@@ -1125,3 +1129,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 已联调证据。

+ 66 - 0
docs/superpowers/plans/2026-09-17-flash-delivery-v1-v2-merge.md

@@ -0,0 +1,66 @@
+# Flash Delivery v1-v2 Merge Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Merge the flash-delivery, rider-capacity and merchant-list work from `test-202609v1` into `test-202609v2` without losing v2 payment-gate or night-market self-delivery behavior.
+
+**Architecture:** Keep the v1 flash-delivery application service as the fulfillment baseline because it owns multi-pickup stops, vehicle snapshots, capacity checks and route fallback. Reapply v2 payment-gate dependencies and behavior to that baseline, then merge DTO, controller, localization and tests so rider new-task filtering applies both vehicle and accepted payment type.
+
+**Tech Stack:** Java 21, Spring Boot, MyBatis, JUnit 5, Mockito, MySQL migration documentation.
+
+**Spec:** `specs/024-flash-delivery/spec.md`, `specs/031-payment-method-config/spec.md`
+
+## Global Constraints
+
+- Do not execute database SQL; record migrations only in `updatesql/sql.md`.
+- Preserve v2 deletion of `messages_vi.properties`; do not reintroduce Vietnamese resources.
+- Preserve existing night-market self-delivery behavior and payment-method gate behavior.
+- Run affected flash-delivery and payment-gate tests with JDK 21 before committing.
+
+---
+
+### Task 1: Resolve production conflicts
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java`
+- Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java`
+
+**Interfaces:**
+- Consumes: `PaymentMethodGateService`, `RiderDeliveryAcceptanceService`, vehicle snapshots and multi-pickup stop services.
+- Produces: vehicle-and-payment filtered rider new-task queries, payment-gated flash creation and idempotent rider payment confirmation.
+
+- [ ] Keep v1 multi-pickup, vehicle route/pricing, estimated-duration fallback and rider-capacity calls.
+- [ ] Add v2 `PaymentMethodGateService` dependency, home/quote payment options, payment validation, rider accepted-payment filtering and `confirmPayment` behavior.
+- [ ] Combine `newTask` query constraints using both `vehicle_type` and accepted `pay_type` values.
+
+### Task 2: Resolve contract, localization and test conflicts
+
+**Files:**
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages*.properties`
+- Delete: `ruoyi-admin/src/main/resources/i18n/messages_vi.properties`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java`
+- Modify: `ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java`
+
+**Interfaces:**
+- Consumes: merged production APIs from Task 1.
+- Produces: tests asserting both vehicle and payment behavior, while maintaining v2 language policy.
+
+- [ ] Retain v2 payment and self-delivery messages plus v1 vehicle/capacity messages in supported resource bundles.
+- [ ] Keep `messages_vi.properties` deleted because v2 intentionally removed Vietnamese support.
+- [ ] Update test fixtures for the merged application-service constructor and assert neither payment filtering nor vehicle filtering is dropped.
+
+### Task 3: Verify and integrate
+
+**Files:**
+- Modify: `specs/024-flash-delivery/tasks.md`
+- Modify: `updatesql/sql.md` only if merge resolution requires preserving both documented migrations.
+
+**Interfaces:**
+- Consumes: merged production and test sources.
+- Produces: one merge commit on `test-202609v2`.
+
+- [ ] Verify no conflict markers remain and that both self-delivery DDL and vehicle DDL remain documented.
+- [ ] Run JDK 21 targeted flash-delivery and payment-method tests, then `git diff --check`.
+- [ ] Review staged paths, commit the merge, and report the SHA plus unexecuted external integration checks.

+ 20 - 2
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));
     }
 
-    /** 骑手确认已实际收款:收款状态 0→1 并记日志,不影响订单状态;重复调用幂等。 */
+    /** 骑手确认实际收款;仅首次条件更新写入审计日志,重复调用幂等成功。 */
     @PostMapping("/orders/{id}/confirmPayment")
     @Auth
     public AjaxResult confirmPayment(@RequestHeader String token, @PathVariable Long id) {
@@ -63,7 +64,7 @@ public class FlashDeliveryRiderController extends BaseController {
         return success();
     }
 
-    /** 上传取件凭证并将已接单订单推进为已取件。 */
+    /** 上传指定取货站凭证;全部取货站完成后订单才进入已取件。 */
     @PostMapping("/orders/{id}/pickup")
     @Auth
     public AjaxResult pickup(@RequestHeader String token, @PathVariable Long id,
@@ -81,5 +82,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();
 }

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

@@ -13,6 +13,12 @@ public class FlashDeliveryHomeView {
     private List<String> deliveryTypes;
     /** 当前时段各服务的运价摘要,无可用运价时为空列表。 */
     private List<FlashDeliveryServiceView> services;
-    /** 闪送可选支付方式(031 闸门开放集合,含就绪度),App 下单支付方式渲染依据。 */
+    /** 闪送维度可用支付方式,供用户端下单页展示。 */
     private List<com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption> payMethods;
+    /** 取货点有效上限,合法范围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();
 }

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

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

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

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

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

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

+ 13 - 8
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;
@@ -19,18 +21,22 @@ public class FlashDeliveryQuoteRequest {
      */
     private String deliveryType;
 
+    /** 配送车型,必填:1=机车,2=轿车。 */
+    private Integer vehicleType;
+
     /**
-     * 物品类别,必填。
+     * 旧单点输入的物品类别;使用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,15 +65,14 @@ public class FlashDeliveryQuoteRequest {
      */
     private Date scheduledPickupEndAt;
 
-    /**
-     * 支付方式(031),选填,缺省现金 4。
-     * 与主订单系统同域(1=到付、3=LINE Pay、4=现金、6=线下转账);服务端经闸门校验(闪送维度仅平台开关层)。
-     */
+    /** 支付方式选填,缺省现金;服务端按闪送支付闸门校验。 */
     private String payType;
 
-    /** 取件联系人、电话、地址、交接方式和经纬度,必填。 */
+    /** 旧单点取件地址,与pickups数组二选一。 */
     private FlashDeliveryAddressRequest pickup;
 
     /** 收件联系人、电话、地址、交接方式和经纬度,必填。 */
     private FlashDeliveryAddressRequest delivery;
+    /** 按用户顺序提交完整取货点及物品,不能与旧pickup字段同时出现。 */
+    private List<FlashDeliveryPickupRequest> pickups;
 }

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

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

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

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

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

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

@@ -15,6 +15,8 @@ public class FlashDeliveryUserOrderListView {
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=加急。 */
     private String deliveryType;
+    /** 配送车型:1=机车、2=轿车。 */
+    private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
     /** 用户编辑待接单订单使用的乐观锁版本。 */
@@ -41,7 +43,7 @@ public class FlashDeliveryUserOrderListView {
     private String deliveryDetailAddress;
     /** 预计配送时长(秒)。 */
     private Integer estimatedDurationSeconds;
-    /** 基础配送费(TWD),已含距离费。 */
+    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
     private Long baseDeliveryFee;
     /** 加急费(TWD),普通配送为 0。 */
     private Long urgentFee;
@@ -57,4 +59,9 @@ public class FlashDeliveryUserOrderListView {
     private Date deliveredAt;
     /** 创建时间。 */
     private Date createTime;
+    /** 取货站总数及已实际确认的取货站数。 */
+    private Integer pickupStopCount;
+    private Integer pickedUpStopCount;
+    /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
+    private Boolean canUserCancel;
 }

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

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

+ 60 - 14
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/route/GoogleRoutesDistanceProvider.java

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

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

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

+ 493 - 147
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -9,7 +9,7 @@ import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
 import com.ruoyi.app.flashdelivery.route.GeoPoint;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
-import com.ruoyi.app.order.RiderDeliveryExclusivityService;
+import com.ruoyi.app.order.RiderDeliveryAcceptanceService;
 import com.ruoyi.app.order.RiderDeliveryLockService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.exception.ServiceException;
@@ -19,9 +19,11 @@ import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.flash.*;
 import com.ruoyi.system.mapper.InfoUserMapper;
-import com.ruoyi.system.service.PaymentMethodGateService;
 import com.ruoyi.system.mapper.flash.*;
+import com.ruoyi.system.service.PaymentMethodGateService;
+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;
 
@@ -54,7 +56,6 @@ public class FlashDeliveryApplicationService {
     private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
     /** 支付方式取值与主订单系统同域:4=现金、6=线下转账(027 线下转账支付)。 */
     private static final String PAY_TYPE_CASH = "4";
-    private static final String PAY_TYPE_TRANSFER = "6";
     private static final DateTimeFormatter PRICING_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
     private static final int MAX_DISTANCE_METERS = 40_000;
@@ -63,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;
@@ -70,8 +73,8 @@ public class FlashDeliveryApplicationService {
     private final FlashDeliveryRouteService routeService;
     private final FlashDeliveryPricingCalculator pricingCalculator;
     private final RiderDeliveryLockService riderDeliveryLockService;
-    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
-    /** 支付方式闸门(031):闪送下单校验与抢单过滤的唯一判定来源。 */
+    private final RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
+    /** 031 支付闸门:闪送下单、报价展示和骑手待抢列表使用同一可用性规则。 */
     private final PaymentMethodGateService paymentMethodGateService;
 
     public FlashDeliveryApplicationService(FlashDeliveryOrderMapper orderMapper,
@@ -82,9 +85,13 @@ public class FlashDeliveryApplicationService {
                                            FlashDeliveryRouteService routeService,
                                            FlashDeliveryPricingCalculator pricingCalculator,
                                            RiderDeliveryLockService riderDeliveryLockService,
-                                           RiderDeliveryExclusivityService riderDeliveryExclusivityService,
+                                           RiderDeliveryAcceptanceService riderDeliveryAcceptanceService,
+                                           FlashDeliveryOrderStopMapper stopMapper,
+                                           FlashDeliveryTargetAdjustmentMapper adjustmentMapper,
                                            PaymentMethodGateService paymentMethodGateService) {
         this.orderMapper = orderMapper;
+        this.stopMapper = stopMapper;
+        this.adjustmentMapper = adjustmentMapper;
         this.pricingMapper = pricingMapper;
         this.imageMapper = imageMapper;
         this.logMapper = logMapper;
@@ -92,19 +99,21 @@ public class FlashDeliveryApplicationService {
         this.routeService = routeService;
         this.pricingCalculator = pricingCalculator;
         this.riderDeliveryLockService = riderDeliveryLockService;
-        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
+        this.riderDeliveryAcceptanceService = riderDeliveryAcceptanceService;
         this.paymentMethodGateService = paymentMethodGateService;
     }
 
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
     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()));
+        FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()), FlashDeliveryVehicleType.MOTORCYCLE);
         view.setServices(pricing == null ? List.of() : SERVICE_TYPES.stream()
                 .map(serviceType -> serviceView(pricing, serviceType)).toList());
-        // 031:首页返回闪送维度可选支付方式(平台开关开放集合),App 下单渲染用
         view.setPayMethods(paymentMethodGateService.listAvailable(
                 PaymentMethodGateService.GateScope.RIDER_FLASH, null, null));
         return view;
@@ -122,6 +131,7 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
         view.setServiceType(request.getServiceType());
         view.setDeliveryType(request.getDeliveryType());
+        view.setVehicleType(request.getVehicleType());
         view.setDeliveryMode(normalizeDeliveryMode(request.getDeliveryMode()));
         view.setScheduledPickupStartAt(request.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(request.getScheduledPickupEndAt());
@@ -140,7 +150,6 @@ public class FlashDeliveryApplicationService {
         applyBreakdown(view, context.breakdown());
         view.setCurrency("TWD");
         view.setPricingVersion(context.pricing().getConfigVersion());
-        // 031:报价响应同样携带可选支付方式,与首页同源
         view.setPayMethods(paymentMethodGateService.listAvailable(
                 PaymentMethodGateService.GateScope.RIDER_FLASH, null, null));
         return view;
@@ -161,7 +170,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");
         }
@@ -182,17 +191,16 @@ public class FlashDeliveryApplicationService {
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
         order.setDeliveryType(request.getDeliveryType());
+        order.setVehicleType(request.getVehicleType());
         order.setStatus(WAITING_ACCEPTANCE);
-        order.setPackageType(request.getPackageType());
-        order.setQuantity(request.getQuantity());
-        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());
@@ -224,10 +232,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);
@@ -242,14 +252,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;
     }
 
     /** 地址修改先报价,用户确认前不改动订单。 */
@@ -274,13 +290,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());
@@ -301,6 +318,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);
@@ -340,7 +360,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");
@@ -353,22 +375,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(), vehicleTypeOf(order));
+        // 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(),
@@ -379,10 +395,11 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryQuoteRequest quoteRequest = new FlashDeliveryQuoteRequest();
         quoteRequest.setServiceType(order.getServiceType());
         quoteRequest.setDeliveryType(order.getDeliveryType());
+        quoteRequest.setVehicleType(vehicleTypeOf(order));
         quoteRequest.setDeliveryMode(order.getDeliveryMode());
         quoteRequest.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         quoteRequest.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
-        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;
     }
@@ -390,9 +407,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) {
@@ -404,8 +421,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");
@@ -421,7 +439,6 @@ public class FlashDeliveryApplicationService {
     public FlashDeliveryRiderOrderPageView riderOrders(Long riderId, int pageNum, int pageSize,
                                                         String tab, BigDecimal longitude, BigDecimal latitude) {
         InfoUser rider = requireRider(riderId);
-        // 031:骑手接受的支付方式集合(未设置=平台开放全部),newTask 页签按此过滤
         Set<String> acceptedPayTypes = acceptedRiderPayTypes(rider);
         if (!hasText(tab)) throw fail("flash.delivery.tab.invalid");
         String selectedTab = tab.trim();
@@ -433,7 +450,8 @@ public class FlashDeliveryApplicationService {
             if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
             validateRiderCoordinates(effectiveLongitude, effectiveLatitude);
         }
-        QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, selectedTab,
+        Integer riderVehicleType = riderVehicleType(rider);
+        QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, riderVehicleType, selectedTab,
                 effectiveLongitude, effectiveLatitude, true, acceptedPayTypes);
         IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
         FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView();
@@ -444,7 +462,7 @@ public class FlashDeliveryApplicationService {
         result.setSize(orders.getSize());
         if ("newTask".equals(selectedTab)) {
             result.setNearbyTaskCount(orders.getTotal());
-            QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, selectedTab,
+            QueryWrapper<FlashDeliveryOrder> maximumQuery = riderOrdersQuery(riderId, riderVehicleType, selectedTab,
                     effectiveLongitude, effectiveLatitude, false, acceptedPayTypes)
                     .select("MAX(amount)");
             result.setHighestOrderAmount(firstLong(orderMapper.selectObjs(maximumQuery)));
@@ -458,7 +476,8 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryOrder order = requireOrder(orderId);
         if (WAITING_ACCEPTANCE.equals(order.getStatus()) && order.getRiderId() == null) {
             // 不兼容闪送的骑手按订单不存在处理,避免泄露待抢任务信息。
-            if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH")) {
+            if (!isAvailableAt(order, new Date()) || !rider.supportsDeliveryType("FLASH")
+                    || !isRiderVehicleCompatible(rider, order)) {
                 throw fail("flash.delivery.order.not.found");
             }
             // 接单前显示取送文字地址,但隐藏联系人、电话、精确坐标、实际 PIN 和履约图片。
@@ -471,12 +490,12 @@ public class FlashDeliveryApplicationService {
     /** 原子抢单:骑手锁内完成互斥校验与条件更新,同一订单仅一名骑手成功。 */
     @Transactional
     public FlashDeliveryOrderDetailView accept(Long riderId, Long orderId) {
-        InfoUser rider = requireRider(riderId);
-        // 抢单属于获取新任务,要求骑手承接闪送配送。
-        if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
         return riderDeliveryLockService.withLock(riderId, () -> {
             FlashDeliveryOrder target = requireOrder(orderId);
-            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
+            InfoUser rider = requireRider(riderId);
+            if (!isRiderVehicleCompatible(rider, target)) throw fail("flash.delivery.order.not.found");
+            // 账号资格、闪送/总任务容量与急送独占统一在骑手锁内复核,列表结果不能替代接单校验。
+            riderDeliveryAcceptanceService.assertCanAcceptFlash(riderId, target.getDeliveryType());
             Date now = new Date();
             // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
             if (orderMapper.accept(orderId, riderId, now) != 1) {
@@ -487,25 +506,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);
     }
 
     /** 平台运价配置列表,按时段开始时间排序。 */
@@ -542,6 +573,7 @@ public class FlashDeliveryApplicationService {
         Date now = new Date();
         UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
                 .eq("id", pricingId).eq("config_version", request.getConfigVersion())
+                .set("vehicle_type", request.getVehicleType())
                 .set("start_time", request.getStartTime())
                 .set("end_time", request.getEndTime())
                 .set("starting_distance", scaleDistance(request.getStartingDistance()))
@@ -555,6 +587,7 @@ public class FlashDeliveryApplicationService {
                 .setSql("config_version = config_version + 1");
         // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
         if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
+        pricing.setVehicleType(request.getVehicleType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
@@ -600,8 +633,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;
@@ -610,31 +651,42 @@ 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);
         return true;
     }
 
-    /** 查询送达时间早于截止点且未完成的订单,供自动完成任务分批处理。 */
-    /**
-     * 骑手确认收款(031):本人中单且订单已达已送达(或之后)才可确认;
-     * 条件更新 0→1 保证幂等与并发安全,仅首次确认写审计日志;
-     * 收款状态不影响订单状态流转(转账到账有时差,平台不经手资金不设硬闸门,spec FR-013)。
-     */
+    /** 骑手确认实际收款:仅本人已送达订单可确认,条件更新保证重复请求不重复写日志。 */
     @Transactional
     public void confirmPayment(Long riderId, Long orderId) {
         requireRider(riderId);
@@ -645,16 +697,14 @@ public class FlashDeliveryApplicationService {
         }
         if (Integer.valueOf(1).equals(order.getPaymentStatus())) return;
         Date now = new Date();
-        // 条件更新 0→1;并发重复确认只有一方成功,另一方按幂等成功返回
         if (orderMapper.update(null, new UpdateWrapper<FlashDeliveryOrder>()
-                .eq("id", orderId)
-                .eq("payment_status", 0)
-                .set("payment_status", 1)
-                .set("update_time", now)) == 1) {
+                .eq("id", orderId).eq("payment_status", 0)
+                .set("payment_status", 1).set("update_time", now)) == 1) {
             writeLog(orderId, order.getStatus(), order.getStatus(), "RIDER", riderId, "PAYMENT_CONFIRMED", now);
         }
     }
 
+    /** 查询送达时间早于截止点且未完成的订单,供自动完成任务分批处理。 */
     public List<FlashDeliveryOrder> autoCompleteCandidates(Date deadline, int limit) {
         return orderMapper.selectAutoCompletable(deadline, Math.min(Math.max(limit, 1), 500));
     }
@@ -664,42 +714,31 @@ 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");
+        Integer selectedVehicle = requireVehicleType(request.getVehicleType());
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate), selectedVehicle);
+        RouteDistance route = pickupRoute(pickups, request.getDelivery(), selectedVehicle);
         FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
                 route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
-        return new QuoteContext(pricing, route, breakdown);
+        return new QuoteContext(pricing, route, breakdown, pickups);
     }
 
     /** 查询指定时刻命中的运价配置,未配置时直接报错。 */
-    private FlashDeliveryPricing findPricingAt(String targetTime) {
-        FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime);
+    private FlashDeliveryPricing findPricingAt(String targetTime, Integer vehicleType) {
+        FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime, vehicleType);
         if (pricing == null) throw fail("flash.delivery.pricing.not.available");
         return pricing;
     }
 
     /** 查询命中运价;命中多条说明配置重叠,报错交由平台修正。 */
-    private FlashDeliveryPricing findPricingAtOrNull(String targetTime) {
-        List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime);
+    private FlashDeliveryPricing findPricingAtOrNull(String targetTime, Integer vehicleType) {
+        List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime, vehicleType);
         if (pricing == null || pricing.isEmpty()) return null;
         if (pricing.size() > 1) throw fail("flash.delivery.pricing.overlap");
         return pricing.get(0);
@@ -711,7 +750,8 @@ public class FlashDeliveryApplicationService {
         BigDecimal startingDistance = normalizeDistance(request.getStartingDistance());
         BigDecimal distance = normalizeDistance(request.getDistance());
         BigDecimal urgentRate = normalizeRate(request.getUrgentRate());
-        if (!validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
+        if (!FlashDeliveryVehicleType.isSupported(request.getVehicleType())
+                || !validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
                 || toMinute(request.getStartTime()) >= toMinute(request.getEndTime())
                 || startingDistance == null || startingDistance.signum() <= 0
                 || startingDistance.compareTo(MAX_PRICING_DISTANCE) > 0
@@ -729,7 +769,7 @@ public class FlashDeliveryApplicationService {
 
     /** 确保新时段与既有运价不重叠,excludedId 用于修改时排除自身。 */
     private void ensureNoPricingOverlap(FlashDeliveryPricingRequest request, Long excludedId) {
-        if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), excludedId) > 0) {
+        if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), request.getVehicleType(), excludedId) > 0) {
             throw fail("flash.delivery.pricing.overlap");
         }
     }
@@ -737,6 +777,7 @@ public class FlashDeliveryApplicationService {
     /** 将运价请求转换为实体,距离与比例统一保留两位小数。 */
     private FlashDeliveryPricing pricingFrom(FlashDeliveryPricingRequest request) {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
+        pricing.setVehicleType(request.getVehicleType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
@@ -773,19 +814,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) {
@@ -830,14 +858,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;
     }
@@ -849,7 +885,11 @@ public class FlashDeliveryApplicationService {
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
+        view.setVehicleType(vehicleTypeOf(order));
         view.setStatus(order.getStatus());
+        view.setPickupStopCount(order.getPickupStopCount());
+        view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setCanUserCancel(canUserCancel(order));
         view.setOrderVersion(order.getVersion());
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
@@ -881,7 +921,11 @@ public class FlashDeliveryApplicationService {
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
+        view.setVehicleType(vehicleTypeOf(order));
         view.setStatus(order.getStatus());
+        view.setPickupStopCount(order.getPickupStopCount());
+        view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setCanUserCancel(canUserCancel(order));
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
         view.setWeightRange(order.getWeightRange());
@@ -957,7 +1001,11 @@ public class FlashDeliveryApplicationService {
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
         view.setDeliveryType(order.getDeliveryType());
+        view.setVehicleType(vehicleTypeOf(order));
         view.setStatus(order.getStatus());
+        view.setPickupStopCount(order.getPickupStopCount());
+        view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setCanUserCancel(canUserCancel(order));
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
         view.setWeightRange(order.getWeightRange());
@@ -1140,22 +1188,7 @@ 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");
-        }
-    }
-
-    /**
-     * 支付方式经 031 闸门校验(闪送维度仅平台开关层——抢单前无收款方),
-     * 缺省现金 4;取代原先注释掉的白名单硬编码,改由平台配置管控(spec FR-004)。
-     */
+    /** 支付方式由 031 闸门校验;闪送维度按平台开关判定,缺省现金。 */
     private String normalizePayType(String payType) {
         String value = hasText(payType) ? payType.trim() : PAY_TYPE_CASH;
         paymentMethodGateService.assertUsable(value,
@@ -1201,23 +1234,20 @@ public class FlashDeliveryApplicationService {
     }
 
     /** 按页签构建骑手任务查询:newTask 限定可抢状态与时间窗,可选距离过滤和就近排序。 */
-    private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, String tab,
+    private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, Integer riderVehicleType, String tab,
                                                                 BigDecimal longitude, BigDecimal latitude,
-                                                                boolean orderResults,
-                                                                Set<String> acceptedPayTypes) {
+                                                                boolean orderResults, Set<String> acceptedPayTypes) {
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<>();
         Date now = new Date();
         switch (tab) {
             case "newTask" -> {
                 query.eq("status", WAITING_ACCEPTANCE).isNull("rider_id")
+                        .eq("vehicle_type", riderVehicleType)
                         .and(item -> item.eq("delivery_mode", "NOW")
                                 .or().le("scheduled_pickup_start_at", now));
-                // 031:只展示骑手接受其支付方式的订单(现金单恒可见,见 acceptedRiderPayTypes)
-                if (acceptedPayTypes.isEmpty()) {
-                    query.apply("1 = 0");
-                } else {
-                    query.in("pay_type", acceptedPayTypes);
-                }
+                // 同时满足车型和骑手已接受的支付方式,列表不能替代接单接口的资格复核。
+                if (acceptedPayTypes.isEmpty()) query.apply("1 = 0");
+                else query.in("pay_type", acceptedPayTypes);
                 if (longitude != null) {
                     Integer limitKm = newTaskDistanceLimit();
                     if (limitKm != null) {
@@ -1253,33 +1283,27 @@ public class FlashDeliveryApplicationService {
         return query;
     }
 
-    /** 校验骑手坐标:经纬度必须成对出现且在合法范围。 */
-    /**
-     * 骑手接受的支付方式展开为 payType 集合。
-     * 未设置(NULL)=平台闪送维度开放的全部组;现金 4 不纳入管控但闪送现金单恒可见,
-     * 因此始终并入集合(与闸门对现金直接放行的口径一致)。
-     */
+    /** 骑手未设置闪送收款方式时继承平台开放项;现金单按既有规则始终可见。 */
     private Set<String> acceptedRiderPayTypes(InfoUser rider) {
         String csv = rider.getFlashPayMethods();
         List<String> groups = new ArrayList<>();
-        if (csv == null || csv.trim().isEmpty()) {
+        if (!hasText(csv)) {
             for (PaymentMethodGateService.PayMethodOption option : paymentMethodGateService.listAvailable(
                     PaymentMethodGateService.GateScope.RIDER_FLASH, null, null)) {
                 if (!groups.contains(option.getMethodCode())) groups.add(option.getMethodCode());
             }
         } else {
             for (String part : csv.split(",")) {
-                if (!part.trim().isEmpty()) groups.add(part.trim());
+                if (hasText(part)) groups.add(part.trim());
             }
         }
         Set<String> payTypes = new LinkedHashSet<>();
         payTypes.add(PAY_TYPE_CASH);
-        for (String group : groups) {
-            payTypes.addAll(PaymentMethodGateService.payTypesOf(group));
-        }
+        for (String group : groups) payTypes.addAll(PaymentMethodGateService.payTypesOf(group));
         return payTypes;
     }
 
+    /** 校验骑手坐标:经纬度必须成对出现且在合法范围。 */
     private void validateRiderCoordinates(BigDecimal longitude, BigDecimal latitude) {
         if ((longitude == null) != (latitude == null)
                 || longitude != null && (longitude.compareTo(BigDecimal.valueOf(-180)) < 0
@@ -1368,7 +1392,329 @@ 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
+                    || hasText(pickup.getWeightRange()) && !WEIGHT_RANGES.contains(pickup.getWeightRange())
+                    || tooLong(pickup.getSpecification(), 255)) throw fail("flash.delivery.item.invalid");
+            if (sameAddressText(pickup, delivery)
+                    || pickup.getLatitude().compareTo(delivery.getLatitude()) == 0
+                    && pickup.getLongitude().compareTo(delivery.getLongitude()) == 0) {
+                throw fail("flash.delivery.address.same");
+            }
+        }
+    }
+
+    private RouteDistance pickupRoute(List<FlashDeliveryPickupRequest> pickups, FlashDeliveryAddressRequest delivery,
+                                      Integer vehicleType) {
+        List<GeoPoint> points = new ArrayList<>();
+        for (FlashDeliveryPickupRequest pickup : pickups) points.add(new GeoPoint(pickup.getLatitude(), pickup.getLongitude()));
+        points.add(new GeoPoint(delivery.getLatitude(), delivery.getLongitude()));
+        RouteDistance route = points.size() == 2
+                ? routeService.calculate(points.get(0), points.get(1), vehicleType)
+                : routeService.calculate(points, vehicleType);
+        if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
+        return route;
+    }
+
+    private Integer requireVehicleType(Integer vehicleType) {
+        if (!FlashDeliveryVehicleType.isSupported(vehicleType)) {
+            throw fail("flash.delivery.vehicle.type.invalid");
+        }
+        return vehicleType;
+    }
+
+    /** DDL 执行前存在的旧订单按用户确认的机车规则兼容。 */
+    private Integer vehicleTypeOf(FlashDeliveryOrder order) {
+        return FlashDeliveryVehicleType.isSupported(order.getVehicleType())
+                ? order.getVehicleType() : FlashDeliveryVehicleType.MOTORCYCLE;
+    }
+
+    /** 骑手资料复用既有 vehicleType;历史空值和非法值安全回落为机车。 */
+    private Integer riderVehicleType(InfoUser rider) {
+        try {
+            Integer value = Integer.valueOf(rider.getVehicleType());
+            return FlashDeliveryVehicleType.isSupported(value) ? value : FlashDeliveryVehicleType.MOTORCYCLE;
+        } catch (Exception ignored) {
+            return FlashDeliveryVehicleType.MOTORCYCLE;
+        }
+    }
+
+    private boolean isRiderVehicleCompatible(InfoUser rider, FlashDeliveryOrder order) {
+        return riderVehicleType(rider).equals(vehicleTypeOf(order));
+    }
+
+    /** 点数配置缺失、非整数或越界时,有效上限回退为1。 */
+    private int pickupStopLimit() {
+        String value = dictionaryValue("flash_pickup_stop_limit");
+        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) { }
 }

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

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

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

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

+ 5 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -52,7 +52,7 @@ public class OrderLifecycleService {
     private final IPosOrderLinePaymentService linePaymentService;
     private final IPosOrderLineRefundService lineRefundService;
     private final RiderDeliveryLockService riderDeliveryLockService;
-    private final RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+    private final RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
 
     public OrderLifecycleService(IPosOrderService posOrderService,
                                  OrderService billingService,
@@ -83,7 +83,7 @@ public class OrderLifecycleService {
                                  IPosOrderLinePaymentService linePaymentService,
                                  IPosOrderLineRefundService lineRefundService,
                                  RiderDeliveryLockService riderDeliveryLockService,
-                                 RiderDeliveryExclusivityService riderDeliveryExclusivityService) {
+                                 RiderDeliveryAcceptanceService riderDeliveryAcceptanceService) {
         this.posOrderService = posOrderService;
         this.billingService = billingService;
         this.orderLogHelper = orderLogHelper;
@@ -92,7 +92,7 @@ public class OrderLifecycleService {
         this.linePaymentService = linePaymentService;
         this.lineRefundService = lineRefundService;
         this.riderDeliveryLockService = riderDeliveryLockService;
-        this.riderDeliveryExclusivityService = riderDeliveryExclusivityService;
+        this.riderDeliveryAcceptanceService = riderDeliveryAcceptanceService;
     }
 
     public AdminOrderStatusContext getStatusContext(Long id) {
@@ -159,7 +159,8 @@ public class OrderLifecycleService {
     @Transactional(rollbackFor = Exception.class)
     public void acceptDeliveryByRider(Long id, Long riderId) {
         riderDeliveryLockService.withLock(riderId, () -> {
-            riderDeliveryExclusivityService.assertCanAcceptFood(riderId);
+            // 资格、外卖/闪送容量与急送独占必须和订单更新处于同一骑手锁内,避免并发接单绕过上限。
+            riderDeliveryAcceptanceService.assertCanAcceptFood(riderId);
             acceptDeliveryByRiderLocked(id, riderId);
             return null;
         });

+ 0 - 16
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -79,10 +79,6 @@ public class PosOrderQsOprateController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         PayPush push = new PayPush();
         String qsId = jwtUtil.getusid(token);
-        InfoUser qsRider = requireRider(qsId);
-        if (!qsRider.supportsDeliveryType("FOOD")) {
-            throw new ServiceException(MessageUtils.message("no.user.delivery.type.not.enabled"));
-        }
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
         if (order == null) {
             throw new ServiceException(MessageUtils.message("no.order.not.found"));
@@ -192,22 +188,10 @@ public class PosOrderQsOprateController extends BaseController {
             if (lock.tryLock(20L, 10L, TimeUnit.SECONDS)) {
                 boolean releaseInfinally = true;
                 try {
-                    requireRider(qsId);
                     PosOrder orst = posOrderService.getById(posOrder.getId());
                     if (orst.getDeliveryStatus() != null && orst.getDeliveryStatus() == 1L && posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 1L) {
                         throw new ServiceException(MessageUtils.message("no.order.snatched"));
                     }
-                    if (posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 1L) {
-                        QueryWrapper<PosOrder> wrapper = new QueryWrapper<>();
-                        wrapper.eq("qs_id", qsId);
-                        wrapper.in("delivery_status", 1, 2);
-                        wrapper.in("state", 0, 1, 2);
-                        wrapper.eq("after_sale_status", 0);
-                        List<PosOrder> orsts = posOrderService.list(wrapper);
-                        if (orsts.size() > 0) {
-                            throw new ServiceException(MessageUtils.message("no.exist.undelivered.order"));
-                        }
-                    }
                     boolean delivered = posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 3L;
                     boolean org;
                     if (delivered) {

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -819,6 +819,10 @@ public class PosOrderShOprateController extends BaseController {
         if (type != null && !type.isEmpty()) {
             wrapper.eq(PosOrder::getType, Long.valueOf(type));
         }
+        // 外送订单须骑手已接单才对商家可见;自取、堂食不受配送状态限制。
+        wrapper.and(w -> w.ne(PosOrder::getType, 0L)
+                .or(delivery -> delivery.in(PosOrder::getDeliveryStatus, 1L, 2L, 3L)
+                        .isNotNull(PosOrder::getQsId)));
         switch (tab == null ? "" : tab) {
             case "pending":
                 wrapper.eq(PosOrder::getState, 0L)

+ 156 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryAcceptanceService.java

@@ -0,0 +1,156 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
+import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.PICKED_UP;
+
+/**
+ * 骑手接单统一策略:在骑手级锁内检查账号资格、配送类型、进行中任务容量和急送独占。
+ * 调用方必须先取得 {@link RiderDeliveryLockService} 的同一骑手锁,避免并发请求同时绕过容量检查。
+ */
+@Service
+public class RiderDeliveryAcceptanceService {
+    static final String FOOD_LIMIT_DICT = "sys_rider_food_active_order_limit";
+    static final String FLASH_LIMIT_DICT = "sys_rider_flash_active_order_limit";
+    static final String TOTAL_LIMIT_DICT = "sys_rider_total_active_order_limit";
+    private static final String URGENT = "URGENT";
+    private static final int DEFAULT_LIMIT = 1;
+    private static final int MAX_LIMIT = 20;
+    private static final Logger log = LoggerFactory.getLogger(RiderDeliveryAcceptanceService.class);
+
+    private final IInfoUserService riders;
+    private final IPosOrderService foodOrders;
+    private final FlashDeliveryOrderMapper flashOrders;
+
+    public RiderDeliveryAcceptanceService(IInfoUserService riders,
+                                           IPosOrderService foodOrders,
+                                           FlashDeliveryOrderMapper flashOrders) {
+        this.riders = riders;
+        this.foodOrders = foodOrders;
+        this.flashOrders = flashOrders;
+    }
+
+    /** 校验骑手能否再承接一笔外卖;进行中外卖和跨类型总量均受字典上限控制。 */
+    public void assertCanAcceptFood(Long riderId) {
+        requireEligibleRider(riderId, "FOOD");
+        ActiveTasks active = activeTasks(riderId);
+        if (active.urgentFlash() > 0) throw exclusiveConflict();
+        if (active.food() >= configuredLimit(FOOD_LIMIT_DICT)) {
+            throw new ServiceException(MessageUtils.message("rider.accept.food.limit.reached"));
+        }
+        assertTotalCapacity(active);
+    }
+
+    /** 校验骑手能否承接闪送;加急闪送保持跨外卖、闪送的一对一独占。 */
+    public void assertCanAcceptFlash(Long riderId, String deliveryType) {
+        requireEligibleRider(riderId, "FLASH");
+        ActiveTasks active = activeTasks(riderId);
+        if (URGENT.equals(deliveryType)) {
+            if (active.food() > 0 || active.flash() > 0) throw exclusiveConflict();
+            return;
+        }
+        if (active.urgentFlash() > 0) throw exclusiveConflict();
+        if (active.flash() >= configuredLimit(FLASH_LIMIT_DICT)) {
+            throw new ServiceException(MessageUtils.message("rider.accept.flash.limit.reached"));
+        }
+        assertTotalCapacity(active);
+    }
+
+    /**
+     * 统一账号资格口径。空配送类型仍沿用 InfoUser 的历史兼容规则,但停用、未审核和离线账号均不得接单。
+     */
+    private InfoUser requireEligibleRider(Long riderId, String deliveryType) {
+        InfoUser rider = riderId == null ? null : riders.getById(riderId);
+        if (rider == null || !"2".equals(rider.getUserType())) {
+            throw new ServiceException(MessageUtils.message("rider.operation.role.required"));
+        }
+        if (!"1".equals(rider.getAuditStatus())) {
+            throw new ServiceException(MessageUtils.message("no.user.state.no.audit"));
+        }
+        if (!"0".equals(rider.getStatus())) {
+            throw new ServiceException(MessageUtils.message("no.user.stop"));
+        }
+        if (!"0".equals(rider.getOffline())) {
+            throw new ServiceException(MessageUtils.message("rider.accept.offline"));
+        }
+        if (!rider.supportsDeliveryType(deliveryType)) {
+            String key = "FOOD".equals(deliveryType)
+                    ? "no.user.delivery.type.not.enabled"
+                    : "flash.delivery.rider.type.not.enabled";
+            throw new ServiceException(MessageUtils.message(key));
+        }
+        return rider;
+    }
+
+    /** 一次读取两类进行中任务,所有容量判断使用同一数据库快照口径。 */
+    private ActiveTasks activeTasks(Long riderId) {
+        QueryWrapper<PosOrder> foodQuery = new QueryWrapper<PosOrder>()
+                .eq("qs_id", riderId)
+                .in("delivery_status", 1, 2)
+                .in("state", 0, 1, 2)
+                .eq("after_sale_status", 0);
+        QueryWrapper<FlashDeliveryOrder> flashQuery = new QueryWrapper<FlashDeliveryOrder>()
+                .eq("rider_id", riderId)
+                .in("status", ACCEPTED, PICKED_UP);
+        QueryWrapper<FlashDeliveryOrder> urgentQuery = new QueryWrapper<FlashDeliveryOrder>()
+                .eq("rider_id", riderId)
+                .in("status", ACCEPTED, PICKED_UP)
+                .eq("delivery_type", URGENT);
+        return new ActiveTasks(safe(foodOrders.count(foodQuery)),
+                safe(flashOrders.selectCount(flashQuery)), safe(flashOrders.selectCount(urgentQuery)));
+    }
+
+    private void assertTotalCapacity(ActiveTasks active) {
+        if (active.food() + active.flash() >= configuredLimit(TOTAL_LIMIT_DICT)) {
+            throw new ServiceException(MessageUtils.message("rider.accept.total.limit.reached"));
+        }
+    }
+
+    /**
+     * 容量字典只接受 1 至 20 的整数。缺失、空值、越界、解析或缓存异常一律保守回退为 1,绝不放宽为不限单。
+     */
+    private int configuredLimit(String dictType) {
+        List<SysDictData> values;
+        try {
+            values = DictUtils.getDictCache(dictType);
+        } catch (RuntimeException exception) {
+            log.warn("Failed to read rider acceptance limit, fallback to 1, dictType={}", dictType, exception);
+            return DEFAULT_LIMIT;
+        }
+        if (values == null || values.isEmpty()) return DEFAULT_LIMIT;
+        String raw = values.get(0).getDictValue();
+        if (raw == null || !raw.trim().matches("[0-9]+")) return DEFAULT_LIMIT;
+        try {
+            int value = Integer.parseInt(raw.trim());
+            return value >= 1 && value <= MAX_LIMIT ? value : DEFAULT_LIMIT;
+        } catch (NumberFormatException ignored) {
+            return DEFAULT_LIMIT;
+        }
+    }
+
+    private long safe(Long value) {
+        return value == null ? 0L : value;
+    }
+
+    private ServiceException exclusiveConflict() {
+        return new ServiceException(MessageUtils.message("flash.delivery.rider.exclusive.conflict"));
+    }
+
+    private record ActiveTasks(long food, long flash, long urgentFlash) { }
+}

+ 0 - 61
ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java

@@ -1,61 +0,0 @@
-package com.ruoyi.app.order;
-
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
-import com.ruoyi.common.exception.ServiceException;
-import com.ruoyi.common.utils.MessageUtils;
-import com.ruoyi.system.domain.PosOrder;
-import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
-import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
-import com.ruoyi.system.service.IPosOrderService;
-import org.springframework.stereotype.Service;
-
-import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
-import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.PICKED_UP;
-
-/** 统一判断急送订单与外卖、普通闪送之间的骑手独占规则。 */
-@Service
-public class RiderDeliveryExclusivityService {
-    private static final String URGENT = "URGENT";
-
-    private final IPosOrderService foodOrders;
-    private final FlashDeliveryOrderMapper flashOrders;
-
-    public RiderDeliveryExclusivityService(IPosOrderService foodOrders,
-                                           FlashDeliveryOrderMapper flashOrders) {
-        this.foodOrders = foodOrders;
-        this.flashOrders = flashOrders;
-    }
-
-    public void assertCanAcceptFood(Long riderId) {
-        if (hasActiveFlash(riderId, true)) throw conflict();
-    }
-
-    public void assertCanAcceptFlash(Long riderId, String deliveryType) {
-        if (URGENT.equals(deliveryType)) {
-            if (hasActiveFood(riderId) || hasActiveFlash(riderId, false)) throw conflict();
-            return;
-        }
-        if (hasActiveFlash(riderId, true)) throw conflict();
-    }
-
-    private boolean hasActiveFood(Long riderId) {
-        QueryWrapper<PosOrder> query = new QueryWrapper<PosOrder>()
-                .eq("qs_id", riderId)
-                .in("delivery_status", 1, 2)
-                .in("state", 0, 1, 2)
-                .eq("after_sale_status", 0);
-        return foodOrders.count(query) > 0;
-    }
-
-    private boolean hasActiveFlash(Long riderId, boolean urgentOnly) {
-        QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
-                .eq("rider_id", riderId)
-                .in("status", ACCEPTED, PICKED_UP);
-        if (urgentOnly) query.eq("delivery_type", URGENT);
-        return flashOrders.selectCount(query) > 0;
-    }
-
-    private ServiceException conflict() {
-        return new ServiceException(MessageUtils.message("flash.delivery.rider.exclusive.conflict"));
-    }
-}

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

@@ -369,3 +369,22 @@ flash.delivery.payment.confirm.not.allowed=訂單尚未送達,不能確認收
 flash.delivery.order.version.invalid=請提供有效的訂單版本
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍
 flash.delivery.pay.type.invalid=支付方式僅支援現金或線下轉帳
+
+flash.delivery.vehicle.type.invalid=不支援的配送車型
+flash.delivery.rider.vehicle.not.match=騎手配送車型與此訂單不相符
+rider.accept.offline=騎手目前已離線,不能接單
+rider.accept.food.limit.reached=進行中的外賣訂單已達接單上限
+rider.accept.flash.limit.reached=進行中的閃送訂單已達接單上限
+rider.accept.total.limit.reached=進行中的配送任務總數已達接單上限
+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=請記錄已取物品去向及交接憑證。

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

@@ -368,3 +368,22 @@ flash.delivery.payment.confirm.not.allowed=The order has not been delivered yet.
 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
+
+flash.delivery.vehicle.type.invalid=Unsupported vehicle type
+flash.delivery.rider.vehicle.not.match=The rider vehicle type does not match this order
+rider.accept.offline=The rider is offline and cannot accept orders
+rider.accept.food.limit.reached=The active food-delivery order limit has been reached
+rider.accept.flash.limit.reached=The active flash-delivery order limit has been reached
+rider.accept.total.limit.reached=The total active delivery task limit has been reached
+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.

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

@@ -369,3 +369,22 @@ no.order.selfdelivery.unpaid=ออเดอร์ยังไม่ได้ช
 no.store.selfdelivery.hours.invalid=การตั้งค่าช่วงเวลาจัดส่งด้วยตนเองไม่ถูกต้อง
 no.store.stall.selfdelivery.readonly=วิธีการจัดส่งของแผงลอยถูกกำหนดโดยเจ้าของตลาดกลางคืน
 no.user.phone.blank=กรุณาระบุหมายเลขโทรศัพท์
+
+flash.delivery.vehicle.type.invalid=ไม่รองรับประเภทยานพาหนะจัดส่งนี้
+flash.delivery.rider.vehicle.not.match=ประเภทยานพาหนะของไรเดอร์ไม่ตรงกับคำสั่งซื้อนี้
+rider.accept.offline=ไรเดอร์ออฟไลน์อยู่และไม่สามารถรับงานได้
+rider.accept.food.limit.reached=จำนวนออเดอร์ส่งอาหารที่กำลังดำเนินการถึงขีดจำกัดแล้ว
+rider.accept.flash.limit.reached=จำนวนออเดอร์ส่งด่วนที่กำลังดำเนินการถึงขีดจำกัดแล้ว
+rider.accept.total.limit.reached=จำนวนงานจัดส่งที่กำลังดำเนินการทั้งหมดถึงขีดจำกัดแล้ว
+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=บันทึกปลายทางของสินค้าที่รับแล้วและหลักฐานส่งมอบ

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

@@ -369,3 +369,22 @@ flash.delivery.payment.confirm.not.allowed=订单尚未送达,不能确认收
 flash.delivery.order.version.invalid=请提供有效的订单版本
 flash.delivery.tip.additional.invalid=追加小费必须为正整数且不能超出金额范围
 flash.delivery.pay.type.invalid=支付方式仅支持现金或线下转账
+
+flash.delivery.vehicle.type.invalid=不支持的配送车型
+flash.delivery.rider.vehicle.not.match=骑手配送车型与该订单不匹配
+rider.accept.offline=骑手当前已离线,不能接单
+rider.accept.food.limit.reached=进行中的外卖订单已达到接单上限
+rider.accept.flash.limit.reached=进行中的闪送订单已达到接单上限
+rider.accept.total.limit.reached=进行中的配送任务总数已达到接单上限
+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=请记录已取物品去向及交接凭证。

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

@@ -369,3 +369,22 @@ flash.delivery.payment.confirm.not.allowed=訂單尚未送達,不能確認收
 flash.delivery.order.version.invalid=請提供有效的訂單版本
 flash.delivery.tip.additional.invalid=追加小費必須為正整數且不能超出金額範圍
 flash.delivery.pay.type.invalid=支付方式僅支援現金或線下轉帳
+
+flash.delivery.vehicle.type.invalid=不支援的配送車型
+flash.delivery.rider.vehicle.not.match=騎手配送車型與此訂單不相符
+rider.accept.offline=騎手目前已離線,不能接單
+rider.accept.food.limit.reached=進行中的外賣訂單已達接單上限
+rider.accept.flash.limit.reached=進行中的閃送訂單已達接單上限
+rider.accept.total.limit.reached=進行中的配送任務總數已達接單上限
+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

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

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

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

@@ -11,12 +11,11 @@ class FlashDeliveryRiderOrderListViewTest {
     void userListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryUserOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "vehicleType", "status", "packageType",
                 "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
                 "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
                 "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
-                "estimatedDurationSeconds", "amount", "currency", "paymentStatus", "deliveredAt", "createTime",
-                "orderVersion");
+                "estimatedDurationSeconds", "amount", "currency", "paymentStatus", "deliveredAt", "createTime", "orderVersion", "pickupStopCount", "pickedUpStopCount", "canUserCancel");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
 
@@ -24,13 +23,13 @@ class FlashDeliveryRiderOrderListViewTest {
     void riderListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryRiderOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "vehicleType", "status", "packageType",
                 "quantity", "weightRange", "specification", "deliveryMode",
                 "scheduledPickupStartAt", "scheduledPickupEndAt",
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",
                 "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",
                 "estimatedDurationSeconds", "baseDeliveryFee", "distanceFee", "urgentFee",
-                "tipAmount", "amount", "currency", "payType", "paymentStatus", "createTime");
+                "tipAmount", "amount", "currency", "paymentStatus", "createTime", "payType", "pickupStopCount", "pickedUpStopCount", "canUserCancel");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
 

+ 90 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/route/FlashDeliveryRouteServiceTest.java

@@ -3,15 +3,88 @@ 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 com.ruoyi.system.domain.flash.FlashDeliveryVehicleType;
 
 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, FlashDeliveryVehicleType.MOTORCYCLE)).thenReturn(new RouteDistance(32100, 2200, "ROUTE"));
+        assertEquals(new RouteDistance(32100, 2200, "ROUTE"),
+                new FlashDeliveryRouteService(provider).calculate(points));
+        verify(provider).route(points, FlashDeliveryVehicleType.MOTORCYCLE);
+        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, vehicleType) -> {
+            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, vehicleType) -> {
+            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, vehicleType) -> {
+            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");
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> new RouteDistance(5210, 780, "ROUTE");
         FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
 
         RouteDistance result = service.calculate(point("25.0330", "121.5645"),
@@ -24,7 +97,7 @@ class FlashDeliveryRouteServiceTest {
 
     @Test
     void fallsBackToStraightLineWhenProviderFails() {
-        RouteDistanceProvider provider = (a, b, c, d) -> { throw new IllegalStateException("down"); };
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> { throw new IllegalStateException("down"); };
         FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
 
         RouteDistance result = service.calculate(point("25.0330", "121.5645"),
@@ -34,6 +107,21 @@ class FlashDeliveryRouteServiceTest {
         assertEquals("STRAIGHT_LINE", result.source());
     }
 
+    @Test
+    void estimatesStraightLineFallbackDurationBySelectedVehicle() {
+        RouteDistanceProvider provider = (a, b, c, d, vehicleType) -> { throw new IllegalStateException("down"); };
+        FlashDeliveryRouteService service = new FlashDeliveryRouteService(provider);
+
+        RouteDistance motorcycle = service.calculate(point("0", "0"), point("0", "0.01"),
+                FlashDeliveryVehicleType.MOTORCYCLE);
+        RouteDistance car = service.calculate(point("0", "0"), point("0", "0.01"),
+                FlashDeliveryVehicleType.CAR);
+
+        assertEquals("STRAIGHT_LINE", motorcycle.source());
+        assertEquals(161, motorcycle.durationSeconds());
+        assertEquals(134, car.durationSeconds());
+    }
+
     private GeoPoint point(String latitude, String longitude) {
         return new GeoPoint(new BigDecimal(latitude), new BigDecimal(longitude));
     }

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

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

+ 155 - 115
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -15,8 +15,9 @@ import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
-import com.ruoyi.app.order.RiderDeliveryExclusivityService;
+import com.ruoyi.app.order.RiderDeliveryAcceptanceService;
 import com.ruoyi.app.order.RiderDeliveryLockService;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
@@ -55,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import com.ruoyi.common.exception.ServiceException;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
@@ -90,8 +92,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteUsesServerRouteAndCurrentTimePricing() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
 
         var quote = fixture.service.quote(request());
 
@@ -106,8 +108,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteRejectsRoutesLongerThanFortyKilometres() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
     }
@@ -169,8 +171,8 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
                 .thenReturn(List.of(88L));
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
             order.setId(99L);
@@ -214,6 +216,52 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(0L, saved.getValue().getTipAmount());
     }
 
+    @Test
+    void confirmPaymentRequiresDeliveredState() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 9L);
+        FlashDeliveryOrder pickedUp = order(10L, "PICKED_UP");
+        pickedUp.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(pickedUp);
+
+        assertThrows(ServiceException.class, () -> fixture.service.confirmPayment(9L, 10L));
+        verify(fixture.orderMapper, never()).update(any(), any());
+    }
+
+    @Test
+    void confirmPaymentMarksPaidAndWritesLog() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 9L);
+        FlashDeliveryOrder delivered = order(10L, "DELIVERED");
+        delivered.setRiderId(9L);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(delivered);
+        when(fixture.orderMapper.update(any(), any())).thenReturn(1);
+        when(fixture.logMapper.insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class))).thenReturn(1);
+
+        fixture.service.confirmPayment(9L, 10L);
+
+        verify(fixture.orderMapper).update(any(), any());
+        ArgumentCaptor<com.ruoyi.system.domain.flash.FlashDeliveryOrderLog> logCaptor =
+                ArgumentCaptor.forClass(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class);
+        verify(fixture.logMapper).insert(logCaptor.capture());
+        assertEquals("PAYMENT_CONFIRMED", logCaptor.getValue().getReason());
+    }
+
+    @Test
+    void confirmPaymentIsIdempotentOnceConfirmed() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 9L);
+        FlashDeliveryOrder confirmed = order(10L, "DELIVERED");
+        confirmed.setRiderId(9L);
+        confirmed.setPaymentStatus(1);
+        when(fixture.orderMapper.selectById(10L)).thenReturn(confirmed);
+
+        fixture.service.confirmPayment(9L, 10L);
+
+        verify(fixture.orderMapper, never()).update(any(), any());
+        verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
+    }
+
     @Test
     @SuppressWarnings({"rawtypes", "unchecked"})
     void userListOnlyFiltersByOwnerAndReturnsCardFields() {
@@ -244,8 +292,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createDoesNotBindReceiverWhenNormalizedPhoneMatchesMultipleUsers() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("886912345678"))
                 .thenReturn(List.of(88L, 99L));
         doAnswer(invocation -> {
@@ -266,8 +314,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createLeavesReceiverUnboundWhenPhoneHasNoRegisteredUser() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
                 .thenReturn(List.of());
         doAnswer(invocation -> {
@@ -590,7 +638,7 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void flashAcceptUsesRiderLockAndChecksExclusivityBeforeAtomicUpdate() {
+    void flashAcceptUsesRiderLockAndChecksAcceptancePolicyBeforeAtomicUpdate() {
         Fixture fixture = new Fixture();
         rider(fixture, 8L);
         FlashDeliveryOrder waiting = order(10L, "WAITING_ACCEPTANCE");
@@ -601,19 +649,19 @@ class FlashDeliveryApplicationServiceTest {
 
         fixture.service.accept(8L, 10L);
 
-        InOrder order = inOrder(fixture.lockService, fixture.exclusivityService, fixture.orderMapper);
+        InOrder order = inOrder(fixture.lockService, fixture.acceptanceService, fixture.orderMapper);
         order.verify(fixture.lockService).withLock(eq(8L), any(RiderDeliveryLockService.LockedCall.class));
-        order.verify(fixture.exclusivityService).assertCanAcceptFlash(8L, "NORMAL");
+        order.verify(fixture.acceptanceService).assertCanAcceptFlash(8L, "NORMAL");
         order.verify(fixture.orderMapper).accept(eq(10L), eq(8L), any(Date.class));
     }
 
     @Test
-    void flashExclusiveConflictDoesNotUpdateOrder() {
+    void flashAcceptanceConflictDoesNotUpdateOrder() {
         Fixture fixture = new Fixture();
         rider(fixture, 8L);
         when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE"));
         doThrow(new ServiceException("exclusive"))
-                .when(fixture.exclusivityService).assertCanAcceptFlash(8L, "NORMAL");
+                .when(fixture.acceptanceService).assertCanAcceptFlash(8L, "NORMAL");
 
         assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
 
@@ -655,7 +703,7 @@ class FlashDeliveryApplicationServiceTest {
     void concurrentPricingUpdateIsRejected() {
         Fixture fixture = new Fixture();
         when(fixture.pricingMapper.selectById(1L)).thenReturn(pricing());
-        when(fixture.pricingMapper.countOverlapping("00:00", "12:00", 1L)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping(eq("00:00"), eq("12:00"), anyInt(), eq(1L))).thenReturn(0);
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
         FlashDeliveryPricingRequest request = pricingRequest();
         request.setEndTime("12:00");
@@ -670,7 +718,7 @@ class FlashDeliveryApplicationServiceTest {
     void overlappingPricingPeriodIsRejectedBeforeInsert() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(1);
+        when(fixture.pricingMapper.countOverlapping(eq("00:00"), eq("24:00"), anyInt(), isNull())).thenReturn(1);
 
         assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request));
 
@@ -708,7 +756,7 @@ class FlashDeliveryApplicationServiceTest {
     void createPricingPersistsImmediatelyActiveTimePeriod() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping(eq("00:00"), eq("24:00"), anyInt(), isNull())).thenReturn(0);
 
         fixture.service.createPricing(7L, request);
 
@@ -720,7 +768,7 @@ class FlashDeliveryApplicationServiceTest {
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("00:00", "24:00", null);
+                .countOverlapping(eq("00:00"), eq("24:00"), anyInt(), isNull());
         writeOrder.verify(fixture.pricingMapper).insert(any(FlashDeliveryPricing.class));
     }
 
@@ -737,18 +785,18 @@ class FlashDeliveryApplicationServiceTest {
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
 
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt());
     }
 
     @Test
     void multipleCurrentPricingPeriodsFailExplicitlyBeforeRouteLookup() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString()))
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt()))
                 .thenReturn(List.of(pricing(), pricing()));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
 
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt());
     }
 
     @Test
@@ -764,7 +812,7 @@ class FlashDeliveryApplicationServiceTest {
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("00:00", "24:00", 1L);
+                .countOverlapping(eq("00:00"), eq("24:00"), anyInt(), eq(1L));
         writeOrder.verify(fixture.pricingMapper).update(isNull(), any(Wrapper.class));
     }
 
@@ -782,8 +830,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void urgentQuoteReturnsSeparatedFeesAndUsesScheduledPickupTime() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime("16:30")).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(6000, 900, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(eq("16:30"), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(6000, 900, "ROUTE"));
         FlashDeliveryQuoteRequest request = request();
         request.setDeliveryType("URGENT");
         request.setTipAmount(5L);
@@ -800,18 +848,19 @@ class FlashDeliveryApplicationServiceTest {
         var quote = fixture.service.quote(request);
 
         assertEquals(45L, quote.getDistanceFee());
-        assertEquals(135L, quote.getBaseDeliveryFee());
+        // 起步价与 45 元距离附加费分开返回,避免前端重复相加。
+        assertEquals(90L, quote.getBaseDeliveryFee());
         assertEquals(27L, quote.getUrgentFee());
         assertEquals(5L, quote.getTipAmount());
         assertEquals(167L, quote.getAmount());
-        verify(fixture.pricingMapper).selectAtTime("16:30");
+        verify(fixture.pricingMapper).selectAtTime(eq("16:30"), anyInt());
     }
 
     @Test
     void createRejectsChangedQuoteAndReturnsLatestQuoteWithoutInsert() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         FlashDeliveryCreateRequest request = createRequest();
         request.setQuotedAmount(91L);
 
@@ -826,8 +875,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createDefaultsPayTypeToCashAndStoresTransferSelection() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
             order.setId(99L);
@@ -852,10 +901,13 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createRejectsUnsupportedPayType() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         FlashDeliveryCreateRequest request = createRequest();
         request.setPayType("2");
+        org.mockito.Mockito.doThrow(new ServiceException("flash.delivery.pay.type.invalid"))
+                .when(fixture.paymentMethodGateService).assertUsable(eq("2"),
+                        eq(com.ruoyi.system.service.PaymentMethodGateService.GateScope.RIDER_FLASH), isNull(), isNull());
 
         assertThrows(ServiceException.class, () -> fixture.service.create(7L, request));
 
@@ -884,8 +936,8 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteAcceptsAllFourWeightRanges() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
 
         for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
                 "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
@@ -901,8 +953,8 @@ class FlashDeliveryApplicationServiceTest {
         for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
                 "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
             Fixture fixture = new Fixture();
-            when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
-            when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+            when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+            when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
             doAnswer(invocation -> {
                 FlashDeliveryOrder order = invocation.getArgument(0);
                 order.setId(99L);
@@ -923,9 +975,22 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void quoteRejectsMissingAndUnknownWeightRanges() {
+    void quoteAllowsMissingWeightRange() {
         Fixture fixture = new Fixture();
-        for (String weightRange : new String[]{null, "", "SMALL", "OVER_20_KG"}) {
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt()))
+                .thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+
+        FlashDeliveryQuoteRequest request = request();
+        request.setWeightRange(null);
+
+        assertDoesNotThrow(() -> fixture.service.quote(request));
+    }
+
+    @Test
+    void quoteRejectsUnknownWeightRangeWhenProvided() {
+        Fixture fixture = new Fixture();
+        for (String weightRange : new String[]{"", "SMALL", "OVER_20_KG"}) {
             FlashDeliveryQuoteRequest request = request();
             request.setWeightRange(weightRange);
             assertThrows(ServiceException.class, () -> fixture.service.quote(request), String.valueOf(weightRange));
@@ -936,6 +1001,7 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
         request.setServiceType("HELP_SEND");
         request.setDeliveryType("NORMAL");
+        request.setVehicleType(1);
         request.setPackageType("DOCUMENT");
         request.setQuantity(1);
         request.setWeightRange("UP_TO_5_KG");
@@ -953,6 +1019,7 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryQuoteRequest quote = request();
         request.setServiceType(quote.getServiceType());
         request.setDeliveryType(quote.getDeliveryType());
+        request.setVehicleType(quote.getVehicleType());
         request.setPackageType(quote.getPackageType());
         request.setQuantity(quote.getQuantity());
         request.setWeightRange(quote.getWeightRange());
@@ -1004,6 +1071,7 @@ class FlashDeliveryApplicationServiceTest {
 
     private FlashDeliveryPricingRequest pricingRequest() {
         FlashDeliveryPricingRequest request = new FlashDeliveryPricingRequest();
+        request.setVehicleType(1);
         request.setStartTime("00:00");
         request.setEndTime("24:00");
         request.setStartingDistance(new BigDecimal("3.00"));
@@ -1035,51 +1103,6 @@ class FlashDeliveryApplicationServiceTest {
         for (String status : statuses) assertTrue(parameters.contains(status), tab + ":" + status);
     }
 
-    @Test
-    void confirmPaymentRequiresDeliveredState() {
-        Fixture fixture = new Fixture();
-        rider(fixture, 9L);
-        FlashDeliveryOrder pickedUp = order(10L, "PICKED_UP");
-        pickedUp.setRiderId(9L);
-        when(fixture.orderMapper.selectById(10L)).thenReturn(pickedUp);
-
-        assertThrows(ServiceException.class, () -> fixture.service.confirmPayment(9L, 10L));
-        verify(fixture.orderMapper, never()).update(any(), any());
-    }
-
-    @Test
-    void confirmPaymentMarksPaidAndWritesLog() {
-        Fixture fixture = new Fixture();
-        rider(fixture, 9L);
-        FlashDeliveryOrder delivered = order(10L, "DELIVERED");
-        delivered.setRiderId(9L);
-        when(fixture.orderMapper.selectById(10L)).thenReturn(delivered);
-        when(fixture.orderMapper.update(any(), any())).thenReturn(1);
-        when(fixture.logMapper.insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class))).thenReturn(1);
-
-        fixture.service.confirmPayment(9L, 10L);
-
-        verify(fixture.orderMapper).update(any(), any());
-        ArgumentCaptor<com.ruoyi.system.domain.flash.FlashDeliveryOrderLog> logCaptor = ArgumentCaptor.forClass(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class);
-        verify(fixture.logMapper).insert(logCaptor.capture());
-        assertEquals("PAYMENT_CONFIRMED", logCaptor.getValue().getReason());
-    }
-
-    @Test
-    void confirmPaymentIsIdempotentOnceConfirmed() {
-        Fixture fixture = new Fixture();
-        rider(fixture, 9L);
-        FlashDeliveryOrder confirmed = order(10L, "DELIVERED");
-        confirmed.setRiderId(9L);
-        confirmed.setPaymentStatus(1);
-        when(fixture.orderMapper.selectById(10L)).thenReturn(confirmed);
-
-        fixture.service.confirmPayment(9L, 10L);
-
-        verify(fixture.orderMapper, never()).update(any(), any());
-        verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
-    }
-
     private void rider(Fixture fixture, Long riderId) {
         InfoUser rider = new InfoUser();
         rider.setUserId(riderId);
@@ -1091,6 +1114,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");
@@ -1156,10 +1180,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));
@@ -1167,14 +1191,15 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(4500, quote.getDistanceMeters());
         assertEquals(900, quote.getEstimatedDurationSeconds());
         assertEquals(23L, quote.getDistanceFee());
-        assertEquals(113L, quote.getBaseDeliveryFee());
+        // 基础配送费只表示起步价,距离附加费单独返回。
+        assertEquals(90L, quote.getBaseDeliveryFee());
         assertEquals(10L, quote.getTipAmount());
         assertEquals(123L, quote.getAmount());
         assertEquals(2, quote.getOrderVersion());
         assertEquals(1, quote.getPricingVersion());
         assertEquals("pickup road", order.getPickupAddress());
-        // 改址报价必须查询运价表(按定价时点取最新),不再沿用订单快照
-        verify(fixture.pricingMapper).selectAtTime(anyString());
+        // 地址变化只重算路线,不查询最新运价。
+        verify(fixture.pricingMapper, never()).selectAtTime(anyString(), anyInt());
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
         verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
     }
@@ -1256,7 +1281,7 @@ class FlashDeliveryApplicationServiceTest {
             assertThrows(ServiceException.class, () -> fixture.service.updateAddress(user, 10L, new FlashDeliveryAddressConfirmRequest()));
             assertThrows(ServiceException.class, () -> fixture.service.addTip(user, 10L, tipRequest(5L, 2)));
         }
-        verify(fixture.routeService, never()).calculate(any(), any());
+        verify(fixture.routeService, never()).calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt());
         verify(fixture.orderMapper, never()).updateWaitingTip(any(), any());
     }
 
@@ -1300,7 +1325,7 @@ class FlashDeliveryApplicationServiceTest {
         input.setDelivery(request().getDelivery());
         input.getPickup().setPhone("");
         assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, input));
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1000, "GOOGLE"));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(40001, 1000, "GOOGLE"));
         assertThrows(ServiceException.class, () -> fixture.service.quoteAddress(7L, 10L, addressChange()));
         verify(fixture.orderMapper, never()).updateWaitingAddress(any(), any());
     }
@@ -1331,8 +1356,8 @@ class FlashDeliveryApplicationServiceTest {
         assertThrows(ServiceException.class, () -> fixture.service.addTip(7L, 10L, tipRequest(20L, 2)));
         assertEquals(30L, order.getTipAmount());
         verify(fixture.orderMapper).updateWaitingTip(any(), eq(2));
-        verify(fixture.routeService, never()).calculate(any(), any());
-        verify(fixture.pricingMapper, never()).selectAtTime(anyString());
+        verify(fixture.routeService, never()).calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt());
+        verify(fixture.pricingMapper, never()).selectAtTime(anyString(), anyInt());
     }
 
     @Test
@@ -1404,9 +1429,9 @@ class FlashDeliveryApplicationServiceTest {
         order.setDistance(new BigDecimal("1.00"));
         order.setFreight(15L);
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
-        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
-        // 031 后改址按订单定价时点读最新运价:stub 与订单快照同值,金额断言维持不变
-        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(GeoPoint.class), any(GeoPoint.class), anyInt())).thenReturn(new RouteDistance(4500, 900, "GOOGLE"));
+        // 后台现价与快照不同;改址仍以订单保存的价格计算。
+        when(fixture.pricingMapper.selectAtTime(anyString(), anyInt())).thenReturn(List.of(pricing()));
         return order;
     }
 
@@ -1441,6 +1466,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);
@@ -1448,28 +1475,41 @@ class FlashDeliveryApplicationServiceTest {
         final InfoUserMapper userMapper = mock(InfoUserMapper.class);
         final FlashDeliveryRouteService routeService = mock(FlashDeliveryRouteService.class);
         final RiderDeliveryLockService lockService = mock(RiderDeliveryLockService.class);
-        final RiderDeliveryExclusivityService exclusivityService = mock(RiderDeliveryExclusivityService.class);
-        final com.ruoyi.system.service.PaymentMethodGateService gateService =
-                org.mockito.Mockito.mock(com.ruoyi.system.service.PaymentMethodGateService.class);
+        final RiderDeliveryAcceptanceService acceptanceService = mock(RiderDeliveryAcceptanceService.class);
+        final com.ruoyi.system.service.PaymentMethodGateService paymentMethodGateService =
+                mock(com.ruoyi.system.service.PaymentMethodGateService.class);
         final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(
                 orderMapper, pricingMapper, imageMapper, logMapper, userMapper,
-                routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService, gateService);
+                routeService, new FlashDeliveryPricingCalculator(), lockService, acceptanceService, stopMapper, adjustmentMapper,
+                paymentMethodGateService);
 
         Fixture() {
-            // 031:平台闪送维度默认全开(COD/LINE_PAY/OFFLINE;现金 4 由服务端恒并入),既有用例不受过滤影响
-            org.mockito.Mockito.when(gateService.listAvailable(any(), any(), any()))
-                    .thenReturn(java.util.List.of(
-                            new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption("COD", "1", true),
-                            new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption("LINE_PAY", "3", true),
-                            new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption(
-                                    "OFFLINE_TRANSFER", "6", true)));
-            // 闪送维度不开放信用卡(2):闸门对该组合抛错,复现真实判定行为
-            org.mockito.Mockito.doThrow(new ServiceException("支付方式不可用"))
-                    .when(gateService).assertUsable(org.mockito.ArgumentMatchers.eq("2"),
-                            org.mockito.ArgumentMatchers.eq(
-                                    com.ruoyi.system.service.PaymentMethodGateService.GateScope.RIDER_FLASH),
-                            org.mockito.ArgumentMatchers.isNull(),
-                            org.mockito.ArgumentMatchers.isNull());
+            when(paymentMethodGateService.listAvailable(any(), any(), any())).thenReturn(List.of(
+                    new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption("COD", "1", true),
+                    new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption("LINE_PAY", "3", true),
+                    new com.ruoyi.system.service.PaymentMethodGateService.PayMethodOption("OFFLINE_TRANSFER", "6", true)));
+            // 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();

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

@@ -32,6 +32,8 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.edit.not.allowed", "flash.delivery.order.version.invalid",
             "flash.delivery.tip.additional.invalid",
             "flash.delivery.rider.exclusive.conflict",
+            "rider.accept.offline", "rider.accept.food.limit.reached",
+            "rider.accept.flash.limit.reached", "rider.accept.total.limit.reached",
             "address.access.denied", "address.data.invalid");
 
     @Test

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

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

@@ -0,0 +1,445 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
+import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.app.order.RiderDeliveryAcceptanceService;
+import com.ruoyi.app.order.RiderDeliveryLockService;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.flash.*;
+import org.junit.jupiter.api.*;
+import org.mockito.MockedStatic;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+
+import java.math.BigDecimal;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** 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(), eq(1));
+        }
+    }
+
+    @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(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            assertEquals(4000, f.service.quote(request).getDistanceMeters());
+            var points = org.mockito.ArgumentCaptor.forClass(List.class);
+            verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(3, points.getValue().size());
+            for (int size : new int[] {0, 27}) {
+                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(), eq(1));
+        }
+    }
+
+    @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(), eq(1));
+        }
+    }
+
+    @Test void twentySixPickupsAreAcceptedAndAllTwentySevenRoutePointsRemainOrdered() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = multiEnabled()) {
+            when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(39000, 1800, "ROUTE"));
+            assertEquals(39000, f.service.quote(quoteRequest(26)).getDistanceMeters());
+            var points = org.mockito.ArgumentCaptor.forClass(List.class);
+            verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(27, points.getValue().size());
+        }
+    }
+
+    @Test void multiCreatePersistsEveryItemAndOnlyFirstItemAsOrderSummary() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = multiEnabled()) {
+            when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            FlashDeliveryCreateRequest request = new FlashDeliveryCreateRequest();
+            org.springframework.beans.BeanUtils.copyProperties(quoteRequest(2), request);
+            request.getPickups().get(1).setPackageType("GIFT");
+            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(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            var quote = f.service.quoteAddress(7L, 10L, request);
+            assertEquals(100L, quote.getStartingFare()); assertEquals(110L, quote.getAmount());
+            verify(f.prices, never()).selectAtTime(anyString(), anyInt());
+            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(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            var quote = f.service.quoteAddress(7L, 10L, request);
+            request.setQuotedDistanceMeters(quote.getDistanceMeters()); request.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee());
+            request.setQuotedDistanceFee(quote.getDistanceFee()); request.setQuotedUrgentFee(quote.getUrgentFee()); request.setQuotedAmount(quote.getAmount());
+            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.setVehicleType(1); f.order.setTipAmount(0L); f.order.setPricingId(1L); f.order.setPricingVersion(1);
+        f.order.setStartingFare(100L); f.order.setStartingDistance(BigDecimal.valueOf(3));
+        f.order.setDistance(BigDecimal.ONE); f.order.setFreight(10L); f.order.setUrgentRate(BigDecimal.ZERO); f.order.setMinimumUrgentFee(0L);
+    }
+
+    private static FlashDeliveryQuoteRequest quoteRequest(int count) {
+        FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
+        request.setServiceType("HELP_SEND"); request.setDeliveryType("URGENT"); request.setVehicleType(1); 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(RiderDeliveryAcceptanceService.class),
+                stops, adjustments, mock(com.ruoyi.system.service.PaymentMethodGateService.class));
+        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(), anyInt())).thenReturn(List.of(price));
+        }
+    }
+}

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

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

+ 8 - 8
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java

@@ -46,7 +46,7 @@ class OrderLifecycleServiceTest {
     private IUserWalletService userWalletService;
     private IPointsTransactionService pointsTransactionService;
     private RiderDeliveryLockService riderDeliveryLockService;
-    private RiderDeliveryExclusivityService riderDeliveryExclusivityService;
+    private RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
     private OrderLifecycleService service;
 
     @BeforeEach
@@ -57,14 +57,14 @@ class OrderLifecycleServiceTest {
         userWalletService = mock(IUserWalletService.class);
         pointsTransactionService = mock(IPointsTransactionService.class);
         riderDeliveryLockService = mock(RiderDeliveryLockService.class);
-        riderDeliveryExclusivityService = mock(RiderDeliveryExclusivityService.class);
+        riderDeliveryAcceptanceService = mock(RiderDeliveryAcceptanceService.class);
         when(riderDeliveryLockService.withLock(any(), any())).thenAnswer(invocation -> {
             RiderDeliveryLockService.LockedCall<?> call = invocation.getArgument(1);
             return call.call();
         });
         service = new OrderLifecycleService(posOrderService, billingService, orderLogHelper,
                 userWalletService, pointsTransactionService, null, null,
-                riderDeliveryLockService, riderDeliveryExclusivityService);
+                riderDeliveryLockService, riderDeliveryAcceptanceService);
     }
 
     @Test
@@ -226,7 +226,7 @@ class OrderLifecycleServiceTest {
     }
 
     @Test
-    void foodAcceptUsesSharedRiderLockAndChecksExclusivityBeforeCas() {
+    void foodAcceptUsesSharedRiderLockAndChecksAcceptancePolicyBeforeCas() {
         PosOrder order = deliveryOrder();
         order.setState(0L);
         order.setDeliveryStatus(0L);
@@ -236,17 +236,17 @@ class OrderLifecycleServiceTest {
 
         service.acceptDeliveryByRider(10L, 88L);
 
-        InOrder calls = inOrder(riderDeliveryLockService, riderDeliveryExclusivityService, posOrderService);
+        InOrder calls = inOrder(riderDeliveryLockService, riderDeliveryAcceptanceService, posOrderService);
         calls.verify(riderDeliveryLockService)
                 .withLock(eq(88L), any(RiderDeliveryLockService.LockedCall.class));
-        calls.verify(riderDeliveryExclusivityService).assertCanAcceptFood(88L);
+        calls.verify(riderDeliveryAcceptanceService).assertCanAcceptFood(88L);
         calls.verify(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
     }
 
     @Test
-    void foodExclusiveConflictDoesNotUpdateOrder() {
+    void foodAcceptanceConflictDoesNotUpdateOrder() {
         doThrow(new ServiceException("exclusive"))
-                .when(riderDeliveryExclusivityService).assertCanAcceptFood(88L);
+                .when(riderDeliveryAcceptanceService).assertCanAcceptFood(88L);
 
         assertThrows(ServiceException.class, () -> service.acceptDeliveryByRider(10L, 88L));
 

+ 31 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderListPaymentFilterTest.java

@@ -18,6 +18,9 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.MockedStatic;
 import org.mockito.ArgumentCaptor;
 import org.springframework.test.util.ReflectionTestUtils;
@@ -111,6 +114,34 @@ class OrderListPaymentFilterTest {
         assertEquals(1, Collections.frequency(parameters, "1"));
     }
 
+    @ParameterizedTest
+    @NullAndEmptySource
+    @ValueSource(strings = {"pending", "preparing", "ready", "completed", "cancelled", "refund"})
+    void merchantListRequiresAssignedRiderForDeliveryAcrossAllTabs(String tab) {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IInfoUserService users = mock(IInfoUserService.class);
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        InfoUser merchant = new InfoUser();
+        merchant.setUserId(801L);
+        merchant.setUserType("3");
+        auth.when(AuthContext::requireUserId).thenReturn(801L);
+        when(users.getOne(any())).thenReturn(merchant);
+        when(orders.page(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>());
+
+        controller.orderList(tokenFor(801L), 1, 10, tab, null, null);
+
+        LambdaQueryWrapper<PosOrder> query = capturedQuery(orders);
+        String sql = query.getSqlSegment();
+        for (var entry : query.getParamNameValuePairs().entrySet()) {
+            sql = sql.replace("#{ew.paramNameValuePairs." + entry.getKey() + "}",
+                    String.valueOf(entry.getValue()));
+        }
+        // 非外送订单保留;外送须已接单并有骑手,取餐和送达后的订单仍可查询。
+        assertTrue(sql.contains("sh_id = 801 AND (type <> 0 OR (delivery_status IN (1,2,3) AND qs_id IS NOT NULL))"), sql);
+    }
+
     @BeforeEach
     void setUpMessages() {
         messages = mockStatic(MessageUtils.class);

+ 226 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryAcceptanceServiceTest.java

@@ -0,0 +1,226 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+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 java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+class RiderDeliveryAcceptanceServiceTest {
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    @BeforeAll
+    static void initializeMessages() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreMessages() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @Test
+    void missingFoodLimitFallsBackToOneAndRejectsSecondFoodOrder() {
+        Fixture fixture = new Fixture();
+        fixture.activeFood(1L);
+        fixture.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            dictionaries.when(() -> DictUtils.getDictCache("sys_rider_flash_active_order_limit"))
+                    .thenReturn(List.of(dictionary("10")));
+            dictionaries.when(() -> DictUtils.getDictCache("sys_rider_total_active_order_limit"))
+                    .thenReturn(List.of(dictionary("10")));
+
+            ServiceException error = assertThrows(ServiceException.class,
+                    () -> fixture.service.assertCanAcceptFood(123L));
+
+            assertEquals("rider.accept.food.limit.reached", error.getMessage());
+        }
+    }
+
+    @Test
+    void malformedFoodLimitsFallBackToOneButValidTwoAllowsOneActiveOrder() {
+        for (String invalid : new String[] {null, "", "0", "-1", "1.5", "abc", "21", "999999999999"}) {
+            Fixture fixture = new Fixture();
+            fixture.activeFood(1L);
+            fixture.activeFlash(0L, 0L);
+            try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+                stubLimits(dictionaries, invalid, "10", "10");
+                assertThrows(ServiceException.class, () -> fixture.service.assertCanAcceptFood(123L), invalid);
+            }
+        }
+
+        Fixture fixture = new Fixture();
+        fixture.activeFood(1L);
+        fixture.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "2", "10", "2");
+            assertDoesNotThrow(() -> fixture.service.assertCanAcceptFood(123L));
+        }
+    }
+
+    @Test
+    void normalFlashChecksItsOwnLimitAndTheCrossTypeTotalLimit() {
+        Fixture flashFull = new Fixture();
+        flashFull.activeFood(0L);
+        flashFull.activeFlash(1L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "10", "1", "10");
+            ServiceException error = assertThrows(ServiceException.class,
+                    () -> flashFull.service.assertCanAcceptFlash(123L, "NORMAL"));
+            assertEquals("rider.accept.flash.limit.reached", error.getMessage());
+        }
+
+        Fixture totalFull = new Fixture();
+        totalFull.activeFood(1L);
+        totalFull.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "10", "10", "1");
+            ServiceException error = assertThrows(ServiceException.class,
+                    () -> totalFull.service.assertCanAcceptFlash(123L, "NORMAL"));
+            assertEquals("rider.accept.total.limit.reached", error.getMessage());
+        }
+    }
+
+    @Test
+    void missingOrInvalidFlashAndTotalLimitsAlsoFallBackToOne() {
+        Fixture flashFull = new Fixture();
+        flashFull.activeFood(0L);
+        flashFull.activeFlash(1L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "10", "21", "10");
+            assertMessage("rider.accept.flash.limit.reached",
+                    () -> flashFull.service.assertCanAcceptFlash(123L, "NORMAL"));
+        }
+
+        Fixture totalFull = new Fixture();
+        totalFull.activeFood(1L);
+        totalFull.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            dictionaries.when(() -> DictUtils.getDictCache("sys_rider_food_active_order_limit"))
+                    .thenReturn(List.of(dictionary("10")));
+            assertMessage("rider.accept.total.limit.reached",
+                    () -> totalFull.service.assertCanAcceptFood(123L));
+        }
+    }
+
+    @Test
+    void urgentDeliveryRemainsExclusiveAcrossFoodAndFlashOrders() {
+        Fixture fixture = new Fixture();
+        fixture.activeFood(1L);
+        fixture.activeFlash(0L, 0L);
+        try (MockedStatic<DictUtils> dictionaries = mockStatic(DictUtils.class)) {
+            stubLimits(dictionaries, "10", "10", "10");
+            ServiceException error = assertThrows(ServiceException.class,
+                    () -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
+            assertEquals("flash.delivery.rider.exclusive.conflict", error.getMessage());
+        }
+    }
+
+    @Test
+    void rejectsRidersThatAreNotApprovedEnabledOnlineAndTypeCompatible() {
+        Fixture fixture = new Fixture();
+
+        fixture.rider.setAuditStatus("0");
+        assertMessage("no.user.state.no.audit", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setAuditStatus("1");
+        fixture.rider.setStatus("1");
+        assertMessage("no.user.stop", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setStatus("0");
+        fixture.rider.setOffline("1");
+        assertMessage("rider.accept.offline", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setOffline("0");
+        fixture.rider.setUserType("0");
+        assertMessage("rider.operation.role.required", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setUserType("2");
+        fixture.rider.setDeliveryType("FLASH");
+        assertMessage("no.user.delivery.type.not.enabled", () -> fixture.service.assertCanAcceptFood(123L));
+
+        fixture.rider.setDeliveryType("FOOD");
+        assertMessage("flash.delivery.rider.type.not.enabled",
+                () -> fixture.service.assertCanAcceptFlash(123L, "NORMAL"));
+    }
+
+    private static void assertMessage(String expected, Runnable call) {
+        assertEquals(expected, assertThrows(ServiceException.class, call::run).getMessage());
+    }
+
+    private static void stubLimits(MockedStatic<DictUtils> dictionaries,
+                                   String food, String flash, String total) {
+        dictionaries.when(() -> DictUtils.getDictCache("sys_rider_food_active_order_limit"))
+                .thenReturn(List.of(dictionary(food)));
+        dictionaries.when(() -> DictUtils.getDictCache("sys_rider_flash_active_order_limit"))
+                .thenReturn(List.of(dictionary(flash)));
+        dictionaries.when(() -> DictUtils.getDictCache("sys_rider_total_active_order_limit"))
+                .thenReturn(List.of(dictionary(total)));
+    }
+
+    private static SysDictData dictionary(String value) {
+        SysDictData data = new SysDictData();
+        data.setDictValue(value);
+        return data;
+    }
+
+    private static class Fixture {
+        final IInfoUserService riders = mock(IInfoUserService.class);
+        final IPosOrderService foodOrders = mock(IPosOrderService.class);
+        final FlashDeliveryOrderMapper flashOrders = mock(FlashDeliveryOrderMapper.class);
+        final InfoUser rider = eligibleRider();
+        final RiderDeliveryAcceptanceService service =
+                new RiderDeliveryAcceptanceService(riders, foodOrders, flashOrders);
+
+        Fixture() {
+            when(riders.getById(123L)).thenReturn(rider);
+        }
+
+        void activeFood(long count) {
+            when(foodOrders.count(any(Wrapper.class))).thenReturn(count);
+        }
+
+        void activeFlash(long all, long urgent) {
+            when(flashOrders.selectCount(any(Wrapper.class))).thenReturn(all, urgent);
+        }
+
+        private static InfoUser eligibleRider() {
+            InfoUser rider = new InfoUser();
+            rider.setUserId(123L);
+            rider.setUserType("2");
+            rider.setAuditStatus("1");
+            rider.setStatus("0");
+            rider.setOffline("0");
+            rider.setDeliveryType("FOOD,FLASH");
+            return rider;
+        }
+    }
+}

+ 0 - 126
ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java

@@ -1,126 +0,0 @@
-package com.ruoyi.app.order;
-
-import com.baomidou.mybatisplus.core.conditions.Wrapper;
-import com.ruoyi.common.exception.ServiceException;
-import com.ruoyi.common.utils.spring.SpringUtils;
-import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
-import com.ruoyi.system.service.IPosOrderService;
-import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
-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 static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.when;
-import static org.mockito.Mockito.verify;
-
-class RiderDeliveryExclusivityServiceTest {
-    private static ConfigurableListableBeanFactory originalBeanFactory;
-
-    @BeforeAll
-    static void initializeMessages() {
-        originalBeanFactory = (ConfigurableListableBeanFactory)
-                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
-        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
-        StaticMessageSource messageSource = new StaticMessageSource();
-        messageSource.setUseCodeAsDefaultMessage(true);
-        beanFactory.registerSingleton("messageSource", messageSource);
-        new SpringUtils().postProcessBeanFactory(beanFactory);
-    }
-
-    @AfterAll
-    static void restoreMessages() {
-        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
-    }
-
-    @Test
-    void urgentRejectsActiveFood() {
-        Fixture fixture = new Fixture();
-        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(1L);
-        assertThrows(ServiceException.class,
-                () -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
-    }
-
-    @Test
-    void urgentRejectsActiveNormalFlash() {
-        Fixture fixture = new Fixture();
-        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(0L);
-        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
-        assertThrows(ServiceException.class,
-                () -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
-    }
-
-    @Test
-    void urgentAllowsRiderWithoutActiveWork() {
-        Fixture fixture = new Fixture();
-        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(0L);
-        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
-        assertDoesNotThrow(() -> fixture.service.assertCanAcceptFlash(123L, "URGENT"));
-    }
-
-    @Test
-    void normalFlashRejectsActiveUrgent() {
-        Fixture fixture = new Fixture();
-        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
-        assertThrows(ServiceException.class,
-                () -> fixture.service.assertCanAcceptFlash(123L, "NORMAL"));
-    }
-
-    @Test
-    void foodRejectsActiveUrgent() {
-        Fixture fixture = new Fixture();
-        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
-        assertThrows(ServiceException.class, () -> fixture.service.assertCanAcceptFood(123L));
-    }
-
-    @Test
-    void foodAllowsActiveNormalFlashWhenNoUrgentExists() {
-        Fixture fixture = new Fixture();
-        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
-        assertDoesNotThrow(() -> fixture.service.assertCanAcceptFood(123L));
-    }
-
-    @Test
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    void activeQueriesUseOnlyDefinedFoodAndFlashStates() {
-        Fixture fixture = new Fixture();
-        when(fixture.foodOrders.count(any(Wrapper.class))).thenReturn(0L);
-        when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
-
-        fixture.service.assertCanAcceptFlash(123L, "URGENT");
-        fixture.service.assertCanAcceptFood(123L);
-
-        ArgumentCaptor<Wrapper> foodQuery = ArgumentCaptor.forClass(Wrapper.class);
-        ArgumentCaptor<Wrapper> flashQuery = ArgumentCaptor.forClass(Wrapper.class);
-        verify(fixture.foodOrders).count(foodQuery.capture());
-        verify(fixture.flashOrders, times(2)).selectCount(flashQuery.capture());
-        String foodSql = foodQuery.getValue().getSqlSegment().toLowerCase();
-        String allFlashSql = flashQuery.getAllValues().get(0).getSqlSegment().toLowerCase();
-        String urgentFlashSql = flashQuery.getAllValues().get(1).getSqlSegment().toLowerCase();
-        assertTrue(foodSql.contains("qs_id"));
-        assertTrue(foodSql.contains("delivery_status"));
-        assertTrue(foodSql.contains("state"));
-        assertTrue(foodSql.contains("after_sale_status"));
-        assertTrue(allFlashSql.contains("rider_id"));
-        assertTrue(allFlashSql.contains("status"));
-        assertTrue(urgentFlashSql.contains("delivery_type"));
-        assertTrue(!allFlashSql.contains("service_type"));
-        assertTrue(!urgentFlashSql.contains("service_type"));
-    }
-
-    private static class Fixture {
-        final IPosOrderService foodOrders = mock(IPosOrderService.class);
-        final FlashDeliveryOrderMapper flashOrders = mock(FlashDeliveryOrderMapper.class);
-        final RiderDeliveryExclusivityService service =
-                new RiderDeliveryExclusivityService(foodOrders, flashOrders);
-    }
-}

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

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

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

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

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

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

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

+ 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 - 4
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java

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

+ 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> {
+}

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

@@ -10,6 +10,7 @@
         <result property="riderId" column="rider_id"/>
         <result property="serviceType" column="service_type"/>
         <result property="deliveryType" column="delivery_type"/>
+        <result property="vehicleType" column="vehicle_type"/>
         <result property="status" column="status"/>
         <result property="packageType" column="package_type"/>
         <result property="quantity" column="quantity"/>
@@ -71,6 +72,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">
@@ -88,7 +95,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},
@@ -159,4 +169,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";

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

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

+ 6 - 0
specs/006-orderstate/plan.md

@@ -108,3 +108,9 @@ E:\QtwCode\foodie\foodie-admin-vue\
 新增订单配送推送服务统一处理两类跨入口副作用:到付创建或在线支付成功后通知附近可接单骑手,以及已分配骑手订单取消后的相关方通知。用户聚合下单、旧 `/addorder` 到付入口、LINE Pay 和 OMG 支付成功入口共用同一开放条件,避免不同支付方式形成不同履约顺序。
 
 商家接单和出餐在原支付、归属校验基础上增加骑手分配校验;骑手新任务查询增加 `state=0` 和 `payStatus=1` 条件。骑手占用查询只统计 `state IN (0,1,2)` 且 `deliveryStatus IN (1,2)` 的有效履约订单,取消订单不继续占用骑手。
+
+## 2026-09-15 增量实施设计
+
+在 `PosOrderShOprateController.orderList` 公共查询条件中追加 `type <> 0 OR (delivery_status IN (1,2,3) AND qs_id IS NOT NULL)`,与门店权限及各页签条件以 AND 组合后交由现有分页查询执行。接口参数和响应保持现状,不修改商家受理、支付、配送状态流转,也不新增字段。
+
+定向验证各页签、空页签及未传页签均携带接单过滤,非外送分支可保留自取/堂食,并回归支付条件和子账号门店权限。

+ 6 - 0
specs/006-orderstate/spec.md

@@ -725,3 +725,9 @@ wrapper.last("ORDER BY ST_Distance_Sphere(point(longitude, latitude), " +
 - [美团外卖开放平台 — 订单退款与取消](https://developer.waimai.meituan.com/home/questionDetail/6295)
 - [美团外卖开放平台 — 订单确认退款请求](https://developer.waimai.meituan.com/home/docDetail/122)
 - [美团外卖订单中心演进](https://tech.meituan.com/2016/09/09/mt-waimai-order-evolution.html)
+
+## 2026-09-15 增量:商家列表隐藏未接单外送订单
+
+- `/system/orderShOprate/orderList` 的全部页签及不传 `tab` 的查询,外送订单(`type=0`)仅在 `deliveryStatus IN (1,2,3)` 且 `qsId` 非空时可见;未被骑手接单的外送订单不返回,包括取消、售后页签。
+- 自取、堂食不要求骑手接单,沿用原支付、订单状态及商家门店权限条件。
+- 过滤在数据库分页前执行,列表记录和分页总数使用同一条件;已取餐、已送达的订单不因配送状态推进而被隐藏。

+ 6 - 0
specs/006-orderstate/tasks.md

@@ -241,3 +241,9 @@ Task T022: "骑手端 PosOrderQsOprateController.orderList()"
 - Phase 1 完成后需手动执行 SQL(updatesql/sql.md)
 - 前端文件编辑需用 Python 脚本处理 CRLF 换行符问题
 - 旧方法(PosOrderController 中旧列表查询)保留不删
+
+## 2026-09-15 增量:商家列表接单过滤
+
+- [x] T042 为商家全部页签、空页签及未传页签补充接单过滤回归,修改前 8 个新增用例均因缺少条件失败。
+- [x] T043 在 `orderList` 分页前统一限制外送订单须 `deliveryStatus IN (1,2,3)` 且 `qsId` 非空,保留自取/堂食、支付和门店权限条件。
+- [x] T044 使用 JDK 21 运行 `OrderListPaymentFilterTest`、`PosOrderShOprateControllerTest`、`PosOrderQsOprateControllerTest` 定向测试,23/23 通过;未执行真实数据库分页联调和完整回归。

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

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

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

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

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

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

+ 52 - 8
specs/024-flash-delivery/plan.md

@@ -239,6 +239,14 @@ foodie-admin-vue/
 6. `home` 对三种服务分别读取当前时段,只返回有配置的服务;`quote/create` 每次重新读取当前时段并计算。订单固化 `pricingId,pricingVersion,pricingStartTime,pricingEndTime,startingDistance,startingFare,distance,freight,distanceFee`。
 7. 报价与公开服务 DTO 移除旧计价字段,保留 `estimatedDurationSeconds`;金额 DTO 和订单实体改为整数 `Long`。
 
+### 2026-09-17 基础配送费口径调整
+
+`baseDeliveryFee` 统一表示起步价,`distanceFee` 保持独立距离附加费;不增加配送小计字段。服务端计算加急费时仍使用起步价与距离附加费之和,订单总额为基础配送费、距离附加费、加急费与小费之和。既有订单快照不回写。
+
+### 2026-09-17 路线降级预计时长
+
+Google Routes 无路线、异常或不可用时,`FlashDeliveryRouteService` 继续以 Haversine 直线距离并写入 `distanceSource=STRAIGHT_LINE`。同时按订单车型生成 `estimatedDurationSeconds`:机车按 25 km/h,轿车按 30 km/h,结果向上取整且最少 60 秒。该值仅用于履约展示,不参与报价或订单金额计算;不新增来源字段,既有 `distanceSource` 即可标识降级估算。
+
 ### 平台前端交互
 
 - 价格页表格按服务类型、开始时间排序,列出起止时间、起送距离/价格、计价距离/金额、版本和更新时间。
@@ -701,19 +709,55 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 ### 2026-09-09:编辑操作订单 ID 改为请求体
 
 按用户要求,仅调整确认修改地址和追加小费:在 FlashDeliveryAddressConfirmRequest、FlashDeliveryTipAddRequest 添加 orderId;Controller 两个方法只接 token 与 RequestBody DTO,通过 DTO 将订单 ID 传给既有 Service。requireEditableOrder 在查询前拒绝空/非正订单 ID。同步端点契约、JSON 绑定/身份传递测试、App 文档及定向验证,原有费用和并发业务规则保持不变。
-## 2026-09-14:多取货点实施计划
+## 2026-09-14:多取货点实施计划(2026-09-15 修订)
 
-**目标**:每站独立联系人及物品,骑手自主取件顺序,单点可省略 stopId/多点必传,重复取件幂等,任意站取货后禁止用户取消。本节替换原多点增量方案,生产代码仍待实施
-**实现**:新订单统一写站点表,订单表维护首点地址/物品摘要与 pickup_stop_count;不做历史迁移、无站点回退或测试数据清理。用户顺序用于整线报价展示,实际取件顺序由骑手决定且不变价;追加小费不重算路线。
-**约束**:JDK 21;ruoyi-admin -> ruoyi-system;明确 DTO、token 请求头、Service 国际化校验;SQL 只记录 updatesql/sql.md;保留原编码换行。本轮仅更新现有 spec.md、plan.md、tasks.md,不执行生产实现、SQL、测试或提交
+**目标**:每站独立联系人及物品,默认按用户顺序引导、取得下单人同意并记录骑手声明后允许调整,单点可省略 stopId/多点必传,重复取件幂等,任意站取货后禁止用户取消。本节替换原多点增量方案;后端与平台 PC 已按此实现,实际验证与待联调项见 validation.md
+**实现**:新订单统一写站点表,订单表维护首点地址/物品摘要与 pickup_stop_count;不做历史迁移、无站点回退或测试数据清理。用户顺序用于整线报价、展示和默认引导;骑手沟通同意后调整实际取件顺序,记录原因及声明,保留原顺序且不变价,前往与取件分离;追加小费不重算路线。
+**约束**: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。
 3. **请求、报价及创建(T091)**:在 ruoyi-admin 的 flashdelivery/dto 新增含完整地址和物品字段的取货点 DTO,QuoteRequest/CreateRequest 扩展 pickups 数组;保留旧 pickup + 订单级物品单点适配。两种地址输入同时出现、数组与旧订单级物品同时提交均拒绝;逐站校验物品、数量、重量范围、规格、地址/坐标及取收同址,并统一校验开关和上限。FlashDeliveryApplicationService 沿用创建幂等、路线及费用复算,创建站点、摘要与图片处于同一事务。
-4. **待接单编辑(T091)**:AddressChangeRequest/AddressConfirmRequest 同步完整 pickups + delivery;多点不得以 pickup 覆盖站点集合,旧单点编辑保留物品。沿用原运价快照及 orderVersion,确认时复算整线;先执行校验创建人/待接单/无骑手/版本的订单条件 UPDATE,成功后同事务保存站点、首点摘要、数量和收件人绑定,失败整体回滚。addTip 保留只增加小费及 amount,不查路线、不重算其他费用,不重验开关或数量上限。
-5. **取件及取消(T092、T093)**:FlashDeliveryProofRequest 增加可选 stopId;原 pickup 接口单点可省略、多点必传,另提供路径 stopId 接口;两处 ID 同传必须一致,共用取件业务实现,不自动选择下一未取站。FlashDeliveryOrderMapper 新增按订单 ID SELECT ... FOR UPDATE;取件、用户/平台取消均在事务内先锁订单行,再读最新站点状态,不能使用锁前读到的状态校验。校验订单、骑手、PICKUP 类型,允许任意未取站;已确认站点重试无新写入。新取件必须处于 ACCEPTED,站点和凭证保存后检查是否全部取完,递增订单版本并记录站点日志;仅全部取完更新 PICKED_UP/pickedUpAt。用户取消锁内检查任意已取站点并拒绝;平台沿用介入权限。图片、日志或其他更新失败必须全部回滚。
-6. **视图及契约(T093、T094)**:列表增加 pickupStopCount 并保留首点摘要,各角色详情增加带 ID、独立物品及凭证的 stops,pickupImageUrls 仅作聚合展示;接单前逐站隐藏联系人、电话、坐标、PIN 及图片。平台详情同步展示各站供介入核对。实现阶段同步 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源;平台改动同步四语言。错误 key 覆盖缺少 stopId、越权、输入冲突、点数超限、开关关闭;不增加乱序错误。导航使用 travelmode=two-wheeler,目标为骑手选定单站,全部取完后为收货点
-7. **测试(T088、T095)**:扩展 ApplicationService、ControllerContract、RouteService、OrderView、RiderOrderListView、MapperXml、I18nContract 测试。覆盖独立物品、旧单点适配/冲突、完整站点编辑/物品保持/版本竞争;单点省略 ID、多点缺 ID 拒绝、B 先于 A 成功、跨单/收货站/他人骑手拒绝;响应丢失后重试及并发重复请求不追加凭证或推进别站,最后两站并发完成只推进一次,凭证失败整体回滚。取消与取件并发验证两种先后结果,不能仅用顺序调用宣称并发通过。数量覆盖 0、1、26、27 及字典缺失/非法,26 点对应 25 intermediates;覆盖整线 40 公里、降级、脱敏和追加小费不调用路线。
+4. **待接单编辑(T091)**:AddressChangeRequest/AddressConfirmRequest 同步完整 pickups + delivery;多点不得以 pickup 覆盖站点集合,旧单点编辑保留物品;待接单调整顺序也使用完整站点序列重报价并确认,接单后骑手调整访问先后不走地址编辑和重报价。沿用原运价快照及 orderVersion,确认时复算整线;先执行校验创建人/待接单/无骑手/版本的订单条件 UPDATE,成功后同事务保存站点、首点摘要、数量和收件人绑定,失败整体回滚。addTip 保留只增加小费及 amount,不查路线、不重算其他费用,不重验开关或数量上限。
+5. **取件及取消(T092、T093)**:FlashDeliveryProofRequest 增加可选 stopId;原 pickup 接口单点可省略、多点必传,另提供路径 stopId 接口;两处 ID 同传必须一致,共用取件业务实现,不自动选择下一未取站。FlashDeliveryOrderMapper 新增按订单 ID SELECT ... FOR UPDATE;取件、用户/平台取消均在事务内先锁订单行,再读最新站点状态,不能使用锁前读到的状态校验。校验订单、骑手、PICKUP 类型;新取件目标须符合 FR-096,非默认站须先保存调整原因及已取得同意声明,不能直接以 stopId 绕过;已确认站点重试无新写入,不能因目标已变化拒绝幂等重试。新取件必须处于 ACCEPTED,站点和凭证保存后检查是否全部取完,递增订单版本并记录站点日志;仅全部取完更新 PICKED_UP/pickedUpAt。用户取消锁内检查任意已取站点并拒绝;平台沿用介入权限,按 FR-098 记录异常站、处理结果、已取物品去向及交接凭证后再关闭异常订单,不能漏取、虚假取件或直接完成。图片、日志或其他更新失败必须全部回滚。
+6. **视图及契约(T093、T094)**:列表增加 pickupStopCount 并保留首点摘要,各角色详情增加带 ID、独立物品及凭证的 stops,pickupImageUrls 仅作聚合展示;接单前逐站隐藏联系人、电话、坐标、PIN 及图片。用户、骑手及平台展示已取数量/总数量,部分取件仍为 ACCEPTED,但用户取消入口按任意站是否已取控制并在刷新时更新;平台详情同步展示各站和调整记录供介入核对。实现阶段同步 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源;平台改动同步四语言。错误 key 覆盖缺少 stopId、越权、输入冲突、点数超限、开关关闭;允许按规则调整,补充缺少原因/同意声明、目标或调整记录不匹配等国际化错误。导航使用 travelmode=two-wheeler,默认指向用户顺序中最早的未取站,保存调整后为选定单站,全部取完后为收货点;客户端生成 URL,导航不等同取件
+7. **测试(T088、T095)**:扩展 ApplicationService、ControllerContract、RouteService、OrderView、RiderOrderListView、MapperXml、I18nContract 测试。覆盖独立物品、旧单点适配/冲突、完整站点编辑/物品保持/版本竞争;单点省略 ID、多点缺 ID 拒绝、记录同意声明及原因后 B 先于 A 成功、缺少调整记录/原因/声明拒绝、跨单/收货站/他人骑手拒绝;响应丢失后重试及并发重复请求不追加凭证或推进别站,最后两站并发完成只推进一次,凭证失败整体回滚。取消与取件并发验证两种先后结果,不能仅用顺序调用宣称并发通过。数量覆盖 0、1、26、27 及字典缺失/非法,26 点对应 25 intermediates;覆盖整线 40 公里、降级、脱敏和追加小费不调用路线。
 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 同步,后端契约与平台页面已落实;用户/骑手 App 的排序、目标选择及导航页面仍需客户端接入。
+
+## 2026-09-16:骑手接单资格与容量实施计划
+
+**范围**:仅调整当前有效外卖、闪送接单入口。待抢列表共用过滤、服务端位置过期和距离复核明确留待后续,不借本次改动扩大范围。
+
+**实现**:以 `RiderDeliveryAcceptanceService` 取代只判断急送的 `RiderDeliveryExclusivityService`。外卖与闪送在 `RiderDeliveryLockService` 锁内调用统一策略,一次读取两类进行中任务并依次判断资格、急送独占、类型容量和合计容量;订单自身仍使用既有条件更新保证只能被抢一次。三个容量字典只接受 1..20,任何缺失或非法配置都保守回退 1。
+
+**持久化和文案**:不新增业务表字段;仅在 `updatesql/sql.md` 记录三个字典类型及初始值 1,不执行数据库操作。新增离线、外卖容量、闪送容量和合计容量错误,并同步全部后端语言资源。
+
+**验证**:单元测试覆盖资格、三类容量、非法配置回退和急送双向独占;应用服务测试覆盖校验发生在骑手锁内且早于订单条件更新。使用 JDK 21 运行定向测试、模块构建和差异检查,并单列仓库既有的非本功能失败。
+11. **补充验收(T100)**:覆盖默认 A、沟通并保存调整后前往 B、仅导航状态不变、缺原因/声明或直接绕过调整操作拒绝、B 取件后默认回 A、原顺序/物品/金额不变、部分取件进度及取消入口、平台明确标注骑手声明、无法取货的介入与交接记录;覆盖调整请求重复、与取件/取消竞争、目标变化后的取件幂等重试。联调验证前不得将 App 行为标记为已验证,骑手声明不能作为已验证用户同意真实性的依据。
+
 外部约束:Google Routes 单次最多 25 个中途点,Maps URL 使用 two-wheeler 且途经点数量受平台限制;本期采用字典有效上限最多 26 个取货点与单站导航,不做无限站点分段请求。
+
+## 2026-09-17:按配送车型计价实施计划
+
+1. 为运价、报价请求/响应和订单快照增加 `vehicleType`(1=机车、2=轿车),数据库迁移仅写入 `updatesql/sql.md`,旧数据以默认值 1 保持原行为。
+2. 运价查询和时段重叠检查按车型隔离;报价、创建、待接单地址改价均使用订单车型,并将机车映射到 Google Routes `TWO_WHEELER`、轿车映射到 `DRIVE`,重量范围和规格仅在传入时校验。
+3. 骑手沿用既有 `info_user.vehicle_type`;待抢查询、待抢详情和接单重复核验订单车型,历史空值或非法值安全按机车处理。
+4. 平台端运价页面将车型作为必填配置维度,新增车型展示和四语言文案;用户 App 不在本次改动范围内。
+5. 补充服务、DTO 与平台前端定向测试,执行 JDK 21 受影响模块测试、前端测试及两个仓库的差异/换行检查;MySQL 迁移和真实 App 联调单列为未执行项。
+
+### 2026-09-17 实施进度
+
+- [x] 车型运价、报价/订单快照、骑手车型接单限制、重量/规格选填和 SQL 迁移登记已实现;SQL 未直接执行。
+- [x] 路线服务已按车型透传:机车为 Google Routes `TWO_WHEELER`,轿车为 `DRIVE`;报价、创建及待接单改址均覆盖,历史空值按机车回落。
+- [x] 路线车型映射、路线服务透传、车型报价、Mapper/DTO/i18n/接口契约定向测试通过;JDK 21 执行 `mvn -q -pl ruoyi-admin -am -DskipTests package` 通过;平台端 `npm run test:flash-delivery` 8 项通过;两个仓库 `git diff --check` 通过。
+- [ ] 未执行 MySQL 迁移、真实 Google Routes 请求、真实用户 App 联调或完整回归。平台端 `npm run build:prod` 因本机缺少 `vue-cli-service` 未完成。两轮路线是 Google Beta;用户 App 若展示两轮路线,需要另行补充 Google 要求的提示文案。
+
+### 2026-09-17 共享地图 Key 与路线降级诊断
+
+- [x] 闪送改为与外卖共用 `sys_googlemap_key`,不再依赖测试库中缺失的 `sys_google_routes_key`;Key 不进入闪送响应或业务日志。
+- [x] Google Routes 失败时记录车型与脱敏失败原因后降级直线距离;HTTP 非成功响应记录状态码,不记录地址、坐标、Key 或完整响应。
+- [x] `GoogleRoutesDistanceProviderTest` 与 `FlashDeliveryRouteServiceTest` 共 5 项通过。
+- [ ] 从当前开发机访问 `routes.googleapis.com:443` 连接超时,未能验证共享 Key 的 Routes API 权限;需在测试服务器部署后查看新告警日志或从该服务器网络发起验证。

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

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

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

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

+ 50 - 14
specs/024-flash-delivery/spec.md

@@ -4,7 +4,7 @@
 
 **创建日期**:2026-08-31
 
-**状态**:既有功能已验证;2026-09-07 设计稿费用与物品信息对齐增量已确认,待实施;2026-09-14 多取货点增量已确认,待实施
+**状态**:既有功能已验证;2026-09-07 设计稿费用与物品信息对齐增量已确认,待实施;2026-09-14 多取货点增量已确认,2026-09-15 按方式 2 修订取件顺序规则,待实施
 
 **输入**:基于蓝湖“闪送”分组 7 个设计页面、2026-08-24 用户端/骑手端补充原型及 2026-09-03 更新的 19 张用户端设计稿,实现帮送/帮取业务场景、普通/1 对 1 加急配送、共享地址簿、路线报价、完整物品信息、立即/预约配送、费用明细与报价校验、可选 PIN 交付、创建订单、寄件人/收件人订单查询、骑手主动抢单、急送独占、取件、送达、用户签收、平台配置与介入的非支付业务闭环。
 
@@ -178,7 +178,7 @@
 - **FR-005**:立即订单必须按报价时刻匹配唯一运价时段,预约订单必须按 `scheduledPickupStartAt` 匹配唯一运价时段;全部时间段之间不得重叠,没有匹配时段时必须拒绝报价和创建订单。
 - **FR-006**:计价必须与现有外卖订单一致:起送距离内只收起送价格;超出不足 0.5 公里不加价,超出 0.5 公里但不足 1 公里按 1 公里计,超出至少 1 公里按实际超出距离计;里程费用四舍五入到整数元后与起送价格相加,所有 TWD 金额均为整数且不再按百位或千位特殊取整。
 - **FR-007**:系统必须优先使用地图路线距离,地图失败时使用经纬度直线距离并向客户端返回距离来源。
-- **FR-008**:地图密钥必须从服务端配置读取,不得返回客户端或写入业务日志
+- **FR-008**:闪送与外卖共用字典 `sys_googlemap_key`;闪送接口和业务日志不得返回或写入该 Key,Google Cloud 必须限制其 API、来源和配额
 - **FR-009**:创建订单时前端必须回传报价配置 ID、版本、基础配送费、距离费、加急费和总金额;服务端必须重新计算距离和价格并逐项精确校验,一致后才能保存地址、路线、金额和计价配置快照,不一致时不得创建订单并必须返回最新报价。
 - **FR-010**:系统必须使用客户端请求号保证同一用户的订单创建幂等。
 - **FR-011**:闪送订单不得复用餐饮订单或打车订单数据模型。
@@ -240,9 +240,9 @@
 - **FR-067**:闪送继续保持非支付业务,创建成功直接进入 `WAITING_ACCEPTANCE`,不增加待支付状态、支付倒计时、支付接口或退款流程。
 - **FR-068**:所有闪送金额继续使用整数 TWD;蓝湖稿中的小数金额仅为视觉占位,不改变接口和数据库金额类型。
 - **FR-069**:用户端不得展示固定或伪造的附近骑手人数及预计接单分钟数,统一使用“发布后等待附近骑手接单”;本期不增加实时骑手数量或接单预测接口。
-- **FR-070**:普通配送费等于起送价格与距离费之和;普通配送的加急费为 0,加急配送的加急费等于“普通配送费 × 当前时段加急比例”四舍五入到整数 TWD 后与最低加急费取较大值。
-- **FR-071**:骑手小费由用户提交,为独立的非负整数 TWD;订单总金额等于普通配送费、加急费和骑手小费之和,小费不得参与加急费计算。
-- **FR-072**:报价和订单响应必须分别返回 `baseDeliveryFee`、`distanceFee`、`urgentFee`、`tipAmount` 和 `amount`;加急报价还必须返回命中的 `urgentRate` 和 `minimumUrgentFee`,费用名称不得混用。
+- **FR-070**:`baseDeliveryFee` 仅表示起送价格,`distanceFee` 表示距离附加费;普通配送费仅在服务端按两者之和计算,不增加小计字段。普通配送的加急费为 0,加急配送的加急费等于“普通配送费 × 当前时段加急比例”四舍五入到整数 TWD 后与最低加急费取较大值。
+- **FR-071**:骑手小费由用户提交,为独立的非负整数 TWD;订单总金额等于基础配送费、距离附加费、加急费和骑手小费之和,小费不得参与加急费计算。
+- **FR-072**:报价和订单响应必须分别返回 `baseDeliveryFee`、`distanceFee`、`urgentFee`、`tipAmount` 和 `amount`;加急报价还必须返回命中的 `urgentRate` 和 `minimumUrgentFee`,费用名称不得混用。历史订单金额快照不回写。
 
 ### 关键实体
 
@@ -295,17 +295,17 @@
 - 本次只新增以上能力;沿用现有取消规则、非支付边界。复用现有字段与 version,无数据库迁移。
 
 - FR-078(2026-09-09 入参调整):确认修改地址与追加小费使用 POST /orders/address 和 POST /orders/tip;订单 ID 通过 JSON 请求体 orderId 传入,必须是有效正整数,token 仍放请求头。
-## 2026-09-14:闪送多取货点(多取一送)
+## 2026-09-14:闪送多取货点(多取一送,2026-09-15 修订
 
-本节按本轮审核及用户确认更新,替换此前多点增量规则;生产功能仍待实施。单笔订单包含 1 至 N 个取货点和 1 个收货点,每个取货点独立关联联系人和物品信息。用户填写顺序用于初始路线报价和展示,骑手自行决定实际取件顺序。按整条路线距离计价,不新增每取货点附加费;点间不设单独距离限制,整条报价路线仍不得超过 40 公里。
+本节按 2026-09-15 用户确认的方式 2 更新:默认按用户顺序引导,取得下单人同意后允许调整实际取件顺序;替换此前无条件自由取件的规则。2026-09-15 已落实后端接口与平台 PC;用户/骑手 App 接入和真实数据库联调仍待执行,验证范围见 validation.md。单笔订单包含 1 至 N 个取货点和 1 个收货点,每个取货点独立关联联系人和物品信息。用户填写顺序用于初始路线报价、展示和默认取件引导;调整实际取件顺序须遵守 FR-094 至 FR-098。按整条路线距离计价,不新增每取货点附加费;点间不设单独距离限制,整条报价路线仍不得超过 40 公里。
 
 ### 用户故事 8:用户发布多取货点订单并由骑手逐点取件(优先级:P1)
 
-用户逐站填写联系人、地址和物品,填写唯一收货点后获取整条路线报价;骑手接单后自主选择未取货站点,逐站确认并上传凭证,全部取货点完成后订单进入已取件,送达流程不变。
+用户逐站填写联系人、地址和物品,填写唯一收货点后获取整条路线报价;骑手接单后默认按用户顺序前往未取货站点,需要调整时先与下单人沟通、取得同意并保存调整记录,再逐站确认取件并上传凭证,全部取货点完成后订单进入已取件,送达流程不变。
 
 **优先级原因**:每站独立物品和凭证用于履约核对与纠纷定位。
 
-**独立测试**:验证每站物品独立、报价按用户顺序而取件顺序自由、单点可省略 stopId、多点必传 stopId、重试不推进其他站点、取件与用户取消互斥,以及全部取货点完成时仅推进一次主状态。
+**独立测试**:验证每站物品独立、按用户顺序报价和默认引导、沟通同意后调整并记录骑手声明、前往与取件分离、单点可省略 stopId、多点必传 stopId、重试不推进其他站点、取件与用户取消互斥,以及全部取货点完成时仅推进一次主状态。
 
 **验收场景**:
 
@@ -313,29 +313,65 @@
 2. **假如** 数量超过字典有效上限,**当** 报价、创建或修改取货点,**那么** 返回国际化错误;字典缺失或非法时有效上限为 1。
 3. **假如** 多地点开关关闭,**当** 报价、创建或编辑提交多于一个取货点,**那么** 拒绝;既有订单履约及仅追加小费不受开关和上限调整影响。
 4. **假如** 订单只有一个取货点,**当** 骑手不传 stopId,**那么** 定位唯一取货点;多点订单未传 stopId 必须拒绝,不自动选择未取货站点。
-5. **假如** B 点在用户排序中位于 A 点之后,**当** 本单骑手先确认 B 点且凭证有效,**那么** 允许;主状态保持 ACCEPTED,全部取货点完成后才进入 PICKED_UP。
+5. **假如** 用户顺序为 A → B → C 且均未取件,**当** 骑手查看订单,**那么** 默认突出显示前往 A;骑手取得下单人同意、填写原因并声明已取得同意,保存先前往 B 的调整记录后,可以实际到 B 取货并上传凭证确认;主状态保持 ACCEPTED,全部取货点完成后才进入 PICKED_UP。
 6. **假如** 请求指定非本单站点、收货站点或他人订单站点,**当** 取件确认,**那么** 拒绝;同一骑手重试已确认站点只返回已确认结果,不重复写凭证、时间、日志或推进其他站点。
 7. **假如** 任意站点已经取货,**当** 用户自助取消,**那么** 拒绝并要求平台介入;与取件并发时,取件先成功则用户取消失败,取消先成功则取件失败。
 8. **假如** 用户选择加急和多个取货点,**当** 报价或创建,**那么** 允许并沿用整条路线计价及骑手独占规则。
-9. **假如** 待接单订单修改地址,**当** 报价或确认保存,**那么** 使用完整站点序列和原运价快照重算整线;仅追加小费时只增加小费和总金额,不请求路线或重算其他费用。
+9. **假如** 待接单订单修改地址或调整站点顺序,**当** 报价或确认保存,**那么** 使用完整站点序列和原运价快照重算整线,经用户确认新报价后保存;接单后骑手仅调整实际取件先后不重算金额;仅追加小费时只增加小费和总金额,不请求路线或重算其他费用。
 10. **假如** 整线超过 40 公里或地图服务失败,**当** 报价、创建或地址编辑重报价,**那么** 按整线执行距离上限和逐段直线降级;点数超限在地图调用前拒绝。
 11. **假如** 旧单点请求提交 pickup 与订单级物品字段,**当** 报价或创建,**那么** 适配为一个含独立物品的取货点;pickup 与 pickups 同时提交时拒绝。
 12. **假如** 多点地址保存与抢单或其他编辑并发,**当** 保存,**那么** 站点、摘要、收件人绑定、路线和金额必须同一事务提交或回滚,不出现两套地址或报价。
 
+13. **假如** 用户下单前调整站点顺序,**当** 确认报价,**那么** 按最终顺序计算并展示路线和费用,提示默认按此顺序取件、骑手如需调整将先沟通。
+14. **假如** 骑手选择非默认未取站,**当** 缺少调整原因或未声明已取得下单人同意,**那么** 不保存调整,也不能通过直接调用取件确认绕过所需记录;首期不要求用户在 App 内点击批准。
+15. **假如** 已保存先前往 B 的调整,**当** 打开或退出导航,**那么** B 仍为待取件,不生成取件凭证、不增加已取数量、不推进主状态;实际交接并提交有效图片后才可确认已取。
+16. **假如** 用户顺序为 A → B → C,**当** B 已取且 A/C 待取,**那么** 保留原始顺序和报价,展示已取 1/3 站及各站状态,默认下一站回到 A,用户自助取消不可用。
+17. **假如** 平台核对调整,**当** 查看记录,**那么** 展示调整前后目标站、原因、骑手、操作时间和骑手声明,不显示为用户已在 App 审批;订单聊天可作为沟通依据。
+18. **假如** 某站暂未备好,**当** 沟通同意后先去其他站,**那么** 原站继续待取;最终无法取货时联系平台处理,不能删除站点、虚假取件或直接完成整单。平台记录已取物品去向和交接凭证后再关闭异常订单。
+19. **假如** 调整请求重试或与取件、取消并发,**当** 保存,**那么** 不重复追加同一次记录,不以过期状态覆盖目标;取消后禁止新调整,已取站不能作为新目标。
+
 - FR-079:通用站点表 flash_delivery_order_stop 保存订单 ID、类型(PICKUP/DELIVERY)、用户填写顺序、快照和履约信息,作为新订单站点数据唯一真相;所有新订单(含单点)统一写入取货和收货站点。订单表 pickup_* 维护首取货点摘要,新增 pickup_stop_count。当前为测试阶段,不做历史订单迁移、无站点履约回退或历史详情适配,不自动清理测试数据。
 - FR-080:每个取货点独立保存联系人、电话、地址、详细地址、市/区、交接方式、经纬度和物品字段 packageType、quantity、weightRange、specification;逐站按既有物品规则校验并供骑手核对。唯一收货点保存收件信息,收件账号绑定和交付 PIN 维持订单级一套。既有订单级物品字段仅保留首取货点兼容摘要,不代表多点订单全部物品,不作为站点物品来源。
 - FR-081:数据字典 flash_pickup_stop_limit 配置取货点上限,不含收货点;仅接受 1 至 26 的整数,缺失、空值或非法配置时有效上限为 1。报价、创建、修改取货点均校验非空且不超过有效上限,超限返回国际化错误;首页下发有效 pickupStopLimit。26 个取货点对应 25 个中途点,超点数在地图调用前拒绝,不作为地图故障降级。
 - FR-082:flash_show_multi_pickup 控制多点能力;关闭时多于一个取货点的报价、创建和地址编辑均拒绝。开关或上限调整不影响已创建订单取件、送达及仅追加小费。
 - FR-083:flash_show_urgent_option 经首页配置下发,仅控制 App 加急选项展示;后端仍按 deliveryType 执行既有校验,不受该展示开关影响。
-- FR-084:初始及地址编辑报价按用户填写顺序计算:首取货点为起点,其余取货点为中途点,收货点为终点,不自动优化顺序;地图失败逐段直线距离求和,40 公里上限按整线执行。stop_order 仅用于报价和展示,骑手改变实际取件顺序不重排用户站点、不重算订单金额。
+- FR-084:初始及地址编辑报价按用户填写顺序计算:首取货点为起点,其余取货点为中途点,收货点为终点,不自动优化顺序;地图失败逐段直线距离求和,40 公里上限按整线执行。stop_order 用于报价、展示及默认取件引导;骑手按 FR-095 改变实际取件顺序不重排用户站点、不重算订单金额。
 - FR-085:沿用 FR-004 至 FR-006、FR-070 至 FR-072 的运价、报价回传校验和整数 TWD 取整,以整线距离替代原单段距离,不新增站点附加费或按物品计价项;保留 FR-076 小费只增加小费及总金额,不重算路线、基础配送费或加急费。
 - FR-086:deliveryType=URGENT 允许多个取货点,FR-065、FR-066 骑手独占规则不变。
-- FR-087:骑手可选择本单任一未取货站点,每站至少一张取件图片。保留 POST /system/flashDelivery/rider/orders/{id}/pickup,请求 DTO 增加可选 stopId;单点可省略,多点必传。另提供 POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/pickup,路径 ID 为操作目标,同时传请求体 stopId 时必须一致。缺失必需 ID、站点不属本单或不是 PICKUP、骑手不是接单人时拒绝;同一骑手重试已确认站点幂等返回,不重复写凭证、日志或确认时间。保存每站状态、时间和凭证;全部取货点完成才进入 PICKED_UP,pickedUpAt 记录最后一个实际完成取件的站点时间,不按 stop_order 判断末站。
+- FR-087:骑手默认按用户顺序取件,按 FR-095 保存沟通同意后的调整记录后可以先取其他未取站;每站实际取到货后至少上传一张取件图片。保留 POST /system/flashDelivery/rider/orders/{id}/pickup,请求 DTO 增加可选 stopId;单点可省略,多点必传。另提供 POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/pickup,路径 ID 为操作目标,同时传请求体 stopId 时必须一致。缺失必需 ID、站点不属本单或不是 PICKUP、骑手不是接单人时拒绝;同一骑手重试已确认站点幂等返回,不重复写凭证、日志或确认时间。新取件按 FR-096 校验当前目标及必要的调整记录;保存每站状态、时间和凭证;全部取货点完成才进入 PICKED_UP,pickedUpAt 记录最后一个实际完成取件的站点时间,不按 stop_order 判断末站。
 - FR-088:任意取货点已确认即禁止用户自助取消,不限定用户排序第一站;全部未取时维持现有取消规则,平台保留介入能力。取件与用户/平台取消共用订单行级并发保护:同一事务先锁订单行,再读取最新订单及站点状态并校验、更新。站点状态、凭证、主状态、版本和日志一并提交或回滚,不能只在取消前单独查询一次。取消成功后禁止新增取件;任一站取件成功后用户取消必须失败。
 - FR-089:列表保留现有字段和首取货点地址/物品摘要,新增 pickupStopCount;用户、骑手及平台详情新增 stops,包含站点 ID、类型、用户顺序、联系人、电话、地址、交接方式、坐标、独立物品、状态、确认时间及凭证。凭证按站关联,既有 pickupImageUrls 可聚合展示,不能用聚合列表覆盖各站凭证。骑手接单前逐站隐藏联系人、电话、精确坐标、PIN 及凭证图片,保留文字地址及履约判断所需物品信息。
-- FR-090:客户端按骑手选择的未取货站点拼装 Google Maps URL,“导航到下一站”的目标由骑手选择,全部取完后导航到唯一收货点。使用 api=1、travelmode=two-wheeler、dir_action=navigate,正确编码参数,起点默认设备当前位置;不依赖携带全部途经点的 URL 履约。后端按 FR-029 权限返回坐标,不生成导航 URL,不新增导航接口。
+- FR-090:客户端按 FR-094 至 FR-096 确定的当前目标站拼装 Google Maps URL,默认指向用户顺序中最早的未取站,保存沟通同意后的调整后指向选定站,全部取完后导航到唯一收货点。选择前往、打开导航与确认已取货是独立操作。使用 api=1、travelmode=two-wheeler、dir_action=navigate,正确编码参数,起点默认设备当前位置;不依赖携带全部途经点的 URL 履约。后端按 FR-029 权限返回坐标,不生成导航 URL,不新增用于生成导航 URL 的接口;调整记录保存属于履约操作,不能仅保存在客户端
 - FR-091:站点表、订单表加列、站点凭证关联所需变更及三个字典项 SQL 只记录在 updatesql/sql.md,不直接执行数据库操作。
 - FR-092:报价和创建新增 pickups 数组,每项含完整取货地址及 FR-080 物品字段;保留旧 pickup 与订单级物品作为单点输入,统一适配后校验。pickup 与 pickups 不得同时提交;使用 pickups 时不允许同时提交旧订单级物品字段,冲突返回国际化错误。AddressChange/Confirm DTO 同步支持完整 pickups 与唯一 delivery;旧 pickup 编辑仅适用于单点并保留该站物品,多点编辑缺少完整 pickups 必须拒绝,不能丢弃其余站点。
 - FR-093:地址编辑沿用 FR-073 至 FR-075、FR-077 的权限、状态、版本及原运价快照,重算整线;订单原子条件更新成功后,在同一事务保存完整站点、首点摘要、数量、收件人绑定及费用,失败全部回滚。完整站点输入保留并校验逐站物品,不因编辑联系人或地址清空物品。
 
+- FR-094:下单前用户可调整取货点顺序,按最终顺序报价并确认;待接单订单调整顺序沿用 FR-093 重报价及确认流程。下单页提示“默认按此顺序取件,骑手如需调整将先与您沟通”,用户可见文案按现有国际化要求实现。骑手接单后可以查看全部站点,默认突出显示用户顺序中最早的未取站为下一站。
+- FR-095:骑手需要先去其他未取站时,须先联系下单人并取得同意,再选择“先前往该站”,填写调整原因并声明“已取得下单人同意”。服务端保存订单、调整前目标站、调整后目标站、原因、骑手身份、操作时间及同意声明。首期不要求用户在 App 内点击批准;骑手勾选只证明其作出了声明,不证明下单人实际同意,平台不得标记为用户审批通过。订单聊天可作为沟通依据;未取得同意不能自行调整,无法联系下单人时联系平台处理。
+- FR-096:选择前往与确认已取货分开:前者只确定目标并记录必要的调整,不能修改站点取件状态、取件时间、凭证或主状态;后者必须实际交接并提交有效取件图片。新取件确认以当前目标为准;选择非默认站须先保存 FR-095 记录,不能通过直接提交 stopId 绕过。已确认站点的重试仍按 FR-087 幂等处理,不受目标已变化影响。选定目标在取件前保持;再次改变目标时记录调整。目标站取件后回到用户顺序中最早的未取站,全部取完后转为收货点。调整操作校验接单骑手、订单状态和本单未取站,保存须防重复并与取件、取消使用一致的订单并发保护。
+- FR-097:保留用户原始站点顺序及报价,按每站实际确认时间和状态展示履约进度;用户、骑手及平台显示已取数量/总数量(如“已取 1/3 站”)。部分取件主状态仍为 ACCEPTED,取消入口按是否任意站已取判断,不能仅依据主状态显示可取消。平台详情可查看 FR-095 调整记录;调整声明和取件凭证分别展示,不相互替代。
+- FR-098:骑手仅能调整本单未取货地点的访问先后,不能新增或删除地点、修改地址或物品,也不能漏取。某站暂时无法取货可以沟通后先去其他站,该站继续保留待取件;最终无法取货、缺件或拒收须联系平台处理,不能虚假确认取件、擅自部分履约或直接完成整单。平台介入时记录问题站、处理结果及已取物品去向和交接凭证,再关闭异常订单;沿用既有介入权限,本次不新增支付退款功能。
+
+## 2026-09-16:骑手接单资格与容量限制
+
+本次只收口外卖和闪送接单接口的服务端最终校验,不调整待抢列表过滤,也不增加位置新鲜度或接单距离限制。列表结果不能替代接单接口复核,后续实现列表共用规则时必须继续保留接口校验。
+
+- FR-099:外卖和闪送接单必须先取得同一把骑手锁,再在锁内统一校验骑手身份、审核状态、账号状态、在线状态、对应配送类型、进行中任务容量和急送独占;外卖原“已有未完成订单”查询迁入锁内,避免并发请求同时越过容量判断。
+- FR-100:使用 `sys_rider_food_active_order_limit`、`sys_rider_flash_active_order_limit`、`sys_rider_total_active_order_limit` 三个数据字典分别限制进行中外卖、进行中闪送和两类任务合计数量。每项只接受 1 至 20 的整数;缺失、空值、非整数、越界、溢出或字典读取异常时有效值均为 1,绝不回退为不限单。初始配置三项均为 1。
+- FR-101:进行中外卖沿用 `qs_id=骑手`、`delivery_status IN (1,2)`、`state IN (0,1,2)`、`after_sale_status=0`;进行中闪送为当前骑手的 `ACCEPTED` 或 `PICKED_UP`。达到对应类型上限或合计上限时拒绝新接单,订单的原子条件更新仍负责判断是否已被其他骑手抢走。
+- FR-102:接单资格统一要求骑手存在、`userType=2`、`auditStatus=1`、`status=0`、`offline=0`,并开通目标订单对应的 `FOOD` 或 `FLASH` 配送类型。失败时返回可区分的国际化原因,包括角色不符、未审核、账号停用、离线、配送类型未开通、类型容量已满、总容量已满、急送独占冲突或订单已被抢。
+- FR-103:`URGENT` 闪送继续跨外卖和闪送独占:骑手已有任一进行中配送任务时不能接加急闪送;骑手已有进行中加急闪送时不能再接外卖或普通闪送。独占判断与容量判断使用同一骑手锁。
+- FR-104:本阶段不把接单规则复用到待抢列表,也不校验服务端位置更新时间和接单距离;因此列表可能出现最终不可接的订单,App 必须以接单接口结果为准并按明确原因刷新。位置规则须在定位上报频率、过期阈值和距离口径另行确认后实施。
+
+**方案来源**:上述为本项目 2026-09-15 已确认的方式 2。Lalamove 的业务规范仅作为讨论背景;骑手声明、调整记录及具体 App 操作是本项目设计,不标注为已核实的 Lalamove 操作流程。
+
 地图约束依据:[Google Routes 中途点限制](https://developers.google.com/maps/documentation/routes/intermed_waypoints)、[Google Maps URL 参数及平台途经点限制](https://developers.google.com/maps/documentation/urls/get-started)。
+
+## 2026-09-16 确认、2026-09-17 实施:按配送车型计价
+
+- 用户报价与创建闪送订单均传 `vehicleType`:`1`=机车、`2`=轿车;服务端拒绝缺失或其他数值。
+- 闪送运价按“车型 + 时段”分别配置;报价、创建及待接单改址均以订单车型命中运价,订单保存车型与既有价格快照,后续调价不改变历史订单。
+- 报价、创建及待接单改址调用 Google Routes 时同样传订单车型:机车使用 `TWO_WHEELER`,轿车使用 `DRIVE`;返回的路线距离和预计时长随订单保存。地图失败时降级为直线距离并标记 `STRAIGHT_LINE`,预计时长按机车 25 km/h、轿车 30 km/h 保守估算,向上取整且最少 60 秒;该展示估算不参与计价。
+- 骑手沿用既有 `info_user.vehicle_type`(`1`=机车、`2`=轿车);待抢列表、待抢详情和接单均要求与订单车型一致。历史骑手、运价和订单均默认机车,保证迁移后既有行为不变。
+- 重量范围与体积/规格说明改为选填;未传时保存 `NULL`,不参与本期运费计算。物品类别、数量、小费及地址校验保持必填规则。
+- 本期不修改用户 App 界面;后续 App 仅需在报价和创建请求都传相同的 `vehicleType`。

+ 51 - 11
specs/024-flash-delivery/tasks.md

@@ -162,15 +162,55 @@
 - 本轮 URL/入参调整后未重跑完整回归,前一阶段完整回归为 474 项通过;真实地图、数据库及 App 联调仍未执行。
 ## Phase 18:多取货点(多取一送)
 
-本阶段按 2026-09-14 审核后用户确认更新,T088 至 T095 仍待实施;本轮仅调整文档,未运行代码测试或构建
-
-- [x] T087 记录多取货点规格、计划与任务:多取一送、通用站点表、整线距离计价、每站独立联系人及物品、骑手自主取件顺序、用户顺序用于报价展示、加急允许多点、点间无独立距离限制、双开关与数量上限走数据字典
-- [ ] 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 一致性、自由选择本单未取站、每站凭证、重试无副作用、全部取完才推进 PICKED_UP;不得动态选择下一未取站
-- [ ] T093 实现取件与用户/平台取消共用订单行锁及事务,任意站已取禁止用户自助取消,取消后禁止新取件;补 stops 独立物品/凭证、列表首点摘要及 pickupStopCount、逐站脱敏、平台详情及首页有效 pickupStopLimit/双开关
-- [ ] T094 更新 contracts/api.md、data-model.md、quickstart.md、docs/flash-delivery-app-api.md 和现有全部后端语言资源,平台改动同步四语言;明确新旧输入、站点物品、地址编辑、幂等及取消,导航到骑手所选单站并使用 travelmode=two-wheeler
+本阶段按 2026-09-15 用户确认的方式 2 更新,以 FR-094 至 FR-098 替换原无条件自由取件规则。后端、平台 PC 和关联文档已实现并执行本地验证;T097 仅记录需求修订。T095、T099、T100 的真实 MySQL 并发/回滚及 App 联调部分仍待执行,不能用 Mock 测试替代,详细结果见 validation.md
+
+- [x] T087 记录多取货点规格、计划与任务:多取一送、通用站点表、整线距离计价、每站独立联系人及物品、原取件顺序设计(已由 T097 修订)、用户顺序用于报价展示、加急允许多点、点间无独立距离限制、双开关与数量上限走数据字典
+- [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、取件自由顺序、任意站取货后禁止用户取消及并发保护、不考虑历史订单、字典限制 1..26/非法回退 1;其余采用审核建议,保留旧单点输入、小费不重算和修正导航参数
+- [x] T096 按用户逐项确认修订本三件套:每站独立物品、单点可省略/多点必传 stopId、原自由取件规则(已由 T097 替换)、任意站取货后禁止用户取消及并发保护、不考虑历史订单、字典限制 1..26/非法回退 1;其余采用审核建议,保留旧单点输入、小费不重算和修正导航参数
+
+- [x] T097 按 2026-09-15 会话确认的方式 2 修订 spec.md、plan.md、tasks.md:用户可排序并确认报价、默认下一站、沟通同意后调整及记录原因/骑手/时间/声明、原顺序与金额保留、实际进度、前往与取件分离、不能漏取及异常交接边界;首期无用户 App 审批,骑手声明不等同用户同意证明
+- [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 部署与后端切换不在本次交付内。
+
+## Phase 19:骑手接单资格与容量限制
+
+- [x] T101 记录 FR-099 至 FR-104:外卖/闪送共用锁内资格与容量检查,三个字典上限 1..20、缺失或非法回退 1,急送继续跨类型独占;待抢列表和位置规则不在本阶段实施
+- [x] T102 先补策略及应用服务测试确认旧实现缺少普通闪送容量、跨类型总容量和锁内统一资格校验,再实现 `RiderDeliveryAcceptanceService` 并迁移两个当前有效接单入口
+- [x] T103 同步六套后端国际化文案、App 接口文档、契约及 `updatesql/sql.md`,三个字典初始值均为 1,SQL 只记录不执行
+- [x] T104 使用 JDK 21 运行策略、外卖/闪送接单及国际化定向测试和模块构建,执行差异检查;单列与本次无关的仓库基线失败及未执行的真实数据库/App 联调
+
+### Phase 19 验证记录(2026-09-16)
+
+- JDK 21 定向运行 `RiderDeliveryAcceptanceServiceTest`、`FlashDeliveryI18nContractTest`、`OrderLifecycleServiceTest` 及闪送抢单的 3 个应用服务用例,共 30 项通过,0 失败/错误/跳过。
+- JDK 21 执行 `mvn -pl ruoyi-admin -am -DskipTests package`,全部参与模块 `BUILD SUCCESS`;`git diff --check` 通过。
+- 扩大运行完整 `FlashDeliveryApplicationServiceTest` 时发现仓库基线既有矛盾:`normalizePayType` 当前允许任意非空支付类型,而 `createRejectsUnsupportedPayType` 仍要求拒绝 `2`;本次未修改该逻辑,也未为接单容量需求顺带修复。
+- 三个字典 SQL 仅记录、未执行;未进行真实 MySQL 并发、Redis 多实例或骑手 App 联调,待抢列表和位置/距离规则按 FR-104 保持未实施。
+
+## Phase 20:合并车型路线、计价与多取货点
+
+- [x] T105 将 `test` 的车型运价、Google 路线车型、直线降级预计时长和骑手车型匹配合入 `test-202609v1`,保留多取一送的完整站点序列、骑手容量限制及其 SQL。
+- [x] T106 统一基础配送费语义:`baseDeliveryFee` 仅为起步价,距离附加费独立使用 `distanceFee`,订单金额由各费用相加;地址改动仍按订单快照计算。
+- [x] T107 重申重量区间和规格为选填:未填写不阻断报价;填写重量时仍校验四档枚举,规格最长 255 字符。
+- [x] T108 使用 JDK 21 运行闪送相关定向测试(127 项),并记录车型迁移 SQL;真实 MySQL 迁移、Google 地图及 App 联调未执行。
+
+## Phase 21:合并至 test-202609v2
+
+- [x] T109 将多取货点、车型计价与路线、降级预计时长、骑手容量限制合入 `test-202609v2`,同时保留 031 支付闸门、骑手支付方式过滤与夜市自配送流转。
+- [x] T110 在待抢闪送任务查询中同时按骑手车型与可接受支付方式过滤;确认收款接口继续仅允许本人已送达/已完成订单首次写入收款审计。
+- [x] T111 维持 `test-202609v2` 已确认的越南语资源删除策略,将 v1 新增的车型、容量和多取货点文案合并至其余支持语言;自配送与车型 SQL 均保留为手动迁移登记。

+ 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 的有序多经点与整线直线降级。
+- 未运行全仓完整回归,未部署上线。

+ 120 - 0
updatesql/sql.md

@@ -1553,3 +1553,123 @@ CREATE TABLE pos_store_self_delivery_hours (
 ALTER TABLE pos_order
   ADD COLUMN self_delivery TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=商家自配送(下单时快照,不进骑手池,运费归sh_id)';
 ```
+
+
+## 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');
+```
+
+## 2026-09-16 骑手进行中配送任务接单上限
+
+> 用途:分别限制骑手进行中的外卖、闪送和两类任务合计数量。有效值为 1 至 20;缺失、空值、非法、越界或读取异常时服务端保守回退为 1。初始值均为 1;脚本只记录,由开发者手动执行。
+
+```sql
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '骑手外卖接单上限', 'sys_rider_food_active_order_limit', '0', 'admin', NOW(), '进行中外卖上限,整数1至20,缺失或非法回退1'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='sys_rider_food_active_order_limit');
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '骑手闪送接单上限', 'sys_rider_flash_active_order_limit', '0', 'admin', NOW(), '进行中闪送上限,整数1至20,缺失或非法回退1'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='sys_rider_flash_active_order_limit');
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '骑手配送总接单上限', 'sys_rider_total_active_order_limit', '0', 'admin', NOW(), '进行中外卖和闪送合计上限,整数1至20,缺失或非法回退1'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='sys_rider_total_active_order_limit');
+
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '进行中外卖上限', '1', 'sys_rider_food_active_order_limit', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='sys_rider_food_active_order_limit');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '进行中闪送上限', '1', 'sys_rider_flash_active_order_limit', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='sys_rider_flash_active_order_limit');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '进行中配送任务总上限', '1', 'sys_rider_total_active_order_limit', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='sys_rider_total_active_order_limit');
+```
+
+## 2026-09-17 闪送按车型计价(024-flash-delivery)
+
+> 用途:闪送运价和订单快照增加 `vehicle_type`(1=机车、2=轿车)。默认值为 1,确保历史运价和订单继续按机车处理;骑手复用既有 `info_user.vehicle_type` 字段。脚本只登记,由开发者备份后手动执行。
+
+```sql
+ALTER TABLE flash_delivery_pricing
+  ADD COLUMN vehicle_type TINYINT NOT NULL DEFAULT 1 COMMENT '配送车型:1=机车,2=轿车' AFTER id,
+  ADD KEY idx_flash_pricing_vehicle_period (vehicle_type, start_time, end_time);
+
+ALTER TABLE flash_delivery_order
+  ADD COLUMN vehicle_type TINYINT NOT NULL DEFAULT 1 COMMENT '下单车型快照:1=机车,2=轿车' AFTER delivery_type,
+  ADD KEY idx_flash_available_vehicle (status, vehicle_type, delivery_mode, scheduled_pickup_start_at);
+```