Selaa lähdekoodia

放开闪送逐站取送顺序并弃用目标接口

qmj 3 päivää sitten
vanhempi
sitoutus
6fa638a306

+ 9 - 16
docs/flash-delivery-app-api.md

@@ -1136,28 +1136,21 @@ POST /system/address/{id}/top
 }
 ```
 
-创建时再附加既有幂等号、报价版本和金额回传字段;待接单编辑使用完整数组,调整顺序同样先重报价并确认。下单页面提示“默认按此顺序取件,骑手如需调整将先与您沟通”。用户原顺序及金额不会因骑手调整访问先后而改变。
+创建时再附加既有幂等号、报价版本和金额回传字段;待接单编辑使用完整数组,调整顺序同样先重报价并确认。下单页面应说明客户顺序用于报价和展示,骑手可自行决定实际访问顺序;移除“骑手如需调整将先与您沟通”的旧提示。用户原顺序及金额不会因骑手调整访问先后而改变。
 
-### 骑手目标与实际取件
+### 骑手自主选择站点并确认取件
 
-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;所有取货点完成前不能送达。某站未备好可以沟通后换顺序,但站点不能删除,也不能用虚假取件跳过;最终无法取货须联系平台处理。
+1. 详情读取 `stops[]`,骑手自主选择待完成站;站点 `id` 是服务端 ID,不是数组下标。客户顺序仅用于报价与展示。
+2. 实际到站取到货后,直接调用 `POST /rider/orders/{id}/stops/{stopId}/pickup` 提交本站 `imageUrls`;也可使用原 `/pickup`,多点必须提供 body `stopId`,单点可省略。无需先调用目标接口、填写原因或声明客户同意。
+3. `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` 已过期,仅兼容旧客户端保存可选导航目标;新 App 移除“目前目标”和“设为目前目标”。站点不能删除,也不能用虚假取件跳过;最终无法取货须联系平台处理。
 
 ### 进度、取消与导航
 
 各端显示 `pickedUpStopCount/pickupStopCount`,保留原站点顺序展示实际进度;部分已取仍为 `ACCEPTED`,用户取消按钮使用 `canUserCancel` 且结合当前用户角色。任意取件、取消、调整或刷新后重新读取最新详情,不能仅按主状态判断按钮。
 
-导航目标由 `currentTargetStopId` 定位,全部取完后为唯一收货点。客户端生成 Google Maps URL,使用 `api=1&travelmode=two-wheeler&dir_action=navigate`,地址参数正确编码,起点取设备当前位置。没有定位或无法启动外部地图时,保留目标地址并允许重试;不得因此自动取件。后端不返回导航 URL。
+导航目标由骑手在 `stops[]` 中自行选择。客户端生成 Google Maps URL,使用 `api=1&travelmode=two-wheeler&dir_action=navigate`,地址参数正确编码,起点取设备当前位置。没有定位或无法启动外部地图时,保留目标地址并允许重试;不得因此自动取件。后端不返回导航 URL。
 
-上线前用户、骑手端必须完成数组、逐站凭证、目标调整及状态按钮适配,再调高多点数量上限。当前工作区未提供对应 App 页面实现,本节为客户端交付契约,不能作为 App 已联调证据。
+上线前用户、骑手端必须完成数组、逐站凭证及状态按钮适配,再调高多点数量上限。当前工作区未提供对应 App 页面实现,本节为客户端交付契约,不能作为 App 已联调证据。
 
 ## 2026-09-30:闪送完成后的骑手收入账单
 
@@ -1201,7 +1194,7 @@ POST /system/flashDelivery/rider/orders/stops/deliver
 
 ## 2026-10-14 多点履约与改址修复
 
-v2 订单即使只有一个收货站,也必须使用上述逐站送达接口并传 `stopId`;旧整单送达接口拒绝 v2 订单。每次送达只接受详情中 `currentTargetStopId` 对应的待送站;需调整送达顺序时,骑手通过原目标调整入口选站,v2 的自主调整规则以文末 2026-10-15 修订为准。末站送达后重复请求可安全返回,不会再次保存凭证或通知。全部收货站完成才进入 `DELIVERED`;确认收货或系统/平台完成进入 `COMPLETED` 后才生成一次骑手收入账单。
+v2 订单即使只有一个收货站,也必须使用上述逐站送达接口并传 `stopId`;旧整单送达接口拒绝 v2 订单。每次送达可直接指定任意符合条件的待送站,无需先通过目标调整入口选站。末站送达后重复请求可安全返回,不会再次保存凭证或通知。全部收货站完成才进入 `DELIVERED`;确认收货或系统/平台完成进入 `COMPLETED` 后才生成一次骑手收入账单。
 
 `stops[].id` 是站点 ID。启用 PIN 时,v2 详情在对应收货站的 `deliveryPinCode` 返回各站独立收货码;发起人可见全部,系统内匹配的收件人仅能看到自己的站点收货码,骑手看不到。收件人卡片主位的收货地址指向本人站点;其他收货站的文字地址与备注对寄件、收件同口径返回(2026-10-15 放开),联系人和坐标仍隐藏。匹配不到系统用户时,仍保留联系人电话和按站收货码,由发起人线下告知;本阶段不实现逐站收件人推送。v2 订单级 `deliveryPinCode` 不再用作送达码。订单列表和详情提供 `deliveryStopCount`、`deliveredStopCount` 供展示进度。
 
@@ -1220,7 +1213,7 @@ POST /system/flashDelivery/v2/orders/address
 
 v2 报价、创建和待接单完整改址的 `stops[]` 按客户顺序提交:首站取件、末站收货,中间允许交错,例如取1→取2→送1→取3→送2。不得自动按类型分组;地图报价使用完整原顺序。各站备注由客户说明物品,骑手负责核对,不新增取送关联字段。简明接入见 [v2 前端交接文档](flash-delivery-v2-frontend-handoff.md)。
 
-骑手详情的 `currentTargetStopId` 默认指向客户原序中最早未完成站;完成该站后重新读详情。v2 可自主使用现有目标接口选择其他待完成取/送站,`orderVersion` 和 `clientRequestId` 仍必填,`reason` 可选(≤500字),`consentDeclared` 可省略,不再强制客户同意声明。实际取送仍须确认当前目标并上传本站凭证,送达时核验本站 PIN;尚未取过货不能送,漏取时不能确认最后一个未完成收货站。调整不会修改原站序或运费。旧契约订单保留原阶段与同意限制。
+骑手详情的 `currentTargetStopId` 仅是兼容旧客户端的导航提示;新旧订单均可直接按任意待完成站 `stopId` 确认,无需先设目标。新 App 不再显示“目前目标”或“设为目前目标”,目标接口标记过期。取送仍须上传本站凭证,送达时核验本站 PIN;尚未取过货不能送,漏取时不能确认最后一个未完成收货站。旧契约订单只有一个收货站,须先完成所有取货站。实际取送不修改原站序或运费。
 
 五个 Tab 与名称保持:`newTask` 新任务、`toPickup` 待取件、`delivering` 配送中、`completed` 已送达、`cancelled` 已取消。未全部取完仍为 ACCEPTED,即使部分站点已送达;全部取完才为 PICKED_UP,全部取送站完成才为 DELIVERED。每笔订单只计一个 Tab,角标继续按订单数。
 

+ 5 - 5
docs/flash-delivery-v2-frontend-handoff.md

@@ -9,7 +9,7 @@
 | 用户填写闪送订单 | 按客户顺序编辑一个 `stops` 数组,首站取货、末站收货,中间可以交错取送;物品信息只填写一次。根据 `/home` 的取货点和收货点数量上限控制可添加数量。 |
 | 报价与下单 | 使用 `/v2/quote` 和 `/v2/orders`。创建时把本次报价的配置 ID、版本和费用原样回传;创建请求号在重试时保持一致。 |
 | 用户/收件人订单列表和详情 | 展示取货、收货两个进度;站点使用 `stops[].id`,不可把数组下标当成站点 ID。逐站收货码按权限展示。 |
-| 骑手履约 | 保留原五个 Tab 和名称,卡片增加取件、送达双进度。从详情的 `currentTargetStopId` 找目标,可自主选择其他未完成站;每站上传凭证并刷新详情。逐站送达使用固定路径的新接口,`orderId`、`stopId`、PIN 和照片全部放 body。 |
+| 骑手履约 | 保留原五个 Tab 和名称,卡片增加取件、送达双进度。直接从 `stops[]` 选择待完成站,移除“目前目标/设为目前目标”;每站上传凭证并刷新详情。逐站送达使用固定路径的新接口,`orderId`、`stopId`、PIN 和照片全部放 body。 |
 | 待接单改址 | v2 使用新的完整站点报价与保存接口。保存成功后刷新详情,替换本地旧站点 ID 和收货码。 |
 | 发起人消息 | 识别两种逐站进度事件,凭 `ddId` 打开订单详情,再读取最新状态。逐站收件人通知本期不接入。 |
 
@@ -88,19 +88,19 @@ v2 的订单级 `deliveryPinCode` 为空,收货码在各个 DELIVERY 站的 `s
 
 用户端取消按钮以 `canUserCancel` 为准;任一取货站已取货后不可自助取消。确认收货入口只在整单 `DELIVERED` 后提供;系统仍会检查全部收货站是否真的已送达。骑手收入账单只在订单进入 `COMPLETED` 时生成一次。
 
