Преглед на файлове

feat: 闪送支持交错取送并补充双进度说明

qmj преди 4 дни
родител
ревизия
e75af125ed
променени са 24 файла, в които са добавени 306 реда и са изтрити 57 реда
  1. 11 1
      docs/flash-delivery-app-api.md
  2. 22 9
      docs/flash-delivery-v2-frontend-handoff.md
  3. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  4. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  5. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  6. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTargetRequest.java
  7. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2StopRequest.java
  8. 36 13
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  9. 2 1
      ruoyi-admin/src/main/resources/i18n/messages.properties
  10. 2 1
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  11. 2 1
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  12. 2 1
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  13. 2 1
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  14. 148 8
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryV2Test.java
  15. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  16. 2 2
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  17. 5 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  18. 8 4
      specs/024-flash-delivery/contracts/api.md
  19. 6 0
      specs/024-flash-delivery/data-model.md
  20. 10 0
      specs/024-flash-delivery/plan.md
  21. 8 0
      specs/024-flash-delivery/quickstart.md
  22. 12 4
      specs/024-flash-delivery/spec.md
  23. 8 0
      specs/024-flash-delivery/tasks.md
  24. 9 0
      specs/024-flash-delivery/validation.md

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

@@ -1217,7 +1217,7 @@ POST /system/flashDelivery/rider/orders/stops/deliver
 
 ## 2026-10-14 多点履约与改址修复
 
-v2 订单即使只有一个收货站,也必须使用上述逐站送达接口并传 `stopId`;旧整单送达接口拒绝 v2 订单。每次送达只接受详情中 `currentTargetStopId` 对应的待送站;需调整送达顺序时,骑手通过原目标调整入口记录原因与发起人已同意,再送新的目标站。末站送达后重复请求可安全返回,不会再次保存凭证或通知。全部收货站完成才进入 `DELIVERED`;确认收货或系统/平台完成进入 `COMPLETED` 后才生成一次骑手收入账单。
+v2 订单即使只有一个收货站,也必须使用上述逐站送达接口并传 `stopId`;旧整单送达接口拒绝 v2 订单。每次送达只接受详情中 `currentTargetStopId` 对应的待送站;需调整送达顺序时,骑手通过原目标调整入口选站,v2 的自主调整规则以文末 2026-10-15 修订为准。末站送达后重复请求可安全返回,不会再次保存凭证或通知。全部收货站完成才进入 `DELIVERED`;确认收货或系统/平台完成进入 `COMPLETED` 后才生成一次骑手收入账单。
 
 `stops[].id` 是站点 ID。启用 PIN 时,v2 详情在对应收货站的 `deliveryPinCode` 返回各站独立收货码;发起人可见全部,系统内匹配的收件人仅能看到自己的站点收货码,骑手看不到。收件人列表卡片展示自己的收货地址,详情隐藏其他收货站的联系人和精确地址。匹配不到系统用户时,仍保留联系人电话和按站收货码,由发起人线下告知;本阶段不实现逐站收件人推送。v2 订单级 `deliveryPinCode` 不再用作送达码。订单列表和详情提供 `deliveryStopCount`、`deliveredStopCount` 供展示进度。
 
@@ -1231,3 +1231,13 @@ POST /system/flashDelivery/v2/orders/address
 报价 body 为 `{"orderId":10,"orderVersion":0,"stops":[...]}`,其中 `stops` 必须是完整的取货与收货站数组,结构与 v2 创建相同。确认保存 body 再附上报价返回的 `quotedDistanceMeters`、`quotedBaseDeliveryFee`、`quotedDistanceFee`、`quotedUrgentFee`、`quotedAmount`。服务端沿用该订单的物品和运价快照,并核对版本与报价;保存成功后按返回详情刷新全部 `stops[].id` 和收货码。旧改址入口遇到 v2 订单会拒绝,旧订单的参数和行为保持原样。
 
 每站取货或送达首次成功提交后,发起人收到一次进度推送及消息中心记录。payload 的 `eventType` 为 `FLASH_PICKUP_STOP_COMPLETED` 或 `FLASH_DELIVERY_STOP_COMPLETED`,并含 `ddId`、真实订单 `state`、`stopId`、`stopType`、从 0 开始的 `stopOrder`、`completedStopCount`、`totalStopCount`。同站重试不会重复通知,最后一站不再叠发旧整单阶段通知。App 按 `ddId` 打开订单详情,以最新详情展示站点进度。逐站收件人通知记录为后续需求,本轮暂缓。
+
+## 2026-10-15 v2 交错取送与双进度(Tab 不变)
+
+v2 报价、创建和待接单完整改址的 `stops[]` 按客户顺序提交:首站取件、末站收货,中间允许交错,例如取1→取2→送1→取3→送2。不得自动按类型分组;地图报价使用完整原顺序。各站备注由客户说明物品,骑手负责核对,不新增取送关联字段。简明接入见 [v2 前端交接文档](flash-delivery-v2-frontend-handoff.md)。
+
+骑手详情的 `currentTargetStopId` 默认指向客户原序中最早未完成站;完成该站后重新读详情。v2 可自主使用现有目标接口选择其他待完成取/送站,`orderVersion` 和 `clientRequestId` 仍必填,`reason` 可选(≤500字),`consentDeclared` 可省略,不再强制客户同意声明。实际取送仍须确认当前目标并上传本站凭证,送达时核验本站 PIN;尚未取过货不能送,漏取时不能确认最后一个未完成收货站。调整不会修改原站序或运费。旧契约订单保留原阶段与同意限制。
+
+五个 Tab 与名称保持:`newTask` 新任务、`toPickup` 待取件、`delivering` 配送中、`completed` 已送达、`cancelled` 已取消。未全部取完仍为 ACCEPTED,即使部分站点已送达;全部取完才为 PICKED_UP,全部取送站完成才为 DELIVERED。每笔订单只计一个 Tab,角标继续按订单数。
+
+列表和详情同时展示两组已有字段:`已取件 {pickedUpStopCount}/{pickupStopCount} · 已送达 {deliveredStopCount}/{deliveryStopCount}`。例如已取2/3、已送1/2仍在待取件;取完后3/3、1/2进入配送中;全部送达3/3、2/2进入已送达。不要用主状态覆盖双进度,逐站推送中的 `state` 也可能仍为 ACCEPTED。本轮仅提供接口及文档,App 源码由前端人员接入,无新 SQL。

+ 22 - 9
docs/flash-delivery-v2-frontend-handoff.md

@@ -1,15 +1,15 @@
 # 闪送 v2 多点取送前端对接说明
 
-更新时间:2026-10-14。本文对应当前仓库中的后端实现,供用户端和骑手端接入;联调前需确认服务端部署版本和 [10 月 14 日数据库执行说明](../updatesql/sql.md)。
+更新时间:2026-10-15。本文对应当前仓库中的后端实现,供用户端和骑手端接入;联调前需确认服务端部署版本和 [10 月 14 日数据库执行说明](../updatesql/sql.md)。10 月 15 日交错取送与进度调整无新增字段、无新增 SQL。
 
 ## 本次要调整的页面和流程
 
 | 页面/场景 | 前端调整 |
 | --- | --- |
-| 用户填写闪送订单 | 按顺序编辑一个 `stops` 数组:先全部取货站,再全部收货站;物品信息只填写一次。根据 `/home` 的取货点和收货点数量上限控制可添加数量。 |
+| 用户填写闪送订单 | 按客户顺序编辑一个 `stops` 数组,首站取货、末站收货,中间可以交错取送;物品信息只填写一次。根据 `/home` 的取货点和收货点数量上限控制可添加数量。 |
 | 报价与下单 | 使用 `/v2/quote` 和 `/v2/orders`。创建时把本次报价的配置 ID、版本和费用原样回传;创建请求号在重试时保持一致。 |
 | 用户/收件人订单列表和详情 | 展示取货、收货两个进度;站点使用 `stops[].id`,不可把数组下标当成站点 ID。逐站收货码按权限展示。 |
-| 骑手履约 | 从详情的 `currentTargetStopId` 找当前目标;每站上传凭证并刷新详情。逐站送达改用固定路径的新接口,`orderId`、`stopId`、PIN 和照片全部放 body。 |
+| 骑手履约 | 保留原五个 Tab 和名称,卡片增加取件、送达双进度。从详情的 `currentTargetStopId` 找目标,可自主选择其他未完成站;每站上传凭证并刷新详情。逐站送达使用固定路径的新接口,`orderId`、`stopId`、PIN 和照片全部放 body。 |
 | 待接单改址 | v2 使用新的完整站点报价与保存接口。保存成功后刷新详情,替换本地旧站点 ID 和收货码。 |
 | 发起人消息 | 识别两种逐站进度事件,凭 `ddId` 打开订单详情,再读取最新状态。逐站收件人通知本期不接入。 |
 
@@ -37,12 +37,13 @@
     {"type":"PICKUP","name":"取件人甲","phone":"0911000001","address":"台北市信义区甲路1号","addressDetail":"1楼","longitude":121.5601,"latitude":25.0301,"remark":"取文件袋甲"},
     {"type":"PICKUP","name":"取件人乙","phone":"0911000002","address":"台北市信义区乙路2号","longitude":121.5602,"latitude":25.0302,"remark":"取文件袋乙"},
     {"type":"DELIVERY","name":"收件人丙","phone":"0911000003","address":"台北市大安区丙路3号","longitude":121.5603,"latitude":25.0303,"remark":"交给本人"},