-## 骑手端:目标、取货、送达
+## 骑手端:自由取货、送达
 
-骑手从详情 `currentTargetStopId` 定位当前站点;v2 默认指向客户原序中最早未完成站,可以是取货站或收货站。骑手也可选择其他待完成站,例如先把附近三个取货点全部取完再送。实际调整不重排客户原站点、不重算运费。操作成功后重新请求详情,不继续使用旧的目标 ID、状态或版本。
+骑手直接从详情 `stops[]` 选择待完成取货/收货站,使用该站 `id` 确认;无需先设为当前目标。新旧订单都不限制取货站顺序,v2 收货站也可自由选择,但未取过任何货不能送达,仍有待取货站时不能确认最后一个收货站。客户原站序仅用于展示和报价,实际取送不重排站点或重算运费。App 移除“目前目标”标识和“设为目前目标”操作;操作成功后重新请求详情。
 
 | 操作 | 当前接口 | 提交内容 |
 | --- | --- | --- |
 | 逐站取货 | `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`;v2 的 `reason` 可选(≤500字),`consentDeclared` 可省略,不再要求客户同意声明。 |
+| 选择目标站 | `POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` | **已过期,仅兼容旧客户端**;新客户端不要调用。旧客户端调用时 body 含 `orderVersion`、`clientRequestId`,`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 订单调用它会被拒绝。
 
-取送非当前目标站会被拒绝。v2 改变顺序时先使用目标调整接口选站,服务端保留操作记录,选站本身不等于确认取送;实际物品由骑手根据备注核对。尚未取过任何货不能选择送货;尚有未取货点时不能确认最后一个未完成收货站,须先完成剩余取件。旧契约订单仍按原同意声明与先取后送规则履约。调整请求的 `clientRequestId` 在同一次操作重试时保持不变。某站已成功取货或送达的重试不会重复保存照片和进度;末站送达后主状态已变化时也是如此。
+取送任何待完成站都直接提交该站 ID,无需目标调整记录或客户同意声明;实际物品由骑手根据备注核对。旧契约订单只有一个收货站,必须先完成所有取货站,避免漏取提前完单。某站已成功取货或送达的重试不会重复保存照片和进度;末站送达后主状态已变化时也是如此。
 
 ## 用户端:v2 待接单改址
 

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

@@ -134,7 +134,8 @@ public class FlashDeliveryRiderController extends BaseController {
         return success();
     }
 
-    /** 保存前往目标及必要的骑手同意声明,不推进取件状态。 */
+    /** 兼容旧客户端的可选导航目标;新客户端直接按 stopId 确认取送。 */
+    @Deprecated
     @PostMapping("/orders/{id}/stops/{stopId}/target")
     @Auth
     public AjaxResult target(@RequestHeader String token, @PathVariable Long id, @PathVariable Long stopId,

+ 3 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -757,7 +757,7 @@ public class FlashDeliveryApplicationService {
                 : !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");
+        // 逐站确认以本站 stopId 为准;当前目标只用于导航提示,不限制实际送达顺序。
         // 不建立取送物品关联,骑手按备注核对;但不能漏取站点就确认最后一站并结束整单。
         boolean lastDelivery = rows.stream().filter(s -> "DELIVERY".equals(s.getStopType()))
                 .filter(s -> !"DELIVERED".equals(s.getStatus())).count() == 1;
@@ -2296,10 +2296,7 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.transition.not.allowed");
         }
         Long before = targetStopId(order, rows);
-        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(v2
-                ? "flash.delivery.target.reason.too.long" : "flash.delivery.target.consent.required");
+        if (tooLong(request.getReason(), 500)) throw fail("flash.delivery.target.reason.too.long");
         Date now = new Date();
         FlashDeliveryTargetAdjustment adjustment = new FlashDeliveryTargetAdjustment();
         adjustment.setOrderId(orderId); adjustment.setFromStopId(before); adjustment.setToStopId(stopId);
@@ -2338,7 +2335,7 @@ public class FlashDeliveryApplicationService {
         // 已确认站点的重试直接成功,不因当前目标或主状态已变化而重复写凭证。
         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");
+        // 骑手可直接确认任一待取站;当前目标只用于导航提示,不限制实际取货顺序。
         // 每站取件都在同一订单锁内验证可信资金事实,现金/转账保持原履约路径。
         if ("3".equals(order.getPayType())) flashLinePayFactService.assertPickupPaid(order);
         List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);

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

@@ -112,6 +112,15 @@ class FlashDeliveryControllerContractTest {
             assertNotNull(method.getParameters()[3].getAnnotation(RequestBody.class));
         }
     }
+
+    @Test
+    void targetSelectionEndpointIsDeprecatedForLegacyClients() throws Exception {
+        Method method = FlashDeliveryRiderController.class.getMethod("target", String.class, Long.class,
+                Long.class, com.ruoyi.app.flashdelivery.dto.FlashDeliveryTargetRequest.class);
+        assertNotNull(method.getAnnotation(Deprecated.class));
+        assertArrayEquals(new String[]{"/orders/{id}/stops/{stopId}/target"},
+                method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value());
+    }
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
     @BeforeAll

+ 15 - 6
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java

@@ -71,14 +71,23 @@ class FlashDeliveryMultiPickupTest {
     }
     @AfterAll static void restoreMessages() { new SpringUtils().postProcessBeanFactory(original); }
 
-    @Test void changedTargetRequiresDeclarationAndCannotBeBypassedByPickup() {
+    @Test void oldMultiPickupCanConfirmNonDefaultStationWithoutSelectingTarget() {
         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));
+        f.service.pickup(8L, 10L, proof(12L));
+        assertEquals("PICKED_UP", f.rows.get(1).getStatus());
+        assertEquals(1, f.order.getPickedUpStopCount());
+        assertEquals(11L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        verify(f.adjustments, never()).insert(any(FlashDeliveryTargetAdjustment.class));
+    }
+
+    @Test void oldTargetSelectionNoLongerRequiresConsentAndDoesNotBindPickup() {
+        Fixture f = new Fixture();
+        assertEquals(12L, f.service.selectTarget(8L, 10L, 12L,
+                target(0, "optional-navigation", null, false)).getCurrentTargetStopId());
+        f.service.pickup(8L, 10L, proof(11L));
+        assertEquals("PICKED_UP", f.rows.get(0).getStatus());
+        assertEquals(12L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
     }
 
     @Test void selectingBDoesNotCollectThenPickupReturnsToAAndDisablesCancellation() {

+ 32 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryV2Test.java

@@ -186,6 +186,32 @@ class FlashDeliveryV2Test {
         verify(f.billing, never()).insert(any(com.ruoyi.system.domain.UserBilling.class));
     }
 
+    @Test void v2RiderDirectlyConfirmsNonDefaultPickupWithoutSelectingTarget() {
+        Fixture f = new Fixture();
+        assertEquals(11L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+
+        pickup(f, 12L);
+
+        assertEquals("PICKED_UP", f.rows.get(1).getStatus());
+        assertEquals(1, f.order.getPickedUpStopCount());
+        assertEquals(11L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        verify(f.adjustments, never()).insert(any(FlashDeliveryTargetAdjustment.class));
+    }
+
+    @Test void v2RiderDirectlyDeliversNonDefaultStopWhilePickupRemains() {
+        Fixture f = interleaved();
+        pickup(f, 11L);
+        assertEquals(12L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+
+        f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L));
+
+        assertEquals("DELIVERED", f.rows.get(4).getStatus());
+        assertEquals(1, f.order.getDeliveredStopCount());
+        assertEquals("ACCEPTED", f.order.getStatus());
+        assertEquals(12L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        verify(f.adjustments, never()).insert(any(FlashDeliveryTargetAdjustment.class));
+    }
+
     @Test void riderListCardsCarryPerStopAddressesForMultiStopOrders() {
         Fixture f = new Fixture(); pickup(f, 11L); pickup(f, 12L);
         var page = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<FlashDeliveryOrder>(1, 10);
@@ -265,7 +291,7 @@ class FlashDeliveryV2Test {
         verify(f.notifications, times(1)).notifyDeliveryStop(any(), any(), anyInt(), anyInt());
     }
 
-    @Test void interleavedDeliveryStillValidatesRiderTargetAndOwnPinBeforeWriting() {
+    @Test void interleavedDeliveryStillValidatesRiderAndOwnPinBeforeWriting() {
         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");
@@ -398,12 +424,13 @@ class FlashDeliveryV2Test {
         assertThrows(ServiceException.class, () -> f.service.deliverAtStop(9L, 10L, 14L, deliveryProof(14L)));
     }
 
-    @Test void deliveryCannotSkipCurrentTargetWithoutSelectingIt() {
+    @Test void deliveryCanCompleteNonDefaultStopWithoutSelectingTarget() {
         Fixture f = ready();
-        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L)));
-        verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+        f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L));
+        assertEquals(1, f.order.getDeliveredStopCount());
+        assertEquals(13L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
         f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L));
-        assertEquals(14L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
+        assertEquals("DELIVERED", f.order.getStatus());
     }
 
     @Test void riderCanSelectDeliveryTargetWithoutConsentAndReturnsToRemainingSequence() {

+ 3 - 3
specs/024-flash-delivery/contracts/api.md

@@ -185,9 +185,9 @@
 |---|---|---|
 | 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 |
+| POST | `/system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` | **已过期,仅兼容旧客户端**;`orderVersion,clientRequestId` 必填,`reason`(≤500字)和 `consentDeclared` 可选;仅保存导航目标,不是取送前置条件 |
 
-目标调整使用订单版本及客户端幂等号。切换到不同目标时缺少原因/声明,以及他人订单、收货站、已取站、过期版本及取消订单均拒绝新调整;同一请求重试不能重复写调整记录。选择当前目标可以省略原因和声明,仍以请求号记录一次选择并增加版本;省略声明按 false 处理。原目标未完成前保持所选站点;完成后回到原顺序中最早未取站。目标选择不代表取货,不产生取件凭证;直接上传非当前目标的取件凭证不能绕过调整记录。已确认站点由原骑手重试时幂等返回,不受目标已改变影响。
+目标接口使用订单版本及客户端幂等号,仍校验骑手、订单状态、站点归属和版本;同一请求重试不能重复写记录。省略声明按 false 处理;目标选择不代表取货,不产生取件凭证。新旧订单均可直接提交非当前目标的待取货站 ID 与本站凭证,已确认站点由原骑手重试时幂等返回。
 
 全部取货点完成才进入 `PICKED_UP`;`pickedUpAt` 是最后一个实际完成站点的时间。仅部分已取时订单仍为 `ACCEPTED`,用户取消必须拒绝。取件、目标调整和用户/平台取消先锁订单行后读取最新站点,事务内统一保存状态、版本、凭证及日志。
 
@@ -263,7 +263,7 @@
 
 全部收货站确认送达后才推进主状态 DELIVERED;随后确认收货、平台完成或系统自动完成进入 COMPLETED 时才生成一笔骑手收入账单。任一收货站已送达后订单进入禁止取消状态(用户与平台同口径)。旧整单送达接口拒绝全部 v2 订单,只有一个收货站的 v2 订单也使用此逐站接口并提供 `stopId`。
 
-2026-10-15 v2 修订:允许尚未全部取货时逐站送达,仍需至少一站已取货、正确的当前目标/按站 PIN/凭证。未取完保持 ACCEPTED,送达计数独立累加;确认最后一个未完成收货站前必须全部取完。默认目标按客户原序最早未完成站,使用既有 `/rider/orders/{id}/stops/{stopId}/target` 可自主选其他待完成站,body 仍必填 `orderVersion,clientRequestId`,`reason` 可选、`consentDeclared` 不再必需,保留审计与幂等且不重算运费;旧契约订单继续原同意门禁。物品通过备注由客户说明、骑手核对,不增加站点关联字段。
+2026-10-15 修订:新旧订单均不以当前目标限制站点确认;骑手直接提交所选站的 `stopId` 与凭证,送达时校验本站 PIN。v2 允许尚未全部取货时逐站送达,但须至少一站已取货;未取完保持 ACCEPTED,送达计数独立累加,确认最后一个未完成收货站前必须全部取完。旧契约唯一收货站仍须先完成全部取货站。`currentTargetStopId` 是兼容旧客户端的导航提示,目标接口已过期,新客户端不再调用。物品通过备注由客户说明、骑手核对,不增加站点关联字段。
 
 骑手原 Tab 名称、参数及订单计数不变:`toPickup` 对应 ACCEPTED、`delivering` 对应 PICKED_UP、`completed` 对应 DELIVERED/COMPLETED。列表/详情同时使用已有 `pickedUpStopCount/pickupStopCount` 和 `deliveredStopCount/deliveryStopCount` 展示“已取件 2/3 · 已送达 1/2”;此示例仍在待取件,一笔订单只计一个 Tab 角标。
 

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

@@ -813,3 +813,9 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 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/` 不改动。
+
+## 2026-10-15:新旧订单逐站直接确认,不以当前目标限制取送
+
+1. 在 `FlashDeliveryV2Test` 和 `FlashDeliveryMultiPickupTest` 先证明新旧订单可直接凭 `stopId` 确认非默认取货站及可送达的收货站,无需先调用目标接口;保留未取货禁止送达、漏取禁止末站送达、骑手权限、按站 PIN/图片和重复确认测试。
+2. `FlashDeliveryApplicationService` 对新旧订单移除取货和逐站送达的当前目标一致性校验,目标调整接口的原因/同意声明也不再作为切换前置条件。目标接口标记 `@Deprecated`,`currentTargetStopId` 及目标接口仅兼容旧 App 的可选导航提示;确认后清空已选目标并回到原序最早未完成站,不重排路线或重算运费。
+3. 更新 024 规格、契约、任务与 App 交接文档;运行 JDK 21 定向测试、完整后端测试和模块构建,核对差异与换行。无 SQL;骑手 App 源码不在本仓库,页面移除“设为目前目标”由 App 接入方依据文档完成。

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

@@ -299,15 +299,15 @@
 - FR-078(2026-09-09 入参调整):确认修改地址与追加小费使用 POST /orders/address 和 POST /orders/tip;订单 ID 通过 JSON 请求体 orderId 传入,必须是有效正整数,token 仍放请求头。
 ## 2026-09-14:闪送多取货点(多取一送,2026-09-15 修订)
 
-本节按 2026-09-15 用户确认的方式 2 更新:默认按用户顺序引导,取得下单人同意后允许调整实际取件顺序;替换此前无条件自由取件的规则。2026-09-15 已落实后端接口与平台 PC;用户/骑手 App 接入和真实数据库联调仍待执行,验证范围见 validation.md。单笔订单包含 1 至 N 个取货点和 1 个收货点,每个取货点独立关联联系人和物品信息。用户填写顺序用于初始路线报价、展示和默认取件引导;调整实际取件顺序须遵守 FR-094 至 FR-098。按整条路线距离计价,不新增每取货点附加费;点间不设单独距离限制,整条报价路线仍不得超过 40 公里。
+本节历史规则于 2026-10-15 修订:新旧订单均由骑手自主决定实际取送顺序,按站 ID 直接确认,无须先选择目标或取得同意。单笔旧契约订单包含 1 至 N 个取货点和 1 个收货点,每个取货点独立关联联系人和物品信息。用户填写顺序用于初始路线报价、展示和默认导航提示,不限制履约顺序。按整条路线距离计价,不新增每取货点附加费;点间不设单独距离限制,整条报价路线仍不得超过 40 公里。
 
 ### 用户故事 8:用户发布多取货点订单并由骑手逐点取件(优先级:P1)
 