+    {"type":"PICKUP","name":"取件人戊","phone":"0911000005","address":"台北市大安区戊路5号","longitude":121.5605,"latitude":25.0305,"remark":"取给丁的文件"},
     {"type":"DELIVERY","name":"收件人丁","phone":"0911000004","address":"台北市大安区丁路4号","longitude":121.5604,"latitude":25.0304,"remark":"交前台"}
   ]
 }
 ```
 
-至少一个 `PICKUP` 和一个 `DELIVERY`,取货站必须排在所有收货站之前,路线顺序就是数组顺序;同一地址或坐标不能重复。每站分别填写姓名、电话、完整地址和经纬度,可选门牌、交接方式、最长 200 字备注。v2 站点不要提交旧结构中的逐站物品字段;`packageType`、正整数 `quantity`、可选 `weightRange` 和 `specification` 只在订单级出现一次。`SCHEDULED` 订单还需提交 `scheduledPickupStartAt`、`scheduledPickupEndAt`,`NOW` 订单无需提交这两个字段。
+至少一个 `PICKUP` 和一个 `DELIVERY`,首站取货、末站收货,中间允许交错;默认路线及报价顺序就是数组顺序,不要把站点按取/送重新分组。同一地址或坐标不能重复。每站分别填写姓名、电话、完整地址和经纬度,可选门牌、交接方式、最长 200 字备注。客户在备注写清本站取什么/送什么,骑手负责核对,不需要提交取货站与收货站关联关系。v2 站点不要提交旧结构中的逐站物品字段;`packageType`、正整数 `quantity`、可选 `weightRange` 和 `specification` 只在订单级出现一次。`SCHEDULED` 订单还需提交 `scheduledPickupStartAt`、`scheduledPickupEndAt`,`NOW` 订单无需提交这两个字段。
 
 创建订单使用 `POST /system/flashDelivery/v2/orders`。body 包含报价时的业务字段和完整 `stops`,再增加:
 
@@ -69,7 +70,19 @@
 - 用户详情:`GET /system/flashDelivery/orders/{id}`;按订单号打开消息:`GET /system/flashDelivery/orders/byOrderNo?orderNo=...`。
 - 骑手列表与详情:`GET /system/flashDelivery/rider/orders`、`GET /system/flashDelivery/rider/orders/{id}`,沿用原有 `tab`、分页和可选坐标参数。
 
-详情 `data.stops` 按路线顺序返回。请求站点字段叫 `type`,响应字段叫 `stopType`;响应的 `id` 才是后续履约所需的 `stopId`,`stopOrder` 从 0 开始。每站有 `status`、`remark`、`pickedUpAt`/`deliveredAt` 和 `imageUrls`。列表与详情提供 `pickupStopCount`、`pickedUpStopCount`、`deliveryStopCount`、`deliveredStopCount`。部分取货时整单仍为 `ACCEPTED`;全部取完为 `PICKED_UP`;部分送达仍为 `PICKED_UP`;全部送达为 `DELIVERED`;确认收货或平台/系统完成后为 `COMPLETED`。页面不要只靠整单 `status` 推算逐站进度。
+详情 `data.stops` 按客户原路线顺序返回。请求站点字段叫 `type`,响应字段叫 `stopType`;响应的 `id` 才是后续履约所需的 `stopId`,`stopOrder` 从 0 开始。每站有 `status`、`remark`、`pickedUpAt`/`deliveredAt` 和 `imageUrls`。列表与详情提供 `pickupStopCount`、`pickedUpStopCount`、`deliveryStopCount`、`deliveredStopCount`。未全部取完时整单仍为 `ACCEPTED`,即使有站点已送达;全部取完为 `PICKED_UP`;全部取送站完成为 `DELIVERED`;确认收货或平台/系统完成后为 `COMPLETED`。页面不要只靠整单 `status` 推算逐站进度。
+
+### 骑手 Tab 保持原样,只增加进度
+
+| 原 Tab(参数不变) | 订单归属 |
+| --- | --- |
+| 新任务 `newTask` | `WAITING_ACCEPTANCE`,沿用原有可抢范围规则 |
+| 待取件 `toPickup` | `ACCEPTED`,还有取货站未完成;可以已有部分送达 |
+| 配送中 `delivering` | `PICKED_UP`,已全部取货,还有收货站未完成 |
+| 已送达 `completed` | `DELIVERED` / `COMPLETED` |
+| 已取消 `cancelled` | `CANCELLED` |
+
+卡片及详情同时显示 `已取件 {pickedUpStopCount}/{pickupStopCount} · 已送达 {deliveredStopCount}/{deliveryStopCount}`,文本按 App 四语言规范接入。例如取1→取2→送1→取3→送2:送1后显示“已取件 2/3 · 已送达 1/2”,仍在待取件;取3后显示“已取件 3/3 · 已送达 1/2”,进入配送中;送2后进入已送达。不要把它拆成两条订单或同时放入两个 Tab,角标计订单数量而非站点数,继续使用原列表及 `GET /system/flashDelivery/rider/orderCount`。列表展示进度;进入详情后使用 `currentTargetStopId` 突出下一站的取/送类型。
 
 v2 的订单级 `deliveryPinCode` 为空,收货码在各个 DELIVERY 站的 `stops[].deliveryPinCode`。发起人可查看全部收货码;已在系统内匹配的收件人只能查看自己站的收货码及联系人、精确地址和凭证;骑手看不到任何实际收货码。未匹配系统用户的收货站仍可下单,但此人不会进入“我收的”列表,也不会收到 App 推送;发起人需自行告知该站收货码。**逐站收件人通知本期暂缓。**
 
@@ -77,17 +90,17 @@ v2 的订单级 `deliveryPinCode` 为空,收货码在各个 DELIVERY 站的 `s
 
 ## 骑手端:目标、取货、送达
 
-骑手从详情 `currentTargetStopId` 定位当前站点;默认依次指向最早未完成的取货站,全部取完后依次指向最早未送达的收货站。操作成功后重新请求详情,不继续使用旧的目标 ID、状态或版本。
+骑手从详情 `currentTargetStopId` 定位当前站点;v2 默认指向客户原序中最早未完成站,可以是取货站或收货站。骑手也可选择其他待完成站,例如先把附近三个取货点全部取完再送。实际调整不重排客户原站点、不重算运费。操作成功后重新请求详情,不继续使用旧的目标 ID、状态或版本。
 
 | 操作 | 当前接口 | 提交内容 |
 | --- | --- | --- |
 | 逐站取货 | `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/pickup` | 现有接口,body 为 `{"imageUrls":["/profile/pickup.jpg"]}`;如同时传 `stopId`,必须与路径一致。 |
-| 选择目标站 | `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` | 现有接口,body 含 `orderVersion`、`clientRequestId`,改变默认目标时还需 `reason` 和 `consentDeclared:true`。 |
+| 选择目标站 | `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` | 现有接口,body 含 `orderVersion`、`clientRequestId`;v2 的 `reason` 可选(≤500字),`consentDeclared` 可省略,不再要求客户同意声明。 |
 | 逐站送达 | `POST /system/flashDelivery/rider/orders/stops/deliver` | **新接口**,body 含 `orderId`、`stopId`、`imageUrls`,启用 PIN 时再传该站 `pinCode`。 |
 
 取货和送达凭证每站至少 1 张、最多 9 张。新送达接口中 `orderId`、`stopId` 均为正整数;**只有一个收货站的 v2 订单也必须传 `stopId`**。骑手在送达现场向收件人核对其本站收货码,不能使用别站或订单级收货码。新骑手端可统一根据详情中的站点 ID 使用逐站送达接口;旧 `/rider/orders/{id}/deliver` 只供旧客户端处理旧订单,v2 订单调用它会被拒绝。
 
-送达非当前目标站会被拒绝。改变顺序时,先取得发起人同意,再使用目标调整接口登记原因和 `consentDeclared:true`;该字段是骑手声明,当前没有发起人 App 内审批流程。调整请求的 `clientRequestId` 在同一次操作重试时保持不变。某站已成功取货或送达的重试不会重复保存照片和进度;末站送达后主状态已变化时也是如此。
+取送非当前目标站会被拒绝。v2 改变顺序时先使用目标调整接口选站,服务端保留操作记录,选站本身不等于确认取送;实际物品由骑手根据备注核对。尚未取过任何货不能选择送货;尚有未取货点时不能确认最后一个未完成收货站,须先完成剩余取件。旧契约订单仍按原同意声明与先取后送规则履约。调整请求的 `clientRequestId` 在同一次操作重试时保持不变。某站已成功取货或送达的重试不会重复保存照片和进度;末站送达后主状态已变化时也是如此。
 
 ## 用户端:v2 待接单改址
 
@@ -129,7 +142,7 @@ Content-Type: application/json
 ## 联调验收重点
 
 1. 单取单送和多取多送都走 v2 报价/创建;收货站只有一站时,送达仍提供 `stopId`。
-2. 每站首次取货/送达后进度计数与当前目标正确;部分送达时整单仍为 `PICKED_UP`,不能提前确认完成或记账。
+2. 验证取1→取2→送1→取3→送2,以及骑手自主改为先取完再送;送1后 `ACCEPTED`、进度 2/3 与 1/2,取3后 `PICKED_UP`,送2后 `DELIVERED`。五个 Tab 和名称不变,不能提前确认完成或记账。
 3. 修改第二个收货站后,其他站、订单级物品和原运价仍在;保存后旧站点 ID/PIN 不再使用。
 4. 发起人看全部收货码,匹配的第二站收件人只看自己的地址、联系人、凭证和 PIN;骑手看不到 PIN;未匹配用户不出现 App 收件人入口。
 5. 发起人收到每站一次进度消息并能按订单号打开详情;收件人没有逐站推送。真实设备推送和 App 页面尚需联调。

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

@@ -33,9 +33,9 @@ public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
     /** 交付 PIN,仅启用 PIN 的用户详情返回,骑手端始终为空。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private String deliveryPinCode;
-    /** 按用户原始顺序返回站点、独立物品及本站凭证。 */
+    /** 按用户原始顺序返回取送站点、本站备注及凭证;v2 允许取送交错。 */
     private List<FlashDeliveryStopView> stops = List.of();
-    /** 当前前往目标,全部取货后指向唯一收货站。 */
+    /** 当前前往目标;v2 为原序最早未完成站或骑手自主选定站,不由 Tab 主状态推导。 */
     private Long currentTargetStopId;
     /** 目标调整记录,骑手声明与实际取货凭证分别展示。 */
     private List<FlashDeliveryTargetAdjustment> targetAdjustments = List.of();

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

@@ -79,10 +79,10 @@ public class FlashDeliveryOrderView {
     private Date createTime;
     /** 最后更新时间。 */
     private Date updateTime;
-    /** 取货站总数及已实际确认的取货站数。 */
+    /** 取货站总数及已取数;与送达进度独立,未全部取完仍为 ACCEPTED。 */
     private Integer pickupStopCount;
     private Integer pickedUpStopCount;
-    /** 收货站总数与已送达数;历史单按单收货站兼容。 */
+    /** 收货站总数与已送达数;交错取送时 ACCEPTED 也可能有非零已送达数。 */
     private Integer deliveryStopCount;
     private Integer deliveredStopCount;
     /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */

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

@@ -67,10 +67,10 @@ public class FlashDeliveryRiderOrderListView {
     private Integer paymentStatus;
     /** 创建时间。 */
     private Date createTime;
-    /** 取货站总数及已实际确认的取货站数。 */
+    /** 取货站总数及已取数;与送达进度同时展示,未全部取完仍在待取件 Tab。 */
     private Integer pickupStopCount;
     private Integer pickedUpStopCount;
-    /** 收货站总数与已送达数;历史单按单收货站兼容。 */
+    /** 收货站总数与已送达数;交错取送时 ACCEPTED 也可能已送达,不能按主状态置零。 */
     private Integer deliveryStopCount;
     private Integer deliveredStopCount;
     /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */

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

@@ -2,15 +2,15 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
-/** 选择前往取货站点,不推进取件状态或改变报价路线。 */
+/** 选择前往取/送站点,不确认取送、不修改客户原站序或运费;v2 允许骑手自主选择。 */
 @Data
 public class FlashDeliveryTargetRequest {
     /** 订单详情版本,必填,用于拒绝过期的目标选择请求。 */
     private Integer orderVersion;
     /** 本次骑手操作的幂等编号,最长64字符;重试保持编号和内容一致。 */
     private String clientRequestId;
-    /** 改变当前目标的原因,最长500字符。 */
+    /** 调整原因,最长500字符;v2 可选,旧契约订单改变目标仍必填。 */
     private String reason;
-    /** 骑手声明已取得下单人同意,不代表用户App内审批。 */
+    /** 旧契约订单的骑手同意声明;v2 可省略,保留字段用于兼容和记录实际请求。 */
     private Boolean consentDeclared;
 }

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

@@ -3,7 +3,7 @@ package com.ruoyi.app.flashdelivery.dto;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
-/** v2 统一站点:取货点与收货点放在同一数组,顺序即用户添加顺序与骑手路线顺序。 */
+/** v2 统一站点:可交错取送,数组顺序用于客户默认路线与报价,骑手实际目标单独记录。 */
 @Data
 @EqualsAndHashCode(callSuper = true)
 public class FlashDeliveryV2StopRequest extends FlashDeliveryAddressRequest {

+ 36 - 13
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -768,7 +768,7 @@ public class FlashDeliveryApplicationService {
         notificationService.notifyDelivered(requireOrder(orderId));
     }
 
-    /** v2 逐站送达:校验按站收货码并提交凭证;全部收货站完成才整单推进为已送达,仅推进一次。 */
+    /** 逐站送达:v2 允许交错取送;校验本站收货码和凭证,全部取送完成才推进整单已送达。 */
     @Transactional
     public void deliverAtStop(Long riderId, Long orderId, Long stopId, FlashDeliveryDeliverRequest request) {
         requireRider(riderId);
@@ -779,10 +779,18 @@ public class FlashDeliveryApplicationService {
         if (!"DELIVERY".equals(stop.getStopType())) throw fail("flash.delivery.stop.invalid");
         // 已确认站点的重试直接成功,不重复写凭证或推进计数。
         if ("DELIVERED".equals(stop.getStatus())) return;
-        if (!PICKED_UP.equals(order.getStatus()) || !allPicked(rows)) {
+        boolean v2 = order.getDeliveryStopCount() != null;
+        String fromStatus = order.getStatus();
+        // v2 未取完时仍归待取件,但已取到的物品可送;旧单继续先取完再送。
+        if (v2 ? (!ACCEPTED.equals(fromStatus) && !PICKED_UP.equals(fromStatus)) || !anyPicked(rows)
+                : !PICKED_UP.equals(fromStatus) || !allPicked(rows)) {
             throw fail("flash.delivery.transition.not.allowed");
         }
         if (!Objects.equals(stopId, targetStopId(order, rows))) throw fail("flash.delivery.target.mismatch");
+        // 不建立取送物品关联,骑手按备注核对;但不能漏取站点就确认最后一站并结束整单。
+        boolean lastDelivery = rows.stream().filter(s -> "DELIVERY".equals(s.getStopType()))
+                .filter(s -> !"DELIVERED".equals(s.getStatus())).count() == 1;
+        if (lastDelivery && !allPicked(rows)) throw fail("flash.delivery.transition.not.allowed");
         String expectedPin = order.getDeliveryStopCount() == null
                 ? order.getDeliveryPinCode() : stop.getDeliveryPinCode();
         if (Boolean.TRUE.equals(order.getPinRequired())
@@ -798,12 +806,12 @@ public class FlashDeliveryApplicationService {
         order.setDeliveredStopCount((int) rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())
                 && "DELIVERED".equals(s.getStatus())).count());
         order.setCurrentTargetStopId(null); order.setUpdateTime(now);
-        if (allDelivered(rows)) {
+        if (allPicked(rows) && allDelivered(rows)) {
             // 末站完成才整单推进,与取件侧同一行锁与条件更新路径。
             order.setStatus(DELIVERED); order.setDeliveredAt(now);
         }
         if (orderMapper.updateDeliveryFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
-        writeLog(orderId, PICKED_UP, order.getStatus(), "RIDER", riderId, "DELIVERY_STOP:" + stopId, now);
+        writeLog(orderId, fromStatus, order.getStatus(), "RIDER", riderId, "DELIVERY_STOP:" + stopId, now);
         if (order.getDeliveryStopCount() != null) {
             notificationService.notifyDeliveryStop(order, stop, order.getDeliveredStopCount(), expectedDeliveryStopCount(order));
         } else if (DELIVERED.equals(order.getStatus())) {
@@ -963,7 +971,7 @@ public class FlashDeliveryApplicationService {
         }).toList();
     }
 
-    /** v2 站点结构校验:类型、地址、备注、上限与开关、取前收后及站点去重。 */
+    /** v2 站点校验:客户原序可交错取送,首站取货、末站收货,保留数量和地址去重校验。 */
     private List<FlashDeliveryV2StopRequest> normalizeV2Stops(List<FlashDeliveryV2StopRequest> stops) {
         if (stops == null || stops.isEmpty()) throw fail("flash.delivery.stops.required");
         for (FlashDeliveryV2StopRequest stop : stops) {
@@ -981,11 +989,10 @@ public class FlashDeliveryApplicationService {
                 || pickups.size() > pickupStopLimit() || deliveries.size() > deliveryStopLimit()) {
             throw fail("flash.delivery.stops.limit.exceeded");
         }
-        // 顺序即路线:先取完再送;交错取送属后续增量。
-        boolean deliverySeen = false;
-        for (FlashDeliveryV2StopRequest stop : stops) {
-            if ("DELIVERY".equals(stop.getType())) deliverySeen = true;
-            else if (deliverySeen) throw fail("flash.delivery.stops.order.invalid");
+        // 仅约束路线起终点,不按类型重排;报价、创建和完整改址都保留客户填写的顺序。
+        if (!"PICKUP".equals(stops.get(0).getType())
+                || !"DELIVERY".equals(stops.get(stops.size() - 1).getType())) {
+            throw fail("flash.delivery.stops.order.invalid");
         }
         for (int i = 0; i < stops.size(); i++) {
             for (int j = i + 1; j < stops.size(); j++) {
@@ -2273,6 +2280,15 @@ public class FlashDeliveryApplicationService {
     /** 选择的目标在刷新后保留;本站完成后回到原顺序最早的未完成站点。 */
     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.getDeliveryStopCount() != null) {
+            // v2 的主状态用于 Tab;实际下一站由客户原序和骑手选择决定,不能按主状态强制只取或只送。
+            if (order.getCurrentTargetStopId() != null && rows.stream().anyMatch(s ->
+                    Objects.equals(s.getId(), order.getCurrentTargetStopId()) && "PENDING".equals(s.getStatus()))) {
+                return order.getCurrentTargetStopId();
+            }
+            return rows.stream().filter(s -> "PENDING".equals(s.getStatus()))
+                    .map(FlashDeliveryOrderStop::getId).findFirst().orElse(null);
+        }
         String targetType = PICKED_UP.equals(order.getStatus()) ? "DELIVERY" : "PICKUP";
         if (order.getCurrentTargetStopId() != null && rows.stream().anyMatch(s ->
                 Objects.equals(s.getId(), order.getCurrentTargetStopId())
@@ -2328,14 +2344,20 @@ public class FlashDeliveryApplicationService {
         }
         List<FlashDeliveryOrderStop> rows = requireStops(order);
         FlashDeliveryOrderStop target = requireStop(rows, stopId);
+        boolean v2 = order.getDeliveryStopCount() != null;
         String targetType = PICKED_UP.equals(order.getStatus()) ? "DELIVERY" : "PICKUP";
-        if (!targetType.equals(target.getStopType()) || !"PENDING".equals(target.getStatus())) {
+        if (!"PENDING".equals(target.getStatus()) || (!v2 && !targetType.equals(target.getStopType()))) {
             throw fail("flash.delivery.stop.invalid");
         }
+        // v2 骑手可自主调整取送先后,但没有取到任何货时不能直接选择收货站。
+        if (v2 && "DELIVERY".equals(target.getStopType()) && !anyPicked(rows)) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
         Long before = targetStopId(order, rows);
-        if (!Objects.equals(before, stopId) && (!hasText(request.getReason()) || tooLong(request.getReason(), 500)
+        if (!v2 && !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");
+        if (tooLong(request.getReason(), 500)) throw fail(v2
+                ? "flash.delivery.target.reason.too.long" : "flash.delivery.target.consent.required");
         Date now = new Date();
         FlashDeliveryTargetAdjustment adjustment = new FlashDeliveryTargetAdjustment();
         adjustment.setOrderId(orderId); adjustment.setFromStopId(before); adjustment.setToStopId(stopId);
@@ -2384,6 +2406,7 @@ public class FlashDeliveryApplicationService {
         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);
+        // 已送达进度独立保留;只有全部取完才从待取件 Tab 转配送中,不以是否已送过货判断。
         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);

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

@@ -441,7 +441,8 @@ flash.delivery.stops.required=Vui lòng gửi ít nhất một điểm nhận v
 flash.delivery.stop.type.invalid=Loại điểm dừng không hợp lệ
 flash.delivery.stop.remark.too.long=Ghi chú của điểm dừng không được vượt quá 200 ký tự
 flash.delivery.stops.limit.exceeded=Số điểm nhận hoặc điểm giao vượt quá giới hạn
-flash.delivery.stops.order.invalid=Điểm giao phải đứng sau tất cả các điểm nhận
+flash.delivery.stops.order.invalid=Điểm đầu tiên phải là điểm nhận và điểm cuối cùng phải là điểm giao
+flash.delivery.target.reason.too.long=Lý do thay đổi không được vượt quá 500 ký tự
 
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=Vui lòng tải lại trước khi lưu: thiếu phiên bản hồ sơ

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

@@ -427,7 +427,8 @@ flash.delivery.stops.required=Submit at least one pickup stop and one delivery s
 flash.delivery.stop.type.invalid=Invalid stop type
 flash.delivery.stop.remark.too.long=Stop remark cannot exceed 200 characters
 flash.delivery.stops.limit.exceeded=Too many pickup or delivery stops
-flash.delivery.stops.order.invalid=Delivery stops must be placed after all pickup stops
+flash.delivery.stops.order.invalid=The first stop must be a pickup and the last stop must be a delivery
+flash.delivery.target.reason.too.long=The adjustment reason must not exceed 500 characters
 
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=Refresh before saving: material version is missing

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

@@ -428,7 +428,8 @@ flash.delivery.stops.required=โปรดระบุจุดรับสิ
 flash.delivery.stop.type.invalid=ประเภทจุดแวะไม่ถูกต้อง
 flash.delivery.stop.remark.too.long=หมายเหตุของจุดแวะต้องไม่เกิน 200 ตัวอักษร
 flash.delivery.stops.limit.exceeded=จำนวนจุดรับหรือจุดส่งเกินขีดจำกัด
-flash.delivery.stops.order.invalid=จุดส่งต้องอยู่หลังจุดรับทั้งหมด
+flash.delivery.stops.order.invalid=จุดแรกต้องเป็นจุดรับและจุดสุดท้ายต้องเป็นจุดส่ง
+flash.delivery.target.reason.too.long=เหตุผลในการปรับเปลี่ยนต้องไม่เกิน 500 ตัวอักษร
 
 # 034 Stall qualification: common material rules and public images
 stall.qualification.version.required=โปรดรีเฟรชก่อนบันทึก เนื่องจากขาดเวอร์ชันของข้อมูล

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

@@ -429,7 +429,8 @@ flash.delivery.stops.required=请提交至少一个取货点和一个收货点
 flash.delivery.stop.type.invalid=站点类型不正确
 flash.delivery.stop.remark.too.long=站点备注不能超过200字
 flash.delivery.stops.limit.exceeded=取货点或收货点数量超出限制
-flash.delivery.stops.order.invalid=收货点必须排在全部取货点之后
+flash.delivery.stops.order.invalid=首站必须是取货点,末站必须是收货点
+flash.delivery.target.reason.too.long=调整原因不能超过500个字符
 
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=请刷新后再保存,缺少资料版本

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

@@ -428,7 +428,8 @@ flash.delivery.stops.required=請提交至少一個取貨點和一個收貨點
 flash.delivery.stop.type.invalid=站點類型不正確
 flash.delivery.stop.remark.too.long=站點備註不能超過200字
 flash.delivery.stops.limit.exceeded=取貨點或收貨點數量超出限制
-flash.delivery.stops.order.invalid=收貨點必須排在全部取貨點之後
+flash.delivery.stops.order.invalid=首站必須是取貨點,末站必須是收貨點
+flash.delivery.target.reason.too.long=調整原因不能超過500個字元
 
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=請重新整理後再儲存,缺少資料版本

+ 148 - 8
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryV2Test.java

@@ -82,6 +82,10 @@ class FlashDeliveryV2Test {
             wrongOrder.getStops().add(0, stop("DELIVERY", "0911111112", "台北市松山區B街2號", "d1"));
             assertThrows(ServiceException.class, () -> f.service.createV2(7L, wrongOrder));
 
+            FlashDeliveryV2CreateRequest wrongEnd = v2Create();
+            Collections.swap(wrongEnd.getStops(), 1, 3);
+            assertThrows(ServiceException.class, () -> f.service.createV2(7L, wrongEnd));
+
             FlashDeliveryV2CreateRequest tooMany = v2Create();
             for (int i = 0; i < 2; i++) tooMany.getStops().add(stop("DELIVERY", "091111111" + (4 + i), "台北市中山區C街" + i + "號", "d" + i));
             assertThrows(ServiceException.class, () -> f.service.createV2(7L, tooMany));
@@ -116,6 +120,132 @@ class FlashDeliveryV2Test {
         }
     }
 
+    @Test void interleavedQuoteAndCreatePreserveCustomerRouteAndRemarks() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryV2CreateRequest request = v2Create();
+            Collections.swap(request.getStops(), 1, 2);
+            FlashDeliveryQuoteView quote = f.service.quoteV2(request);
+            request.setPricingId(quote.getPricingId()); request.setPricingVersion(quote.getPricingVersion());
+            request.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee()); request.setQuotedDistanceFee(quote.getDistanceFee());
+            request.setQuotedUrgentFee(quote.getUrgentFee()); request.setQuotedAmount(quote.getAmount());
+            f.service.createV2(7L, request);
+            var saved = argThatStops(f);
+            assertEquals(List.of("PICKUP", "DELIVERY", "PICKUP", "DELIVERY"),
+                    saved.stream().map(FlashDeliveryOrderStop::getStopType).toList());
+            assertEquals(List.of("取月餅", "送管理室", "取牛奶", "送前台"),
+                    saved.stream().map(FlashDeliveryOrderStop::getRemark).toList());
+            assertEquals(List.of(0, 1, 2, 3), saved.stream().map(FlashDeliveryOrderStop::getStopOrder).toList());
+            assertEquals(Arrays.asList(null, 31L, null, 32L),
+                    saved.stream().map(FlashDeliveryOrderStop::getReceiverUserId).toList());
+            var expected = request.getStops().stream().map(s ->
+                    new com.ruoyi.app.flashdelivery.route.GeoPoint(s.getLatitude(), s.getLongitude())).toList();
+            assertEquals(expected, f.capturedPoints.subList(0, 4));
+            assertEquals(expected, f.capturedPoints.subList(4, 8));
+        }
+    }
+
+    @Test void interleavedFulfilmentKeepsBothProgressCountsAndOriginalTabs() {
+        Fixture f = interleaved();
+        pickup(f, 11L); pickup(f, 12L);
+        assertEquals(13L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L));
+        FlashDeliveryOrderDetailView detail = f.service.riderDetail(8L, 10L);
+        assertEquals("ACCEPTED", detail.getStatus());
+        assertEquals(2, detail.getPickedUpStopCount()); assertEquals(3, detail.getPickupStopCount());
+        assertEquals(1, detail.getDeliveredStopCount()); assertEquals(2, detail.getDeliveryStopCount());
+        assertEquals(15L, detail.getCurrentTargetStopId()); assertFalse(detail.getCanUserCancel());
+        var page = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<FlashDeliveryOrder>(1, 10);
+        page.setRecords(List.of(f.order));
+        when(f.orders.selectPage(any(), any())).thenReturn(page);
+        var card = f.service.riderOrders(8L, 1, 10, "toPickup", null, null).getRecords().get(0);
+        assertEquals(2, card.getPickedUpStopCount()); assertEquals(3, card.getPickupStopCount());
+        assertEquals(1, card.getDeliveredStopCount()); assertEquals(2, card.getDeliveryStopCount());
+        var query = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(f.orders).selectPage(any(), query.capture());
+        assertTrue(query.getValue().getSqlSegment().contains("status"));
+        assertTrue(query.getValue().getParamNameValuePairs().containsValue("ACCEPTED"));
+        // 已送站重试不重复存图、通知,也不恢复旧目标。
+        f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L));
+        assertEquals(15L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        pickup(f, 15L);
+        assertEquals("PICKED_UP", f.order.getStatus());
+        assertEquals(3, f.order.getPickedUpStopCount()); assertEquals(1, f.order.getDeliveredStopCount());
+        f.service.riderOrders(8L, 1, 10, "delivering", null, null);
+        verify(f.orders, times(2)).selectPage(any(), query.capture());
+        assertTrue(query.getValue().getSqlSegment().contains("status"));
+        assertTrue(query.getValue().getParamNameValuePairs().containsValue("PICKED_UP"));
+        f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L));
+        assertEquals("DELIVERED", f.order.getStatus()); assertEquals(2, f.order.getDeliveredStopCount());
+        assertNull(f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        assertNotNull(f.order.getPickedUpAt()); assertNotNull(f.order.getDeliveredAt());
+        verify(f.images, times(5)).insert(any(FlashDeliveryOrderImage.class));
+        verify(f.notifications, times(3)).notifyPickupStop(any(), any(), anyInt(), eq(3));
+        verify(f.notifications, times(2)).notifyDeliveryStop(any(), any(), anyInt(), eq(2));
+        verify(f.billing, never()).insert(any(com.ruoyi.system.domain.UserBilling.class));
+    }
+
+    @Test void riderCanCollectNearbyRemainingPickupBeforeDeliveryWithoutConsent() {
+        Fixture f = interleaved(); pickup(f, 11L); pickup(f, 12L);
+        assertEquals(13L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        when(f.adjustments.insert(any(FlashDeliveryTargetAdjustment.class))).thenReturn(1);
+        FlashDeliveryTargetRequest request = new FlashDeliveryTargetRequest();
+        request.setOrderVersion(f.order.getVersion()); request.setClientRequestId("nearby-pickup");
+        assertEquals(15L, f.service.selectTarget(8L, 10L, 15L, request).getCurrentTargetStopId());
+        assertEquals(2, f.order.getPickedUpStopCount()); assertEquals(0, f.order.getDeliveredStopCount());
+        pickup(f, 15L);
+        assertEquals("PICKED_UP", f.order.getStatus());
+        assertEquals(13L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        assertEquals(List.of(0, 1, 2, 3, 4), f.rows.stream().map(FlashDeliveryOrderStop::getStopOrder).toList());
+        assertEquals(100L, f.order.getAmount());
+        var adjustment = org.mockito.ArgumentCaptor.forClass(FlashDeliveryTargetAdjustment.class);
+        verify(f.adjustments).insert(adjustment.capture());
+        assertFalse(adjustment.getValue().getConsentDeclared());
+        assertNull(adjustment.getValue().getReason());
+        when(f.adjustments.selectOne(any())).thenReturn(adjustment.getValue());
+        assertEquals(13L, f.service.selectTarget(8L, 10L, 15L, request).getCurrentTargetStopId());
+        verify(f.adjustments, times(1)).insert(any(FlashDeliveryTargetAdjustment.class));
+        assertThrows(ServiceException.class, () -> f.service.selectTarget(8L, 10L, 13L, request));
+        verify(f.routes, never()).calculate(anyList(), anyInt());
+    }
+
+    @Test void interleavedDeliveryCannotStartWithoutPickupOrFinishWithPendingPickup() {
+        Fixture f = interleaved();
+        when(f.adjustments.insert(any(FlashDeliveryTargetAdjustment.class))).thenReturn(1);
+        FlashDeliveryTargetRequest request = new FlashDeliveryTargetRequest();
+        request.setOrderVersion(0); request.setClientRequestId("no-goods");
+        assertThrows(ServiceException.class, () -> f.service.selectTarget(8L, 10L, 13L, request));
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L)));
+        pickup(f, 11L); pickup(f, 12L);
+        f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L));
+        request.setOrderVersion(f.order.getVersion()); request.setClientRequestId("last-dropoff");
+        f.service.selectTarget(8L, 10L, 14L, request);
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L)));
+        assertEquals("ACCEPTED", f.order.getStatus()); assertEquals("PENDING", f.rows.get(4).getStatus());
+        assertNull(f.order.getDeliveredAt());
+        verify(f.images, times(3)).insert(any(FlashDeliveryOrderImage.class));
+        verify(f.notifications, times(1)).notifyDeliveryStop(any(), any(), anyInt(), anyInt());
+    }
+
+    @Test void interleavedDeliveryStillValidatesRiderTargetAndOwnPinBeforeWriting() {
+        Fixture f = interleaved(); pickup(f, 11L); pickup(f, 12L);
+        f.order.setPinRequired(true); f.rows.get(2).setDeliveryPinCode("1111");
+        f.rows.get(4).setDeliveryPinCode("2222");
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(9L, 10L, 13L, deliveryProof(13L)));
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L)));
+        FlashDeliveryDeliverRequest proof = deliveryProof(13L); proof.setPinCode("2222");
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 13L, proof));
+        proof.setPinCode("1111"); proof.setImageUrls(List.of());
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 13L, proof));
+        assertEquals("PENDING", f.rows.get(2).getStatus());
+        verify(f.images, times(2)).insert(any(FlashDeliveryOrderImage.class));
+        proof.setImageUrls(List.of("/profile/d.jpg"));
+        f.service.deliverAtStop(8L, 10L, 13L, proof);
+        assertEquals("DELIVERED", f.rows.get(2).getStatus());
+        assertEquals("ACCEPTED", f.order.getStatus());
+    }
+
     @Test void v2PickupNotifiesSenderForEachCommittedStopWithoutDuplicateStageMessage() {
         Fixture f = new Fixture();
         FlashDeliveryProofRequest proof = new FlashDeliveryProofRequest();
@@ -255,7 +385,7 @@ class FlashDeliveryV2Test {
         assertThrows(ServiceException.class, () -> f.service.deliverAtStop(9L, 10L, 14L, deliveryProof(14L)));
     }
 
-    @Test void deliveryCannotSkipTargetWithoutRecordedSenderConsent() {
+    @Test void deliveryCannotSkipCurrentTargetWithoutSelectingIt() {
         Fixture f = ready();
         assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L)));
         verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