-用户逐站填写联系人、地址和物品,填写唯一收货点后获取整条路线报价;骑手接单后默认按用户顺序前往未取货站点,需要调整时先与下单人沟通、取得同意并保存调整记录,再逐站确认取件并上传凭证,全部取货点完成后订单进入已取件,送达流程不变。
+用户逐站填写联系人、地址和物品,填写唯一收货点后获取整条路线报价;骑手接单后可自主选择未完成取货站,直接按站 ID 确认并上传凭证,全部取货点完成后订单进入已取件,送达流程不变。
 
 **优先级原因**:每站独立物品和凭证用于履约核对与纠纷定位。
 
-**独立测试**:验证每站物品独立、按用户顺序报价和默认引导、沟通同意后调整并记录骑手声明、前往与取件分离、单点可省略 stopId、多点必传 stopId、重试不推进其他站点、取件与用户取消互斥,以及全部取货点完成时仅推进一次主状态。
+**独立测试**:验证每站物品独立、按用户顺序报价、非默认站直接确认、单点可省略 stopId、多点必传 stopId、重试不推进其他站点、取件与用户取消互斥,以及全部取货点完成时仅推进一次主状态。
 
 **验收场景**:
 
@@ -315,7 +315,7 @@
 2. **假如** 数量超过字典有效上限,**当** 报价、创建或修改取货点,**那么** 返回国际化错误;字典缺失或非法时有效上限为 1。
 3. **假如** 取货点数量上限为 1,**当** 报价、创建或编辑提交多于一个取货点,**那么** 拒绝;既有订单履约及仅追加小费不受上限调整影响。
 4. **假如** 订单只有一个取货点,**当** 骑手不传 stopId,**那么** 定位唯一取货点;多点订单未传 stopId 必须拒绝,不自动选择未取货站点。