@@ -263,14 +393,12 @@ class FlashDeliveryV2Test {
         assertEquals(14L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
     }
 
-    @Test void senderConsentAllowsDeliveryTargetChangeAndReturnsToRemainingSequence() {
+    @Test void riderCanSelectDeliveryTargetWithoutConsentAndReturnsToRemainingSequence() {
         Fixture f = ready();
         when(f.adjustments.insert(any(FlashDeliveryTargetAdjustment.class))).thenReturn(1);
         FlashDeliveryTargetRequest request = new FlashDeliveryTargetRequest();
-        request.setClientRequestId("delivery-target-1"); request.setOrderVersion(0); request.setReason("sender asked to deliver second first");
+        request.setClientRequestId("delivery-target-1"); request.setOrderVersion(0);
         request.setConsentDeclared(false);
-        assertThrows(ServiceException.class, () -> f.service.selectTarget(8L, 10L, 14L, request));
-        request.setConsentDeclared(true);
         assertEquals(14L, assertDoesNotThrow(() -> f.service.selectTarget(8L, 10L, 14L, request)).getCurrentTargetStopId());
         f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L));
         assertEquals(13L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
@@ -308,7 +436,8 @@ class FlashDeliveryV2Test {
             Class<?> quoteType = assertDoesNotThrow(() -> Class.forName("com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2AddressChangeRequest"));
             Class<?> confirmType = assertDoesNotThrow(() -> Class.forName("com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2AddressConfirmRequest"));
             var body = json.createObjectNode(); body.put("orderId", 10); body.put("orderVersion", 0);
-            body.set("stops", json.valueToTree(v2Create().getStops()));
+            var interleavedStops = v2Create().getStops(); Collections.swap(interleavedStops, 1, 2);
+            body.set("stops", json.valueToTree(interleavedStops));
             Object quoteRequest = json.treeToValue(body, quoteType);
             FlashDeliveryQuoteView quote = (FlashDeliveryQuoteView) f.service.getClass().getMethod("quoteAddressV2", Long.class, quoteType).invoke(f.service, 7L, quoteRequest);
             assertEquals(1L, quote.getPricingId()); assertEquals(100L, quote.getAmount());
@@ -321,9 +450,9 @@ class FlashDeliveryV2Test {
             var detail = (FlashDeliveryOrderDetailView) f.service.getClass().getMethod("updateAddressV2", Long.class, confirmType).invoke(f.service, 7L, confirm);
             assertEquals(4, detail.getStops().size()); assertEquals(2, f.order.getDeliveryStopCount());
             assertEquals("FOOD_INGREDIENTS", f.order.getPackageType()); assertEquals(8, f.order.getQuantity());
-            assertEquals(List.of("取月餅", "取牛奶", "送管理室", "送前台"), f.rows.stream().map(FlashDeliveryOrderStop::getRemark).toList());
+            assertEquals(List.of("取月餅", "送管理室", "取牛奶", "送前台"), f.rows.stream().map(FlashDeliveryOrderStop::getRemark).toList());
             assertEquals(32L, f.rows.get(3).getReceiverUserId());
-            assertNotNull(f.rows.get(2).getDeliveryPinCode());
+            assertNotNull(f.rows.get(1).getDeliveryPinCode());
             assertEquals(1, f.order.getVersion());
             verify(f.prices, never()).selectAtTime(anyString(), anyInt());
         }
@@ -334,6 +463,17 @@ class FlashDeliveryV2Test {
         f.rows.stream().filter(row -> "PICKUP".equals(row.getStopType())).forEach(row -> row.setStatus("PICKED_UP"));
         return f;
     }
+    private static Fixture interleaved() {
+        Fixture f = new Fixture(); f.order.setPinRequired(false); f.order.setPickupStopCount(3);
+        f.rows.get(3).setStopOrder(4);
+        f.rows.add(3, stop(15L, 3, "PICKUP", "0911111114"));
+        return f;
+    }
+    private static void pickup(Fixture f, Long stopId) {
+        FlashDeliveryProofRequest proof = new FlashDeliveryProofRequest();
+        proof.setImageUrls(List.of("/profile/p.jpg"));
+        f.service.pickupAtStop(8L, 10L, stopId, proof);
+    }
     private static void receivers(Fixture f) {
         f.order.setReceiverUserId(31L); f.rows.get(2).setReceiverUserId(31L); f.rows.get(3).setReceiverUserId(32L);
     }

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

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

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

@@ -216,14 +216,14 @@
           and status = 'ACCEPTED' and version = #{order.version}
     </update>
 
-    <!-- v2 逐站送达:仅在已取件状态推进,末站完成时写入整单送达时间。 -->
+    <!-- 交错送达可能仍处于待取件;行锁内按骑手、可履约状态和版本推进,整单完成时写送达时间。 -->
     <update id="updateDeliveryFulfilment">
         update flash_delivery_order
         set status = #{order.status}, delivered_stop_count = #{order.deliveredStopCount},
             current_target_stop_id = #{order.currentTargetStopId},
             delivered_at = #{order.deliveredAt}, update_time = #{order.updateTime}, version = version + 1
         where id = #{order.id} and rider_id = #{order.riderId}
-          and status = 'PICKED_UP' and version = #{order.version}
+          and status in ('ACCEPTED', 'PICKED_UP') and version = #{order.version}
     </update>
 
     <!-- 预约时段结束仍无人接单时,由任务分批收口;只读取仍可取消的待接订单。 -->

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

@@ -33,6 +33,11 @@ class FlashDeliveryMapperXmlTest {
                 .getBoundSql(java.util.Map.of("order", order));
         assertTrue(delivery.getSql().contains("current_target_stop_id"));
         assertTrue(delivery.getParameterMappings().stream().anyMatch(mapping -> mapping.getProperty().equals("order.currentTargetStopId")));
+        String deliverySql = delivery.getSql().replaceAll("\\s+", " ").toLowerCase();
+        // 交错送达可能仍处于待取件;数据库条件仍须保护骑手、版本和可履约状态。
+        assertTrue(deliverySql.contains("status in ('accepted', 'picked_up')"));
+        assertTrue(deliverySql.contains("rider_id = ?"));
+        assertTrue(deliverySql.contains("version = ?"));
         var participant = configuration.getMappedStatement(namespace + "transitionByParticipant")
                 .getBoundSql(java.util.Map.of("id", 10L, "userId", 32L, "expectedStatus", "DELIVERED", "nextStatus", "COMPLETED", "now", new java.util.Date()));
         String sql = participant.getSql().replaceAll("\\s+", " ").toLowerCase();

+ 8 - 4
specs/024-flash-delivery/contracts/api.md

@@ -234,7 +234,7 @@
 
 | 方法 | 路径 | 说明 |
 | --- | --- | --- |
-| POST | `/system/flashDelivery/v2/quote` | 统一结构报价:订单级物品 + `pickups[]`(1..N) + `deliveries[]`(1..M) |
+| POST | `/system/flashDelivery/v2/quote` | 统一结构报价:订单级物品 + `stops[]`(至少 1 取、1 收,允许交错) |
 | POST | `/system/flashDelivery/v2/orders` | 按 v2 报价创建订单,服务端复算费用与路线 |
 
 请求结构(要点):取货点与收货点放在同一个 `stops[]` 数组,顺序即用户添加顺序、也是骑手路线顺序,元素以 `type`(`PICKUP`/`DELIVERY`)区分:
@@ -246,12 +246,12 @@
     {"type":"PICKUP","name":"王小姐","phone":"17788880000","address":"台北市信義區松高路11號","addressDetail":"1樓門市","latitude":25.0410,"longitude":121.5670,"remark":"取 2 箱牛奶"},
     {"type":"DELIVERY","name":"陳先生","phone":"17788881111","address":"台北市松山區南京東路五段123巷4弄5號","addressDetail":"3樓","latitude":25.0520,"longitude":121.5760,"remark":"整份交給管理室代收"}
   ],
-  "packageType":"食品雜貨","quantity":8,"weightRange":"4","specification":null,
-  "deliveryMode":"NOW","payType":"CASH","tipAmount":0
+  "packageType":"FOOD_INGREDIENTS","quantity":8,"weightRange":"UP_TO_5_KG","specification":null,
+  "deliveryMode":"NOW","payType":"4","tipAmount":0
 }
 ```
 
-地址模型与 Lalamove 一致:站点只存 `address` 完整地址文本(台湾地址为连续文字,不做城市/区结构化拆分)、可选 `addressDetail`(樓層/室)与必填经纬度;路线与计价以经纬度为准,地址文本用于展示和骑手导航。物品主体 `packageType,quantity,weightRange?,specification?` 只出现一次;`remark` 选填、不超过 200 字;预约时间、支付方式、小费等订单级字段沿用现有契约。服务端校验:至少 1 个 `PICKUP` 与 1 个 `DELIVERY`、取货站必须全部位于收货站之前(先取完再送,交错取送属后续增量),并按 `flash_pickup_stop_limit`/`flash_delivery_stop_limit` 限制数量;v2 运费计算使用独立的新计价方法,报价路线经点为全部站点,整线 40 公里上限不变。旧单点订单的市/区结构化字段仅随旧接口与旧订单继续存在。
+地址模型与 Lalamove 一致:站点只存 `address` 完整地址文本(台湾地址为连续文字,不做城市/区结构化拆分)、可选 `addressDetail`(樓層/室)与必填经纬度;路线与计价以经纬度为准,地址文本用于展示和骑手导航。物品主体 `packageType,quantity,weightRange?,specification?` 只出现一次;`remark` 选填、不超过 200 字;预约时间、支付方式、小费等订单级字段沿用现有契约。服务端校验:至少 1 个 `PICKUP` 与 1 个 `DELIVERY`、首站取货、末站收货,中间可按客户顺序交错,并按 `flash_pickup_stop_limit`/`flash_delivery_stop_limit` 限制数量;v2 运费计算使用独立的新计价方法,报价路线经点为全部站点,不按类型重排,整线 40 公里上限不变。旧单点订单的市/区结构化字段仅随旧接口与旧订单继续存在。
 
 响应要点:报价返回整线距离、时长与费用明细(结构同现有报价);创建返回订单详情,`stops[].id` 是后续履约所需的站点 ID,另有状态与备注。服务端按收货站联系人电话固化 `receiverUserId`(可空),详情不返回该内部 ID。启用收货码时每个收货站生成独立 `deliveryPinCode`;发起人可见全部,已绑定收件人仅可见自己的站点,骑手不可见。v2 订单级 `deliveryPinCode` 为空。列表和详情返回 `deliveryStopCount`、`deliveredStopCount`。
 
@@ -263,6 +263,10 @@
 
 全部收货站确认送达后才推进主状态 DELIVERED;随后确认收货、平台完成或系统自动完成进入 COMPLETED 时才生成一笔骑手收入账单。任一收货站已送达后订单进入禁止取消状态(用户与平台同口径)。旧整单送达接口拒绝全部 v2 订单,只有一个收货站的 v2 订单也使用此逐站接口并提供 `stopId`。
 
+2026-10-15 v2 修订:允许尚未全部取货时逐站送达,仍需至少一站已取货、正确的当前目标/按站 PIN/凭证。未取完保持 ACCEPTED,送达计数独立累加;确认最后一个未完成收货站前必须全部取完。默认目标按客户原序最早未完成站,使用既有 `/rider/orders/{id}/stops/{stopId}/target` 可自主选其他待完成站,body 仍必填 `orderVersion,clientRequestId`,`reason` 可选、`consentDeclared` 不再必需,保留审计与幂等且不重算运费;旧契约订单继续原同意门禁。物品通过备注由客户说明、骑手核对,不增加站点关联字段。
+
+骑手原 Tab 名称、参数及订单计数不变:`toPickup` 对应 ACCEPTED、`delivering` 对应 PICKED_UP、`completed` 对应 DELIVERED/COMPLETED。列表/详情同时使用已有 `pickedUpStopCount/pickupStopCount` 和 `deliveredStopCount/deliveryStopCount` 展示“已取件 2/3 · 已送达 1/2”;此示例仍在待取件,一笔订单只计一个 Tab 角标。
+
 每个取货站或收货站首次确认成功且事务提交后,向发起人推送一次逐站进度并写入消息中心。消息 payload 使用 `FLASH_PICKUP_STOP_COMPLETED` 或 `FLASH_DELIVERY_STOP_COMPLETED`,包含 `ddId,state,stopId,stopType,stopOrder,completedStopCount,totalStopCount`;`stopOrder` 为从 0 开始的路线下标,文案中站号从 1 开始。重试、失败和回滚不重复通知;最后一站不叠发同阶段整单提醒。逐站收件人通知本阶段暂缓。
 
 ### v2 待接单改址

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

@@ -59,3 +59,9 @@
 - `flash_delivery_order` 增加 `delivery_stop_count`、`delivered_stop_count`,镜像取件侧计数;全部收货站完成才推进 DELIVERED。订单级物品字段恢复为唯一物品来源,站点表不再承载结构化物品;订单级 `delivery_pin_code` 仅对 v2 之前的旧订单继续使用。
 - 字典仅保留 `flash_pickup_stop_limit` 与 `flash_delivery_stop_limit` 两项站点数量上限(均为 1..10,缺失或非法回退 1,默认 1)。无多点独立开关,加急选项始终开放。
 - DDL 与字典调整见 `updatesql/sql.md`:测试库已按旧版增量路径执行并核对 18 条站点记录,其他环境仍需按实际表结构选择执行路径;无历史订单站点迁移或回写。
+
+## 2026-10-15:交错取送(复用已有字段)
+
+- v2(`delivery_stop_count` 非空)站点 `stop_order` 保留客户交错顺序,目标从全体 PENDING 站中按原序选取;骑手可选择其他待完成站,操作审计保留,`reason` 与 `consent_declared` 不再是 v2 改序前置条件。旧单原规则不变。
+- 主状态 ACCEPTED 覆盖所有未全部取完的阶段,可以同时具有非零 `delivered_stop_count`;全部取完才进入 PICKED_UP。最后一个未完成收货站送达前必须已全部取完,全部站完成进入 DELIVERED,后续完成结算不变。
+- 卡片及详情复用 `picked_up_stop_count/pickup_stop_count` 与 `delivered_stop_count/delivery_stop_count` 双进度,Tab 及角标按原主状态和订单数量统计。未新增表、字段、取送关联或数据迁移。

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

@@ -803,3 +803,13 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 1. 移除 `flash_show_multi_pickup`、`flash_show_multi_dropoff`、`flash_show_urgent_option` 三个字典依赖;取/收货点只按各自 1..10 的数量上限校验,加急始终可用。
 2. 首页保留现有 `showMultiPickup`、`showMultiDropoff`、`showUrgentOption` 字段兼容客户端;前两个按上限是否大于 1 派生,后者固定为 true。
 3. 在 `updatesql/sql.md` 登记两项上限初始化和旧开关字典清理语句;用户后续明确要求更新测试库,已按旧版增量路径执行并复核。其他环境不自动执行;同步现有规格、接口契约、前端交接文档及定向测试。
+
+## 2026-10-15:交错取送与双进度实施计划
+
+目标:按 FR-129 至 FR-131 在当前 `test-202609v2` 实现,保持 Tab 和接口参数,复用现有表与字段,无迁移 SQL。
+
+1. T140:在 `FlashDeliveryV2Test` 先补客户交错顺序的报价/创建/改址、取1→取2→送1→取3→送2、骑手无同意声明先取后送、未取禁止送达、漏取禁止整单送达、错 PIN/目标和重试不重复通知测试;在 `FlashDeliveryMapperXmlTest` 检查送达条件更新允许 ACCEPTED/PICKED_UP 且仍限制骑手/版本。先运行 JDK 21 定向测试证明旧限制拒绝新流程。
+2. T141:修改 `FlashDeliveryApplicationService.normalizeV2Stops/targetStopId/selectTarget/deliverAtStop`;v2 默认按所有待完成站点原序选目标,骑手可自主选择待完成站,送达允许部分取件并保持 ACCEPTED,末次送达仍核实全部取件。旧单继续原阶段与同意门禁。修改 `FlashDeliveryOrderMapper.updateDeliveryFulfilment` 的 SQL 状态条件及注释;取件、送达、目标仍在订单行锁与版本保护内,不改变费用、PIN、支付和记账路径。
+3. T142:同步 spec、plan、tasks、data-model、契约和两份 App 文档,给出四个已有进度字段及 Tab 不变的明确接入示例,修订旧“先取完再送”描述并标注历史范围;补充代码注释。运行定向测试、后端回归、模块构建及差异/换行检查,记录真实 MySQL、App 和设备推送未联调;不提交/推送,等待用户明确指令。
+
+验证重点:真实站点进度驱动目标与完成,不能因主状态 ACCEPTED 丢弃已送达数量;条件更新覆盖未取完阶段送达;目标调整不绕过按站 PIN、图片、支付和权限;已送站重试不恢复旧目标;未完取件不能提前生成收入。使用本机 JDK 21 `C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1`,仅命令内设置环境。既有未跟踪 `.test-hold/` 与 `specs/037-order-no-rider-auto-cancel/` 不改动。

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

@@ -1,5 +1,13 @@
 # 闪送 API 联调快速开始
 
+## 当前 v2:交错取送与双进度(2026-10-15)
+
+新 App 对接以 [v2 前端交接文档](../../docs/flash-delivery-v2-frontend-handoff.md) 为准,以下早期步骤保留作旧契约验收参考。v2 `stops[]` 首站取货、末站收货,中间按客户原序交错,不重排。骑手可自主通过目标接口选择其他待完成取/送站(版本、请求幂等号必填,原因/同意声明可省略);取送仍核验当前目标和本站凭证/PIN。
+
+验收取1→取2→送1→取3→送2:送1后 ACCEPTED,列表和详情同时显示已取2/3、已送1/2,仍归原待取件 `toPickup`;取3后 PICKED_UP,归原配送中 `delivering`;送2后 DELIVERED,归原已送达 `completed`。骑手先选择取3再送的方式也应可用,原站序和运费不变。未取过货不能送;仍有待取货站不能确认最后一个未完成收货站;同站重试不重复凭证/进度/通知,不提前记账。新任务、待取件、配送中、已送达、已取消五个 Tab、名称和角标订单数不变,无新增字段或 SQL。
+
+## 原单点与旧契约步骤
+
 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、版本及各项金额,服务端复算不一致时返回最新报价且不创建订单;收件账号匹配结果固化在新订单中,不追溯历史订单。

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

@@ -411,7 +411,7 @@
 
 ### 用户故事 9:用户发布多取多送订单并由骑手逐站取件与送达(优先级:P1)
 
-用户设置订单级物品主体(类型、数量、重量段、规格,一次填写),逐站填写取货点和收货点的联系人、电话、地址及选填备注,获取整条路线报价后提交;骑手接单后按顺序逐站取件(沿用既有逐站取件规则),全部取货站完成后进入已取件,再按顺序逐站送达确认并上传凭证,全部收货站完成订单才进入已送达。
+用户设置订单级物品主体(类型、数量、重量段、规格,一次填写),逐站填写取货点和收货点的联系人、电话、地址及选填备注,获取整条路线报价后提交;2026-10-15 修订后允许按客户顺序交错取送,骑手可自主调整实际访问顺序。未全部取完仍为待取件,全部取完进入配送中,所有取送站完成才为已送达,各站独立确认并上传凭证。
 
 **优先级原因**:多点取送是闪送对复杂配送场景的核心能力;物品一次设置贴合真实整车配送并简化填单。
 
@@ -425,11 +425,11 @@
 5. **假如**收货站电话能匹配注册用户,**当**创建订单,**那么**按站固化收件人;逐站收件人通知按用户最新要求本阶段不做,后续明确要求时再补充;匹配不到则该站不固化,仅保留联系人文字。启用收货码时按收货站生成与校验。
 6. **假如**客户端仍调用旧报价/创建接口,**当**过渡期内请求,**那么**行为与现状一致并返回废弃标注;退役批次在 App 两端切换并联调通过前不得执行。
 
-- FR-116:v2 报价与创建契约只接受统一站点数组 `stops[]`:取货点与收货点放在同一数组,元素以 `type`(`PICKUP`/`DELIVERY`)区分,包含联系人、电话、完整地址文本、可选详细地址(樓層/室)、必填经纬度及选填备注(不超过 200 字);地址采用单一文本字段(如「台北市大安區信義路三段56號10樓」),不做城市/区结构化拆分,路由与计价以经纬度为准(与 Lalamove 地址模型一致);数组顺序即用户添加顺序,也是骑手路线顺序与站点 `stop_order`,类型是取/送的唯一区别。物品主体(类型、数量、重量段、规格)只在订单级设置一次,新契约不存在逐站物品字段。数组必须至少含 1 个取货站与 1 个收货站,且取货站全部位于收货站之前(先取完再送,与设计稿一致;交错取送属后续增量)。
+- FR-116:v2 报价与创建契约只接受统一站点数组 `stops[]`:取货点与收货点放在同一数组,元素以 `type`(`PICKUP`/`DELIVERY`)区分,包含联系人、电话、完整地址文本、可选详细地址(樓層/室)、必填经纬度及选填备注(不超过 200 字);地址采用单一文本字段(如「台北市大安區信義路三段56號10樓」),不做城市/区结构化拆分,路由与计价以经纬度为准(与 Lalamove 地址模型一致);数组顺序即用户添加顺序,也是默认路线与站点 `stop_order`,类型是取/送的唯一区别。物品主体(类型、数量、重量段、规格)只在订单级设置一次,新契约不存在逐站物品字段。数组必须至少含 1 个取货站与 1 个收货站,首站取货、末站收货,中间允许客户自行交错安排取送,履约规则见 2026-10-15 修订。
 - FR-117:取货点和收货点数量上限分别走数据字典 `flash_pickup_stop_limit`、`flash_delivery_stop_limit`(有效值 1..10,字典缺失或非法回退 1)。不设独立多点开关;数量超过对应有效上限时拒绝报价、创建及地址编辑。初始上限均为 1,App 就绪联调后可分别调高。首页兼容字段 `showMultiPickup`、`showMultiDropoff` 分别由对应上限是否大于 1 派生。
 - FR-118:v2 运费计算使用独立的新计价方法(不挂接旧报价逻辑),整条路线距离与车型运价规则复用底层实现,车型按订单级重量段判定;旧报价计算方法随 FR-122 退役批次删除;报价路线经点为全部取货站与收货站,整线仍不得超过 40 公里,不新增每站点附加费。
 - FR-119:每个收货站按联系人电话匹配注册普通用户并按站固化收件人,匹配不到不固化且不影响创建;收货码及详情可见性按站判定;逐站收件人推送暂缓,见 FR-126。取货站仍以收货站电话匹配的收件人为准,不存在按取货站认领。
-- FR-120:送达阶段沿用取件侧状态机规则:主状态 PICKED_UP 后骑手按顺序或经下单人同意调整后逐站确认送达,站点级幂等、凭证入 `flash_delivery_order_image`(`stop_id` 指向收货站);全部收货站完成才推进 DELIVERED,仅推进一次。任一收货站已送达后用户与平台均不能取消(镜像 FR-111/FR-112),异常由客服人工处理。
+- FR-120:v2 逐站送达按 FR-129 至 FR-131 允许交错取送和骑手自主调整,站点级幂等、凭证入 `flash_delivery_order_image`(`stop_id` 指向收货站);全部取送站完成才推进 DELIVERED,仅推进一次。任一收货站已送达后用户与平台均不能取消(镜像 FR-111/FR-112),异常由客服人工处理。
 - FR-121:逐站收件人通知按 2026-10-14 用户最新要求暂不实施,后续明确要求时再补充;启用收货码时每收货站独立生成、校验,不再使用订单级单码。骑手收入账单仍按整单一次记录,不按站点拆分。
 - FR-122:新增 `POST /system/flashDelivery/v2/quote` 与 `POST /system/flashDelivery/v2/orders`;骑手新增 `POST /system/flashDelivery/rider/orders/stops/deliver`。旧报价/创建接口及旧送达接口自 v2 上线起在契约与代码中标注废弃,过渡期行为不变、不接收新功能;删除为独立收尾批次,前置条件是用户/骑手 App 两端已全部切换 v2 并通过真实环境联调,删除时同步更新契约文档与测试。
 - FR-123:`flash_delivery_order_stop` 增加备注与按站收件人字段,`flash_delivery_order` 增加收货站计数与已送达计数;SQL 按环境区分完整结构与旧版增量路径;2026-10-14 已在测试库执行旧版增量路径,其他环境未执行。无历史订单迁移、不回写旧单。
@@ -439,4 +439,12 @@
 
 - FR-126(2026-10-14 审查修复):v2 每个取货/收货站首次确认成功并提交事务后,向订单发起人推送该站进度并沿既有通道写入消息中心,含站点 ID、站号、类型与已完成/总数量,通知中的订单状态使用真实主状态,末站不重复发送同阶段整单通知。幂等重试、失败或回滚不产生新通知;通知失败不回滚履约。逐站收件人通知不做且不作为当前交付阻塞,后续用户明确要求时再实施。
 - FR-127(2026-10-14 审查修复):旧整单送达与旧改址入口拒绝处理 v2 订单,但旧接口路径、参数和历史订单行为保持;v2 新增全 body 改址报价/保存入口,接收完整 stops,复用运价快照与条件更新,保留订单级物品并原子保存全体站点、计数、按站收件人与 PIN,确认保存后客户端刷新新 stopId/PIN。用户/平台/自动完成均核实全部收货站的真实已送达状态后才允许完成记账。
-- FR-128(2026-10-14 审查修复):发起人可见全部收货站的 PIN,注册收件人仅可见本人的收货站 PIN/联系人/凭证,骑手始终不返回实际 PIN;receiver 列表、详情与确认收货覆盖所有已按站固化的收件人,未注册或未匹配者不增加 App 通知渠道。送达默认指向最早未送达站,改顺序须记录发起人同意;末站成功后即使主状态已为 DELIVERED/COMPLETED,所属骑手重试已确认站仍幂等。
+- FR-128(2026-10-14 审查修复,目标规则于 2026-10-15 修订):发起人可见全部收货站的 PIN,注册收件人仅可见本人的收货站 PIN/联系人/凭证,骑手始终不返回实际 PIN;receiver 列表、详情与确认收货覆盖所有已按站固化的收件人,未注册或未匹配者不增加 App 通知渠道。v2 默认按客户原序选择最早未完成取/送站,骑手自主调整规则见 FR-130;末站成功后即使主状态已为 DELIVERED/COMPLETED,所属骑手重试已确认站仍幂等。
+
+## 2026-10-15:客户顺序交错取送与骑手双进度(用户确认)
+
+本节修订 v2 的 FR-116、FR-120、FR-128 以及此前“先取完再送”和“改变顺序须客户同意”的限制;旧契约订单继续沿用原履约规则。
+
+- FR-129:v2 客户提交的 `stops[]` 顺序用于报价、保存及默认导航,不自动按类型重排。首站为取货、末站为收货,中间允许取送交错,例如取1→取2→送1→取3→送2;下单与待接单改址共用校验。客户通过每站备注说明物品,骑手负责核对实际取送,不新增取货站与收货站强制关联字段。
+- FR-130:v2 骑手可自主选择其他未完成取货/收货站作为当前目标,包括先取完所有货再送;沿用目标接口、版本与请求幂等,原因可选,同意声明不再作为必需条件,保留实际调整审计。尚未取过货不能送达;存在未取货站时不能确认最后一个未完成收货站,以防漏取提前结束。每站取送仍需对应类型、所属骑手、当前目标、凭证及按站 PIN 校验;完成目标后回到客户原顺序最早的未完成站,不修改原站点顺序和运费。
+- FR-131:现有五个 Tab、名称、参数和订单计数保持:`newTask` 新任务、`toPickup` 待取件、`delivering` 配送中、`completed` 已送达、`cancelled` 已取消。取货未全部完成时主状态保持 ACCEPTED,即使部分收货站已送达;全部取完后为 PICKED_UP;全部取送站完成后才为 DELIVERED,后续确认收货/自动完成和记账沿用原流程。卡片与详情同时显示已取件 `pickedUpStopCount/pickupStopCount`、已送达 `deliveredStopCount/deliveryStopCount`,如“已取件 2/3 · 已送达 1/2”;Tab 角标计订单而非站点,一单仅属于一个 Tab。App 由前端人员实现,本仓库提供接入文档,不修改 App 源码。

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

@@ -290,3 +290,11 @@ Phase 24 本地验证:最终后端 164 类/1140 项测试、0 失败/错误/
 Phase 29 验证:先移除测试里的开关桩,定向 41 项出现预期 RED(2 失败、8 错误,均为旧开关拒绝或首页字段不符);实现后定向测试通过。JDK 21 执行 `mvn -q -pl ruoyi-admin -am test`,本轮 admin 报告 136 类/1048 项、0 失败/错误/跳过;`mvn -q -pl ruoyi-admin -am -DskipTests package` 成功。19 个受影响文件保留 CRLF,`git -c core.whitespace=cr-at-eol diff --check` 通过。当时 SQL 与真实 App 联调尚未执行;后续测试库执行结果见 T139,App 联调仍未执行。
 
 - [x] T139 用户明确要求后,连接配置标注的测试站点 `foodie`,核对旧版表结构及 18 条站点旧列数据与订单级数据一致;执行旧版增量 ALTER、两项上限字典初始化和三项废弃字典清理。复查新列/旧列、18 条站点及仅存的两项上限值 1;正式库与 App 联调未执行。
+
+## Phase 30:客户顺序交错取送与双进度(2026-10-15,已验证)
+
+- [x] T140 补交错顺序报价/创建/改址、交错履约双进度与 Tab、骑手自主目标调整、按站 PIN/凭证/重试、未取禁止送达及漏取禁止整单完成的行为测试;送达 SQL 覆盖 ACCEPTED/PICKED_UP 与骑手/版本条件,确认 RED(服务 23 项中 3 失败/4 错误,均命中旧顺序、目标或阶段/同意限制;XML 7 项中 1 失败,命中旧 SQL 状态限制)。
+- [x] T141 放开 v2 交错站点与履约,默认按客户原序、允许骑手自主选择未完成站,保留旧单规则、行锁/版本、按站凭证与 PIN、末站送达及记账门禁,补充注释。
+- [x] T142 保留 Tab 名称及计数,复用四个进度字段,更新相关规格和 App 接入文档;执行 JDK 21 定向/回归/构建与差异检查,记录未验证项与未提交状态。
+
+Phase 30 本地验证记录:服务/SQL 测试均确认过预期 RED;修正列表查询测试需先触发 MyBatis SQL 参数生成的测试断言后,执行 JDK 21 `mvn -q -pl ruoyi-admin -am test`,本轮新鲜报告 166 类/1192 项,0 失败、0 错误、0 跳过,包含 v2 履约 23 项及 XML 7 项;`mvn -q -pl ruoyi-admin -am -DskipTests package` 退出码 0。只读复核无阻断项,差异/换行检查通过。当前分支 `test-202609v2`,用户已要求提交与推送,实际结果以 Git 记录为准;App 代码未修改,真实数据库事务/并发、设备推送、支付网关及 App 联调未执行,本次无 SQL。

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

@@ -57,3 +57,12 @@ Mock 测试覆盖业务分支、事务注解、订单锁及当前读调用顺序
 - 用户明确要求后,连接 `application-druid.yml` 标注的测试站点 `foodie`(MySQL 5.7.44),执行 `updatesql/sql.md` 的旧版增量补丁、两项上限字典初始化及废弃开关清理;未执行完整结构路径。
 - 执行前 18 条站点旧列内容与对应订单级字段核对;执行后站点仍为 18 条,新增站点 3 列和订单 2 列,旧站点 6 列已删除。字典仅有取货/收货上限各一条,均为 1。
 - 仅验证数据库结构及字典状态;真实 App 联调、线上迁移和真实并发履约未验证。
+
+## 2026-10-15:v2 交错取送与骑手双进度
+
+- 用户确认在 `test-202609v2` 修改;保留全部原 Tab、名称、参数和角标订单数,App 源码由前端人员接入。客户原序可交错,骑手可自主选择其他待完成站,不新增取送关联、表或字段。
+- RED:服务 `FlashDeliveryV2Test` 23 项中 3 失败/4 错误,分别命中旧顺序、默认目标、未取完不可送及必须同意的限制;XML 7 项中 1 失败,命中只允许 PICKED_UP 的数据库更新条件。
+- 实现后定向测试仅一项新列表断言失败,原因为 QueryWrapper 的参数需先生成 SQL 才绑定;修正测试显式读取 SQL 后,运行完整 `mvn -q -pl ruoyi-admin -am test`,本轮新鲜报告 166 类/1192 项,0 失败/错误/跳过;包含 v2 23 项、XML 7 项及旧单/支付/记账/通知回归。
+- `mvn -q -pl ruoyi-admin -am -DskipTests package` 退出码 0;实际 JDK 为 `C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1`,只设置命令环境。只读代码复核未发现 Critical/Important/Minor 问题;差异与原有换行检查通过。
+- 验证覆盖:交错报价/创建/改址保留原序;取2/3、送1/2仍待取件;取完才配送中;最后送达才整单已送达;骑手无同意声明选站和重试;未取禁送、漏取禁末送;错误骑手/目标/PIN/空凭证拒绝;站点重试不重复通知/写图;旧单同意和先取后送门禁保持。
+- 未执行:真实 MySQL 多连接竞争/回滚、真实地图/支付网关/设备推送和 App 联调。无新增 SQL,未操作数据库;用户已要求提交与推送,实际结果以 Git 记录为准;既有未跟踪文件保持原样。