-5. **假如** 用户顺序为 A → B → C 且均未取件,**当** 骑手查看订单,**那么** 默认突出显示前往 A;骑手取得下单人同意、填写原因并声明已取得同意,保存先前往 B 的调整记录后,可以实际到 B 取货并上传凭证确认;主状态保持 ACCEPTED,全部取货点完成后才进入 PICKED_UP。
+5. **假如** 用户顺序为 A → B → C 且均未取件,**当** 骑手先到 B,**那么** 可直接提交 B 的站点 ID 和取件凭证;主状态保持 ACCEPTED,全部取货点完成后才进入 PICKED_UP。
 6. **假如** 请求指定非本单站点、收货站点或他人订单站点,**当** 取件确认,**那么** 拒绝;同一骑手重试已确认站点只返回已确认结果,不重复写凭证、时间、日志或推进其他站点。
 7. **假如** 任意站点已经取货,**当** 用户自助取消,**那么** 拒绝并要求平台介入;与取件并发时,取件先成功则用户取消失败,取消先成功则取件失败。
 8. **假如** 用户选择加急和多个取货点,**当** 报价或创建,**那么** 允许并沿用整条路线计价及骑手独占规则。
@@ -324,12 +324,12 @@
 11. **假如** 旧单点请求提交 pickup 与订单级物品字段,**当** 报价或创建,**那么** 适配为一个含独立物品的取货点;pickup 与 pickups 同时提交时拒绝。
 12. **假如** 多点地址保存与抢单或其他编辑并发,**当** 保存,**那么** 站点、摘要、收件人绑定、路线和金额必须同一事务提交或回滚,不出现两套地址或报价。
 
-13. **假如** 用户下单前调整站点顺序,**当** 确认报价,**那么** 按最终顺序计算并展示路线和费用,提示默认按此顺序取件、骑手如需调整将先沟通。
-14. **假如** 骑手选择非默认未取站,**当** 缺少调整原因或未声明已取得下单人同意,**那么** 不保存调整,也不能通过直接调用取件确认绕过所需记录;首期不要求用户在 App 内点击批准。
+13. **假如** 用户下单前调整站点顺序,**当** 确认报价,**那么** 按最终顺序计算并展示路线和费用;该顺序不约束骑手实际取件顺序。
+14. **假如** 骑手选择非默认未取站,**当** 未调用目标接口且未提交调整原因或同意声明,**那么** 仍可直接确认该站取件;目标接口仅兼容旧客户端导航。
 15. **假如** 已保存先前往 B 的调整,**当** 打开或退出导航,**那么** B 仍为待取件,不生成取件凭证、不增加已取数量、不推进主状态;实际交接并提交有效图片后才可确认已取。
 16. **假如** 用户顺序为 A → B → C,**当** B 已取且 A/C 待取,**那么** 保留原始顺序和报价,展示已取 1/3 站及各站状态,默认下一站回到 A,用户自助取消不可用。
 17. **假如** 平台核对调整,**当** 查看记录,**那么** 展示调整前后目标站、原因、骑手、操作时间和骑手声明,不显示为用户已在 App 审批;订单聊天可作为沟通依据。
-18. **假如** 某站暂未备好,**当** 沟通同意后先去其他站,**那么** 原站继续待取;最终无法取货时联系平台处理,不能删除站点、虚假取件或直接完成整单。平台记录已取物品去向和交接凭证后再关闭异常订单。
+18. **假如** 某站暂未备好,**当** 骑手先去其他站,**那么** 原站继续待取;最终无法取货时联系平台处理,不能删除站点、虚假取件或直接完成整单。平台记录已取物品去向和交接凭证后再关闭异常订单。
 19. **假如** 调整请求重试或与取件、取消并发,**当** 保存,**那么** 不重复追加同一次记录,不以过期状态覆盖目标;取消后禁止新调整,已取站不能作为新目标。
 
 - FR-079:通用站点表 flash_delivery_order_stop 保存订单 ID、类型(PICKUP/DELIVERY)、用户填写顺序、快照和履约信息,作为新订单站点数据唯一真相;所有新订单(含单点)统一写入取货和收货站点。订单表 pickup_* 维护首取货点摘要,新增 pickup_stop_count。当前为测试阶段,不做历史订单迁移、无站点履约回退或历史详情适配,不自动清理测试数据。
@@ -337,20 +337,20 @@
 - FR-081:数据字典 `flash_pickup_stop_limit` 配置取货点上限,不含收货点;仅接受 1 至 10 的整数,缺失、空值或非法配置时有效上限为 1。报价、创建、修改取货点均校验非空且不超过有效上限,超限返回国际化错误;首页下发有效 `pickupStopLimit`。超点数在地图调用前拒绝,不作为地图故障降级。
 - FR-082:多取货点没有独立开关;报价、创建和地址编辑按 `flash_pickup_stop_limit` 校验数量。上限调整不影响已创建订单取件、送达及仅追加小费。首页兼容字段 `showMultiPickup` 由有效上限是否大于 1 派生。
 - FR-083:加急功能始终可用,不设展示字典开关;首页兼容字段 `showUrgentOption` 固定返回 true。后端继续按 `deliveryType` 执行既有加急校验与计费。
-- FR-084:初始及地址编辑报价按用户填写顺序计算:首取货点为起点,其余取货点为中途点,收货点为终点,不自动优化顺序;地图失败逐段直线距离求和,40 公里上限按整线执行。stop_order 用于报价、展示及默认取件引导;骑手按 FR-095 改变实际取件顺序不重排用户站点、不重算订单金额。
+- FR-084:初始及地址编辑报价按用户填写顺序计算:首取货点为起点,其余取货点为中途点,收货点为终点,不自动优化顺序;地图失败逐段直线距离求和,40 公里上限按整线执行。stop_order 用于报价、展示及默认取件引导;骑手改变实际取件顺序不重排用户站点、不重算订单金额。
 - FR-085:沿用 FR-004 至 FR-006、FR-070 至 FR-072 的运价、报价回传校验和整数 TWD 取整,以整线距离替代原单段距离,不新增站点附加费或按物品计价项;保留 FR-076 小费只增加小费及总金额,不重算路线、基础配送费或加急费。
 - FR-086:deliveryType=URGENT 允许多个取货点,FR-065、FR-066 骑手独占规则不变。
-- 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-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-088:任意取货点已确认即禁止用户自助取消,不限定用户排序第一站;全部未取时维持现有取消规则,平台保留介入能力。取件与用户/平台取消共用订单行级并发保护:同一事务先锁订单行,再读取最新订单及站点状态并校验、更新。站点状态、凭证、主状态、版本和日志一并提交或回滚,不能只在取消前单独查询一次。取消成功后禁止新增取件;任一站取件成功后用户取消必须失败。
 - FR-089:列表保留现有字段和首取货点地址/物品摘要,新增 pickupStopCount;用户、骑手及平台详情新增 stops,包含站点 ID、类型、用户顺序、联系人、电话、地址、交接方式、坐标、独立物品、状态、确认时间及凭证。凭证按站关联,既有 pickupImageUrls 可聚合展示,不能用聚合列表覆盖各站凭证。骑手接单前逐站隐藏联系人、电话、精确坐标、PIN 及凭证图片,保留文字地址及履约判断所需物品信息。
-- FR-090:客户端按 FR-094 至 FR-096 确定的当前目标站拼装 Google Maps URL,默认指向用户顺序中最早的未取站,保存沟通同意后的调整后指向选定站,全部取完后导航到唯一收货点。选择前往、打开导航与确认已取货是独立操作。使用 api=1、travelmode=two-wheeler、dir_action=navigate,正确编码参数,起点默认设备当前位置;不依赖携带全部途经点的 URL 履约。后端按 FR-029 权限返回坐标,不生成导航 URL,不新增用于生成导航 URL 的接口;调整记录保存属于履约操作,不能仅保存在客户端。
+- FR-090:客户端可按骑手选择的待完成站拼装 Google Maps URL,默认提示用户顺序中最早的未完成站;导航选择仅是客户端行为,不作为确认取送的前置条件。使用 api=1、travelmode=two-wheeler、dir_action=navigate,正确编码参数,起点默认设备当前位置;不依赖携带全部途经点的 URL 履约。后端按 FR-029 权限返回坐标,不生成导航 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-094:下单前用户可调整取货点顺序,按最终顺序报价并确认;待接单订单调整顺序沿用 FR-093 重报价及确认流程。骑手接单后可以查看全部站点,客户顺序仅用于展示和默认导航提示,不限制实际取货先后。
+- FR-095:`POST /system/flashDelivery/rider/orders/{id}/stops/{stopId}/target` 标记过期,仅兼容旧客户端的可选导航目标保存。调用时仍校验接单骑手、订单状态、本单待完成站、订单版本与请求幂等;原因可选(最多 500 字),同意声明可省略,记录骑手提供的值但不作为客户审批。新客户端无需调用,直接按站 ID 确认取送。
+- FR-096:导航与实际取送分开:选择目标不能修改站点状态、时间、凭证或主状态;确认取送须实际交接并提交本站有效凭证。确认任意待完成站不要求当前目标一致,也不要求先保存 FR-095 记录。已确认站点的重试仍按 FR-087 幂等处理。服务端 `currentTargetStopId` 仅作为兼容旧客户端的导航提示;订单原站序和金额不变。
 - FR-097:保留用户原始站点顺序及报价,按每站实际确认时间和状态展示履约进度;用户、骑手及平台显示已取数量/总数量(如“已取 1/3 站”)。部分取件主状态仍为 ACCEPTED,取消入口按是否任意站已取判断,不能仅依据主状态显示可取消。平台详情可查看 FR-095 调整记录;调整声明和取件凭证分别展示,不相互替代。
 - FR-098:骑手仅能调整本单未取货地点的访问先后,不能新增或删除地点、修改地址或物品,也不能漏取。某站暂时无法取货可以沟通后先去其他站,该站继续保留待取件;最终无法取货、缺件或拒收须联系平台处理,不能虚假确认取件、擅自部分履约或直接完成整单。平台介入时记录问题站、处理结果及已取物品去向和交接凭证,再关闭异常订单;沿用既有介入权限,本次不新增支付退款功能。
 
@@ -443,8 +443,8 @@
 
 ## 2026-10-15:客户顺序交错取送与骑手双进度(用户确认)
 
-本节修订 v2 的 FR-116、FR-120、FR-128 以及此前“先取完再送”和“改变顺序须客户同意”的限制;旧契约订单继续沿用原履约规则。
+本节修订 v2 的 FR-116、FR-120、FR-128 以及此前“先取完再送”和“改变顺序须客户同意”的限制;2026-10-15 再修订为新旧订单均不限制目标站顺序,旧契约唯一收货站仍须先完成全部取货站,防止漏取而提前完单。
 
 - FR-129:v2 客户提交的 `stops[]` 顺序用于报价、保存及默认导航,不自动按类型重排。首站为取货、末站为收货,中间允许取送交错,例如取1→取2→送1→取3→送2;下单与待接单改址共用校验。客户通过每站备注说明物品,骑手负责核对实际取送,不新增取货站与收货站强制关联字段。
-- FR-130:v2 骑手可自主选择其他未完成取货/收货站作为当前目标,包括先取完所有货再送;沿用目标接口、版本与请求幂等,原因可选,同意声明不再作为必需条件,保留实际调整审计。尚未取过货不能送达;存在未取货站时不能确认最后一个未完成收货站,以防漏取提前结束。每站取送仍需对应类型、所属骑手、当前目标、凭证及按站 PIN 校验;完成目标后回到客户原顺序最早的未完成站,不修改原站点顺序和运费。
+- 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

@@ -298,3 +298,11 @@ Phase 29 验证:先移除测试里的开关桩,定向 41 项出现预期 RED
 - [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。
+
+## Phase 31:新旧订单自由取送、目标接口过期(2026-10-15)
+
+- [x] T143 新旧订单按待完成站 `stopId` 直接确认,移除当前目标一致性校验;旧订单目标接口的原因/同意声明不再作为前置条件,保留类型、骑手、凭证、PIN、末站防漏取与重试校验。新旧订单服务测试先复现原限制,再验证直接确认。
+- [x] T144 目标接口保留路径并标记 `@Deprecated`,添加接口契约测试;更新现有 spec、plan、API 契约和前端交接文档,明确 App 移除目标控件。本轮无 SQL,骑手 App 源码不在本仓库。
+- [x] T145 JDK 21 完整后端测试、模块构建、差异/换行检查。
+
+Phase 31 验证:新旧订单取送顺序及目标接口契约测试先出现预期 RED;JDK 21 执行 `mvn -q -pl ruoyi-admin -am test` 成功,本轮 admin 报告 136 类/1061 项、0 失败/错误/跳过;`mvn -q -pl ruoyi-admin -am -DskipTests package` 成功,差异空白检查通过。无 SQL;骑手 App 源码不在本仓库,真实设备和数据库联调未执行。