Просмотр исходного кода

Merge branch 'test-202609v2' into codex/036-multi-store-checkout

# Conflicts:
#	updatesql/sql.md
qmj 1 неделя назад
Родитель
Сommit
36c7888dc8
100 измененных файлов с 4264 добавлено и 696 удалено
  1. 60 33
      docs/flash-delivery-app-api.md
  2. 150 0
      docs/flash-delivery-v2-frontend-handoff.md
  3. 34 0
      docs/multi-store-test-hold.md
  4. 38 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryRiderController.java
  5. 43 15
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  6. 7 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  7. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  8. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  9. 14 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderCountView.java
  10. 6 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  11. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopDeliverRequest.java
  12. 5 10
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopView.java
  13. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryTargetRequest.java
  14. 0 67
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  15. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressChangeRequest.java
  16. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressConfirmRequest.java
  17. 41 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2CreateRequest.java
  18. 57 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2QuoteRequest.java
  19. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2StopRequest.java
  20. 598 121
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  21. 58 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java
  22. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryUserCardStops.java
  23. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java
  24. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java
  25. 11 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java
  26. 17 5
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  27. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java
  28. 15 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java
  29. 5 2
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java
  30. 295 21
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundService.java
  31. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundTask.java
  32. 60 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgRefundGateway.java
  33. 22 5
      ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java
  34. 15 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  35. 212 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutService.java
  36. 42 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutTask.java
  37. 100 44
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  38. 20 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  39. 4 85
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  40. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java
  41. 116 10
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchService.java
  42. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationSaveRequest.java
  43. 25 8
      ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationService.java
  44. 41 3
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java
  45. 12 1
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java
  46. 27 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  47. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/InfoUserResetPwdDto.java
  48. 1 0
      ruoyi-admin/src/main/resources/application.yml
  49. 10 2
      ruoyi-admin/src/main/resources/i18n/messages.properties
  50. 10 2
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  51. 10 2
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  52. 10 2
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  53. 10 2
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  54. 94 7
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  55. 1 13
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  56. 57 45
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  57. 33 26
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  58. 292 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java
  59. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryRiderBillingTest.java
  60. 634 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryV2Test.java
  61. 22 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java
  62. 12 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java
  63. 27 3
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java
  64. 14 2
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryServiceTest.java
  65. 95 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRefundServiceTest.java
  66. 1 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTest.java
  67. 2 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
  68. 0 37
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderListPaymentFilterTest.java
  69. 65 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutServiceTest.java
  70. 65 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java
  71. 0 54
      ruoyi-admin/src/test/java/com/ruoyi/app/order/UserOrderTabFilterTest.java
  72. 32 8
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java
  73. 6 0
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationMapperTest.java
  74. 49 3
      ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationServiceTest.java
  75. 58 1
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryServiceTest.java
  76. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java
  77. 9 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/StallQualificationApplication.java
  78. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  79. 8 11
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderStop.java
  80. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryOrderMapper.java
  81. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java
  82. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java
  83. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java
  84. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java
  85. 23 4
      ruoyi-system/src/main/resources/mapper/chanting/StallQualificationMapper.xml
  86. 21 1
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  87. 3 6
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderStopMapper.xml
  88. 13 4
      ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml
  89. 7 2
      ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml
  90. 43 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/StallQualificationMapperMpRenderTest.java
  91. 44 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/StallQualificationMapperXmlTest.java
  92. 36 1
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  93. 5 0
      specs/006-orderstate/tasks.md
  94. 2 0
      specs/016-omg-payment/data-model.md
  95. 12 5
      specs/016-omg-payment/frontend-integration.md
  96. 3 1
      specs/016-omg-payment/omg-official-api-ref.md
  97. 2 0
      specs/016-omg-payment/plan.md
  98. 2 0
      specs/016-omg-payment/quickstart.md
  99. 2 0
      specs/016-omg-payment/spec.md
  100. 66 4
      specs/024-flash-delivery/contracts/api.md

+ 60 - 33
docs/flash-delivery-app-api.md

@@ -673,25 +673,9 @@ POST /system/flashDelivery/orders
 
 
 创建时,后端会移除收件手机号中的空白、`+`、`-`、`(`、`)` 后,匹配有效且未删除的普通用户账号。仅在恰好匹配一个账号时把该账号固化为收件人;无匹配或重复匹配均不绑定。此绑定只执行一次,历史订单不会因收件人之后注册或修改手机号而自动认领。
 创建时,后端会移除收件手机号中的空白、`+`、`-`、`(`、`)` 后,匹配有效且未删除的普通用户账号。仅在恰好匹配一个账号时把该账号固化为收件人;无匹配或重复匹配均不绑定。此绑定只执行一次,历史订单不会因收件人之后注册或修改手机号而自动认领。
 
 
-### 7.4 查询用户订单列表
+### 7.4 查询用户订单列表(已删除,改用统一订单列表)
 
 
-```http
-GET /system/flashDelivery/orders?page=1&size=10&role=sender
-```
-
-查询参数:
-
-| 参数 | 必填 | 说明 |
-|---|---:|---|
-| `page` | 否 | 默认 `1`,小于 `1` 时按 `1` 处理 |
-| `size` | 否 | 默认 `10`,范围为 `1` 至 `100` |
-| `role` | 否 | `sender` 查询“我发的”,`receiver` 查询“我收的”;默认 `sender` |
-
-仅查询当前用户作为指定角色的订单,按创建时间倒序返回全部状态的订单摘要。接口不接受 `status`、`scene` 或 `serviceType`;响应 `data.records` 中每项为用户列表摘要对象。
-
-`role` 只接受 `sender` 或 `receiver`,不区分大小写;传空值按 `sender` 处理,传其他值返回业务错误。“我收的”依据订单当前绑定的收件账号;创建时匹配,确认修改取送信息时重新匹配收件电话,查询时不会重新按手机号匹配。
-
-设计原型中的“待接单、配送中、已完成、已取消”筛选页签当前没有对应查询参数。第一版建议只展示“全部”列表;如需页签,只能基于当前页数据本地过滤,且不应依赖它得到准确的分页总数。
+`GET /system/flashDelivery/orders` 已于 2026-10-15 删除,用户端订单列表统一走 `GET /system/unifiedOrder/list`(外卖与闪送合并,游标分页)。原 `role=sender|receiver` 语义由统一列表的 `role` 字段承接:寄件人条目 `role=sender`,收件人条目 `role=receiver`;收件人条目的 `deliveryAddress` 为本人站点地址,多站点单附 `stops` 逐站列表与双进度计数。
 
 
 ### 7.5 查询用户订单详情
 ### 7.5 查询用户订单详情
 
 
@@ -1139,7 +1123,7 @@ POST /system/address/{id}/top
 
 
 ### 下单与编辑
 ### 下单与编辑
 
 
-首页读取 `pickupStopLimit/showMultiPickup/showUrgentOption`。用户可新增取货点、填写每站独立物品,并在报价前排序;最终 `pickups` 数组顺序即报价顺序。每个数组项直接包含地址字段和 `packageType/quantity/weightRange/specification`。数组模式不再传旧 `pickup` 或订单级物品。唯一 `delivery` 保持原格式。
+首页读取 `pickupStopLimit` 控制取货点数量;兼容字段 `showMultiPickup` 由上限是否大于 1 派生,`showUrgentOption` 固定为 true,加急始终展示。用户可新增取货点、填写每站独立物品,并在报价前排序;最终 `pickups` 数组顺序即报价顺序。每个数组项直接包含地址字段和 `packageType/quantity/weightRange/specification`。数组模式不再传旧 `pickup` 或订单级物品。唯一 `delivery` 保持原格式。
 
 
 ```json
 ```json
 {
 {
@@ -1152,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` 且结合当前用户角色。任意取件、取消、调整或刷新后重新读取最新详情,不能仅按主状态判断按钮。
 各端显示 `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:闪送完成后的骑手收入账单
 ## 2026-09-30:闪送完成后的骑手收入账单
 
 
@@ -1191,3 +1168,53 @@ POST /system/address/{id}/top
 | `payType` / `paymentStatus` | 4 现金、6 线下转账 / 0 未确认收款、1 已确认 |
 | `payType` / `paymentStatus` | 4 现金、6 线下转账 / 0 未确认收款、1 已确认 |
 
 
 App 应根据来源打开对应订单详情,不能将账单收入展示为平台可提现金额,也不能将有账单标记为已收款。已有“未接单报价不得展示为收益”的限制不变;本节只放开已完成订单的收入账单。此次没有修改 App 页面,真实 App 展示需客户端接入验证。部署前执行 `updatesql/sql.md` 中本次 `user_billing.order_source` 增量 SQL。
 App 应根据来源打开对应订单详情,不能将账单收入展示为平台可提现金额,也不能将有账单标记为已收款。已有“未接单报价不得展示为收益”的限制不变;本节只放开已完成订单的收入账单。此次没有修改 App 页面,真实 App 展示需客户端接入验证。部署前执行 `updatesql/sql.md` 中本次 `user_billing.order_source` 增量 SQL。
+
+## 2026-10-14 新多点取送接口的参数位置
+
+本次新增接口带 body 时,业务参数统一放 body,token 保持请求头;GET 简单参数和已有旧接口保持原样。v2 报价/创建继续使用 `/system/flashDelivery/v2/quote` 与 `/system/flashDelivery/v2/orders`。
+
+新逐站送达采用固定路径:
+
+```http
+POST /system/flashDelivery/rider/orders/stops/deliver
+```
+
+```json
+{
+  "orderId": 10,
+  "stopId": 13,
+  "pinCode": "4821",
+  "imageUrls": ["/profile/delivery.jpg"]
+}
+```
+
+订单、站点 ID 必须为正整数;`stopId` 从订单详情 `stops` 中对应 DELIVERY 站的 `id` 取得,单收货站也必须传。照片至少一张;订单启用 PIN 时提交对应收货站的码,未启用时可省略 `pinCode`。服务器核实站点归属和骑手权限;全部收货站送达后才推进整单状态。
+
+此前当日新增的带路径 ID 逐站送达地址不再作为新接口契约。已有 LINE Pay、旧取件/送达/目标调整/改址及平台接口未调整,仍按原文档接入。
+
+## 2026-10-14 多点履约与改址修复
+
+v2 订单即使只有一个收货站,也必须使用上述逐站送达接口并传 `stopId`;旧整单送达接口拒绝 v2 订单。每次送达可直接指定任意符合条件的待送站,无需先通过目标调整入口选站。末站送达后重复请求可安全返回,不会再次保存凭证或通知。全部收货站完成才进入 `DELIVERED`;确认收货或系统/平台完成进入 `COMPLETED` 后才生成一次骑手收入账单。
+
+`stops[].id` 是站点 ID。启用 PIN 时,v2 详情在对应收货站的 `deliveryPinCode` 返回各站独立收货码;发起人可见全部,系统内匹配的收件人仅能看到自己的站点收货码,骑手看不到。收件人卡片主位的收货地址指向本人站点;其他收货站的文字地址与备注对寄件、收件同口径返回(2026-10-15 放开),联系人和坐标仍隐藏。匹配不到系统用户时,仍保留联系人电话和按站收货码,由发起人线下告知;本阶段不实现逐站收件人推送。v2 订单级 `deliveryPinCode` 不再用作送达码。订单列表和详情提供 `deliveryStopCount`、`deliveredStopCount` 供展示进度。
+
+v2 待接单订单修改站点使用两个新接口,token 在请求头,其余业务参数全部在 JSON body:
+
+```http
+POST /system/flashDelivery/v2/orders/address/quote
+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` 仅是兼容旧客户端的导航提示;新旧订单均可直接按任意待完成站 `stopId` 确认,无需先设目标。新 App 不再显示“目前目标”或“设为目前目标”,目标接口标记过期。取送仍须上传本站凭证,送达时核验本站 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。

+ 150 - 0
docs/flash-delivery-v2-frontend-handoff.md

@@ -0,0 +1,150 @@
+# 闪送 v2 多点取送前端对接说明
+
+更新时间:2026-10-15。本文对应当前仓库中的后端实现,供用户端和骑手端接入;联调前需确认服务端部署版本和 [10 月 14 日数据库执行说明](../updatesql/sql.md)。10 月 15 日交错取送与进度调整无新增字段、无新增 SQL。
+
+## 本次要调整的页面和流程
+
+| 页面/场景 | 前端调整 |
+| --- | --- |
+| 用户填写闪送订单 | 按客户顺序编辑一个 `stops` 数组,首站取货、末站收货,中间可以交错取送;物品信息只填写一次。根据 `/home` 的取货点和收货点数量上限控制可添加数量。 |
+| 报价与下单 | 使用 `/v2/quote` 和 `/v2/orders`。创建时把本次报价的配置 ID、版本和费用原样回传;创建请求号在重试时保持一致。 |
+| 用户/收件人订单列表和详情 | 展示取货、收货两个进度;站点使用 `stops[].id`,不可把数组下标当成站点 ID。逐站收货码按权限展示。 |
+| 骑手履约 | 保留原五个 Tab 和名称,卡片增加取件、送达双进度。直接从 `stops[]` 选择待完成站,移除“目前目标/设为目前目标”;每站上传凭证并刷新详情。逐站送达使用固定路径的新接口,`orderId`、`stopId`、PIN 和照片全部放 body。 |
+| 待接单改址 | v2 使用新的完整站点报价与保存接口。保存成功后刷新详情,替换本地旧站点 ID 和收货码。 |
+| 发起人消息 | 识别两种逐站进度事件,凭 `ddId` 打开订单详情,再读取最新状态。逐站收件人通知本期不接入。 |
+
+所有下列新 POST 接口使用 `token` 请求头和 JSON body。只有**新接口**采用“业务参数统一放 body”的约定;现有 GET、旧取件、旧目标调整、旧订单接口及 LINE Pay 接口的参数格式保持原样。响应仍为项目通用 `AjaxResult`,业务数据在 `data` 中;订单详情的 `data` 直接是订单对象,不再套一层 `order`。
+
+## 用户端:填写、报价、创建
+
+先调用 `GET /system/flashDelivery/home`,使用 `data.pickupStopLimit`、`data.deliveryStopLimit` 控制添加站点按钮和提交数量。有效范围为 1–10,默认均为 1;对应上限大于 1 即可使用多站点,无需开关。兼容字段 `showMultiPickup`、`showMultiDropoff` 分别表示对应上限是否大于 1,`showUrgentOption` 始终为 true;加急选项直接展示。服务类型、配送等级和可用支付方式也从首页响应读取。
+
+`POST /system/flashDelivery/v2/quote` 的示例 body:
+
+```json
+{
+  "serviceType": "HELP_SEND",
+  "deliveryType": "NORMAL",
+  "vehicleType": 1,
+  "packageType": "DOCUMENT",
+  "quantity": 2,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "两个文件袋",
+  "tipAmount": 0,
+  "deliveryMode": "NOW",
+  "payType": "4",
+  "stops": [
+    {"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` 订单无需提交这两个字段。
+
+创建订单使用 `POST /system/flashDelivery/v2/orders`。body 包含报价时的业务字段和完整 `stops`,再增加:
+
+```json
+{
+  "clientRequestId": "客户端生成并持久保存的本次下单请求号",
+  "pricingId": 123,
+  "pricingVersion": 1,
+  "quotedBaseDeliveryFee": 80,
+  "quotedDistanceFee": 10,
+  "quotedUrgentFee": 0,
+  "quotedAmount": 90,
+  "pinRequired": true,
+  "senderImageUrls": [],
+  "userNote": "请注意文件袋"
+}
+```
+
+上述价格数字只展示字段形状,实际值必须取本次报价 `data` 的 `pricingId`、`pricingVersion`、`baseDeliveryFee`、`distanceFee`、`urgentFee`、`amount`,不可使用示例数字。`clientRequestId` 最长 64 字符;同一次创建的网络重试使用相同值,新订单使用新值。`pinRequired` 省略时默认为 `true`,开启后每个收货站有独立四位收货码。报价在创建前变化时,接口会返回错误并在 `data` 附最新报价;展示新金额让用户重新确认后再提交。
+
+## 订单详情、收件人和进度
+
+- 用户列表:统一订单列表 `GET /system/unifiedOrder/list`(2026-10-15 起旧闪送专属列表 `GET /system/flashDelivery/orders` 已删除);`receiver` 角色条目包括每一个已匹配到系统普通用户的收货站。收件人列表卡片的 `deliveryAddress` 指向本人站点;`stops` 全站文字地址与备注对寄件、收件同口径返回,联系人、电话、坐标不落卡片。
+- 用户详情:`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`;全部取送站完成为 `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`。发起人可查看全部收货码;已在系统内匹配的收件人只能查看自己站的收货码、联系人、坐标和凭证,其他站的文字地址与备注同口径开放(2026-10-15);骑手看不到任何实际收货码。未匹配系统用户的收货站仍可下单,但此人不会进入“我收的”列表,也不会收到 App 推送;发起人需自行告知该站收货码。**逐站收件人通知本期暂缓。**
+
+用户端取消按钮以 `canUserCancel` 为准;任一取货站已取货后不可自助取消。确认收货入口只在整单 `DELIVERED` 后提供;系统仍会检查全部收货站是否真的已送达。骑手收入账单只在订单进入 `COMPLETED` 时生成一次。
+
+## 骑手端:自由取货、送达
+
+骑手直接从详情 `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`,`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 订单调用它会被拒绝。
+
+取送任何待完成站都直接提交该站 ID,无需目标调整记录或客户同意声明;实际物品由骑手根据备注核对。旧契约订单只有一个收货站,必须先完成所有取货站,避免漏取提前完单。某站已成功取货或送达的重试不会重复保存照片和进度;末站送达后主状态已变化时也是如此。
+
+## 用户端:v2 待接单改址
+
+仅待接单、未分配骑手的 v2 订单可编辑。先从详情读取 `orderVersion` 和完整站点,编辑后提交所有取货与收货站;只提交被修改的一站会丢失其余站点,因此服务端要求完整数组。
+
+```http
+POST /system/flashDelivery/v2/orders/address/quote
+token: <登录 token>
+Content-Type: application/json
+```
+
+```json
+{"orderId":10,"orderVersion":0,"stops":[{"type":"PICKUP","name":"...","phone":"...","address":"...","longitude":121.56,"latitude":25.03},{"type":"DELIVERY","name":"...","phone":"...","address":"...","longitude":121.57,"latitude":25.04}]}
+```
+
+确认保存调用 `POST /system/flashDelivery/v2/orders/address`,body 保留相同的 `orderId`、`orderVersion`、**完整** `stops`,再回传报价 `data` 中的 `distanceMeters`→`quotedDistanceMeters`、`baseDeliveryFee`→`quotedBaseDeliveryFee`、`distanceFee`→`quotedDistanceFee`、`urgentFee`→`quotedUrgentFee`、`amount`→`quotedAmount`。只改站点,物品、配送方式、小费和原运价快照继续沿用订单保存值。报价或版本变化时重新获取并让用户确认。保存成功后以返回详情更新全部 `stops[].id` 和各站收货码;旧站点 ID 已失效。旧改址入口会拒绝 v2 订单,旧订单仍沿用原入口。
+
+## 发起人逐站通知
+
+每个取货/收货站首次成功且事务提交后,服务端向**订单发起人**推送并写入消息中心。两种 `eventType`:`FLASH_PICKUP_STOP_COMPLETED`、`FLASH_DELIVERY_STOP_COMPLETED`。消息 payload 示例:
+
+```json
+{
+  "ddId": "订单号",
+  "state": "PICKED_UP",
+  "type": -1,
+  "pushType": 3,
+  "eventType": "FLASH_PICKUP_STOP_COMPLETED",
+  "stopId": 13,
+  "stopType": "PICKUP",
+  "stopOrder": 0,
+  "completedStopCount": 2,
+  "totalStopCount": 2
+}
+```
+
+`state` 是通知发生时真实的整单状态,不应写死为示例值;`stopOrder` 为路线数组的 0 基下标,通知文案的站号为 1 基。收到事件后按 `ddId` 请求 `/orders/byOrderNo` 并刷新详情;不要从消息体推断联系人、地址或 PIN。相同站点的重试不重复通知,最后一站也不叠发旧整单阶段通知。逐站收件人通知留待后续需求。
+
+## 联调验收重点
+
+1. 单取单送和多取多送都走 v2 报价/创建;收货站只有一站时,送达仍提供 `stopId`。
+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 页面尚需联调。
+
+现有单点、支付及其他旧接口以 [原闪送 App API 文档](flash-delivery-app-api.md) 为准。数据库结构和不同环境的执行方式见 [10 月 14 日 SQL 说明](../updatesql/sql.md);本次问题修复没有额外字段变更;2026-10-14 测试库已按旧版增量路径完成结构与字典更新,其他环境仍需按 SQL 说明执行。

+ 34 - 0
docs/multi-store-test-hold.md

@@ -0,0 +1,34 @@
+# 多店结算未完成测试暂存记录(并行会话协调)
+
+> 记录时间:2026-10-14
+> 状态:暂存中;待 036 多店结算会话补齐 DTO 后按文末命令还原
+> 背景:同一工作区有两个并行会话——闪送多点取送(specs/024)与多店结算(specs/036)。本记录由 024 会话写入,供协调使用。
+
+## 事项
+
+036 会话的未完成测试 `ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java`(未跟踪文件)引用了尚未创建的 `com.ruoyi.app.order.dto.MultiStoreBatchCreateRequest`、`MultiStoreQuoteRequest`、`MultiStoreQuoteView` 等 DTO,导致 `ruoyi-admin` 模块 `test-compile` 整体失败,阻塞其他会话的定向测试与全量回归。
+
+按用户 2026-10-14 指示:先排除出编译范围,不修改其代码。
+
+## 处理
+
+- 文件已移动到仓库根目录 `.test-hold/MultiStoreCheckoutBatchServiceTest.java.hold`,内容字节未改(5057 bytes 原样),未进入 git 历史。
+- `.test-hold/README.md` 附还原说明;该目录保持未跟踪。
+- 移出后 `mvn -pl ruoyi-admin -am test-compile` 全绿;当日闪送 v2 后端全量回归 1024/1024 通过即在此条件下运行。
+
+## 还原方法
+
+036 会话补齐上述 DTO 后执行(Git Bash):
+
+```bash
+mv .test-hold/MultiStoreCheckoutBatchServiceTest.java.hold \
+   ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java
+```
+
+或 Windows CMD:
+
+```bat
+move .test-hold\MultiStoreCheckoutBatchServiceTest.java.hold ruoyi-admin\src\test\java\com\ruoyi\app\order\MultiStoreCheckoutBatchServiceTest.java
+```
+
+还原后删除 `.test-hold/` 目录与本记录中已完成的部分即可。

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

@@ -3,10 +3,13 @@ package com.ruoyi.app.flashdelivery.controller;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryTargetRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryTargetRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryStopDeliverRequest;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
@@ -42,6 +45,22 @@ public class FlashDeliveryRiderController extends BaseController {
         return success(service.riderOrders(userId(token), page, size, tab, longitude, latitude));
         return success(service.riderOrders(userId(token), page, size, tab, longitude, latitude));
     }
     }
 
 
+    /**
+     * 骑手端任务角标数量:一次返回闪送订单 newTask/toPickup/delivering 三个页签的计数,
+     * App 无需逐页签切换请求;newTask 距离过滤口径与列表一致。
+     *
+     * @param token 登录令牌,用于解析当前骑手 ID
+     * @param longitude 骑手当前经度;选填,传入时必须与纬度同时提供
+     * @param latitude 骑手当前纬度;选填,传入时必须与经度同时提供
+     */
+    @GetMapping("/orderCount")
+    @Auth
+    public AjaxResult orderCount(@RequestHeader String token,
+                                 @RequestParam(required = false) BigDecimal longitude,
+                                 @RequestParam(required = false) BigDecimal latitude) {
+        return success(service.riderOrderCounts(userId(token), longitude, latitude));
+    }
+
     /** 接单前隐藏敏感字段;接单后仅中单骑手可见完整履约信息。 */
     /** 接单前隐藏敏感字段;接单后仅中单骑手可见完整履约信息。 */
     @GetMapping("/orders/{id}")
     @GetMapping("/orders/{id}")
     @Auth
     @Auth
@@ -81,6 +100,7 @@ public class FlashDeliveryRiderController extends BaseController {
     }
     }
 
 
     /** 上传送达凭证并将已取件订单推进为已送达。 */
     /** 上传送达凭证并将已取件订单推进为已送达。 */
+    @Deprecated
     @PostMapping("/orders/{id}/deliver")
     @PostMapping("/orders/{id}/deliver")
     @Auth
     @Auth
     public AjaxResult deliver(@RequestHeader String token, @PathVariable Long id,
     public AjaxResult deliver(@RequestHeader String token, @PathVariable Long id,
@@ -89,6 +109,22 @@ public class FlashDeliveryRiderController extends BaseController {
         return success();
         return success();
     }
     }
 
 
+    /** v2 逐站送达:业务参数统一放 body,全部收货站完成订单才进入已送达。 */
+    @PostMapping("/orders/stops/deliver")
+    @Auth
+    public AjaxResult deliverAtStop(@RequestHeader String token,
+                                    @RequestBody FlashDeliveryStopDeliverRequest request) {
+        if (request == null) throw new ServiceException(MessageUtils.message("flash.delivery.request.required"));
+        if (request.getOrderId() == null || request.getOrderId() <= 0) {
+            throw new ServiceException(MessageUtils.message("flash.delivery.order.not.found"));
+        }
+        if (request.getStopId() == null || request.getStopId() <= 0) {
+            throw new ServiceException(MessageUtils.message("flash.delivery.stop.id.required"));
+        }
+        service.deliverAtStop(userId(token), request.getOrderId(), request.getStopId(), request);
+        return success();
+    }
+
     /** 只确认指定站点取货,不自动选择其他待取站点。 */
     /** 只确认指定站点取货,不自动选择其他待取站点。 */
     @PostMapping("/orders/{id}/stops/{stopId}/pickup")
     @PostMapping("/orders/{id}/stops/{stopId}/pickup")
     @Auth
     @Auth
@@ -98,7 +134,8 @@ public class FlashDeliveryRiderController extends BaseController {
         return success();
         return success();
     }
     }
 
 
-    /** 保存前往目标及必要的骑手同意声明,不推进取件状态。 */
+    /** 兼容旧客户端的可选导航目标;新客户端直接按 stopId 确认取送。 */
+    @Deprecated
     @PostMapping("/orders/{id}/stops/{stopId}/target")
     @PostMapping("/orders/{id}/stops/{stopId}/target")
     @Auth
     @Auth
     public AjaxResult target(@RequestHeader String token, @PathVariable Long id, @PathVariable Long stopId,
     public AjaxResult target(@RequestHeader String token, @PathVariable Long id, @PathVariable Long stopId,

+ 43 - 15
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -24,7 +24,8 @@ public class FlashDeliveryUserController extends BaseController {
     @Auth
     @Auth
     public AjaxResult home(@RequestHeader String token) { return success(service.home()); }
     public AjaxResult home(@RequestHeader String token) { return success(service.home()); }
 
 
-    /** 根据取送坐标获取服务端实时报价。 */
+    /** 根据取送坐标获取服务端实时报价;v2 上线起废弃,App 切换后随退役批次删除。 */
+    @Deprecated
     @PostMapping("/quote")
     @PostMapping("/quote")
     @Auth
     @Auth
     public AjaxResult quote(@RequestHeader String token, @RequestBody FlashDeliveryQuoteRequest request) {
     public AjaxResult quote(@RequestHeader String token, @RequestBody FlashDeliveryQuoteRequest request) {
@@ -32,7 +33,19 @@ public class FlashDeliveryUserController extends BaseController {
         return success(service.quote(request));
         return success(service.quote(request));
     }
     }
 
 
+    /**
+     * v2 多点取送实时报价:取货点与收货点统一放在 stops 数组,顺序即路线顺序;
+     * 物品主体整单一次设置。旧 /quote 过渡期保留,见契约废弃说明。
+     */
+    @PostMapping("/v2/quote")
+    @Auth
+    public AjaxResult quoteV2(@RequestHeader String token, @RequestBody FlashDeliveryV2QuoteRequest request) {
+        userId(token);
+        return success(service.quoteV2(request));
+    }
+
     /** 幂等创建闪送订单,客户端不能指定用户、距离或金额。 */
     /** 幂等创建闪送订单,客户端不能指定用户、距离或金额。 */
+    @Deprecated
     @PostMapping("/orders")
     @PostMapping("/orders")
     @Auth
     @Auth
     public AjaxResult create(@RequestHeader String token, @RequestBody FlashDeliveryCreateRequest request) {
     public AjaxResult create(@RequestHeader String token, @RequestBody FlashDeliveryCreateRequest request) {
@@ -44,21 +57,16 @@ public class FlashDeliveryUserController extends BaseController {
         }
         }
     }
     }
 
 
-    /**
-     * 分页查询当前用户参与的闪送订单。
-     *
-     * @param token 登录令牌,用于解析当前用户 ID
-     * @param page 页码,从 1 开始,默认 1
-     * @param size 每页数量,默认 10,服务端限制为 1 至 100
-     * @param role 订单角色;sender 表示我发的,receiver 表示我收的,不传按 sender 处理
-     */
-    @GetMapping("/orders")
+    /** v2 幂等创建多点取送订单;旧 /orders 过渡期保留,全部功能调整完成后随退役批次删除。 */
+    @PostMapping("/v2/orders")
     @Auth
     @Auth
-    public AjaxResult orders(@RequestHeader String token,
-                             @RequestParam(defaultValue = "1") int page,
-                             @RequestParam(defaultValue = "10") int size,
-                             @RequestParam(required = false) String role) {
-        return success(service.userOrders(userId(token), page, size, role));
+    public AjaxResult createV2(@RequestHeader String token, @RequestBody FlashDeliveryV2CreateRequest request) {
+        try {
+            return success(service.createV2(userId(token), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"),
+                    exception.getLatestQuote());
+        }
     }
     }
 
 
     /** 查询本人订单详情,直接返回订单字段和按类型分组的履约图片。 */
     /** 查询本人订单详情,直接返回订单字段和按类型分组的履约图片。 */
@@ -96,6 +104,26 @@ public class FlashDeliveryUserController extends BaseController {
         }
         }
     }
     }
 
 
+    /** v2 修改完整站点的预报价,沿用订单原有物品与运价快照。 */
+    @PostMapping("/v2/orders/address/quote")
+    @Auth
+    public AjaxResult quoteAddressV2(@RequestHeader String token,
+                                     @RequestBody FlashDeliveryV2AddressChangeRequest request) {
+        return success(service.quoteAddressV2(userId(token), request));
+    }
+
+    /** v2 确认完整站点改址,订单和站点 ID 均通过 body 接收。 */
+    @PostMapping("/v2/orders/address")
+    @Auth
+    public AjaxResult updateAddressV2(@RequestHeader String token,
+                                      @RequestBody FlashDeliveryV2AddressConfirmRequest request) {
+        try {
+            return success(service.updateAddressV2(userId(token), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"), exception.getLatestQuote());
+        }
+    }
+
     /** 待接单时追加正整数小费,同步增加订单总金额。 */
     /** 待接单时追加正整数小费,同步增加订单总金额。 */
     @PostMapping("/orders/tip")
     @PostMapping("/orders/tip")
     @Auth
     @Auth

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

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

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

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

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

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

@@ -0,0 +1,14 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+
+/** 骑手端任务角标数量:一次返回三个页签的计数,App 无需逐页签切换请求。 */
+@Data
+public class FlashDeliveryRiderOrderCountView {
+    /** 新任务(可抢任务)数量;骑手未开通闪送时为 0。 */
+    private long newTask;
+    /** 待取件数量。 */
+    private long toPickup;
+    /** 配送中数量。 */
+    private long delivering;
+}

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

@@ -67,11 +67,16 @@ public class FlashDeliveryRiderOrderListView {
     private Integer paymentStatus;
     private Integer paymentStatus;
     /** 创建时间。 */
     /** 创建时间。 */
     private Date createTime;
     private Date createTime;
-    /** 取货站总数及已实际确认的取货站数。 */
+    /** 取货站总数及已取数;与送达进度同时展示,未全部取完仍在待取件 Tab。 */
     private Integer pickupStopCount;
     private Integer pickupStopCount;
     private Integer pickedUpStopCount;
     private Integer pickedUpStopCount;
+    /** 收货站总数与已送达数;交错取送时 ACCEPTED 也可能已送达,不能按主状态置零。 */
+    private Integer deliveryStopCount;
+    private Integer deliveredStopCount;
     /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
     /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
     private Boolean canUserCancel;
     private Boolean canUserCancel;
     /** 用户备注;仅骑手本人任务返回,待抢单卡片为空。 */
     /** 用户备注;仅骑手本人任务返回,待抢单卡片为空。 */
     private String userNote;
     private String userNote;
+    /** v2 多站点单的逐站列表(按用户顺序);卡片只含文字地址与状态,联系人、电话、坐标、凭证见详情。 */
+    private java.util.List<FlashDeliveryStopView> stops;
 }
 }

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopDeliverRequest.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** 新逐站送达请求;orderId 与继承的 stopId 均必填,业务参数统一从请求体读取。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryStopDeliverRequest extends FlashDeliveryDeliverRequest {
+    /** 闪送订单 ID,必填正整数;stopId 指定本单的收货站。 */
+    private Long orderId;
+}

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

@@ -15,19 +15,14 @@ public class FlashDeliveryStopView {
     private String phone;
     private String phone;
     private String address;
     private String address;
     private String addressDetail;
     private String addressDetail;
-    private String city;
-    private String area;
     private String handoffMethod;
     private String handoffMethod;
     private BigDecimal longitude;
     private BigDecimal longitude;
     private BigDecimal latitude;
     private BigDecimal latitude;
-    /** 本站独立申报的物品类别。 */
-    private String packageType;
-    /** 本站物品数量,必须为正整数。 */
-    private Integer quantity;
-    /** 本站物品重量范围,不代表整单重量。 */
-    private String weightRange;
-    /** 本站物品尺寸或规格,选填,最长255字符。 */
-    private String specification;
+    /** 选填备注,告知骑手该站取什么或送什么。 */
+    private String remark;
+    /** 按站收货码,仅发起人或本站绑定收件人可见,骑手不返回。 */
+    @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
+    private String deliveryPinCode;
     private String status;
     private String status;
     private Date pickedUpAt;
     private Date pickedUpAt;
     private Date deliveredAt;
     private Date deliveredAt;

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

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

@@ -1,67 +0,0 @@
-package com.ruoyi.app.flashdelivery.dto;
-
-import lombok.Data;
-
-import java.util.Date;
-
-/** 用户端闪送订单卡片,仅包含列表页面所需字段。 */
-@Data
-public class FlashDeliveryUserOrderListView {
-    /** 订单 ID。 */
-    private Long id;
-    /** 订单号。 */
-    private String orderNo;
-    /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
-    private String serviceType;
-    /** 配送等级:NORMAL=普通、URGENT=加急。 */
-    private String deliveryType;
-    /** 配送车型:1=机车、2=轿车。 */
-    private Integer vehicleType;
-    /** 订单状态,取值见 FlashDeliveryStatus。 */
-    private String status;
-    /** 用户编辑待接单订单使用的乐观锁版本。 */
-    private Integer orderVersion;
-    /** 物品类别。 */
-    private String packageType;
-    /** 物品数量。 */
-    private Integer quantity;
-    /** 重量范围。 */
-    private String weightRange;
-    /** 取件方式:NOW=立即、SCHEDULED=预约。 */
-    private String deliveryMode;
-    /** 预约取件时间窗开始时间,立即单为空。 */
-    private Date scheduledPickupStartAt;
-    /** 预约取件时间窗结束时间,立即单为空。 */
-    private Date scheduledPickupEndAt;
-    /** 取件主地址。 */
-    private String pickupAddress;
-    /** 取件详细地址。 */
-    private String pickupDetailAddress;
-    /** 收件主地址。 */
-    private String deliveryAddress;
-    /** 收件详细地址。 */
-    private String deliveryDetailAddress;
-    /** 预计配送时长(秒)。 */
-    private Integer estimatedDurationSeconds;
-    /** 基础配送费(TWD),仅表示起步价,不含距离附加费。 */
-    private Long baseDeliveryFee;
-    /** 加急费(TWD),普通配送为 0。 */
-    private Long urgentFee;
-    /** 小费(TWD)。 */
-    private Long tipAmount;
-    /** 订单总金额(TWD)。 */
-    private Long amount;
-    /** 币种。 */
-    private String currency;
-    /** 收款状态:0=未收款,1=骑手已确认收款。 */
-    private Integer paymentStatus;
-    /** 送达时间。 */
-    private Date deliveredAt;
-    /** 创建时间。 */
-    private Date createTime;
-    /** 取货站总数及已实际确认的取货站数。 */
-    private Integer pickupStopCount;
-    private Integer pickedUpStopCount;
-    /** 任一站已取货即禁止用户自助取消,不能仅按订单主状态判断。 */
-    private Boolean canUserCancel;
-}

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressChangeRequest.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import java.util.List;
+import lombok.Data;
+
+/** v2 待接单改址:只提交完整站点,订单级物品与运价沿用原订单。 */
+@Data
+public class FlashDeliveryV2AddressChangeRequest {
+    private Long orderId;
+    private Integer orderVersion;
+    private List<FlashDeliveryV2StopRequest> stops;
+}

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

@@ -0,0 +1,15 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** v2 确认改址时回传最新报价,订单/版本/完整站点均在 body。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryV2AddressConfirmRequest extends FlashDeliveryV2AddressChangeRequest {
+    private Integer quotedDistanceMeters;
+    private Long quotedBaseDeliveryFee;
+    private Long quotedDistanceFee;
+    private Long quotedUrgentFee;
+    private Long quotedAmount;
+}

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

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

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

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

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

@@ -0,0 +1,15 @@
+package com.ruoyi.app.flashdelivery.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/** v2 统一站点:可交错取送,数组顺序用于客户默认路线与报价,骑手实际目标单独记录。 */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FlashDeliveryV2StopRequest extends FlashDeliveryAddressRequest {
+    /** 站点类型,必填:PICKUP 取货、DELIVERY 收货;类型是取/送的唯一区别。 */
+    private String type;
+
+    /** 选填备注,去除首尾空格后最长 200 字,告知骑手该站取什么或送什么。 */
+    private String remark;
+}

+ 598 - 121
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -38,7 +38,6 @@ import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.*;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicLong;
-import java.util.function.Function;
 
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
 
 
@@ -129,9 +128,13 @@ public class FlashDeliveryApplicationService {
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
     /** 闪送首页:返回开放的业务场景、配送等级,以及当前时段各服务的公开运价摘要。 */
     public FlashDeliveryHomeView home() {
     public FlashDeliveryHomeView home() {
         FlashDeliveryHomeView view = new FlashDeliveryHomeView();
         FlashDeliveryHomeView view = new FlashDeliveryHomeView();
-        view.setPickupStopLimit(pickupStopLimit());
-        view.setShowMultiPickup(dictionaryEnabled("flash_show_multi_pickup"));
-        view.setShowUrgentOption(dictionaryEnabled("flash_show_urgent_option"));
+        int pickupLimit = pickupStopLimit();
+        int deliveryLimit = deliveryStopLimit();
+        view.setPickupStopLimit(pickupLimit);
+        view.setShowMultiPickup(pickupLimit > 1);
+        view.setDeliveryStopLimit(deliveryLimit);
+        view.setShowMultiDropoff(deliveryLimit > 1);
+        view.setShowUrgentOption(true);
         view.setServiceTypes(SERVICE_TYPES);
         view.setServiceTypes(SERVICE_TYPES);
         view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
         view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
         FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()), FlashDeliveryVehicleType.MOTORCYCLE);
         FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()), FlashDeliveryVehicleType.MOTORCYCLE);
@@ -295,22 +298,6 @@ public class FlashDeliveryApplicationService {
         return message != null && message.contains("uk_flash_order_no");
         return message != null && message.contains("uk_flash_order_no");
     }
     }
 
 
-    /** 分页查询当前用户参与的订单卡片;role=sender 按寄件人、receiver 按收件人过滤。 */
-    public IPage<FlashDeliveryUserOrderListView> userOrders(Long userId, int pageNum, int pageSize, String role) {
-        String selectedRole = hasText(role) ? role.trim().toLowerCase(Locale.ROOT) : "sender";
-        if (!"sender".equals(selectedRole) && !"receiver".equals(selectedRole)) {
-            throw fail("flash.delivery.tab.invalid");
-        }
-        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), order -> {
-            FlashDeliveryUserOrderListView view = userOrderListView(order);
-            view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && canUserCancel(order));
-            return view;
-        });
-    }
-
     /** 查询本人订单详情,仅寄件人和匹配到的收件人可见。 */
     /** 查询本人订单详情,仅寄件人和匹配到的收件人可见。 */
     public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
     public FlashDeliveryOrderDetailView userDetail(Long userId, Long orderId) {
         return userDetail(userId, requireOrder(orderId));
         return userDetail(userId, requireOrder(orderId));
@@ -323,7 +310,7 @@ public class FlashDeliveryApplicationService {
 
 
     private FlashDeliveryOrderDetailView userDetail(Long userId, FlashDeliveryOrder order) {
     private FlashDeliveryOrderDetailView userDetail(Long userId, FlashDeliveryOrder order) {
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
-        FlashDeliveryOrderDetailView view = participantDetail(order, true, false);
+        FlashDeliveryOrderDetailView view = participantDetail(order, true, false, userId);
         view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && Boolean.TRUE.equals(view.getCanUserCancel()));
         view.setCanUserCancel(Objects.equals(userId, order.getUserId()) && Boolean.TRUE.equals(view.getCanUserCancel()));
         return view;
         return view;
     }
     }
@@ -354,24 +341,7 @@ public class FlashDeliveryApplicationService {
         copyPickupSummary(order, pickups);
         copyPickupSummary(order, pickups);
         copyAddress(request.getDelivery(), order, false);
         copyAddress(request.getDelivery(), order, false);
         order.setReceiverUserId(resolveReceiverUserId(order.getDeliveryPhone()));
         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());
-        order.setPricingEndTime(quote.getEndTime());
-        order.setStartingDistance(quote.getStartingDistance());
-        order.setStartingFare(quote.getStartingFare());
-        order.setDistance(quote.getDistance());
-        order.setFreight(quote.getFreight());
-        order.setUrgentRate(quote.getUrgentRate());
-        order.setMinimumUrgentFee(quote.getMinimumUrgentFee());
-        order.setDistanceFee(quote.getDistanceFee());
-        order.setBaseDeliveryFee(quote.getBaseDeliveryFee());
-        order.setUrgentFee(quote.getUrgentFee());
-        order.setAmount(quote.getAmount());
+        applyAddressQuote(order, quote);
         Date now = new Date();
         Date now = new Date();
         order.setUpdateTime(now);
         order.setUpdateTime(now);
         // 即使上面的查询仍显示待接单,骑手也可能已接走,最终以条件 UPDATE 为准。
         // 即使上面的查询仍显示待接单,骑手也可能已接走,最终以条件 UPDATE 为准。
@@ -435,16 +405,12 @@ public class FlashDeliveryApplicationService {
     /** 地址修改报价:使用订单保存的运价快照重算,不采用当前运价,避免改地址顺带变价。 */
     /** 地址修改报价:使用订单保存的运价快照重算,不采用当前运价,避免改地址顺带变价。 */
     private FlashDeliveryQuoteView calculateAddressQuote(FlashDeliveryOrder order,
     private FlashDeliveryQuoteView calculateAddressQuote(FlashDeliveryOrder order,
                                                          FlashDeliveryAddressChangeRequest request) {
                                                          FlashDeliveryAddressChangeRequest request) {
+        if (order.getDeliveryStopCount() != null) throw fail("flash.delivery.edit.not.allowed");
         List<FlashDeliveryPickupRequest> pickups = editablePickups(order, request);
         List<FlashDeliveryPickupRequest> pickups = editablePickups(order, request);
         validatePickups(pickups, request.getDelivery());
         validatePickups(pickups, request.getDelivery());
         RouteDistance route = pickupRoute(pickups, request.getDelivery(), vehicleTypeOf(order));
         RouteDistance route = pickupRoute(pickups, request.getDelivery(), vehicleTypeOf(order));
         // FR-074/093:改址沿用下单快照,只重算整条路线,平台后续调价不影响此单。
         // 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());
+        FlashDeliveryPricing pricing = pricingSnapshot(order);
         FlashDeliveryPriceBreakdown breakdown;
         FlashDeliveryPriceBreakdown breakdown;
         try {
         try {
             breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
             breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
@@ -464,6 +430,111 @@ public class FlashDeliveryApplicationService {
         return view;
         return view;
     }
     }
 
 
+    /** v2 待接单改址报价;订单级物品、配送方式与原运价快照保持不变。 */
+    public FlashDeliveryQuoteView quoteAddressV2(Long userId, FlashDeliveryV2AddressChangeRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, request.getOrderId(), request.getOrderVersion());
+        return calculateAddressQuoteV2(order, request);
+    }
+
+    /** v2 改址必须提交全部站点,条件更新成功后再替换全部站点。 */
+    @Transactional
+    public FlashDeliveryOrderDetailView updateAddressV2(Long userId, FlashDeliveryV2AddressConfirmRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        FlashDeliveryOrder order = requireEditableOrder(userId, request.getOrderId(), request.getOrderVersion());
+        FlashDeliveryQuoteView quote = calculateAddressQuoteV2(order, request);
+        if (!Objects.equals(request.getQuotedDistanceMeters(), quote.getDistanceMeters())
+                || !Objects.equals(request.getQuotedBaseDeliveryFee(), quote.getBaseDeliveryFee())
+                || !Objects.equals(request.getQuotedDistanceFee(), quote.getDistanceFee())
+                || !Objects.equals(request.getQuotedUrgentFee(), quote.getUrgentFee())
+                || !Objects.equals(request.getQuotedAmount(), quote.getAmount())) {
+            throw new FlashDeliveryQuoteChangedException(quote);
+        }
+        List<FlashDeliveryV2StopRequest> stops = normalizeV2Stops(request.getStops());
+        FlashDeliveryV2QuoteRequest orderItems = v2OrderSnapshot(order, stops);
+        copyPickupSummary(order, pickupAdapters(stops.stream().filter(s -> "PICKUP".equals(s.getType())).toList(), orderItems));
+        FlashDeliveryV2StopRequest firstDelivery = stops.stream().filter(s -> "DELIVERY".equals(s.getType())).findFirst().orElseThrow();
+        copyAddress(firstDelivery, order, false);
+        order.setReceiverUserId(resolveReceiverUserId(firstDelivery.getPhone()));
+        order.setPickupStopCount((int) stops.stream().filter(s -> "PICKUP".equals(s.getType())).count());
+        order.setPickedUpStopCount(0);
+        order.setDeliveryStopCount((int) stops.stream().filter(s -> "DELIVERY".equals(s.getType())).count());
+        order.setDeliveredStopCount(0);
+        order.setCurrentTargetStopId(null);
+        order.setDeliveryPinCode(null);
+        applyAddressQuote(order, quote);
+        Date now = new Date();
+        order.setUpdateTime(now);
+        if (orderMapper.updateWaitingAddress(order, request.getOrderVersion()) != 1) {
+            throw fail("flash.delivery.state.changed");
+        }
+        stopMapper.delete(new QueryWrapper<FlashDeliveryOrderStop>().eq("order_id", order.getId()));
+        saveStopsV2(order, stops, Boolean.TRUE.equals(order.getPinRequired()), now);
+        order.setVersion(order.getVersion() + 1);
+        writeLog(order.getId(), WAITING_ACCEPTANCE, WAITING_ACCEPTANCE, "USER", userId, "ADDRESS_UPDATED", now);
+        return participantDetail(order, true, false);
+    }
+
+    private FlashDeliveryQuoteView calculateAddressQuoteV2(FlashDeliveryOrder order,
+                                                            FlashDeliveryV2AddressChangeRequest request) {
+        if (order.getDeliveryStopCount() == null) throw fail("flash.delivery.edit.not.allowed");
+        List<FlashDeliveryV2StopRequest> stops = normalizeV2Stops(request.getStops());
+        FlashDeliveryV2QuoteRequest orderItems = v2OrderSnapshot(order, stops);
+        validateOrderLevelItem(orderItems);
+        FlashDeliveryPricing pricing = pricingSnapshot(order);
+        RouteDistance route = v2Route(stops, vehicleTypeOf(order));
+        FlashDeliveryPriceBreakdown breakdown;
+        try {
+            breakdown = pricingCalculator.calculateBreakdown(pricing, route.distanceMeters(),
+                    order.getDeliveryType(), order.getTipAmount());
+        } catch (ArithmeticException exception) {
+            throw fail("flash.delivery.tip.invalid");
+        }
+        List<FlashDeliveryPickupRequest> pickups = pickupAdapters(stops.stream()
+                .filter(s -> "PICKUP".equals(s.getType())).toList(), orderItems);
+        FlashDeliveryQuoteView view = quoteView(quoteAdapter(orderItems),
+                new QuoteContext(pricing, route, breakdown, pickups));
+        view.setOrderVersion(order.getVersion());
+        return view;
+    }
+
+    private FlashDeliveryV2QuoteRequest v2OrderSnapshot(FlashDeliveryOrder order, List<FlashDeliveryV2StopRequest> stops) {
+        FlashDeliveryV2QuoteRequest snapshot = new FlashDeliveryV2QuoteRequest();
+        BeanUtils.copyProperties(order, snapshot);
+        snapshot.setStops(stops);
+        return snapshot;
+    }
+
+    private FlashDeliveryPricing pricingSnapshot(FlashDeliveryOrder order) {
+        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());
+        return pricing;
+    }
+
+    private void applyAddressQuote(FlashDeliveryOrder order, FlashDeliveryQuoteView quote) {
+        order.setDistanceMeters(quote.getDistanceMeters());
+        order.setDistanceSource(quote.getDistanceSource());
+        order.setEstimatedDurationSeconds(quote.getEstimatedDurationSeconds());
+        order.setPricingId(quote.getPricingId());
+        order.setPricingVersion(quote.getPricingVersion());
+        order.setPricingStartTime(quote.getStartTime());
+        order.setPricingEndTime(quote.getEndTime());
+        order.setStartingDistance(quote.getStartingDistance());
+        order.setStartingFare(quote.getStartingFare());
+        order.setDistance(quote.getDistance());
+        order.setFreight(quote.getFreight());
+        order.setUrgentRate(quote.getUrgentRate());
+        order.setMinimumUrgentFee(quote.getMinimumUrgentFee());
+        order.setDistanceFee(quote.getDistanceFee());
+        order.setBaseDeliveryFee(quote.getBaseDeliveryFee());
+        order.setUrgentFee(quote.getUrgentFee());
+        order.setAmount(quote.getAmount());
+    }
+
     /** 用户取消:仅待接单或已接单(实际取件前)允许。 */
     /** 用户取消:仅待接单或已接单(实际取件前)允许。 */
     @Transactional
     @Transactional
     public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
     public void userCancel(Long userId, Long orderId, FlashDeliveryReasonRequest request) {
@@ -487,7 +558,8 @@ public class FlashDeliveryApplicationService {
     public void confirmReceipt(Long userId, Long orderId) {
     public void confirmReceipt(Long userId, Long orderId) {
         FlashDeliveryOrder order = lockOrder(orderId);
         FlashDeliveryOrder order = lockOrder(orderId);
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
         if (!isParticipant(userId, order)) throw fail("flash.delivery.order.not.found");
-        if (!allPicked(requireStops(order))) throw fail("flash.delivery.complete.not.allowed");
+        List<FlashDeliveryOrderStop> rows = requireStops(order);
+        if (!allPicked(rows) || !allDelivered(rows)) throw fail("flash.delivery.complete.not.allowed");
         Date now = new Date();
         Date now = new Date();
         if (orderMapper.transitionByParticipant(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
         if (orderMapper.transitionByParticipant(orderId, userId, DELIVERED, COMPLETED, now) != 1) {
             throw fail("flash.delivery.complete.not.allowed");
             throw fail("flash.delivery.complete.not.allowed");
@@ -536,9 +608,11 @@ public class FlashDeliveryApplicationService {
         QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, riderVehicleType, selectedTab,
         QueryWrapper<FlashDeliveryOrder> query = riderOrdersQuery(riderId, riderVehicleType, selectedTab,
                 effectiveLongitude, effectiveLatitude, true, acceptedPayTypes);
                 effectiveLongitude, effectiveLatitude, true, acceptedPayTypes);
         IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
         IPage<FlashDeliveryOrder> orders = orderMapper.selectPage(page(pageNum, pageSize), query);
+        Map<Long, List<FlashDeliveryOrderStop>> stopRows = stopRowsFor(orders.getRecords());
         FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView();
         FlashDeliveryRiderOrderPageView result = new FlashDeliveryRiderOrderPageView();
         result.setRecords(orders.getRecords().stream()
         result.setRecords(orders.getRecords().stream()
-                .map(order -> riderOrderListView(riderId, order, effectiveLongitude, effectiveLatitude)).toList());
+                .map(order -> riderOrderListView(riderId, order, effectiveLongitude, effectiveLatitude,
+                        stopRows.get(order.getId()))).toList());
         result.setTotal(orders.getTotal());
         result.setTotal(orders.getTotal());
         result.setCurrent(orders.getCurrent());
         result.setCurrent(orders.getCurrent());
         result.setSize(orders.getSize());
         result.setSize(orders.getSize());
@@ -552,6 +626,30 @@ public class FlashDeliveryApplicationService {
         return result;
         return result;
     }
     }
 
 
+    /**
+     * 骑手端任务角标数量:一次返回闪送订单 newTask/toPickup/delivering 三个页签的计数,
+     * App 无需逐页签切换请求。newTask 与列表同口径(车型、支付方式、可抢时间窗与可选距离上限);
+     * 骑手未开通闪送时 newTask 计 0(列表接口保持原有报错行为)。
+     */
+    public FlashDeliveryRiderOrderCountView riderOrderCounts(Long riderId, BigDecimal longitude, BigDecimal latitude) {
+        InfoUser rider = requireRider(riderId);
+        Set<String> acceptedPayTypes = acceptedRiderPayTypes(rider);
+        Integer riderVehicleType = riderVehicleType(rider);
+        FlashDeliveryRiderOrderCountView view = new FlashDeliveryRiderOrderCountView();
+        view.setToPickup(orderMapper.selectCount(
+                riderOrdersQuery(riderId, riderVehicleType, "toPickup", null, null, false, acceptedPayTypes)));
+        view.setDelivering(orderMapper.selectCount(
+                riderOrdersQuery(riderId, riderVehicleType, "delivering", null, null, false, acceptedPayTypes)));
+        if (rider.supportsDeliveryType("FLASH")) {
+            validateRiderCoordinates(longitude, latitude);
+            view.setNewTask(orderMapper.selectCount(
+                    riderOrdersQuery(riderId, riderVehicleType, "newTask", longitude, latitude, false, acceptedPayTypes)));
+        } else {
+            view.setNewTask(0L);
+        }
+        return view;
+    }
+
     /** 骑手任务详情:抢单前返回脱敏视图(隐藏联系人、电话、坐标和 PIN);接单后仅中单骑手可见完整信息。 */
     /** 骑手任务详情:抢单前返回脱敏视图(隐藏联系人、电话、坐标和 PIN);接单后仅中单骑手可见完整信息。 */
     public FlashDeliveryOrderDetailView riderDetail(Long riderId, Long orderId) {
     public FlashDeliveryOrderDetailView riderDetail(Long riderId, Long orderId) {
         return riderDetail(riderId, requireOrder(orderId));
         return riderDetail(riderId, requireOrder(orderId));
@@ -616,6 +714,7 @@ public class FlashDeliveryApplicationService {
     public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) {
     public void deliver(Long riderId, Long orderId, FlashDeliveryDeliverRequest request) {
         requireRider(riderId);
         requireRider(riderId);
         FlashDeliveryOrder order = lockOrder(orderId);
         FlashDeliveryOrder order = lockOrder(orderId);
+        if (order.getDeliveryStopCount() != null) throw fail("flash.delivery.transition.not.allowed");
         if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) {
         if (!Objects.equals(riderId, order.getRiderId()) || !PICKED_UP.equals(order.getStatus())) {
             throw fail("flash.delivery.transition.not.allowed");
             throw fail("flash.delivery.transition.not.allowed");
         }
         }
@@ -640,6 +739,312 @@ public class FlashDeliveryApplicationService {
         notificationService.notifyDelivered(requireOrder(orderId));
         notificationService.notifyDelivered(requireOrder(orderId));
     }
     }
 
 
+    /** 逐站送达:v2 允许交错取送;校验本站收货码和凭证,全部取送完成才推进整单已送达。 */
+    @Transactional
+    public void deliverAtStop(Long riderId, Long orderId, Long stopId, FlashDeliveryDeliverRequest request) {
+        requireRider(riderId);
+        FlashDeliveryOrder order = lockOrder(orderId);
+        if (!Objects.equals(riderId, order.getRiderId())) throw fail("flash.delivery.order.not.found");
+        List<FlashDeliveryOrderStop> rows = requireStops(order);
+        FlashDeliveryOrderStop stop = requireStop(rows, stopId);
+        if (!"DELIVERY".equals(stop.getStopType())) throw fail("flash.delivery.stop.invalid");
+        // 已确认站点的重试直接成功,不重复写凭证或推进计数。
+        if ("DELIVERED".equals(stop.getStatus())) return;
+        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");
+        }
+        // 逐站确认以本站 stopId 为准;当前目标只用于导航提示,不限制实际送达顺序。
+        // 不建立取送物品关联,骑手按备注核对;但不能漏取站点就确认最后一站并结束整单。
+        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())
+                && (!hasText(expectedPin) || request == null
+                || !Objects.equals(expectedPin, trimToNull(request.getPinCode())))) {
+            throw fail("flash.delivery.pin.invalid");
+        }
+        List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
+        Date now = new Date();
+        stop.setStatus("DELIVERED"); stop.setDeliveredAt(now);
+        if (stopMapper.updateById(stop) != 1) throw fail("flash.delivery.state.changed");
+        saveStationImages(orderId, stopId, "DELIVERY", "RIDER", riderId, urls, now);
+        order.setDeliveredStopCount((int) rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())
+                && "DELIVERED".equals(s.getStatus())).count());
+        order.setCurrentTargetStopId(null); order.setUpdateTime(now);
+        if (allPicked(rows) && allDelivered(rows)) {
+            // 末站完成才整单推进,与取件侧同一行锁与条件更新路径。
+            order.setStatus(DELIVERED); order.setDeliveredAt(now);
+        }
+        if (orderMapper.updateDeliveryFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
+        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())) {
+            notificationService.notifyDelivered(requireOrder(orderId));
+        }
+    }
+
+    // ==== v2 多点取送统一契约(2026-10-14):统一 stops 数组、订单级物品、独立计价 ====
+
+    /** v2 实时报价:统一站点数组顺序即路线顺序,物品主体在订单级,独立计价方法。 */
+    public FlashDeliveryQuoteView quoteV2(FlashDeliveryV2QuoteRequest request) {
+        QuoteContext context = calculateQuoteV2(request);
+        return quoteView(quoteAdapter(request), context);
+    }
+
+    /** v2 幂等创建:结构与旧创建互斥;按站固化收件人与收货码,回传报价不一致时拒绝。 */
+    @Transactional
+    public FlashDeliveryOrderDetailView createV2(Long userId, FlashDeliveryV2CreateRequest request) {
+        requireUserId(userId);
+        if (request == null || !hasText(request.getClientRequestId())
+                || request.getClientRequestId().trim().length() > 64) {
+            throw fail("flash.delivery.client.request.id.invalid");
+        }
+        String requestId = request.getClientRequestId().trim();
+        FlashDeliveryOrder existing = orderMapper.selectByUserRequestId(userId, requestId);
+        if (existing != null) return participantDetail(lockOrder(existing.getId()), true, false);
+        if (request.getUserNote() != null && request.getUserNote().length() > 500) {
+            throw fail("flash.delivery.note.too.long");
+        }
+        List<FlashDeliveryV2StopRequest> stops = normalizeV2Stops(request.getStops());
+        QuoteContext quote = calculateQuoteV2(request);
+        FlashDeliveryQuoteView latestQuote = quoteView(quoteAdapter(request), quote);
+        if (!matchesQuotedPriceV2(request, latestQuote)) {
+            throw new FlashDeliveryQuoteChangedException(latestQuote);
+        }
+        Date now = new Date();
+        String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
+        validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
+        boolean pinRequired = request.getPinRequired() == null || request.getPinRequired();
+        String payType = normalizePayType(request.getPayType());
+        List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
+        List<FlashDeliveryV2StopRequest> deliveries = stops.stream()
+                .filter(s -> "DELIVERY".equals(s.getType())).toList();
+        FlashDeliveryOrder order = new FlashDeliveryOrder();
+        order.setOrderNo(generateOrderNo());
+        order.setClientRequestId(requestId);
+        order.setUserId(userId);
+        order.setServiceType(request.getServiceType());
+        order.setDeliveryType(request.getDeliveryType());
+        order.setVehicleType(request.getVehicleType());
+        order.setStatus(WAITING_ACCEPTANCE);
+        copyPickupSummary(order, quote.pickups());
+        order.setPickedUpStopCount(0);
+        order.setDeliveryStopCount(deliveries.size());
+        order.setDeliveredStopCount(0);
+        order.setIsDisplay("NOW".equals(deliveryMode));
+        order.setDeliveryMode(deliveryMode);
+        order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
+        order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
+        order.setPinRequired(pinRequired);
+        // v2 只使用各收货站独立的收货码,订单级字段不生成通用码。
+        order.setDeliveryPinCode(null);
+        FlashDeliveryV2StopRequest firstDelivery = deliveries.get(0);
+        copyAddress(firstDelivery, order, false);
+        // 订单级收件人沿用首收货站匹配,逐站收件人保存在站点表。
+        order.setReceiverUserId(resolveReceiverUserId(firstDelivery.getPhone()));
+        order.setDistanceMeters(quote.route().distanceMeters());
+        order.setDistanceSource(quote.route().source());
+        order.setEstimatedDurationSeconds(quote.route().durationSeconds());
+        order.setAmount(quote.breakdown().getAmount());
+        order.setCurrency("TWD");
+        order.setPayType(payType);
+        order.setPricingId(quote.pricing().getId());
+        order.setPricingVersion(quote.pricing().getConfigVersion());
+        order.setPricingStartTime(quote.pricing().getStartTime());
+        order.setPricingEndTime(quote.pricing().getEndTime());
+        order.setStartingDistance(quote.pricing().getStartingDistance());
+        order.setStartingFare(quote.pricing().getStartingFare());
+        order.setDistance(quote.pricing().getDistance());
+        order.setFreight(quote.pricing().getFreight());
+        order.setDistanceFee(quote.breakdown().getDistanceFee());
+        order.setBaseDeliveryFee(quote.breakdown().getBaseDeliveryFee());
+        order.setUrgentRate(quote.pricing().getUrgentRate());
+        order.setMinimumUrgentFee(quote.pricing().getMinimumUrgentFee());
+        order.setUrgentFee(quote.breakdown().getUrgentFee());
+        order.setTipAmount(quote.breakdown().getTipAmount());
+        order.setUserNote(trimToNull(request.getUserNote()));
+        order.setVersion(0);
+        order.setCreateTime(now);
+        order.setUpdateTime(now);
+        try {
+            orderMapper.insert(order);
+        } catch (DataIntegrityViolationException duplicate) {
+            if (isOrderNoConflict(duplicate)) {
+                order.setId(null);
+                order.setOrderNo(generateOrderNo());
+                orderMapper.insert(order);
+            } else {
+                FlashDeliveryOrder concurrent = orderMapper.selectOne(new QueryWrapper<FlashDeliveryOrder>()
+                        .eq("user_id", userId).eq("client_request_id", requestId).last("FOR UPDATE"));
+                if (concurrent != null) return participantDetail(lockOrder(concurrent.getId()), true, false);
+                throw duplicate;
+            }
+        }
+        saveStopsV2(order, stops, pinRequired, now);
+        saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
+        writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
+        if (Boolean.TRUE.equals(order.getIsDisplay())) {
+            notificationService.notifyAvailable(order);
+        }
+        return participantDetail(order, true, false);
+    }
+
+    /** v2 物品主体在订单级一次设置;校验规则与旧逐站物品一致。 */
+    private void validateOrderLevelItem(FlashDeliveryV2QuoteRequest request) {
+        if (!PACKAGE_TYPES.contains(Objects.toString(request.getPackageType(), ""))) {
+            throw fail("flash.delivery.package.type.invalid");
+        }
+        if (request.getQuantity() == null || request.getQuantity() <= 0
+                || hasText(request.getWeightRange()) && !WEIGHT_RANGES.contains(request.getWeightRange())
+                || tooLong(request.getSpecification(), 255)) throw fail("flash.delivery.item.invalid");
+    }
+
+    /** v2 报价核心:站点结构校验后命中运价,路线经点为全部取货站与收货站。 */
+    private QuoteContext calculateQuoteV2(FlashDeliveryV2QuoteRequest request) {
+        if (request == null) throw fail("flash.delivery.request.required");
+        validateServiceType(request.getServiceType());
+        validateDeliveryType(request.getDeliveryType());
+        List<FlashDeliveryV2StopRequest> stops = normalizeV2Stops(request.getStops());
+        if (request.getTipAmount() == null || request.getTipAmount() < 0) throw fail("flash.delivery.tip.invalid");
+        validateOrderLevelItem(request);
+        Date now = new Date();
+        String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
+        validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
+        Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
+        Integer vehicle = requireVehicleType(request.getVehicleType());
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate), vehicle);
+        RouteDistance route = v2Route(stops, vehicle);
+        FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
+                route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
+        List<FlashDeliveryV2StopRequest> pickups = stops.stream()
+                .filter(s -> "PICKUP".equals(s.getType())).toList();
+        return new QuoteContext(pricing, route, breakdown, pickupAdapters(pickups, request));
+    }
+
+    /** v2 物品主体在订单级;内部取货站适配器统一携带订单级物品供既有校验与摘要复用。 */
+    private List<FlashDeliveryPickupRequest> pickupAdapters(List<FlashDeliveryV2StopRequest> pickups,
+                                                            FlashDeliveryV2QuoteRequest request) {
+        return pickups.stream().map(stop -> {
+            FlashDeliveryPickupRequest adapter = new FlashDeliveryPickupRequest();
+            BeanUtils.copyProperties(stop, adapter);
+            adapter.setPackageType(request.getPackageType());
+            adapter.setQuantity(request.getQuantity());
+            adapter.setWeightRange(request.getWeightRange());
+            adapter.setSpecification(request.getSpecification());
+            return adapter;
+        }).toList();
+    }
+
+    /** v2 站点校验:客户原序可交错取送,首站取货、末站收货,保留数量和地址去重校验。 */
+    private List<FlashDeliveryV2StopRequest> normalizeV2Stops(List<FlashDeliveryV2StopRequest> stops) {
+        if (stops == null || stops.isEmpty()) throw fail("flash.delivery.stops.required");
+        for (FlashDeliveryV2StopRequest stop : stops) {
+            if (!"PICKUP".equals(stop.getType()) && !"DELIVERY".equals(stop.getType())) {
+                throw fail("flash.delivery.stop.type.invalid");
+            }
+            validateAddress(stop);
+            if (tooLong(stop.getRemark(), 200)) throw fail("flash.delivery.stop.remark.too.long");
+        }
+        List<FlashDeliveryV2StopRequest> pickups = stops.stream()
+                .filter(s -> "PICKUP".equals(s.getType())).toList();
+        List<FlashDeliveryV2StopRequest> deliveries = stops.stream()
+                .filter(s -> "DELIVERY".equals(s.getType())).toList();
+        if (pickups.isEmpty() || deliveries.isEmpty()
+                || pickups.size() > pickupStopLimit() || deliveries.size() > deliveryStopLimit()) {
+            throw fail("flash.delivery.stops.limit.exceeded");
+        }
+        // 仅约束路线起终点,不按类型重排;报价、创建和完整改址都保留客户填写的顺序。
+        if (!"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++) {
+                FlashDeliveryV2StopRequest a = stops.get(i);
+                FlashDeliveryV2StopRequest b = stops.get(j);
+                if (sameAddressText(a, b)
+                        || a.getLatitude().compareTo(b.getLatitude()) == 0
+                        && a.getLongitude().compareTo(b.getLongitude()) == 0) {
+                    throw fail("flash.delivery.address.same");
+                }
+            }
+        }
+        return stops;
+    }
+
+    /** v2 路线:经点为按用户顺序排列的全部站点;两点退化为单段计算。 */
+    private RouteDistance v2Route(List<FlashDeliveryV2StopRequest> stops, Integer vehicleType) {
+        List<GeoPoint> points = stops.stream()
+                .map(s -> new GeoPoint(s.getLatitude(), s.getLongitude())).toList();
+        RouteDistance route = points.size() == 2
+                ? routeService.calculate(points.get(0), points.get(1), vehicleType)
+                : routeService.calculate(points, vehicleType);
+        if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
+        return route;
+    }
+
+    /** 复用旧报价视图组装:仅读取订单级字段,站点信息不进入报价视图。 */
+    private FlashDeliveryQuoteRequest quoteAdapter(FlashDeliveryV2QuoteRequest request) {
+        FlashDeliveryQuoteRequest adapter = new FlashDeliveryQuoteRequest();
+        adapter.setServiceType(request.getServiceType());
+        adapter.setDeliveryType(request.getDeliveryType());
+        adapter.setVehicleType(request.getVehicleType());
+        adapter.setPackageType(request.getPackageType());
+        adapter.setQuantity(request.getQuantity());
+        adapter.setWeightRange(request.getWeightRange());
+        adapter.setSpecification(request.getSpecification());
+        adapter.setTipAmount(request.getTipAmount());
+        adapter.setDeliveryMode(request.getDeliveryMode());
+        adapter.setScheduledPickupStartAt(request.getScheduledPickupStartAt());
+        adapter.setScheduledPickupEndAt(request.getScheduledPickupEndAt());
+        adapter.setPayType(request.getPayType());
+        return adapter;
+    }
+
+    private boolean matchesQuotedPriceV2(FlashDeliveryV2CreateRequest request, FlashDeliveryQuoteView latest) {
+        return Objects.equals(request.getPricingId(), latest.getPricingId())
+                && Objects.equals(request.getPricingVersion(), latest.getPricingVersion())
+                && Objects.equals(request.getQuotedBaseDeliveryFee(), latest.getBaseDeliveryFee())
+                && Objects.equals(request.getQuotedDistanceFee(), latest.getDistanceFee())
+                && Objects.equals(request.getQuotedUrgentFee(), latest.getUrgentFee())
+                && Objects.equals(request.getQuotedAmount(), latest.getAmount());
+    }
+
+    /** 保存 v2 全部站点:按用户顺序落 stop_order,逐站写入备注、收件人与收货码。 */
+    private void saveStopsV2(FlashDeliveryOrder order, List<FlashDeliveryV2StopRequest> stops,
+                             boolean pinRequired, Date now) {
+        Set<String> deliveryPins = new HashSet<>();
+        for (int i = 0; i < stops.size(); i++) {
+            FlashDeliveryV2StopRequest stop = stops.get(i);
+            FlashDeliveryOrderStop row = new FlashDeliveryOrderStop();
+            row.setName(stop.getName().trim()); row.setPhone(stop.getPhone().trim());
+            row.setAddress(stop.getAddress().trim()); row.setAddressDetail(trimToNull(stop.getAddressDetail()));
+            row.setHandoffMethod(trimToNull(stop.getHandoffMethod()));
+            row.setLongitude(stop.getLongitude()); row.setLatitude(stop.getLatitude());
+            row.setRemark(trimToNull(stop.getRemark()));
+            row.setOrderId(order.getId()); row.setStopType(stop.getType()); row.setStopOrder(i);
+            row.setStatus("PENDING"); row.setCreateTime(now);
+            if ("DELIVERY".equals(stop.getType())) {
+                row.setReceiverUserId(resolveReceiverUserId(stop.getPhone()));
+                if (pinRequired) {
+                    String pin;
+                    do {
+                        pin = String.format(Locale.ROOT, "%04d", PIN_RANDOM.nextInt(10_000));
+                    } while (!deliveryPins.add(pin));
+                    row.setDeliveryPinCode(pin);
+                }
+            }
+            if (stopMapper.insert(row) != 1) throw fail("flash.delivery.state.changed");
+        }
+    }
+
     /** 平台运价配置列表,按时段开始时间排序。 */
     /** 平台运价配置列表,按时段开始时间排序。 */
     public List<FlashDeliveryPricing> adminPricing() {
     public List<FlashDeliveryPricing> adminPricing() {
         return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
         return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
@@ -788,7 +1193,9 @@ public class FlashDeliveryApplicationService {
     @Transactional
     @Transactional
     public boolean complete(Long operatorId, String operatorType, Long orderId) {
     public boolean complete(Long operatorId, String operatorType, Long orderId) {
         FlashDeliveryOrder order = lockOrder(orderId);
         FlashDeliveryOrder order = lockOrder(orderId);
-        if (!DELIVERED.equals(order.getStatus()) || !allPicked(requireStops(order))) return false;
+        if (!DELIVERED.equals(order.getStatus())) return false;
+        List<FlashDeliveryOrderStop> rows = requireStops(order);
+        if (!allPicked(rows) || !allDelivered(rows)) return false;
         Date now = new Date();
         Date now = new Date();
         if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false;
         if (orderMapper.transitionByStatus(orderId, DELIVERED, COMPLETED, "completed_at", now) != 1) return false;
         writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now);
         writeLog(orderId, DELIVERED, COMPLETED, operatorType, operatorId, null, now);
@@ -1043,20 +1450,62 @@ public class FlashDeliveryApplicationService {
     private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
     private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
                                                            boolean userView,
                                                            boolean userView,
                                                            boolean hideSensitiveFields) {
                                                            boolean hideSensitiveFields) {
+        return participantDetail(order, userView, hideSensitiveFields, userView ? order.getUserId() : null);
+    }
+
+    private FlashDeliveryOrderDetailView participantDetail(FlashDeliveryOrder order,
+                                                            boolean userView,
+                                                            boolean hideSensitiveFields, Long viewerId) {
         FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields);
         FlashDeliveryOrderDetailView view = detailOrderView(order, !hideSensitiveFields);
         if (!hideSensitiveFields) view.setOrderVersion(order.getVersion());
         if (!hideSensitiveFields) view.setOrderVersion(order.getVersion());
         List<FlashDeliveryOrderStop> stationRows = stops(order.getId());
         List<FlashDeliveryOrderStop> stationRows = stops(order.getId());
         view.setCanUserCancel(canCancelBeforePickup(order, stationRows));
         view.setCanUserCancel(canCancelBeforePickup(order, stationRows));
         List<FlashDeliveryOrderImage> stationImages = hideSensitiveFields ? List.of() : imageMapper.selectList(currentRead(
         List<FlashDeliveryOrderImage> stationImages = hideSensitiveFields ? List.of() : imageMapper.selectList(currentRead(
                 new QueryWrapper<FlashDeliveryOrderImage>().eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
                 new QueryWrapper<FlashDeliveryOrderImage>().eq("order_id", order.getId()).orderByAsc("proof_type", "sort_order")));
-        view.setStops(stopViews(stationRows, !hideSensitiveFields, stationImages));
+        boolean sender = userView && Objects.equals(viewerId, order.getUserId());
+        boolean v2Receiver = userView && order.getDeliveryStopCount() != null && !sender;
+        if (v2Receiver) {
+            Set<Long> ownStopIds = stationRows.stream().filter(s -> "DELIVERY".equals(s.getStopType())
+                    && Objects.equals(viewerId, s.getReceiverUserId())).map(FlashDeliveryOrderStop::getId)
+                    .collect(java.util.stream.Collectors.toSet());
+            stationImages = stationImages.stream().filter(image -> !"DELIVERY".equals(image.getProofType())
+                    || ownStopIds.contains(image.getStopId())).toList();
+        }
+        List<FlashDeliveryStopView> stationViews = stopViews(stationRows, !hideSensitiveFields, stationImages);
+        if (userView && order.getDeliveryStopCount() != null) {
+            for (int index = 0; index < stationRows.size(); index++) {
+                FlashDeliveryOrderStop stop = stationRows.get(index);
+                FlashDeliveryStopView stopView = stationViews.get(index);
+                if (!"DELIVERY".equals(stop.getStopType())) continue;
+                if (sender || Objects.equals(viewerId, stop.getReceiverUserId())) {
+                    stopView.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? stop.getDeliveryPinCode() : null);
+                } else if (v2Receiver) {
+                    // 他人收货站开放文字地址、备注与交接方式(与列表口径一致),联系人、电话、坐标仍不返回。
+                    stopView.setName(null); stopView.setPhone(null);
+                    stopView.setLongitude(null); stopView.setLatitude(null);
+                }
+            }
+            if (v2Receiver) {
+                stationRows.stream().filter(s -> "DELIVERY".equals(s.getStopType())
+                        && Objects.equals(viewerId, s.getReceiverUserId())).findFirst().ifPresent(own -> {
+                    FlashDeliveryAddressView delivery = new FlashDeliveryAddressView();
+                    delivery.setName(own.getName()); delivery.setPhone(own.getPhone());
+                    delivery.setAddress(own.getAddress()); delivery.setAddressDetail(own.getAddressDetail());
+                    delivery.setHandoffMethod(own.getHandoffMethod());
+                    delivery.setLongitude(own.getLongitude()); delivery.setLatitude(own.getLatitude());
+                    view.setDelivery(delivery);
+                });
+            }
+        }
+        view.setStops(stationViews);
         view.setCurrentTargetStopId(targetStopId(order, stationRows));
         view.setCurrentTargetStopId(targetStopId(order, stationRows));
         if (!hideSensitiveFields) {
         if (!hideSensitiveFields) {
             view.setTargetAdjustments(adjustmentMapper.selectList(currentRead(new QueryWrapper<FlashDeliveryTargetAdjustment>()
             view.setTargetAdjustments(adjustmentMapper.selectList(currentRead(new QueryWrapper<FlashDeliveryTargetAdjustment>()
                     .eq("order_id", order.getId()).orderByAsc("create_time", "id"))));
                     .eq("order_id", order.getId()).orderByAsc("create_time", "id"))));
         }
         }
         if (userView) {
         if (userView) {
-            view.setDeliveryPinCode(Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
+            view.setDeliveryPinCode(sender && order.getDeliveryStopCount() == null
+                    && Boolean.TRUE.equals(order.getPinRequired()) ? order.getDeliveryPinCode() : null);
             // 接单后直接返回骑手完整用户资料;star 填充该骑手的闪送平均分供前端展示。
             // 接单后直接返回骑手完整用户资料;star 填充该骑手的闪送平均分供前端展示。
             if (order.getRiderId() != null) {
             if (order.getRiderId() != null) {
                 InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
                 InfoUser rider = userMapper.selectInfoUserByUserId(order.getRiderId());
@@ -1066,7 +1515,7 @@ public class FlashDeliveryApplicationService {
                 view.setRider(rider);
                 view.setRider(rider);
             }
             }
             // 线下转账单送达/完成后返回骑手启用卡,供用户线下转账结清(仅寄件人视角)
             // 线下转账单送达/完成后返回骑手启用卡,供用户线下转账结清(仅寄件人视角)
-            if (PAY_TYPE_OFFLINE_TRANSFER.equals(order.getPayType())
+            if (sender && PAY_TYPE_OFFLINE_TRANSFER.equals(order.getPayType())
                     && (DELIVERED.equals(order.getStatus()) || COMPLETED.equals(order.getStatus()))) {
                     && (DELIVERED.equals(order.getStatus()) || COMPLETED.equals(order.getStatus()))) {
                 view.setRiderBankCard(riderActiveCardView(order.getRiderId()));
                 view.setRiderBankCard(riderActiveCardView(order.getRiderId()));
             }
             }
@@ -1082,44 +1531,21 @@ public class FlashDeliveryApplicationService {
         return view;
         return view;
     }
     }
 
 
-    /** 订单实体转用户端订单卡片。 */
-    private FlashDeliveryUserOrderListView userOrderListView(FlashDeliveryOrder order) {
-        FlashDeliveryUserOrderListView view = new FlashDeliveryUserOrderListView();
-        view.setId(order.getId());
-        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());
-        view.setWeightRange(order.getWeightRange());
-        view.setDeliveryMode(order.getDeliveryMode());
-        view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
-        view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
-        view.setPickupAddress(order.getPickupAddress());
-        view.setPickupDetailAddress(order.getPickupAddressDetail());
-        view.setDeliveryAddress(order.getDeliveryAddress());
-        view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
-        view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
-        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
-        view.setUrgentFee(order.getUrgentFee());
-        view.setTipAmount(order.getTipAmount());
-        view.setAmount(order.getAmount());
-        view.setCurrency(order.getCurrency());
-        view.setPaymentStatus(order.getPaymentStatus());
-        view.setDeliveredAt(order.getDeliveredAt());
-        view.setCreateTime(order.getCreateTime());
-        return view;
+    /** 订单实体转骑手端任务卡片,可附带骑手到取件点的直线距离。 */
+    /** 列表页一次 IN 批量取回 v2 多站点单的站点,避免逐单 N+1;单点单卡片不需要逐站展示。 */
+    private Map<Long, List<FlashDeliveryOrderStop>> stopRowsFor(List<FlashDeliveryOrder> records) {
+        List<Long> orderIds = records.stream()
+                .filter(order -> order.getDeliveryStopCount() != null)
+                .map(FlashDeliveryOrder::getId).toList();
+        if (orderIds.isEmpty()) return Map.of();
+        return stopMapper.selectList(new QueryWrapper<FlashDeliveryOrderStop>()
+                        .in("order_id", orderIds).orderByAsc("stop_order", "id")).stream()
+                .collect(java.util.stream.Collectors.groupingBy(FlashDeliveryOrderStop::getOrderId));
     }
     }
 
 
-    /** 订单实体转骑手端任务卡片,可附带骑手到取件点的直线距离。 */
     private FlashDeliveryRiderOrderListView riderOrderListView(Long riderId, FlashDeliveryOrder order,
     private FlashDeliveryRiderOrderListView riderOrderListView(Long riderId, FlashDeliveryOrder order,
-                                                                 BigDecimal longitude, BigDecimal latitude) {
+                                                                 BigDecimal longitude, BigDecimal latitude,
+                                                                 List<FlashDeliveryOrderStop> stopRows) {
         FlashDeliveryRiderOrderListView view = new FlashDeliveryRiderOrderListView();
         FlashDeliveryRiderOrderListView view = new FlashDeliveryRiderOrderListView();
         view.setId(order.getId());
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setOrderNo(order.getOrderNo());
@@ -1129,6 +1555,8 @@ public class FlashDeliveryApplicationService {
         view.setStatus(order.getStatus());
         view.setStatus(order.getStatus());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setDeliveryStopCount(expectedDeliveryStopCount(order));
+        view.setDeliveredStopCount(deliveredStopCount(order));
         view.setCanUserCancel(canUserCancel(order));
         view.setCanUserCancel(canUserCancel(order));
         view.setPackageType(order.getPackageType());
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
         view.setQuantity(order.getQuantity());
@@ -1157,6 +1585,10 @@ public class FlashDeliveryApplicationService {
         view.setCreateTime(order.getCreateTime());
         view.setCreateTime(order.getCreateTime());
         // 备注仅对中单骑手本人可见;newTask 待抢卡片保持脱敏为空。
         // 备注仅对中单骑手本人可见;newTask 待抢卡片保持脱敏为空。
         view.setUserNote(Objects.equals(riderId, order.getRiderId()) ? order.getUserNote() : null);
         view.setUserNote(Objects.equals(riderId, order.getRiderId()) ? order.getUserNote() : null);
+        // v2 多站点单卡片逐站展示取送地址与状态;与详情同口径脱敏联系人、电话、坐标和凭证。
+        if (order.getDeliveryStopCount() != null && stopRows != null && !stopRows.isEmpty()) {
+            view.setStops(stopViews(stopRows, false, List.of()));
+        }
         return view;
         return view;
     }
     }
 
 
@@ -1212,6 +1644,8 @@ public class FlashDeliveryApplicationService {
         view.setStatus(order.getStatus());
         view.setStatus(order.getStatus());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickupStopCount(order.getPickupStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
         view.setPickedUpStopCount(order.getPickedUpStopCount());
+        view.setDeliveryStopCount(expectedDeliveryStopCount(order));
+        view.setDeliveredStopCount(deliveredStopCount(order));
         view.setCanUserCancel(canUserCancel(order));
         view.setCanUserCancel(canUserCancel(order));
         view.setPackageType(order.getPackageType());
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
         view.setQuantity(order.getQuantity());
@@ -1333,8 +1767,10 @@ public class FlashDeliveryApplicationService {
 
 
     /** 判断用户是否为订单寄件人或匹配收件人。 */
     /** 判断用户是否为订单寄件人或匹配收件人。 */
     private boolean isParticipant(Long userId, FlashDeliveryOrder order) {
     private boolean isParticipant(Long userId, FlashDeliveryOrder order) {
-        return Objects.equals(userId, order.getUserId())
-                || Objects.equals(userId, order.getReceiverUserId());
+        if (userId == null) return false;
+        if (Objects.equals(userId, order.getUserId()) || Objects.equals(userId, order.getReceiverUserId())) return true;
+        return order.getDeliveryStopCount() != null && stops(order.getId()).stream().anyMatch(s ->
+                "DELIVERY".equals(s.getStopType()) && Objects.equals(userId, s.getReceiverUserId()));
     }
     }
 
 
     /** 校验 token 用户为骑手(userType=2)并返回其资料。 */
     /** 校验 token 用户为骑手(userType=2)并返回其资料。 */
@@ -1424,14 +1860,6 @@ public class FlashDeliveryApplicationService {
         return new Page<>(Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100));
         return new Page<>(Math.max(1, pageNum), Math.min(Math.max(1, pageSize), 100));
     }
     }
 
 
-    /** 分页结果实体转换,保留分页元数据。 */
-    private <T> IPage<T> mapPage(IPage<FlashDeliveryOrder> source,
-                                 Function<FlashDeliveryOrder, T> mapper) {
-        Page<T> result = new Page<>(source.getCurrent(), source.getSize(), source.getTotal());
-        result.setRecords(source.getRecords().stream().map(mapper).toList());
-        return result;
-    }
-
     /** 按页签构建骑手任务查询:newTask 限定可抢状态与时间窗,可选距离过滤和就近排序。 */
     /** 按页签构建骑手任务查询:newTask 限定可抢状态与时间窗,可选距离过滤和就近排序。 */
     private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, Integer riderVehicleType, String tab,
     private QueryWrapper<FlashDeliveryOrder> riderOrdersQuery(Long riderId, Integer riderVehicleType, String tab,
                                                                 BigDecimal longitude, BigDecimal latitude,
                                                                 BigDecimal longitude, BigDecimal latitude,
@@ -1618,6 +2046,9 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
         FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
         BeanUtils.copyProperties(pickups.get(0), pickup);
         BeanUtils.copyProperties(pickups.get(0), pickup);
         BeanUtils.copyProperties(request.getPickup(), pickup);
         BeanUtils.copyProperties(request.getPickup(), pickup);
+        // 站点不再保存逐站物品;单点改址回退时物品取订单级摘要,与统一契约一致。
+        pickup.setPackageType(order.getPackageType()); pickup.setQuantity(order.getQuantity());
+        pickup.setWeightRange(order.getWeightRange()); pickup.setSpecification(order.getSpecification());
         return List.of(pickup);
         return List.of(pickup);
     }
     }
 
 
@@ -1626,9 +2057,6 @@ public class FlashDeliveryApplicationService {
         if (pickups == null || pickups.isEmpty() || pickups.size() > pickupStopLimit()) {
         if (pickups == null || pickups.isEmpty() || pickups.size() > pickupStopLimit()) {
             throw fail("flash.delivery.pickup.limit.exceeded");
             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);
         validateAddress(delivery);
         for (FlashDeliveryPickupRequest pickup : pickups) {
         for (FlashDeliveryPickupRequest pickup : pickups) {
             validateAddress(pickup);
             validateAddress(pickup);
@@ -1688,16 +2116,23 @@ public class FlashDeliveryApplicationService {
 
 
     /** 点数配置缺失、非整数或越界时,有效上限回退为1。 */
     /** 点数配置缺失、非整数或越界时,有效上限回退为1。 */
     private int pickupStopLimit() {
     private int pickupStopLimit() {
-        String value = dictionaryValue("flash_pickup_stop_limit");
+        return stopLimit("flash_pickup_stop_limit");
+    }
+
+    /** v2 收货点上限与取货点同口径,配置缺失或越界回退为1。 */
+    private int deliveryStopLimit() {
+        return stopLimit("flash_delivery_stop_limit");
+    }
+
+    private int stopLimit(String dictKey) {
+        String value = dictionaryValue(dictKey);
         if (value == null || !value.matches("[0-9]+")) return 1;
         if (value == null || !value.matches("[0-9]+")) return 1;
         try {
         try {
             int limit = Integer.parseInt(value);
             int limit = Integer.parseInt(value);
-            return limit >= 1 && limit <= 26 ? limit : 1;
+            return limit >= 1 && limit <= 10 ? limit : 1;
         } catch (NumberFormatException ignored) { return 1; }
         } catch (NumberFormatException ignored) { return 1; }
     }
     }
 
 
-    private boolean dictionaryEnabled(String key) { return "1".equals(dictionaryValue(key)); }
-
     private String dictionaryValue(String key) {
     private String dictionaryValue(String key) {
         try {
         try {
             List<SysDictData> values = DictUtils.getDictCache(key);
             List<SysDictData> values = DictUtils.getDictCache(key);
@@ -1726,8 +2161,7 @@ public class FlashDeliveryApplicationService {
         BeanUtils.copyProperties(address, stop);
         BeanUtils.copyProperties(address, stop);
         stop.setName(address.getName().trim()); stop.setPhone(address.getPhone().trim());
         stop.setName(address.getName().trim()); stop.setPhone(address.getPhone().trim());
         stop.setAddress(address.getAddress().trim()); stop.setAddressDetail(trimToNull(address.getAddressDetail()));
         stop.setAddress(address.getAddress().trim()); stop.setAddressDetail(trimToNull(address.getAddressDetail()));
-        stop.setCity(trimToNull(address.getCity())); stop.setArea(trimToNull(address.getArea()));
-        stop.setHandoffMethod(trimToNull(address.getHandoffMethod())); stop.setSpecification(trimToNull(stop.getSpecification()));
+        stop.setHandoffMethod(trimToNull(address.getHandoffMethod()));
         stop.setOrderId(orderId); stop.setStopType(type); stop.setStopOrder(index);
         stop.setOrderId(orderId); stop.setStopType(type); stop.setStopOrder(index);
         stop.setStatus("PENDING"); stop.setCreateTime(now);
         stop.setStatus("PENDING"); stop.setCreateTime(now);
         if (stopMapper.insert(stop) != 1) throw fail("flash.delivery.state.changed");
         if (stopMapper.insert(stop) != 1) throw fail("flash.delivery.state.changed");
@@ -1751,12 +2185,22 @@ public class FlashDeliveryApplicationService {
         List<FlashDeliveryOrderStop> rows = stops(order.getId());
         List<FlashDeliveryOrderStop> rows = stops(order.getId());
         if (rows == null || pickupStops(rows).isEmpty()
         if (rows == null || pickupStops(rows).isEmpty()
                 || !Objects.equals(order.getPickupStopCount(), pickupStops(rows).size())
                 || !Objects.equals(order.getPickupStopCount(), pickupStops(rows).size())
-                || rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() != 1) {
+                || rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() != expectedDeliveryStopCount(order)) {
             throw fail("flash.delivery.stops.invalid");
             throw fail("flash.delivery.stops.invalid");
         }
         }
         return rows;
         return rows;
     }
     }
 
 
+    /** v2 多收货站订单携带 delivery_stop_count;历史订单与旧接口订单回退为单收货站。 */
+    private int expectedDeliveryStopCount(FlashDeliveryOrder order) {
+        return order.getDeliveryStopCount() == null ? 1 : order.getDeliveryStopCount();
+    }
+
+    private int deliveredStopCount(FlashDeliveryOrder order) {
+        if (order.getDeliveryStopCount() != null) return Objects.requireNonNullElse(order.getDeliveredStopCount(), 0);
+        return DELIVERED.equals(order.getStatus()) || COMPLETED.equals(order.getStatus()) ? 1 : 0;
+    }
+
     private List<FlashDeliveryOrderStop> pickupStops(List<FlashDeliveryOrderStop> rows) {
     private List<FlashDeliveryOrderStop> pickupStops(List<FlashDeliveryOrderStop> rows) {
         return rows.stream().filter(s -> "PICKUP".equals(s.getStopType())).toList();
         return rows.stream().filter(s -> "PICKUP".equals(s.getStopType())).toList();
     }
     }
@@ -1770,16 +2214,33 @@ public class FlashDeliveryApplicationService {
         return !pickups.isEmpty() && pickups.stream().allMatch(s -> "PICKED_UP".equals(s.getStatus()));
         return !pickups.isEmpty() && pickups.stream().allMatch(s -> "PICKED_UP".equals(s.getStatus()));
     }
     }
 
 
-    /** 选择的目标在刷新后保留;该站取货后回到原顺序最早的待取站点。 */
+    private boolean allDelivered(List<FlashDeliveryOrderStop> rows) {
+        List<FlashDeliveryOrderStop> deliveries = rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).toList();
+        return !deliveries.isEmpty() && deliveries.stream().allMatch(s -> "DELIVERED".equals(s.getStatus()));
+    }
+
+    /** 选择的目标在刷新后保留;本站完成后回到原顺序最早的未完成站点。 */
     private Long targetStopId(FlashDeliveryOrder order, List<FlashDeliveryOrderStop> rows) {
     private Long targetStopId(FlashDeliveryOrder order, List<FlashDeliveryOrderStop> rows) {
         if (CANCELLED.equals(order.getStatus()) || COMPLETED.equals(order.getStatus()) || DELIVERED.equals(order.getStatus())) return null;
         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 ->
         if (order.getCurrentTargetStopId() != null && rows.stream().anyMatch(s ->
-                Objects.equals(s.getId(), order.getCurrentTargetStopId()) && "PICKUP".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))) {
+                Objects.equals(s.getId(), order.getCurrentTargetStopId())
+                        && targetType.equals(s.getStopType()) && "PENDING".equals(s.getStatus()))) {
             return order.getCurrentTargetStopId();
             return order.getCurrentTargetStopId();
         }
         }
         return rows.stream().filter(s -> "PICKUP".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))
         return rows.stream().filter(s -> "PICKUP".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))
                 .map(FlashDeliveryOrderStop::getId).findFirst().orElseGet(() -> rows.stream()
                 .map(FlashDeliveryOrderStop::getId).findFirst().orElseGet(() -> rows.stream()
-                        .filter(s -> "DELIVERY".equals(s.getStopType())).map(FlashDeliveryOrderStop::getId).findFirst().orElse(null));
+                        .filter(s -> "DELIVERY".equals(s.getStopType()) && "PENDING".equals(s.getStatus()))
+                        .map(FlashDeliveryOrderStop::getId).findFirst().orElse(null));
     }
     }
 
 
     /** 事务内先锁订单并读取最新状态,再查询站点,统一取货、调整与取消的并发边界。 */
     /** 事务内先锁订单并读取最新状态,再查询站点,统一取货、调整与取消的并发边界。 */
@@ -1817,17 +2278,25 @@ public class FlashDeliveryApplicationService {
             // 重试返回当前进度,不把已过期的历史目标重新写回订单。
             // 重试返回当前进度,不把已过期的历史目标重新写回订单。
             return participantDetail(order, false, false);
             return participantDetail(order, false, false);
         }
         }
-        if (!ACCEPTED.equals(order.getStatus())) throw fail("flash.delivery.transition.not.allowed");
+        if (!ACCEPTED.equals(order.getStatus()) && !PICKED_UP.equals(order.getStatus())) {
+            throw fail("flash.delivery.transition.not.allowed");
+        }
         if (request.getOrderVersion() == null || !Objects.equals(request.getOrderVersion(), order.getVersion())) {
         if (request.getOrderVersion() == null || !Objects.equals(request.getOrderVersion(), order.getVersion())) {
             throw fail("flash.delivery.state.changed");
             throw fail("flash.delivery.state.changed");
         }
         }
         List<FlashDeliveryOrderStop> rows = requireStops(order);
         List<FlashDeliveryOrderStop> rows = requireStops(order);
         FlashDeliveryOrderStop target = requireStop(rows, stopId);
         FlashDeliveryOrderStop target = requireStop(rows, stopId);
-        if (!"PICKUP".equals(target.getStopType()) || !"PENDING".equals(target.getStatus())) throw fail("flash.delivery.stop.invalid");
+        boolean v2 = order.getDeliveryStopCount() != null;
+        String targetType = PICKED_UP.equals(order.getStatus()) ? "DELIVERY" : "PICKUP";
+        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);
         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");
+        if (tooLong(request.getReason(), 500)) throw fail("flash.delivery.target.reason.too.long");
         Date now = new Date();
         Date now = new Date();
         FlashDeliveryTargetAdjustment adjustment = new FlashDeliveryTargetAdjustment();
         FlashDeliveryTargetAdjustment adjustment = new FlashDeliveryTargetAdjustment();
         adjustment.setOrderId(orderId); adjustment.setFromStopId(before); adjustment.setToStopId(stopId);
         adjustment.setOrderId(orderId); adjustment.setFromStopId(before); adjustment.setToStopId(stopId);
@@ -1835,7 +2304,9 @@ public class FlashDeliveryApplicationService {
         adjustment.setReason(trimToNull(request.getReason())); adjustment.setConsentDeclared(Boolean.TRUE.equals(request.getConsentDeclared())); adjustment.setCreateTime(now);
         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");
         if (adjustmentMapper.insert(adjustment) != 1) throw fail("flash.delivery.state.changed");
         order.setCurrentTargetStopId(stopId); order.setUpdateTime(now);
         order.setCurrentTargetStopId(stopId); order.setUpdateTime(now);
-        if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
+        int updated = PICKED_UP.equals(order.getStatus())
+                ? orderMapper.updateDeliveryFulfilment(order) : orderMapper.updateFulfilment(order);
+        if (updated != 1) throw fail("flash.delivery.state.changed");
         order.setVersion(order.getVersion() + 1);
         order.setVersion(order.getVersion() + 1);
         return participantDetail(order, false, false);
         return participantDetail(order, false, false);
     }
     }
@@ -1864,7 +2335,7 @@ public class FlashDeliveryApplicationService {
         // 已确认站点的重试直接成功,不因当前目标或主状态已变化而重复写凭证。
         // 已确认站点的重试直接成功,不因当前目标或主状态已变化而重复写凭证。
         if ("PICKED_UP".equals(stop.getStatus())) return;
         if ("PICKED_UP".equals(stop.getStatus())) return;
         if (!ACCEPTED.equals(order.getStatus())) throw fail("flash.delivery.transition.not.allowed");
         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);
         if ("3".equals(order.getPayType())) flashLinePayFactService.assertPickupPaid(order);
         List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
         List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
@@ -1874,11 +2345,15 @@ public class FlashDeliveryApplicationService {
         saveStationImages(orderId, stopId, "PICKUP", "RIDER", riderId, urls, now);
         saveStationImages(orderId, stopId, "PICKUP", "RIDER", riderId, urls, now);
         order.setPickedUpStopCount((int) pickups.stream().filter(s -> "PICKED_UP".equals(s.getStatus())).count());
         order.setPickedUpStopCount((int) pickups.stream().filter(s -> "PICKED_UP".equals(s.getStatus())).count());
         order.setCurrentTargetStopId(null); order.setUpdateTime(now);
         order.setCurrentTargetStopId(null); order.setUpdateTime(now);
+        // 已送达进度独立保留;只有全部取完才从待取件 Tab 转配送中,不以是否已送过货判断。
         if (allPicked(rows)) { order.setStatus(PICKED_UP); order.setPickedUpAt(now); }
         if (allPicked(rows)) { order.setStatus(PICKED_UP); order.setPickedUpAt(now); }
         if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
         if (orderMapper.updateFulfilment(order) != 1) throw fail("flash.delivery.state.changed");
         writeLog(orderId, ACCEPTED, order.getStatus(), "RIDER", riderId, "PICKUP_STOP:" + stopId, now);
         writeLog(orderId, ACCEPTED, order.getStatus(), "RIDER", riderId, "PICKUP_STOP:" + stopId, now);
-        // 全部取货站完成、订单推进为已取件时通知寄件用户。
-        if (PICKED_UP.equals(order.getStatus())) notificationService.notifyPickedUp(order);
+        if (order.getDeliveryStopCount() != null) {
+            notificationService.notifyPickupStop(order, stop, order.getPickedUpStopCount(), pickups.size());
+        } else if (PICKED_UP.equals(order.getStatus())) {
+            notificationService.notifyPickedUp(order);
+        }
     }
     }
 
 
     private void saveStationImages(Long orderId, Long stopId, String proofType, String operatorType,
     private void saveStationImages(Long orderId, Long stopId, String proofType, String operatorType,
@@ -1897,6 +2372,7 @@ public class FlashDeliveryApplicationService {
         return rows.stream().map(stop -> {
         return rows.stream().map(stop -> {
             FlashDeliveryStopView view = new FlashDeliveryStopView();
             FlashDeliveryStopView view = new FlashDeliveryStopView();
             BeanUtils.copyProperties(stop, view);
             BeanUtils.copyProperties(stop, view);
+            view.setDeliveryPinCode(null);
             if (sensitive) view.setImageUrls(images.stream().filter(i -> Objects.equals(i.getStopId(), stop.getId())
             if (sensitive) view.setImageUrls(images.stream().filter(i -> Objects.equals(i.getStopId(), stop.getId())
                             && Objects.equals(i.getProofType(), stop.getStopType()))
                             && Objects.equals(i.getProofType(), stop.getStopType()))
                     .map(FlashDeliveryOrderImage::getImageUrl).toList());
                     .map(FlashDeliveryOrderImage::getImageUrl).toList());
@@ -1916,9 +2392,10 @@ public class FlashDeliveryApplicationService {
         if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus()) || order.getPickedUpAt() != null
         if (!FlashDeliveryStateMachine.canAdminCancel(order.getStatus()) || order.getPickedUpAt() != null
                 || order.getPickedUpStopCount() != null && order.getPickedUpStopCount() > 0 || rows == null) return false;
                 || order.getPickedUpStopCount() != null && order.getPickedUpStopCount() > 0 || rows == null) return false;
         List<FlashDeliveryOrderStop> pickups = pickupStops(rows);
         List<FlashDeliveryOrderStop> pickups = pickupStops(rows);
-        return !pickups.isEmpty() && Objects.equals(order.getPickupStopCount(), pickups.size())
-                && rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() == 1 && !anyPicked(rows)
-                && pickups.stream().allMatch(s -> "PENDING".equals(s.getStatus()));
+        if (pickups.isEmpty() || !Objects.equals(order.getPickupStopCount(), pickups.size())
+                || rows.stream().filter(s -> "DELIVERY".equals(s.getStopType())).count() != expectedDeliveryStopCount(order)
+                || rows.stream().anyMatch(s -> "DELIVERED".equals(s.getStatus()))) return false;
+        return !anyPicked(rows);
     }
     }
 
 
 
 

+ 58 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationService.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.flashdelivery.service;
 package com.ruoyi.app.flashdelivery.service;
 
 
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.push.BusinessPushProperties;
 import com.ruoyi.app.push.BusinessPushProperties;
 import com.ruoyi.app.push.NativeRiderBatchPushService;
 import com.ruoyi.app.push.NativeRiderBatchPushService;
@@ -8,10 +9,13 @@ import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.framework.manager.AsyncManager;
 import com.ruoyi.framework.manager.AsyncManager;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.RiderPosition;
 import com.ruoyi.system.domain.RiderPosition;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
 import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.system.mapper.RiderPositionMapper;
 import com.ruoyi.system.mapper.RiderPositionMapper;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
@@ -22,6 +26,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 
 import java.util.List;
 import java.util.List;
+import java.util.Locale;
 import java.util.TimerTask;
 import java.util.TimerTask;
 
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
@@ -42,6 +47,8 @@ public class FlashDeliveryNotificationService {
     private static final String CONTENT_ACCEPTED = "no.message.push.delivery.personnel.receiving.order";
     private static final String CONTENT_ACCEPTED = "no.message.push.delivery.personnel.receiving.order";
     private static final String CONTENT_PICKED_UP = "no.message.push.delivery.personnel.qspsz.order";
     private static final String CONTENT_PICKED_UP = "no.message.push.delivery.personnel.qspsz.order";
     private static final String CONTENT_DELIVERED = "no.message.push.delivery.personnel.qsysd.order";
     private static final String CONTENT_DELIVERED = "no.message.push.delivery.personnel.qsysd.order";
+    private static final String CONTENT_PICKUP_STOP = "no.message.push.flash.pickup.stop.completed";
+    private static final String CONTENT_DELIVERY_STOP = "no.message.push.flash.delivery.stop.completed";
     private static final String CONTENT_CANCELLED = "no.message.push.order.cancelled";
     private static final String CONTENT_CANCELLED = "no.message.push.order.cancelled";
     private static final String CONTENT_AVAILABLE = "no.message.push.new.flash.order";
     private static final String CONTENT_AVAILABLE = "no.message.push.new.flash.order";
     private static final int RIDER_NOTIFICATION_LIMIT = 20;
     private static final int RIDER_NOTIFICATION_LIMIT = 20;
@@ -191,6 +198,57 @@ public class FlashDeliveryNotificationService {
         });
         });
     }
     }
 
 
+    /** 每个取货站首次确认成功后,通知订单发起人本站及取货进度。 */
+    public void notifyPickupStop(FlashDeliveryOrder order, FlashDeliveryOrderStop stop, int completed, int total) {
+        notifyStopProgress(order, stop, completed, total, "FLASH_PICKUP_STOP_COMPLETED", CONTENT_PICKUP_STOP);
+    }
+
+    /** 每个收货站首次确认送达后,通知订单发起人本站及送达进度。 */
+    public void notifyDeliveryStop(FlashDeliveryOrder order, FlashDeliveryOrderStop stop, int completed, int total) {
+        notifyStopProgress(order, stop, completed, total, "FLASH_DELIVERY_STOP_COMPLETED", CONTENT_DELIVERY_STOP);
+    }
+
+    private void notifyStopProgress(FlashDeliveryOrder order, FlashDeliveryOrderStop stop, int completed,
+                                    int total, String eventType, String contentKey) {
+        if (order == null || stop == null || order.getUserId() == null || stop.getStopOrder() == null) return;
+        Long senderId = order.getUserId();
+        String orderNo = order.getOrderNo();
+        int routeStopNumber = stop.getStopOrder() + 1;
+        // 在本次成功动作处固定状态和进度;不序列化联系人、地址或收货码。
+        JSONObject payload = new JSONObject();
+        payload.put("ddId", orderNo);
+        payload.put("state", order.getStatus());
+        payload.put("type", PUSH_PAYLOAD_TYPE_NONE);
+        payload.put("pushType", PUSH_TYPE_FLASH);
+        payload.put("eventType", eventType);
+        payload.put("stopId", stop.getId());
+        payload.put("stopType", stop.getStopType());
+        payload.put("stopOrder", stop.getStopOrder());
+        payload.put("completedStopCount", completed);
+        payload.put("totalStopCount", total);
+        String body = payload.toJSONString();
+        runAfterCommit(() -> {
+            InfoUser sender = userMapper.selectById(senderId);
+            if (sender == null) return;
+            String cid = sender.getCid();
+            AsyncManager.me().execute(new TimerTask() {
+                @Override
+                public void run() {
+                    try {
+                        Locale locale = LocaleUtils.getUserLocale(senderId);
+                        String title = MessageUtils.message(TITLE_KEY, locale);
+                        String content = MessageUtils.message(contentKey, locale, routeStopNumber, completed, total)
+                                + ",NO:" + orderNo;
+                        PayPush.userPush(new PayPush(), pushEventService, senderId, cid, title, content, body, "");
+                    } catch (Exception exception) {
+                        log.error("闪送逐站用户推送失败,userId:{}, orderNo:{}, eventType:{}",
+                                senderId, orderNo, eventType, exception);
+                    }
+                }
+            });
+        });
+    }
+
     /** 订单取消后通知接单骑手;未接单(无骑手归属)或骑手缺失时跳过,寄件用户不推。 */
     /** 订单取消后通知接单骑手;未接单(无骑手归属)或骑手缺失时跳过,寄件用户不推。 */
     public void notifyCancelled(FlashDeliveryOrder order) {
     public void notifyCancelled(FlashDeliveryOrder order) {
         runAfterCommit(() -> {
         runAfterCommit(() -> {

+ 23 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryUserCardStops.java

@@ -0,0 +1,23 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryStopView;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
+import org.springframework.beans.BeanUtils;
+
+import java.util.List;
+
+/** 用户端订单卡片逐站列表:全部站点返回文字地址与备注,寄件收件同口径;联系人、电话、坐标、凭证与收货码不落卡片。 */
+public final class FlashDeliveryUserCardStops {
+    private FlashDeliveryUserCardStops() {}
+
+    public static List<FlashDeliveryStopView> of(List<FlashDeliveryOrderStop> rows) {
+        return rows.stream().map(stop -> {
+            FlashDeliveryStopView view = new FlashDeliveryStopView();
+            BeanUtils.copyProperties(stop, view);
+            view.setDeliveryPinCode(null);
+            view.setName(null); view.setPhone(null);
+            view.setLongitude(null); view.setLatitude(null);
+            return view;
+        }).toList();
+    }
+}

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java

@@ -102,6 +102,10 @@ public class OmgPaymentCreateService {
                 OmgPaymentAttempt attempt = attempts.createCreated(order.getDdId(), merchantTradeNo,
                 OmgPaymentAttempt attempt = attempts.createCreated(order.getDdId(), merchantTradeNo,
                         order.getStoreId(), credential.getMerchantId(), order.getAmount(),
                         order.getStoreId(), credential.getMerchantId(), order.getAmount(),
                         credential.getHashKey(), credential.getHashIv());
                         credential.getHashKey(), credential.getHashIv());
+                if (OmgPaymentFormFactory.PRODUCTION_GATEWAY_URL.equals(form.gatewayUrl())
+                        && attempts.markProductionEnvironment(attempt.getId()) != 1) {
+                    throw new IllegalStateException("OMG production environment snapshot failed");
+                }
                 OmgCreatePaymentResponse response = new OmgCreatePaymentResponse(form.gatewayUrl(), form.fields());
                 OmgCreatePaymentResponse response = new OmgCreatePaymentResponse(form.gatewayUrl(), form.fields());
                 return new OmgPaymentCreateOutcome(response, attempt.getId(), order.getDdId(), userId,
                 return new OmgPaymentCreateOutcome(response, attempt.getId(), order.getDdId(), userId,
                         order.getStoreId(), order.getAmount(), maskMerchantTradeNo(merchantTradeNo));
                         order.getStoreId(), order.getAmount(), maskMerchantTradeNo(merchantTradeNo));

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java

@@ -15,6 +15,8 @@ import java.util.Map;
 public class OmgPaymentFormFactory {
 public class OmgPaymentFormFactory {
     public static final String STAGE_GATEWAY_URL =
     public static final String STAGE_GATEWAY_URL =
             "https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5";
             "https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5";
+    public static final String PRODUCTION_GATEWAY_URL =
+            "https://payment.funpoint.com.tw/Cashier/AioCheckOut/V5";
     private static final DateTimeFormatter TRADE_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
     private static final DateTimeFormatter TRADE_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
 
 
@@ -57,7 +59,8 @@ public class OmgPaymentFormFactory {
         Map<String, String> signedFields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
         Map<String, String> signedFields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
         fields.put("CheckMacValue", signer.sign(signedFields, hashKey, hashIv));
         fields.put("CheckMacValue", signer.sign(signedFields, hashKey, hashIv));
         Map<String, String> responseFields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
         Map<String, String> responseFields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
-        return new OmgPaymentForm(STAGE_GATEWAY_URL, responseFields);
+        return new OmgPaymentForm(properties.isProduction() ? PRODUCTION_GATEWAY_URL : STAGE_GATEWAY_URL,
+                responseFields);
     }
     }
 
 
     private static String safeOrderReference(String orderId) {
     private static String safeOrderReference(String orderId) {

+ 11 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java

@@ -16,5 +16,15 @@ public record OmgPaymentGatewayFacts(
         Date paymentDate,
         Date paymentDate,
         Date tradeDate,
         Date tradeDate,
         BigDecimal paymentTypeChargeFee,
         BigDecimal paymentTypeChargeFee,
-        Integer simulatePaid) {
+        Integer simulatePaid,
+        String creditRefundId) {
+    public OmgPaymentGatewayFacts(OmgPaymentFactSource source, String merchantId,
+                                  String merchantTradeNo, int amount, int resultCode,
+                                  String resultMessage, String tradeNo, String paymentType,
+                                  Date paymentDate, Date tradeDate, BigDecimal paymentTypeChargeFee,
+                                  Integer simulatePaid) {
+        this(source, merchantId, merchantTradeNo, amount, resultCode, resultMessage,
+                tradeNo, paymentType, paymentDate, tradeDate, paymentTypeChargeFee,
+                simulatePaid, null);
+    }
 }
 }

+ 17 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -40,6 +40,8 @@ import java.util.Locale;
 /** Verifies and applies one final OMG payment notification transactionally. */
 /** Verifies and applies one final OMG payment notification transactionally. */
 @Service
 @Service
 public class OmgPaymentNotifyService {
 public class OmgPaymentNotifyService {
+    @Autowired(required = false)
+    private OmgPaymentRefundService refundService;
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentNotifyService.class);
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentNotifyService.class);
     private static final int STATUS_PAID = 1;
     private static final int STATUS_PAID = 1;
     private static final int STATUS_SUPERSEDED = 3;
     private static final int STATUS_SUPERSEDED = 3;
@@ -109,7 +111,8 @@ public class OmgPaymentNotifyService {
                 OmgPaymentFactSource.NOTIFY, attempt.getMerchantId(), merchantTradeNo,
                 OmgPaymentFactSource.NOTIFY, attempt.getMerchantId(), merchantTradeNo,
                 attempt.getAmount(), facts.rtnCode, facts.rtnMsg, facts.tradeNo,
                 attempt.getAmount(), facts.rtnCode, facts.rtnMsg, facts.tradeNo,
                 facts.paymentType, facts.paymentDate, facts.tradeDate,
                 facts.paymentType, facts.paymentDate, facts.tradeDate,
-                facts.paymentTypeChargeFee, facts.simulatePaid);
+                facts.paymentTypeChargeFee, facts.simulatePaid,
+                request.value("gwsr"));
         return applyVerifiedFacts(order, attempt, gatewayFacts) != null;
         return applyVerifiedFacts(order, attempt, gatewayFacts) != null;
     }
     }
 
 
@@ -170,10 +173,18 @@ public class OmgPaymentNotifyService {
 
 
         requireSingleUpdate(attempts.markPaid(toUpdate(attempt, facts)), "mark paid");
         requireSingleUpdate(attempts.markPaid(toUpdate(attempt, facts)), "mark paid");
         attempts.supersedeOtherCreated(attempt.getDdId(), attempt.getId());
         attempts.supersedeOtherCreated(attempt.getDdId(), attempt.getId());
-        if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
-            requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid");
-            openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
-            pushPaymentSuccessToUser(order);
+        boolean canFulfill = Long.valueOf(0L).equals(order.getPayStatus())
+                && order.getState() != null && order.getState() != 4L
+                && ("2".equals(order.getPayType()) || "5".equals(order.getPayType()));
+        if (canFulfill) {
+            if (attempts.markOrderPaid(attempt.getDdId()) == 1) {
+                openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
+                pushPaymentSuccessToUser(order);
+            } else if (refundService != null) {
+                refundService.createRefundIntentForAttempt(attempt.getId(), "LATE_PAYMENT");
+            }
+        } else if (refundService != null) {
+            refundService.createRefundIntentForAttempt(attempt.getId(), "LATE_PAYMENT");
         }
         }
         int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
         int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
         if (order.getState() != null && order.getState() == 4L) {
         if (order.getState() != null && order.getState() == 4L) {
@@ -321,6 +332,7 @@ public class OmgPaymentNotifyService {
         OmgPaymentAttempt update = new OmgPaymentAttempt();
         OmgPaymentAttempt update = new OmgPaymentAttempt();
         update.setId(attempt.getId());
         update.setId(attempt.getId());
         update.setTradeNo(facts.tradeNo());
         update.setTradeNo(facts.tradeNo());
+        update.setCreditRefundId(facts.creditRefundId());
         update.setRtnCode(facts.resultCode());
         update.setRtnCode(facts.resultCode());
         update.setRtnMsg(limit(facts.resultMessage(), 200));
         update.setRtnMsg(limit(facts.resultMessage(), 200));
         update.setPaymentType(limit(facts.paymentType(), 20));
         update.setPaymentType(limit(facts.paymentType(), 20));

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java

@@ -4,6 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
 
 
 import java.net.URI;
 import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
 
 
 @Component
 @Component
 @ConfigurationProperties(prefix = "omgpay")
 @ConfigurationProperties(prefix = "omgpay")
@@ -14,6 +16,21 @@ public class OmgPaymentProperties {
     private String returnUrl;
     private String returnUrl;
     private String orderResultUrl;
     private String orderResultUrl;
     private String appReturnUrl;
     private String appReturnUrl;
+    /** TEST is the safe default. Production credentials must explicitly enable PRODUCTION. */
+    private String environment = "TEST";
+    /** MerchantID -> CreditCheckCode, supplied through protected deployment configuration. */
+    private Map<String, String> creditCheckCodes = new HashMap<>();
+
+    public boolean isProduction() {
+        return "PRODUCTION".equalsIgnoreCase(environment);
+    }
+
+    public String getEnvironment() { return environment; }
+    public void setEnvironment(String environment) { this.environment = environment; }
+    public Map<String, String> getCreditCheckCodes() { return creditCheckCodes; }
+    public void setCreditCheckCodes(Map<String, String> creditCheckCodes) {
+        this.creditCheckCodes = creditCheckCodes == null ? new HashMap<>() : creditCheckCodes;
+    }
 
 
     public String getReturnUrl() {
     public String getReturnUrl() {
         return returnUrl;
         return returnUrl;

+ 15 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java

@@ -10,6 +10,7 @@ import org.apache.http.impl.client.HttpClients;
 import org.apache.http.message.BasicNameValuePair;
 import org.apache.http.message.BasicNameValuePair;
 import org.apache.http.util.EntityUtils;
 import org.apache.http.util.EntityUtils;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
+import org.springframework.beans.factory.annotation.Autowired;
 
 
 import java.io.IOException;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.charset.StandardCharsets;
@@ -22,6 +23,8 @@ import java.util.Map;
 public class OmgPaymentQueryGateway {
 public class OmgPaymentQueryGateway {
     static final String STAGE_QUERY_URL =
     static final String STAGE_QUERY_URL =
             "https://payment-stage.funpoint.com.tw/Cashier/QueryTradeInfo/V5";
             "https://payment-stage.funpoint.com.tw/Cashier/QueryTradeInfo/V5";
+    static final String PRODUCTION_QUERY_URL =
+            "https://payment.funpoint.com.tw/Cashier/QueryTradeInfo/V5";
     private static final int MAX_RESPONSE_BYTES = 32 * 1024;
     private static final int MAX_RESPONSE_BYTES = 32 * 1024;
     private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
     private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
             .setConnectTimeout(5_000)
             .setConnectTimeout(5_000)
@@ -29,10 +32,21 @@ public class OmgPaymentQueryGateway {
             .setSocketTimeout(10_000)
             .setSocketTimeout(10_000)
             .build();
             .build();
 
 
+    private final OmgPaymentProperties properties;
+
+    @Autowired
+    public OmgPaymentQueryGateway(OmgPaymentProperties properties) {
+        this.properties = properties;
+    }
+
     public String query(Map<String, String> fields) {
     public String query(Map<String, String> fields) {
+        return query(fields, properties.isProduction() ? "PRODUCTION" : "TEST");
+    }
+
+    public String query(Map<String, String> fields, String environment) {
         List<NameValuePair> form = new ArrayList<>();
         List<NameValuePair> form = new ArrayList<>();
         fields.forEach((name, value) -> form.add(new BasicNameValuePair(name, value)));
         fields.forEach((name, value) -> form.add(new BasicNameValuePair(name, value)));
-        HttpPost post = new HttpPost(STAGE_QUERY_URL);
+        HttpPost post = new HttpPost("PRODUCTION".equals(environment) ? PRODUCTION_QUERY_URL : STAGE_QUERY_URL);
         post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8));
         post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8));
         try (CloseableHttpClient client = HttpClients.custom()
         try (CloseableHttpClient client = HttpClients.custom()
                 .setDefaultRequestConfig(REQUEST_CONFIG)
                 .setDefaultRequestConfig(REQUEST_CONFIG)

+ 5 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java

@@ -99,7 +99,9 @@ public class OmgPaymentQueryService {
     private OmgQueryPaymentResponse queryAttempt(OmgPaymentAttempt attempt, String orderId,
     private OmgQueryPaymentResponse queryAttempt(OmgPaymentAttempt attempt, String orderId,
                                                   Long storeId, String source) {
                                                   Long storeId, String source) {
         try {
         try {
-            Map<String, String> response = parser.parse(gateway.query(buildRequest(attempt)));
+            Map<String, String> response = parser.parse(attempt.getGatewayEnvironment() == null
+                    ? gateway.query(buildRequest(attempt))
+                    : gateway.query(buildRequest(attempt), attempt.getGatewayEnvironment()));
             validateResponse(response, attempt);
             validateResponse(response, attempt);
             String tradeStatus = response.get("TradeStatus");
             String tradeStatus = response.get("TradeStatus");
             String status = synchronizeIfFinal(tradeStatus, response, attempt);
             String status = synchronizeIfFinal(tradeStatus, response, attempt);
@@ -223,7 +225,8 @@ public class OmgPaymentQueryService {
                 attempt.getMerchantId(), attempt.getMerchantTradeNo(), attempt.getAmount(),
                 attempt.getMerchantId(), attempt.getMerchantTradeNo(), attempt.getAmount(),
                 tradeStatus, "TradeStatus=" + tradeStatus, tradeNo, paymentType,
                 tradeStatus, "TradeStatus=" + tradeStatus, tradeNo, paymentType,
                 parseDate(fields.get("PaymentDate"), paid),
                 parseDate(fields.get("PaymentDate"), paid),
-                parseDate(fields.get("TradeDate"), paid), fee, null);
+                parseDate(fields.get("TradeDate"), paid), fee, null,
+                blankToNull(fields.get("gwsr")));
     }
     }
 
 
     private static OmgQueryPaymentResponse toResponse(String status, Map<String, String> fields) {
     private static OmgQueryPaymentResponse toResponse(String status, Map<String, String> fields) {

+ 295 - 21
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundService.java

@@ -1,43 +1,317 @@
 package com.ruoyi.app.omgpay;
 package com.ruoyi.app.omgpay;
 
 
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONArray;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.AUTH_REQUIRED;
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.ORDER_REQUIRED;
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT;
+import java.time.LocalTime;
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 
 
-/**
- * Test-stage refund boundary. OMG publishes no refund endpoint for its test environment,
- * so this service must fail closed before reading payment facts or changing local state.
- */
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.*;
+
+/** 持久化 OMG 退款意图,并按正式信用卡交易事实推进退款。 */
 @Service
 @Service
 public class OmgPaymentRefundService {
 public class OmgPaymentRefundService {
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentRefundService.class);
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentRefundService.class);
+    private final OmgPaymentProperties properties;
+    private final IOmgPaymentAttemptService attempts;
+    private final JdbcTemplate jdbc;
+    private final OmgCheckMacSigner signer;
+    private final OmgRefundGateway gateway;
+    private final OmgQueryResponseParser parser;
+    private final Clock clock;
+
+    /** 保留测试环境边界测试的无参构造。 */
+    public OmgPaymentRefundService() {
+        this(new OmgPaymentProperties(), null, null, null, null, null);
+    }
+
+    @Autowired
+    public OmgPaymentRefundService(OmgPaymentProperties properties,
+                                   IOmgPaymentAttemptService attempts,
+                                   JdbcTemplate jdbc,
+                                   OmgCheckMacSigner signer,
+                                   OmgRefundGateway gateway,
+                                   OmgQueryResponseParser parser) {
+        this(properties, attempts, jdbc, signer, gateway, parser, Clock.systemUTC());
+    }
+
+    OmgPaymentRefundService(OmgPaymentProperties properties,
+                            IOmgPaymentAttemptService attempts, JdbcTemplate jdbc,
+                            OmgCheckMacSigner signer, OmgRefundGateway gateway,
+                            OmgQueryResponseParser parser, Clock clock) {
+        this.properties = properties;
+        this.attempts = attempts;
+        this.jdbc = jdbc;
+        this.signer = signer;
+        this.gateway = gateway;
+        this.parser = parser;
+        this.clock = clock;
+    }
 
 
     public Void refund(Long userId, String orderId) {
     public Void refund(Long userId, String orderId) {
         if (userId == null) {
         if (userId == null) {
-            throw business(AUTH_REQUIRED);
+            throw new OmgPaymentBusinessException(AUTH_REQUIRED);
+        }
+        if (orderId == null || orderId.isBlank() || orderId.trim().length() > 64) {
+            throw new OmgPaymentBusinessException(ORDER_REQUIRED);
+        }
+        if (!properties.isProduction()) {
+            log.warn("OMG refund unavailable in test environment orderId={}",
+                    OmgPaymentController.safeLogOrderId(orderId));
+            throw new OmgPaymentBusinessException(PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT);
+        }
+        OmgPaymentOrderSnapshot order = attempts.selectOrder(orderId.trim());
+        if (order == null || !userId.equals(order.getUserId())
+                || !Long.valueOf(4L).equals(order.getState())
+                || attempts.selectPaidByDdId(orderId.trim()) == null) {
+            throw new OmgPaymentBusinessException(PAYMENT_REFUND_FAILED);
         }
         }
-        String normalizedOrderId = normalizeOrderId(orderId);
-        log.warn("OMG refund blocked environment=TEST, orderId={}, userId={}, reason=GATEWAY_UNAVAILABLE",
-                OmgPaymentController.safeLogOrderId(normalizedOrderId), userId);
-        throw business(PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT);
+        createRefundIntent(orderId.trim(), "USER_REQUEST");
+        return null;
     }
     }
 
 
-    private static String normalizeOrderId(String orderId) {
-        if (orderId == null || orderId.isBlank()) {
-            throw business(ORDER_REQUIRED);
+    /** 与取消/迟到回调同事务,按支付尝试唯一键幂等写入。 */
+    @Transactional(rollbackFor = Exception.class)
+    public void createRefundIntent(String orderId, String source) {
+        jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
+                        + "(attempt_id, dd_id, source, status, create_time, update_time) "
+                        + "SELECT id, dd_id, ?, 'PENDING', NOW(), NOW() "
+                        + "FROM pos_order_omg_attempt WHERE dd_id = ? AND attempt_status = 1",
+                source, orderId);
+        Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_omg_refund WHERE dd_id = ?",
+                Integer.class, orderId);
+        if (count == null || count == 0) {
+            throw new IllegalStateException("OMG captured payment missing for refund");
         }
         }
-        String normalized = orderId.trim();
-        if (normalized.length() > 64) {
-            throw business(ORDER_REQUIRED);
+    }
+
+    /** 迟到或重复支付只退本次实收,不能误退订单上另一笔有效付款。 */
+    @Transactional(rollbackFor = Exception.class)
+    public void createRefundIntentForAttempt(Long attemptId, String source) {
+        int inserted = jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
+                        + "(attempt_id, dd_id, source, status, create_time, update_time) "
+                        + "SELECT id, dd_id, ?, 'PENDING', NOW(), NOW() "
+                        + "FROM pos_order_omg_attempt WHERE id=? AND attempt_status=1",
+                source, attemptId);
+        if (inserted == 0) {
+            Integer existing = jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_omg_refund WHERE attempt_id=?",
+                    Integer.class, attemptId);
+            if (existing == null || existing == 0) {
+                throw new IllegalStateException("OMG captured attempt missing for refund");
+            }
         }
         }
-        return normalized;
     }
     }
 
 
-    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) {
-        return new OmgPaymentBusinessException(code);
+    /** 补偿已取消或已切换渠道后的迟到实收,唯一键保证重复扫描幂等。 */
+    public void ensureOrphanedCapturedIntents() {
+        jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
+                + "(attempt_id, dd_id, source, status, create_time, update_time) "
+                + "SELECT a.id, a.dd_id, 'LATE_PAYMENT_SCAN', 'PENDING', NOW(), NOW() "
+                + "FROM pos_order_omg_attempt a JOIN pos_order o ON o.dd_id=a.dd_id "
+                + "WHERE a.attempt_status=1 AND (o.state=4 OR o.pay_type NOT IN ('2','5')) "
+                + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_refund r WHERE r.attempt_id=a.id)");
+    }
+
+    /** 退款事实已确认后补偿订单支付状态,避免单次任务在两条更新之间中断。 */
+    public void reconcileRefundedOrderStatuses() {
+        jdbc.update("UPDATE pos_order o SET o.pay_status=2 WHERE o.state=4 AND o.pay_status=1 "
+                + "AND o.pay_type IN ('2','5') "
+                + "AND EXISTS (SELECT 1 FROM pos_order_omg_attempt a WHERE a.dd_id=o.dd_id AND a.attempt_status=1) "
+                + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_attempt a "
+                + "LEFT JOIN pos_order_omg_refund r ON r.attempt_id=a.id "
+                + "WHERE a.dd_id=o.dd_id AND a.attempt_status=1 "
+                + "AND (r.id IS NULL OR r.status!='REFUNDED'))");
+    }
+
+    public List<Long> pendingIds(int limit) {
+        return jdbc.queryForList("SELECT id FROM pos_order_omg_refund "
+                        + "WHERE status IN ('PENDING', 'ACTION_UNKNOWN') "
+                        + "AND (lease_until IS NULL OR lease_until < NOW()) ORDER BY id LIMIT ?",
+                Long.class, limit);
     }
     }
+
+    /** 不确定结果必须先查询事实;同一状态不重放同一退款动作。 */
+    public void processOne(Long refundId) {
+        if (!properties.isProduction()) {
+            jdbc.update("UPDATE pos_order_omg_refund SET last_error='TEST_ENVIRONMENT', update_time=NOW() "
+                    + "WHERE id=? AND status IN ('PENDING','ACTION_UNKNOWN')", refundId);
+            return;
+        }
+        int claimed = jdbc.update("UPDATE pos_order_omg_refund SET lease_until=DATE_ADD(NOW(), INTERVAL 2 MINUTE), "
+                        + "update_time=NOW() WHERE id=? AND status IN ('PENDING','ACTION_UNKNOWN') "
+                        + "AND (lease_until IS NULL OR lease_until < NOW())", refundId);
+        if (claimed != 1) {
+            return;
+        }
+        boolean actionStarted = false;
+        String priorStatus = "PENDING";
+        try {
+            RefundRow row = jdbc.queryForObject("SELECT attempt_id, status, last_action, last_gateway_status "
+                            + "FROM pos_order_omg_refund WHERE id=?", (rs, n) ->
+                    new RefundRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4)), refundId);
+            priorStatus = row.status();
+            OmgPaymentAttempt attempt = loadAttempt(row.attemptId());
+            String checkCode = properties.getCreditCheckCodes().get(attempt.getMerchantId());
+            if (!"PRODUCTION".equals(attempt.getGatewayEnvironment())) {
+                defer(refundId, "PAYMENT_CREATED_IN_TEST_ENVIRONMENT", "MANUAL_REVIEW");
+                return;
+            }
+            if (attempt.getCreditRefundId() == null || !attempt.getCreditRefundId().matches("[0-9]+")
+                    || checkCode == null || !checkCode.matches("[0-9]+") || attempt.getTradeNo() == null) {
+                defer(refundId, "MISSING_CREDIT_REFUND_CREDENTIAL", priorStatus);
+                return;
+            }
+            String gatewayStatus = creditStatus(attempt, checkCode);
+            if (List.of("已取消", "銀行已追回", "FULL_REFUND_CONFIRMED").contains(gatewayStatus)) {
+                markRefunded(refundId, attempt);
+                return;
+            }
+            String action = nextAction(gatewayStatus);
+            if (action == null) {
+                defer(refundId, "GATEWAY_STATUS_REQUIRES_REVIEW", "MANUAL_REVIEW");
+                return;
+            }
+            if (row.lastAction() != null
+                    && (row.lastAction().equals(action) || Objects.equals(row.lastGatewayStatus(), gatewayStatus))) {
+                defer(refundId, "WAITING_FOR_GATEWAY_STATE_CHANGE", "ACTION_UNKNOWN");
+                return;
+            }
+            if (inAutoCloseWindow()) {
+                defer(refundId, "AUTO_CLOSE_WINDOW", priorStatus);
+                return;
+            }
+            // 先持久化动作可能已发出,超时后只查事实。
+            jdbc.update("UPDATE pos_order_omg_refund SET status='ACTION_UNKNOWN', last_action=?, "
+                            + "last_gateway_status=?, last_action_time=NOW(), attempt_count=attempt_count+1, "
+                            + "last_error=NULL, update_time=NOW() WHERE id=?",
+                    action, gatewayStatus, refundId);
+            actionStarted = true;
+            Map<String, String> response = parser.parse(gateway.doAction(actionRequest(attempt, action)));
+            if (!attempt.getMerchantId().equals(response.get("MerchantID"))
+                    || !attempt.getMerchantTradeNo().equals(response.get("MerchantTradeNo"))
+                    || !attempt.getTradeNo().equals(response.get("TradeNo"))) {
+                defer(refundId, "REFUND_RESPONSE_IDENTITY_MISMATCH", "MANUAL_REVIEW");
+            } else if (!"1".equals(response.get("RtnCode"))) {
+                defer(refundId, "REFUND_ACTION_REJECTED", "MANUAL_REVIEW");
+            }
+        } catch (Exception error) {
+            log.error("OMG refund result uncertain refundId={}", refundId, error);
+            defer(refundId, "GATEWAY_RESULT_UNKNOWN", actionStarted ? "ACTION_UNKNOWN" : priorStatus);
+        } finally {
+            jdbc.update("UPDATE pos_order_omg_refund SET lease_until=NULL WHERE id=?", refundId);
+        }
+    }
+
+    private OmgPaymentAttempt loadAttempt(Long attemptId) {
+        return jdbc.queryForObject("SELECT id, dd_id, merchant_trade_no, merchant_id, amount, "
+                        + "hash_key_snapshot, hash_iv_snapshot, trade_no, credit_refund_id, gateway_environment "
+                        + "FROM pos_order_omg_attempt WHERE id=? AND attempt_status=1",
+                (rs, n) -> {
+                    OmgPaymentAttempt attempt = new OmgPaymentAttempt();
+                    attempt.setId(rs.getLong("id"));
+                    attempt.setDdId(rs.getString("dd_id"));
+                    attempt.setMerchantTradeNo(rs.getString("merchant_trade_no"));
+                    attempt.setMerchantId(rs.getString("merchant_id"));
+                    attempt.setAmount(rs.getInt("amount"));
+                    attempt.setHashKeySnapshot(rs.getString("hash_key_snapshot"));
+                    attempt.setHashIvSnapshot(rs.getString("hash_iv_snapshot"));
+                    attempt.setTradeNo(rs.getString("trade_no"));
+                    attempt.setCreditRefundId(rs.getString("credit_refund_id"));
+                    attempt.setGatewayEnvironment(rs.getString("gateway_environment"));
+                    return attempt;
+                }, attemptId);
+    }
+
+    private String creditStatus(OmgPaymentAttempt attempt, String checkCode) {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", attempt.getMerchantId());
+        fields.put("CreditRefundId", attempt.getCreditRefundId());
+        fields.put("CreditAmount", String.valueOf(attempt.getAmount()));
+        fields.put("CreditCheckCode", checkCode);
+        fields.put("CheckMacValue", signer.sign(fields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
+        JSONObject response = JSON.parseObject(gateway.queryCredit(fields));
+        if (response == null || (response.getString("RtnMsg") != null
+                && !response.getString("RtnMsg").isBlank())) {
+            throw new IllegalStateException("OMG credit query rejected");
+        }
+        JSONObject value = response.getJSONObject("RtnValue");
+        if (value == null || !attempt.getCreditRefundId().equals(value.getString("TradeID"))
+                || attempt.getAmount() != value.getIntValue("amount")
+                || value.getString("status") == null) {
+            throw new IllegalStateException("OMG credit query identity mismatch");
+        }
+        JSONArray closeData = value.getJSONArray("close_data");
+        if (closeData != null) {
+            long recovered = 0;
+            for (int i = 0; i < closeData.size(); i++) {
+                JSONObject item = closeData.getJSONObject(i);
+                if (item != null && "銀行已追回".equals(item.getString("status"))) {
+                    recovered += item.getLongValue("amount");
+                }
+            }
+            if (recovered == attempt.getAmount()) {
+                return "FULL_REFUND_CONFIRMED";
+            }
+        }
+        return value.getString("status");
+    }
+
+    private Map<String, String> actionRequest(OmgPaymentAttempt attempt, String action) {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", attempt.getMerchantId());
+        fields.put("MerchantTradeNo", attempt.getMerchantTradeNo());
+        fields.put("TradeNo", attempt.getTradeNo());
+        fields.put("Action", action);
+        fields.put("TotalAmount", String.valueOf(attempt.getAmount()));
+        fields.put("CheckMacValue", signer.sign(fields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
+        return fields;
+    }
+
+    static String nextAction(String status) {
+        return switch (status) {
+            case "已授權" -> "N";
+            case "要關帳" -> "E";
+            case "已關帳" -> "R";
+            default -> null;
+        };
+    }
+
+    private boolean inAutoCloseWindow() {
+        LocalTime now = ZonedDateTime.now(clock.withZone(ZoneId.of("Asia/Taipei"))).toLocalTime();
+        return !now.isBefore(LocalTime.of(20, 15)) && now.isBefore(LocalTime.of(20, 30));
+    }
+
+    private void markRefunded(Long refundId, OmgPaymentAttempt attempt) {
+        jdbc.update("UPDATE pos_order_omg_refund SET status='REFUNDED', last_error=NULL, "
+                + "refund_time=NOW(), update_time=NOW() WHERE id=?", refundId);
+        jdbc.update("UPDATE pos_order SET pay_status=2 WHERE dd_id=? AND state=4 "
+                        + "AND pay_status=1 AND pay_type IN ('2','5') "
+                        + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_attempt a "
+                        + "LEFT JOIN pos_order_omg_refund r ON r.attempt_id=a.id "
+                        + "WHERE a.dd_id=pos_order.dd_id AND a.attempt_status=1 "
+                        + "AND (r.id IS NULL OR r.status!='REFUNDED'))", attempt.getDdId());
+    }
+
+    private void defer(Long refundId, String reason, String status) {
+        jdbc.update("UPDATE pos_order_omg_refund SET status=?, last_error=?, update_time=NOW() WHERE id=?",
+                status, reason, refundId);
+    }
+
+    private record RefundRow(Long attemptId, String status, String lastAction, String lastGatewayStatus) { }
 }
 }

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundTask.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.omgpay;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/** 由若依 sys_job 调度;每条意图有数据库租约,重复执行不会并发发送动作。 */
+@Component("omgPaymentRefundTask")
+public class OmgPaymentRefundTask {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentRefundTask.class);
+    private final OmgPaymentRefundService refunds;
+
+    public OmgPaymentRefundTask(OmgPaymentRefundService refunds) {
+        this.refunds = refunds;
+    }
+
+    public void reconcilePending() {
+        refunds.ensureOrphanedCapturedIntents();
+        for (Long id : refunds.pendingIds(100)) {
+            try {
+                refunds.processOne(id);
+            } catch (RuntimeException error) {
+                log.error("OMG refund reconciliation failed id={}", id, error);
+            }
+        }
+        refunds.reconcileRefundedOrderStatuses();
+    }
+}

+ 60 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgRefundGateway.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.omgpay;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** OMG 正式信用卡查询/退款 HTTP 边界;调用前由服务确认环境。 */
+@Component
+public class OmgRefundGateway {
+    private static final String QUERY_URL = "https://payment.funpoint.com.tw/CreditDetail/QueryTrade/V2";
+    private static final String ACTION_URL = "https://payment.funpoint.com.tw/CreditDetail/DoAction";
+    private static final int MAX_RESPONSE_BYTES = 32 * 1024;
+    private static final RequestConfig CONFIG = RequestConfig.custom()
+            .setConnectTimeout(5_000).setConnectionRequestTimeout(5_000)
+            .setSocketTimeout(10_000).build();
+
+    public String queryCredit(Map<String, String> fields) {
+        return post(QUERY_URL, fields);
+    }
+
+    public String doAction(Map<String, String> fields) {
+        return post(ACTION_URL, fields);
+    }
+
+    private String post(String url, Map<String, String> fields) {
+        List<NameValuePair> form = new ArrayList<>();
+        fields.forEach((name, value) -> form.add(new BasicNameValuePair(name, value)));
+        HttpPost post = new HttpPost(url);
+        post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8));
+        try (CloseableHttpClient client = HttpClients.custom()
+                .setDefaultRequestConfig(CONFIG).disableRedirectHandling().build();
+             CloseableHttpResponse response = client.execute(post)) {
+            int status = response.getStatusLine().getStatusCode();
+            if (status < 200 || status >= 300 || response.getEntity() == null
+                    || response.getEntity().getContentLength() > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG refund gateway returned invalid HTTP response");
+            }
+            byte[] bytes = EntityUtils.toByteArray(response.getEntity());
+            if (bytes.length > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG refund gateway response is oversized");
+            }
+            return new String(bytes, StandardCharsets.UTF_8);
+        } catch (IOException error) {
+            throw new IllegalStateException("OMG refund gateway unavailable", error);
+        }
+    }
+}

+ 22 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java

@@ -100,10 +100,18 @@ public class DeliveryOrderNotificationService {
         });
         });
     }
     }
 
 
-    /** 已分配骑手的外送订单取消后,通知除取消人外的相关方。 */
+    /** 外送订单取消后,通知除取消人外的相关方。 */
     public void notifyOrderCancelled(PosOrder order, Long operatorId) {
     public void notifyOrderCancelled(PosOrder order, Long operatorId) {
+        notifyOrderCancelled(order, operatorId, false);
+    }
+
+    public void notifyNoRiderCancelled(PosOrder order) {
+        notifyOrderCancelled(order, null, true);
+    }
+
+    private void notifyOrderCancelled(PosOrder order, Long operatorId, boolean noRiderTimeout) {
         if (order == null || !Long.valueOf(0L).equals(order.getType())
         if (order == null || !Long.valueOf(0L).equals(order.getType())
-                || order.getQsId() == null || infoUserService == null) {
+                || infoUserService == null) {
             return;
             return;
         }
         }
         runAfterCommit(() -> {
         runAfterCommit(() -> {
@@ -120,7 +128,7 @@ public class DeliveryOrderNotificationService {
             }
             }
             List<InfoUser> recipients = infoUserService.listByIds(recipientIds);
             List<InfoUser> recipients = infoUserService.listByIds(recipientIds);
             if (recipients != null) {
             if (recipients != null) {
-                recipients.forEach(recipient -> sendCancelledOrder(recipient, order));
+                recipients.forEach(recipient -> sendCancelledOrder(recipient, order, noRiderTimeout));
             }
             }
             if (merchantNotificationRouter != null && shouldNotifyMerchant(operatorId)) {
             if (merchantNotificationRouter != null && shouldNotifyMerchant(operatorId)) {
                 String ddId = String.valueOf(order.getDdId());
                 String ddId = String.valueOf(order.getDdId());
@@ -155,6 +163,10 @@ public class DeliveryOrderNotificationService {
     }
     }
 
 
     void sendCancelledOrder(InfoUser recipient, PosOrder order) {
     void sendCancelledOrder(InfoUser recipient, PosOrder order) {
+        sendCancelledOrder(recipient, order, false);
+    }
+
+    private void sendCancelledOrder(InfoUser recipient, PosOrder order, boolean noRiderTimeout) {
         if (recipient == null || recipient.getUserId() == null) {
         if (recipient == null || recipient.getUserId() == null) {
             return;
             return;
         }
         }
@@ -174,8 +186,11 @@ public class DeliveryOrderNotificationService {
                     // 只有原链路实际通知的用户带场景,取消操作者和非用户通知范围不变。
                     // 只有原链路实际通知的用户带场景,取消操作者和非用户通知范围不变。
                     String userBody = OrderPushBodyDto.getUserJson(ddId, "4", 0, 1,
                     String userBody = OrderPushBodyDto.getUserJson(ddId, "4", 0, 1,
                             UserNotificationScene.ORDER_CANCELLED);
                             UserNotificationScene.ORDER_CANCELLED);
+                    String messageKey = noRiderTimeout && "3".equals(order.getPayType())
+                            ? "no.message.push.order.no.rider.line.cancelled"
+                            : "no.message.push.order.cancelled";
                     PayPush.userPushHandleLocal(push, pushEventService, recipient.getUserId(), recipient.getCid(),
                     PayPush.userPushHandleLocal(push, pushEventService, recipient.getUserId(), recipient.getCid(),
-                            "no.message.push.message", "no.message.push.order.cancelled", userBody, "", ddId);
+                            "no.message.push.message", messageKey, userBody, "", ddId);
                 }
                 }
             }
             }
         });
         });
@@ -199,7 +214,9 @@ public class DeliveryOrderNotificationService {
     }
     }
 
 
     private boolean isAvailableForRider(PosOrder order) {
     private boolean isAvailableForRider(PosOrder order) {
-        return isPaidUnassignedDeliveryOrder(order) && !Integer.valueOf(1).equals(order.getSelfDelivery());
+        return isPaidUnassignedDeliveryOrder(order)
+                && Boolean.TRUE.equals(order.getIsDisplay())
+                && !Integer.valueOf(1).equals(order.getSelfDelivery());
     }
     }
 
 
     private boolean isPaidUnassignedDeliveryOrder(PosOrder order) {
     private boolean isPaidUnassignedDeliveryOrder(PosOrder order) {

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

@@ -171,7 +171,9 @@ public class OrderLifecycleService {
         if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(0L).equals(order.getState())
         if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(0L).equals(order.getState())
                 || !Long.valueOf(0L).equals(order.getDeliveryStatus())
                 || !Long.valueOf(0L).equals(order.getDeliveryStatus())
                 || !Long.valueOf(1L).equals(order.getPayStatus())
                 || !Long.valueOf(1L).equals(order.getPayStatus())
-                || !Long.valueOf(0L).equals(order.getAfterSaleStatus()) || order.getQsId() != null) {
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus()) || order.getQsId() != null
+                || !Boolean.TRUE.equals(order.getIsDisplay())
+                || Integer.valueOf(1).equals(order.getSelfDelivery())) {
             throw conflict();
             throw conflict();
         }
         }
         PosOrder update = new PosOrder();
         PosOrder update = new PosOrder();
@@ -284,8 +286,14 @@ public class OrderLifecycleService {
 
 
         PosOrder update = new PosOrder();
         PosOrder update = new PosOrder();
         update.setPayStatus(1L);
         update.setPayStatus(1L);
+        if (Long.valueOf(0L).equals(order.getType()) && order.getEffectiveAt() == null) {
+            update.setEffectiveAt(new Date());
+        }
         applyCas(order, update);
         applyCas(order, update);
         order.setPayStatus(1L);
         order.setPayStatus(1L);
+        if (update.getEffectiveAt() != null) {
+            order.setEffectiveAt(update.getEffectiveAt());
+        }
         logStatusChange(order, request, operatorId, operatorName,
         logStatusChange(order, request, operatorId, operatorName,
                 request.getExpectedState(), request.getExpectedDeliveryStatus(),
                 request.getExpectedState(), request.getExpectedDeliveryStatus(),
                 request.getExpectedPayStatus(), request.getExpectedAfterSaleStatus(),
                 request.getExpectedPayStatus(), request.getExpectedAfterSaleStatus(),
@@ -497,6 +505,12 @@ public class OrderLifecycleService {
                 wrapper.eq(PosOrder::getDeliveryStatus, order.getDeliveryStatus());
                 wrapper.eq(PosOrder::getDeliveryStatus, order.getDeliveryStatus());
             }
             }
         }
         }
+        if (update.getQsId() != null) {
+            wrapper.eq(PosOrder::getIsDisplay, true)
+                    .isNull(PosOrder::getQsId)
+                    .and(w -> w.isNull(PosOrder::getSelfDelivery)
+                            .or().eq(PosOrder::getSelfDelivery, 0));
+        }
         if (!posOrderService.update(update, wrapper)) {
         if (!posOrderService.update(update, wrapper)) {
             throw conflict();
             throw conflict();
         }
         }

+ 212 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutService.java

@@ -0,0 +1,212 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.ruoyi.app.omgpay.OmgPaymentRefundService;
+import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.utils.DateUtil;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+
+/** 独立外送单的预约开放和无人接单收口。每单取消在独立事务中完成。 */
+@Service
+public class PosOrderNoRiderTimeoutService {
+    private final IPosOrderService orders;
+    private final IPosOrderLinePaymentService linePayments;
+    private final LinePayRefundService lineRefunds;
+    private final OmgPaymentRefundService omgRefunds;
+    private final OrderLogHelper orderLog;
+    private final DeliveryOrderNotificationService notifications;
+    private final JdbcTemplate jdbc;
+
+    public PosOrderNoRiderTimeoutService(IPosOrderService orders,
+                                        IPosOrderLinePaymentService linePayments,
+                                        LinePayRefundService lineRefunds,
+                                        OmgPaymentRefundService omgRefunds,
+                                        OrderLogHelper orderLog,
+                                        DeliveryOrderNotificationService notifications,
+                                        JdbcTemplate jdbc) {
+        this.orders = orders;
+        this.linePayments = linePayments;
+        this.lineRefunds = lineRefunds;
+        this.omgRefunds = omgRefunds;
+        this.orderLog = orderLog;
+        this.notifications = notifications;
+        this.jdbc = jdbc;
+    }
+
+    public int timeoutMinutes() {
+        try {
+            List<SysDictData> values = DictUtils.getDictCache("pos_order_auto_cancel");
+            if (values != null && !values.isEmpty()) {
+                int minutes = Integer.parseInt(values.get(0).getDictValue());
+                if (minutes >= 1 && minutes <= 1440) {
+                    return minutes;
+                }
+            }
+        } catch (RuntimeException ignored) {
+            // 字典不可用时仍按已确认的 30 分钟口径处理。
+        }
+        return 30;
+    }
+
+    public List<PosOrder> scheduledToOpen(Date now, int limit) {
+        Date openBefore = Date.from(now.toInstant().plusSeconds((long) DateUtil.getAppoinmentOrderHandleTime() * 60));
+        return orders.list(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getType, 0L).eq(PosOrder::getState, 0L)
+                .eq(PosOrder::getPayStatus, 1L).eq(PosOrder::getDeliveryStatus, 0L)
+                .eq(PosOrder::getAfterSaleStatus, 0L).isNotNull(PosOrder::getEffectiveAt)
+                .eq(PosOrder::getIsDisplay, false).isNull(PosOrder::getQsId)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .isNotNull(PosOrder::getDelryTime).ne(PosOrder::getDelryTime, "")
+                .apply("STR_TO_DATE(LEFT(delry_time, 16), '%Y-%m-%d %H:%i') <= {0}", openBefore)
+                .orderByAsc(PosOrder::getId).last("LIMIT " + limit));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean openScheduled(Long id, Date now) {
+        PosOrder order = orders.getById(id);
+        if (order == null || !Long.valueOf(0L).equals(order.getType())
+                || !Long.valueOf(0L).equals(order.getState())
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || order.getEffectiveAt() == null
+                || Boolean.TRUE.equals(order.getIsDisplay()) || order.getQsId() != null
+                || Integer.valueOf(1).equals(order.getSelfDelivery())
+                || !order.getDdId().equals(order.getParentDdId())
+                || order.getDelryTime() == null || order.getDelryTime().isBlank()) {
+            return false;
+        }
+        Date start = appointmentStart(order.getDelryTime());
+        if (start == null || start.toInstant().isAfter(now.toInstant()
+                .plusSeconds((long) DateUtil.getAppoinmentOrderHandleTime() * 60))) {
+            return false;
+        }
+        boolean opened = orders.update(new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, id).eq(PosOrder::getType, 0L)
+                .eq(PosOrder::getState, 0L).eq(PosOrder::getPayStatus, 1L)
+                .eq(PosOrder::getDeliveryStatus, 0L).eq(PosOrder::getAfterSaleStatus, 0L)
+                .isNotNull(PosOrder::getEffectiveAt)
+                .eq(PosOrder::getIsDisplay, false).isNull(PosOrder::getQsId)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .eq(PosOrder::getDelryTime, order.getDelryTime())
+                .set(PosOrder::getIsDisplay, true).set(PosOrder::getDisplayTime, now));
+        if (opened) {
+            order.setIsDisplay(true);
+            order.setDisplayTime(now);
+            notifications.notifyOrderAvailable(order);
+        }
+        return opened;
+    }
+
+    public List<PosOrder> expiredCandidates(Date now, int minutes, int limit) {
+        Date cutoff = Date.from(now.toInstant().minusSeconds((long) minutes * 60));
+        return orders.list(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getType, 0L).eq(PosOrder::getState, 0L)
+                .eq(PosOrder::getDeliveryStatus, 0L).eq(PosOrder::getPayStatus, 1L)
+                .eq(PosOrder::getAfterSaleStatus, 0L).eq(PosOrder::getIsDisplay, true)
+                .isNull(PosOrder::getQsId).isNotNull(PosOrder::getEffectiveAt)
+                .le(PosOrder::getEffectiveAt, cutoff).le(PosOrder::getDisplayTime, cutoff)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .and(w -> w.isNull(PosOrder::getDelryTime).or().eq(PosOrder::getDelryTime, "")
+                        .or().apply("STR_TO_DATE(LEFT(delry_time, 16), '%Y-%m-%d %H:%i') <= {0}", cutoff))
+                .orderByAsc(PosOrder::getEffectiveAt).last("LIMIT " + limit));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean cancelExpired(Long id, Date now, int minutes) {
+        PosOrder order = orders.getById(id);
+        if (!eligible(order, now, minutes)) {
+            return false;
+        }
+        Date cutoff = Date.from(now.toInstant().minusSeconds((long) minutes * 60));
+        LambdaUpdateWrapper<PosOrder> guard = new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, id).eq(PosOrder::getType, 0L)
+                .eq(PosOrder::getState, 0L).eq(PosOrder::getDeliveryStatus, 0L)
+                .eq(PosOrder::getPayStatus, 1L).eq(PosOrder::getAfterSaleStatus, 0L)
+                .eq(PosOrder::getIsDisplay, true).isNull(PosOrder::getQsId)
+                .le(PosOrder::getEffectiveAt, cutoff).le(PosOrder::getDisplayTime, cutoff)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .set(PosOrder::getState, 4L);
+        if (order.getDelryTime() == null) {
+            guard.isNull(PosOrder::getDelryTime);
+        } else {
+            guard.eq(PosOrder::getDelryTime, order.getDelryTime());
+        }
+        boolean cancelled = orders.update(guard);
+        if (!cancelled) {
+            return false;
+        }
+        order.setState(4L);
+        if ("3".equals(order.getPayType())) {
+            for (PosOrderLinePayment payment : linePayments.getByDdId(order.getDdId())) {
+                if ("PAID".equals(payment.getStatus())) {
+                    lineRefunds.createRefundIntent(payment.getId(), "AUTO_NO_RIDER");
+                }
+            }
+        } else if (OrderLifecycleService.isCardWalletPayType(order.getPayType())) {
+            omgRefunds.createRefundIntent(order.getDdId(), "AUTO_NO_RIDER");
+        } else if (OrderLifecycleService.PAY_TYPE_TRANSFER.equals(order.getPayType())) {
+            jdbc.update("INSERT IGNORE INTO pos_order_manual_refund (dd_id, reason, status, create_time) VALUES (?, ?, 'PENDING', NOW())",
+                    order.getDdId(), "AUTO_NO_RIDER");
+        }
+        orderLog.logSync(order.getDdId(), 0, null, "SYSTEM", "超时无骑手自动取消");
+        notifications.notifyNoRiderCancelled(order);
+        return true;
+    }
+
+    static boolean eligible(PosOrder order, Date now, int minutes) {
+        if (order == null || !Long.valueOf(0L).equals(order.getType())
+                || !Long.valueOf(0L).equals(order.getState())
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || !Boolean.TRUE.equals(order.getIsDisplay()) || order.getQsId() != null
+                || Integer.valueOf(1).equals(order.getSelfDelivery())
+                || order.getEffectiveAt() == null || order.getDisplayTime() == null
+                || !order.getDdId().equals(order.getParentDdId())) {
+            return false;
+        }
+        Date start = appointmentStart(order.getDelryTime());
+        if (order.getDelryTime() != null && !order.getDelryTime().isBlank() && start == null) {
+            return false;
+        }
+        long baseline = Math.max(order.getEffectiveAt().getTime(), order.getDisplayTime().getTime());
+        if (start != null) {
+            baseline = Math.max(baseline, start.getTime());
+        }
+        return baseline <= now.getTime() - (long) minutes * 60_000;
+    }
+
+    static Date appointmentStart(String window) {
+        if (window == null || window.isBlank()) {
+            return null;
+        }
+        try {
+            String[] parts = window.trim().split(" ");
+            String start = parts[1].split("-")[0];
+            return Date.from(LocalDateTime.parse(parts[0] + "T" + start)
+                    .atZone(ZoneId.of("Asia/Taipei")).toInstant());
+        } catch (RuntimeException invalid) {
+            return null;
+        }
+    }
+}

+ 42 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutTask.java

@@ -0,0 +1,42 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.PosOrder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+
+/** 由若依 sys_job 每分钟调度,事务由逐单服务方法提供。 */
+@Component("posOrderNoRiderTimeoutTask")
+public class PosOrderNoRiderTimeoutTask {
+    private static final Logger log = LoggerFactory.getLogger(PosOrderNoRiderTimeoutTask.class);
+    private final PosOrderNoRiderTimeoutService service;
+
+    public PosOrderNoRiderTimeoutTask(PosOrderNoRiderTimeoutService service) {
+        this.service = service;
+    }
+
+    public void openScheduledOrders() {
+        Date now = new Date();
+        for (PosOrder order : service.scheduledToOpen(now, 200)) {
+            try {
+                service.openScheduled(order.getId(), new Date());
+            } catch (RuntimeException error) {
+                log.error("Open scheduled food order failed, id={}", order.getId(), error);
+            }
+        }
+    }
+
+    public void cancelExpiredOrders() {
+        Date now = new Date();
+        int minutes = service.timeoutMinutes();
+        for (PosOrder order : service.expiredCandidates(now, minutes, 200)) {
+            try {
+                service.cancelExpired(order.getId(), new Date(), minutes);
+            } catch (RuntimeException error) {
+                log.error("Cancel no-rider food order failed, id={}", order.getId(), error);
+            }
+        }
+    }
+}

+ 100 - 44
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -361,50 +361,7 @@ public class PosOrderQsOprateController extends BaseController {
             }
             }
         }
         }
 
 
-        LambdaQueryWrapper<PosOrder> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(PosOrder::getIsDisplay,true);
-        switch (tab) {
-            case "newTask":
-                wrapper.eq(PosOrder::getType, 0L).eq(PosOrder::getDeliveryStatus, 0L)
-                        .eq(PosOrder::getState, 0L).eq(PosOrder::getPayStatus, 1L)
-                        .eq(PosOrder::getAfterSaleStatus, 0L).isNull(PosOrder::getQsId)
-                        .eq(PosOrder::getSelfDelivery, 0);
-                if (longitude != null && latitude != null) {
-                    // 距离上限(公里)从字典取,未配置则不过滤
-                    Integer limitKm = getNewTaskDistanceLimit();
-                    if (limitKm != null) {
-                        long limitMeters = limitKm.longValue() * 1000L;
-                        wrapper.apply("ST_Distance_Sphere(point(longitude, latitude), point({0}, {1})) <= {2}",
-                                longitude, latitude, limitMeters);
-                    }
-                    wrapper.last("ORDER BY ST_Distance_Sphere(point(longitude, latitude), point(" + longitude + ", " + latitude + ")) ASC");
-                } else {
-                    wrapper.orderByDesc(PosOrder::getCretim);
-                }
-                break;
-            case "toPickup":
-                wrapper.eq(PosOrder::getDeliveryStatus, 1L).eq(PosOrder::getQsId, riderId)
-                        .in(PosOrder::getState, 0L, 1L, 2L)
-                        .eq(PosOrder::getAfterSaleStatus, 0L).orderByAsc(PosOrder::getCretim);
-                break;
-            case "delivering":
-                wrapper.eq(PosOrder::getDeliveryStatus, 2L).eq(PosOrder::getQsId, riderId)
-                        .eq(PosOrder::getState, 2L).eq(PosOrder::getAfterSaleStatus, 0L)
-                        .orderByAsc(PosOrder::getCretim);
-                break;
-                //已送达
-            case "completed":
-                wrapper.eq(PosOrder::getDeliveryStatus, 3L).eq(PosOrder::getAfterSaleStatus, 0L).eq(PosOrder::getQsId, riderId).orderByDesc(PosOrder::getCretim);
-                break;
-            case "cancelled":
-                wrapper.eq(PosOrder::getState, 4L).eq(PosOrder::getQsId, riderId).eq(PosOrder::getAfterSaleStatus, 0L).orderByDesc(PosOrder::getCretim);
-                break;
-            case "refund":
-                wrapper.gt(PosOrder::getAfterSaleStatus, 0L).eq(PosOrder::getQsId, riderId).orderByDesc(PosOrder::getCretim);
-                break;
-            default:
-                break;
-        }
+        LambdaQueryWrapper<PosOrder> wrapper = orderTabWrapper(tab, riderId, longitude, latitude, true);
 
 
         com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> pageParam = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
         com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> pageParam = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
         com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> result = posOrderService.page(pageParam, wrapper);
         com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> result = posOrderService.page(pageParam, wrapper);
@@ -448,6 +405,105 @@ public class PosOrderQsOprateController extends BaseController {
         return success(org);
         return success(org);
     }
     }
 
 
+    /**
+     * 骑手端订单角标数量:一次返回美食订单 newTask/toPickup/delivering 三个页签的订单数,
+     * App 无需逐页签切换请求。newTask 过滤条件(含可选距离上限)与 orderList 完全一致;
+     * 骑手未开通美食配送时 newTask 计 0(列表接口保持原有报错行为)。
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/orderCount")
+    public AjaxResult orderCount(@RequestHeader String token,
+                                 @RequestParam(required = false) BigDecimal longitude,
+                                 @RequestParam(required = false) BigDecimal latitude) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String qsId = jwtUtil.getusid(token);
+        Long riderId = Long.valueOf(qsId);
+        InfoUser rider = requireRider(qsId);
+
+        long newTaskCount = 0L;
+        if (rider.supportsDeliveryType("FOOD")) {
+            newTaskCount = posOrderService.count(orderTabWrapper("newTask", riderId, longitude, latitude, false));
+        }
+        long toPickupCount = posOrderService.count(orderTabWrapper("toPickup", riderId, null, null, false));
+        long deliveringCount = posOrderService.count(orderTabWrapper("delivering", riderId, null, null, false));
+
+        JSONObject org = new JSONObject();
+        org.put("newTask", newTaskCount);
+        org.put("toPickup", toPickupCount);
+        org.put("delivering", deliveringCount);
+        return success(org);
+    }
+
+    /**
+     * 按页签构建骑手订单查询条件;orderList 分页与 orderCount 计数共用,保证角标数量与列表口径一致。
+     * withOrder 为 false 时跳过排序子句,仅用于计数。
+     */
+    private LambdaQueryWrapper<PosOrder> orderTabWrapper(String tab, Long riderId, BigDecimal longitude,
+                                                         BigDecimal latitude, boolean withOrder) {
+        LambdaQueryWrapper<PosOrder> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(PosOrder::getIsDisplay,true);
+        switch (tab) {
+            case "newTask":
+                wrapper.eq(PosOrder::getType, 0L).eq(PosOrder::getDeliveryStatus, 0L)
+                        .eq(PosOrder::getState, 0L).eq(PosOrder::getPayStatus, 1L)
+                        .eq(PosOrder::getAfterSaleStatus, 0L).isNull(PosOrder::getQsId)
+                        .eq(PosOrder::getSelfDelivery, 0);
+                if (longitude != null && latitude != null) {
+                    // 距离上限(公里)从字典取,未配置则不过滤
+                    Integer limitKm = getNewTaskDistanceLimit();
+                    if (limitKm != null) {
+                        long limitMeters = limitKm.longValue() * 1000L;
+                        wrapper.apply("ST_Distance_Sphere(point(longitude, latitude), point({0}, {1})) <= {2}",
+                                longitude, latitude, limitMeters);
+                    }
+                    if (withOrder) {
+                        wrapper.last("ORDER BY ST_Distance_Sphere(point(longitude, latitude), point(" + longitude + ", " + latitude + ")) ASC");
+                    }
+                } else if (withOrder) {
+                    wrapper.orderByDesc(PosOrder::getCretim);
+                }
+                break;
+            case "toPickup":
+                wrapper.eq(PosOrder::getDeliveryStatus, 1L).eq(PosOrder::getQsId, riderId)
+                        .in(PosOrder::getState, 0L, 1L, 2L)
+                        .eq(PosOrder::getAfterSaleStatus, 0L);
+                if (withOrder) {
+                    wrapper.orderByAsc(PosOrder::getCretim);
+                }
+                break;
+            case "delivering":
+                wrapper.eq(PosOrder::getDeliveryStatus, 2L).eq(PosOrder::getQsId, riderId)
+                        .eq(PosOrder::getState, 2L).eq(PosOrder::getAfterSaleStatus, 0L);
+                if (withOrder) {
+                    wrapper.orderByAsc(PosOrder::getCretim);
+                }
+                break;
+                //已送达
+            case "completed":
+                wrapper.eq(PosOrder::getDeliveryStatus, 3L).eq(PosOrder::getAfterSaleStatus, 0L).eq(PosOrder::getQsId, riderId);
+                if (withOrder) {
+                    wrapper.orderByDesc(PosOrder::getCretim);
+                }
+                break;
+            case "cancelled":
+                wrapper.eq(PosOrder::getState, 4L).eq(PosOrder::getQsId, riderId).eq(PosOrder::getAfterSaleStatus, 0L);
+                if (withOrder) {
+                    wrapper.orderByDesc(PosOrder::getCretim);
+                }
+                break;
+            case "refund":
+                wrapper.gt(PosOrder::getAfterSaleStatus, 0L).eq(PosOrder::getQsId, riderId);
+                if (withOrder) {
+                    wrapper.orderByDesc(PosOrder::getCretim);
+                }
+                break;
+            default:
+                break;
+        }
+        return wrapper;
+    }
+
     /**
     /**
      * 返回订单详情,带上store、user信息(返回格式与 orderList 列表项一致,仅返回单条)
      * 返回订单详情,带上store、user信息(返回格式与 orderList 列表项一致,仅返回单条)
      * @param token
      * @param token

+ 20 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -633,7 +633,14 @@ public class PosOrderShOprateController extends BaseController {
         PosOrder update = new PosOrder();
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setId(order.getId());
         update.setPayStatus(1L);
         update.setPayStatus(1L);
-        posOrderService.saveOrUpdate(update);
+        if (Long.valueOf(0L).equals(order.getType()) && order.getEffectiveAt() == null) {
+            update.setEffectiveAt(new Date());
+        }
+        if (!posOrderService.update(update, new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, order.getId()).eq(PosOrder::getPayType, OrderLifecycleService.PAY_TYPE_CASH)
+                .eq(PosOrder::getPayStatus, 0L).ne(PosOrder::getState, 4L))) {
+            throw new ServiceException(MessageUtils.message("no.order.cash.cancelled"));
+        }
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "确认现金收款");
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "确认现金收款");
@@ -666,7 +673,18 @@ public class PosOrderShOprateController extends BaseController {
         PosOrder update = new PosOrder();
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setId(order.getId());
         update.setPayStatus(1L);
         update.setPayStatus(1L);
-        posOrderService.saveOrUpdate(update);
+        if (Long.valueOf(0L).equals(order.getType()) && order.getEffectiveAt() == null) {
+            update.setEffectiveAt(new Date());
+        }
+        if (!posOrderService.update(update, new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, order.getId()).eq(PosOrder::getPayType, OrderLifecycleService.PAY_TYPE_TRANSFER)
+                .eq(PosOrder::getPayStatus, 0L).ne(PosOrder::getState, 4L))) {
+            throw new ServiceException(MessageUtils.message("no.order.transfer.cancelled"));
+        }
+        order.setPayStatus(1L);
+        if (update.getEffectiveAt() != null) {
+            order.setEffectiveAt(update.getEffectiveAt());
+        }
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "确认转账收款");
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "确认转账收款");

+ 4 - 85
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -255,7 +255,6 @@ public class UserOrderController extends BaseController {
             String subddId = isMultiStore ? ddId + String.format("%03d", index) : ddId;
             String subddId = isMultiStore ? ddId + String.format("%03d", index) : ddId;
             PosOrder posOrder = new PosOrder();
             PosOrder posOrder = new PosOrder();
             posOrder.setIsDisplay(false);
             posOrder.setIsDisplay(false);
-            handleIsDisplay(posOrder);
             posOrder.setShdzId(input.getShdzId());
             posOrder.setShdzId(input.getShdzId());
             // 设置子订单的基本信息
             // 设置子订单的基本信息
             posOrder.setDdId(subddId);
             posOrder.setDdId(subddId);
@@ -269,6 +268,7 @@ public class UserOrderController extends BaseController {
             posOrder.setRemarks(item.getRemarks());
             posOrder.setRemarks(item.getRemarks());
             posOrder.setType(item.getType());
             posOrder.setType(item.getType());
             posOrder.setDelryTime(item.getDelryTime());
             posOrder.setDelryTime(item.getDelryTime());
+            handleIsDisplay(posOrder);
             posOrder.setFood(JSON.toJSONString(item.getFood()));
             posOrder.setFood(JSON.toJSONString(item.getFood()));
             if("1".equals(input.getPaymentMethod())){
             if("1".equals(input.getPaymentMethod())){
                 posOrder.setCollectPayment("1");
                 posOrder.setCollectPayment("1");
@@ -299,6 +299,9 @@ public class UserOrderController extends BaseController {
             // 外送到付(payType=1):payStatus=1;现金(4)自取/堂食同到付线下收款语义:payStatus=1;其他:payStatus=0
             // 外送到付(payType=1):payStatus=1;现金(4)自取/堂食同到付线下收款语义:payStatus=1;其他:payStatus=0
             if (item.getType() != null && item.getType() == 0 && "1".equals(input.getPaymentMethod())) {
             if (item.getType() != null && item.getType() == 0 && "1".equals(input.getPaymentMethod())) {
                 posOrder.setPayStatus(1L);
                 posOrder.setPayStatus(1L);
+                if (!isMultiStore) {
+                    posOrder.setEffectiveAt(new Date());
+                }
             } else if ("4".equals(input.getPaymentMethod())
             } else if ("4".equals(input.getPaymentMethod())
                     && (item.getType() != null && (item.getType() == 1 || item.getType() == 2))) {
                     && (item.getType() != null && (item.getType() == 1 || item.getType() == 2))) {
                 posOrder.setPayStatus(1L);
                 posOrder.setPayStatus(1L);
@@ -566,90 +569,6 @@ public class UserOrderController extends BaseController {
         return success(distance);
         return success(distance);
     }
     }
 
 
-    /**
-     * 用户端订单列表(Tab分页)
-     */
-    @GetMapping("/orderList")
-    @Auth
-    @Anonymous
-    public AjaxResult orderList(@RequestHeader String token,
-                                @RequestParam(defaultValue = "1") int page,
-                                @RequestParam(defaultValue = "10") int size,
-                                @RequestParam(required = false) String tab,
-                                @RequestParam(required = false) String type) {
-        JwtUtil jwtUtil = new JwtUtil();
-        String userId = jwtUtil.getusid(token);
-
-        LambdaQueryWrapper<PosOrder> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(PosOrder::getUserId, Long.valueOf(userId));
-
-        // 类型筛选
-        if (type != null && !type.isEmpty()) {
-            wrapper.eq(PosOrder::getType, Long.valueOf(type));
-        }
-
-        applyTabFilter(wrapper, tab);
-        wrapper.orderByDesc(PosOrder::getCretim);
-
-        // 分页
-        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> pageParam =
-                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
-        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> result = posOrderService.page(pageParam, wrapper);
-
-        // 附加关联数据
-        for (PosOrder order : result.getRecords()) {
-            enrichOrderData(order);
-        }
-
-        java.util.Map<String, Object> data = new java.util.LinkedHashMap<>();
-        data.put("records", result.getRecords());
-        data.put("total", result.getTotal());
-        data.put("page", page);
-        data.put("size", size);
-        return success(result);
-    }
-
-    /** 订单列表页签过滤条件(031 US7 抽取便于单测):unpaid 仅在线支付;active 放行到付与现金自取/堂食。 */
-    static void applyTabFilter(LambdaQueryWrapper<PosOrder> wrapper, String tab) {
-        switch (tab) {
-            case "unpaid":
-                // 在线支付订单不区分配送类型;自取/堂食未付款也必须先留在待付款。
-                wrapper.eq(PosOrder::getPayStatus, 0L)
-                       .in(PosOrder::getPayType, "2", "3")
-                       .ne(PosOrder::getState, 4L)
-                       .eq(PosOrder::getAfterSaleStatus, 0L);
-                break;
-            case "active":
-                wrapper.in(PosOrder::getState, 0L, 1L, 2L)
-                       .eq(PosOrder::getAfterSaleStatus, 0L)
-                       // 到付/现金自取堂食可以线下收款;OMG、LINE Pay 等在线订单必须已付款才能履约。
-                       .and(w -> w.eq(PosOrder::getPayStatus, 1L)
-                               .or(cod -> cod.in(PosOrder::getPayType, "1", "4")
-                                       .in(PosOrder::getType, 1L, 2L)));
-                break;
-            case "completed":
-                wrapper.eq(PosOrder::getState, 3L).eq(PosOrder::getAfterSaleStatus, 0L);
-                break;
-            case "cancelled":
-                wrapper.eq(PosOrder::getState, 4L).eq(PosOrder::getAfterSaleStatus, 0L);
-                break;
-            case "refund":
-                wrapper.gt(PosOrder::getAfterSaleStatus, 0L);
-                break;
-            default:
-               break;
-        }
-    }
-
-    private void enrichOrderData(PosOrder order) {
-        try {
-            if (order.getShId() != null) {
-                InfoUser shanghu = infoUserService.getById(order.getShId());
-                order.setPriceAll(shanghu != null ? String.valueOf(shanghu.getNickName()) : null);
-            }
-        } catch (Exception ignored) {}
-    }
-
     /**
     /**
      * 确认取餐(自取类型订单,state=2 → state=3)
      * 确认取餐(自取类型订单,state=2 → state=3)
      */
      */

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java

@@ -193,6 +193,24 @@ public class LinePayService {
         return reconcilePayment(paymentId, source, null);
         return reconcilePayment(paymentId, source, null);
     }
     }
 
 
+    /** 支付方式切换前只读取网关收款事实;不确认付款,查询失败则拒绝切换。 */
+    public boolean isCapturedForSwitch(PosOrderLinePayment payment) {
+        PosStoreLinePay credential = credentialService.getById(payment.getCredentialId());
+        if (credential == null) {
+            throw new IllegalStateException("LINE Pay credential version not found");
+        }
+        try {
+            LinePayResponse response = retrieveWithCredentialRecovery(payment, "PAY_SWITCH",
+                    credential, toCredential(credential)).response();
+            if (response == null || !(response.isSuccess() || "1150".equals(response.returnCode()))) {
+                throw new IllegalStateException("LINE Pay switch query is inconclusive");
+            }
+            return capturedPayment(response, payment) != null;
+        } catch (Exception error) {
+            throw new IllegalStateException("LINE Pay switch query failed", error);
+        }
+    }
+
     public String reconcilePayment(Long paymentId, String source, String reconcileLeaseOwner) {
     public String reconcilePayment(Long paymentId, String source, String reconcileLeaseOwner) {
         PosOrderLinePayment payment = paymentService.getById(paymentId);
         PosOrderLinePayment payment = paymentService.getById(paymentId);
         if (payment == null) {
         if (payment == null) {

+ 116 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchService.java

@@ -1,9 +1,12 @@
 package com.ruoyi.app.pay;
 package com.ruoyi.app.pay;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.ruoyi.app.omgpay.OmgPaymentCreateService;
 import com.ruoyi.app.omgpay.OmgPaymentCreateService;
+import com.ruoyi.app.omgpay.OmgPaymentQueryService;
 import com.ruoyi.app.omgpay.OmgPaymentRetryService;
 import com.ruoyi.app.omgpay.OmgPaymentRetryService;
 import com.ruoyi.app.omgpay.OmgPaymentMethod;
 import com.ruoyi.app.omgpay.OmgPaymentMethod;
+import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.order.OrderLifecycleService;
 import com.ruoyi.app.order.OrderLifecycleService;
 import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
 import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -11,12 +14,19 @@ import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
 import com.ruoyi.system.utils.OrderLogHelper;
 import com.ruoyi.system.utils.OrderLogHelper;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
+import java.util.Date;
+import java.util.List;
 import java.util.Map;
 import java.util.Map;
 
 
 /**
 /**
@@ -45,17 +55,39 @@ public class PayMethodSwitchService {
     private final LinePayService linePayService;
     private final LinePayService linePayService;
     private final OrderLogHelper orderLogHelper;
     private final OrderLogHelper orderLogHelper;
     private final IInfoUserService infoUserService;
     private final IInfoUserService infoUserService;
+    private final IPosOrderLinePaymentService linePayments;
+    private final IOmgPaymentAttemptService omgAttempts;
+    private final OmgPaymentQueryService omgQueryService;
+    private final DeliveryOrderNotificationService notifications;
 
 
     public PayMethodSwitchService(IPosOrderService orderService,
     public PayMethodSwitchService(IPosOrderService orderService,
                                   OmgPaymentRetryService omgPaymentRetryService,
                                   OmgPaymentRetryService omgPaymentRetryService,
                                   LinePayService linePayService,
                                   LinePayService linePayService,
                                   OrderLogHelper orderLogHelper,
                                   OrderLogHelper orderLogHelper,
                                   IInfoUserService infoUserService) {
                                   IInfoUserService infoUserService) {
+        this(orderService, omgPaymentRetryService, linePayService, orderLogHelper,
+                infoUserService, null, null, null, null);
+    }
+
+    @Autowired
+    public PayMethodSwitchService(IPosOrderService orderService,
+                                  OmgPaymentRetryService omgPaymentRetryService,
+                                  LinePayService linePayService,
+                                  OrderLogHelper orderLogHelper,
+                                  IInfoUserService infoUserService,
+                                  IPosOrderLinePaymentService linePayments,
+                                  IOmgPaymentAttemptService omgAttempts,
+                                  OmgPaymentQueryService omgQueryService,
+                                  DeliveryOrderNotificationService notifications) {
         this.orderService = orderService;
         this.orderService = orderService;
         this.omgPaymentRetryService = omgPaymentRetryService;
         this.omgPaymentRetryService = omgPaymentRetryService;
         this.linePayService = linePayService;
         this.linePayService = linePayService;
         this.orderLogHelper = orderLogHelper;
         this.orderLogHelper = orderLogHelper;
         this.infoUserService = infoUserService;
         this.infoUserService = infoUserService;
+        this.linePayments = linePayments;
+        this.omgAttempts = omgAttempts;
+        this.omgQueryService = omgQueryService;
+        this.notifications = notifications;
     }
     }
 
 
     /**
     /**
@@ -68,17 +100,50 @@ public class PayMethodSwitchService {
         String orderId = normalizeOrderId(request == null ? null : request.getOrderId());
         String orderId = normalizeOrderId(request == null ? null : request.getOrderId());
         String method = normalizeMethod(request == null ? null : request.getPaymentMethod());
         String method = normalizeMethod(request == null ? null : request.getPaymentMethod());
         PosOrder order = requireSwitchableOrder(userId, orderId);
         PosOrder order = requireSwitchableOrder(userId, orderId);
-        // 到付/现金是线下收款,仅自取/堂食可用(与下单现金闸门同规则,外送单不放行)
+        // 外送单允许切到到付,现金仍只允许自取/堂食。
         requireOfflineAllowed(method, order);
         requireOfflineAllowed(method, order);
 
 
         String previous = order.getPayType();
         String previous = order.getPayType();
-        if (!method.equals(previous)) {
-            // 先回写再拉起:两个渠道的创建校验都按 order.payType 把关,顺序颠倒会被拒
-            order.setPayType(method);
-            if (!orderService.updateById(order)) {
+        boolean deliveryCod = OrderLifecycleService.PAY_TYPE_OFFLINE.equals(method)
+                && Long.valueOf(0L).equals(order.getType());
+        Date effectiveAt = deliveryCod ? new Date() : null;
+        if (deliveryCod && (!Long.valueOf(0L).equals(order.getState())
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || order.getQsId() != null)) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        if (!method.equals(previous) || deliveryCod) {
+            if (deliveryCod) {
+                verifyNoCapturedPayment(userId, orderId);
+            }
+            LambdaUpdateWrapper<PosOrder> update = new LambdaUpdateWrapper<PosOrder>()
+                    .eq(PosOrder::getDdId, orderId)
+                    .eq(PosOrder::getUserId, userId)
+                    .eq(PosOrder::getPayType, previous)
+                    .eq(PosOrder::getPayStatus, 0L)
+                    .isNull(PosOrder::getEffectiveAt)
+                    .in(PosOrder::getState, 0L, 1L, 2L)
+                    .set(PosOrder::getPayType, method);
+            if (deliveryCod) {
+                update.set(PosOrder::getPayStatus, 1L)
+                        .set(PosOrder::getEffectiveAt, effectiveAt)
+                        .eq(PosOrder::getType, 0L)
+                        .eq(PosOrder::getDeliveryStatus, 0L)
+                        .eq(PosOrder::getAfterSaleStatus, 0L)
+                        .isNull(PosOrder::getQsId);
+            }
+            if (!orderService.update(update)) {
                 throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
                 throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
             }
             }
-            writeSwitchLog(orderId, userId, previous, method);
+            if (!method.equals(previous)) {
+                writeSwitchLog(orderId, userId, previous, method);
+            }
+            if (deliveryCod && notifications != null) {
+                order.setPayStatus(1L);
+                order.setEffectiveAt(effectiveAt);
+                notifications.notifyOrderAvailable(order);
+            }
         }
         }
 
 
         Map<String, Object> data = new LinkedHashMap<>();
         Map<String, Object> data = new LinkedHashMap<>();
@@ -88,6 +153,48 @@ public class PayMethodSwitchService {
         return AjaxResult.success(data);
         return AjaxResult.success(data);
     }
     }
 
 
+    /** 查询未结尝试;已收款或查证失败时停止切换,迟到实收由渠道退款意图兜底。 */
+    private void verifyNoCapturedPayment(Long userId, String orderId) {
+        if (linePayments != null) {
+            List<PosOrderLinePayment> payments = linePayments.getByDdId(orderId);
+            if (payments == null) {
+                throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+            }
+            for (PosOrderLinePayment payment : payments) {
+                if ("PAID".equals(payment.getStatus())) {
+                    throw new ServiceException(MessageUtils.message("no.pay.switch.already.paid"));
+                }
+                if (!List.of("FAILED", "CANCELLED_OR_EXPIRED", "REFUNDED").contains(payment.getStatus())) {
+                    if ("MANUAL_REVIEW".equals(payment.getStatus())
+                            || "CONFIRM_UNKNOWN".equals(payment.getStatus())) {
+                        throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+                    }
+                    try {
+                        if (linePayService.isCapturedForSwitch(payment)) {
+                            throw new ServiceException(MessageUtils.message("no.pay.switch.already.paid"));
+                        }
+                    } catch (ServiceException known) {
+                        throw known;
+                    } catch (RuntimeException inconclusive) {
+                        throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+                    }
+                }
+            }
+        }
+        if (omgAttempts != null) {
+            if (omgAttempts.selectPaidByDdId(orderId) != null) {
+                throw new ServiceException(MessageUtils.message("no.pay.switch.already.paid"));
+            }
+            OmgPaymentAttempt created = omgAttempts.selectActiveCreatedByDdId(orderId);
+            if (created != null) {
+                String status = omgQueryService.reconcile(created).status();
+                if (!"UNPAID".equals(status) && !"FAILED".equals(status)) {
+                    throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+                }
+            }
+        }
+    }
+
     /** 按新方式路由到对应渠道拉起支付;到付/现金无支付环节返回 null。 */
     /** 按新方式路由到对应渠道拉起支付;到付/现金无支付环节返回 null。 */
     private Object createPayment(Long userId, String orderId, String method) {
     private Object createPayment(Long userId, String orderId, String method) {
         if (OrderLifecycleService.isCardWalletPayType(method)) {
         if (OrderLifecycleService.isCardWalletPayType(method)) {
@@ -127,11 +234,10 @@ public class PayMethodSwitchService {
         return order;
         return order;
     }
     }
 
 
-    /** 到付(1)/现金(4) 仅自取(type=1)/堂食(type=2)订单可切换,外送单保持仅商家创建时可用。 */
+    /** 外送可切到付(1);现金(4)仅自取/堂食。 */
     private void requireOfflineAllowed(String method, PosOrder order) {
     private void requireOfflineAllowed(String method, PosOrder order) {
-        boolean offline = OrderLifecycleService.PAY_TYPE_OFFLINE.equals(method)
-                || OrderLifecycleService.PAY_TYPE_CASH.equals(method);
-        if (offline && (order.getType() == null || (order.getType() != 1L && order.getType() != 2L))) {
+        if (OrderLifecycleService.PAY_TYPE_CASH.equals(method)
+                && (order.getType() == null || (order.getType() != 1L && order.getType() != 2L))) {
             throw new ServiceException(MessageUtils.message("no.pay.switch.offline.restricted"));
             throw new ServiceException(MessageUtils.message("no.pay.switch.offline.restricted"));
         }
         }
     }
     }

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/QualificationSaveRequest.java

@@ -10,9 +10,18 @@ public class QualificationSaveRequest {
     private String responsibleName;
     private String responsibleName;
     private String contactPhone;
     private String contactPhone;
     private String identityNumber;
     private String identityNumber;
+    private String email;
+    private String stallIntroduction;
+    private String bankAccountName;
+    private String bankName;
+    private String bankAccountNo;
     /** 新上传传商品图片路径,旧申请可保留本摊位私有附件引用;服务端验证文件。 */
     /** 新上传传商品图片路径,旧申请可保留本摊位私有附件引用;服务端验证文件。 */
     private List<String> identityImageUrls;
     private List<String> identityImageUrls;
     private List<String> stallImageUrls;
     private List<String> stallImageUrls;
+    private List<String> bankProofImageUrls;
+    private List<String> businessRegistrationImageUrls;
+    private List<String> directorScreenshotImageUrls;
+    private List<String> uboDeclarationImageUrls;
     private String foodRegistrationNumber;
     private String foodRegistrationNumber;
     private List<String> foodRegistrationImageUrls;
     private List<String> foodRegistrationImageUrls;
 }
 }

+ 25 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/stall/qualification/StallQualificationService.java

@@ -68,9 +68,21 @@ public class StallQualificationService {
         application.setResponsibleName(text(request.getResponsibleName(), 100));
         application.setResponsibleName(text(request.getResponsibleName(), 100));
         application.setContactPhone(text(request.getContactPhone(), 32));
         application.setContactPhone(text(request.getContactPhone(), 32));
         application.setIdentityNumber(text(request.getIdentityNumber(), 64));
         application.setIdentityNumber(text(request.getIdentityNumber(), 64));
+        application.setEmail(text(request.getEmail(), 255));
+        application.setStallIntroduction(text(request.getStallIntroduction(), 1000));
+        application.setBankAccountName(text(request.getBankAccountName(), 100));
+        application.setBankName(text(request.getBankName(), 100));
+        application.setBankAccountNo(text(request.getBankAccountNo(), 64));
+        if (!application.getEmail().isEmpty() && !application.getEmail().matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")) {
+            throw error("stall.qualification.input.invalid");
+        }
         application.setFoodRegistrationNumber(text(request.getFoodRegistrationNumber(), 64));
         application.setFoodRegistrationNumber(text(request.getFoodRegistrationNumber(), 64));
         application.setIdentityImageUrls(images(storeId, request.getIdentityImageUrls()));
         application.setIdentityImageUrls(images(storeId, request.getIdentityImageUrls()));
         application.setStallImageUrls(images(storeId, request.getStallImageUrls()));
         application.setStallImageUrls(images(storeId, request.getStallImageUrls()));
+        application.setBankProofImageUrls(images(storeId, request.getBankProofImageUrls()));
+        application.setBusinessRegistrationImageUrls(images(storeId, request.getBusinessRegistrationImageUrls()));
+        application.setDirectorScreenshotImageUrls(images(storeId, request.getDirectorScreenshotImageUrls()));
+        application.setUboDeclarationImageUrls(images(storeId, request.getUboDeclarationImageUrls()));
         application.setFoodRegistrationImageUrls(images(storeId, request.getFoodRegistrationImageUrls()));
         application.setFoodRegistrationImageUrls(images(storeId, request.getFoodRegistrationImageUrls()));
         application.setStallNameSnapshot(store.getPosName()); application.setStallAddressSnapshot(store.getAddress());
         application.setStallNameSnapshot(store.getPosName()); application.setStallAddressSnapshot(store.getAddress());
         application.setNightMarketIdSnapshot(store.getNightMarketId());
         application.setNightMarketIdSnapshot(store.getNightMarketId());
@@ -111,7 +123,7 @@ public class StallQualificationService {
 
 
     public StallQualificationApplication adminApplication(Long id) {
     public StallQualificationApplication adminApplication(Long id) {
         StallQualificationApplication application = id == null ? null : mapper.selectApplication(id);
         StallQualificationApplication application = id == null ? null : mapper.selectApplication(id);
-        if (application == null) { throw error("stall.qualification.not.found"); }
+        if (application == null || Integer.valueOf(DRAFT).equals(application.getStatus())) { throw error("stall.qualification.not.found"); }
         return application;
         return application;
     }
     }
     public List<StallQualificationSummary> adminList(int pageNum, int pageSize, Integer status, Long storeId) {
     public List<StallQualificationSummary> adminList(int pageNum, int pageSize, Integer status, Long storeId) {
@@ -121,7 +133,7 @@ public class StallQualificationService {
     public long adminCount(Integer status, Long storeId) { return mapper.countApplications(status, storeId); }
     public long adminCount(Integer status, Long storeId) { return mapper.countApplications(status, storeId); }
     private void checkPage(int pageNum, int pageSize, Integer status) {
     private void checkPage(int pageNum, int pageSize, Integer status) {
         if (pageNum < 1 || pageSize < 1 || pageSize > 100 || (long) (pageNum - 1) * pageSize > Integer.MAX_VALUE
         if (pageNum < 1 || pageSize < 1 || pageSize > 100 || (long) (pageNum - 1) * pageSize > Integer.MAX_VALUE
-                || (status != null && (status < DRAFT || status > REJECTED))) { throw error("stall.qualification.input.invalid"); }
+                || (status != null && (status < PENDING || status > REJECTED))) { throw error("stall.qualification.input.invalid"); }
     }
     }
 
 
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
@@ -137,6 +149,8 @@ public class StallQualificationService {
                 || !Integer.valueOf(PENDING).equals(application.getStatus())
                 || !Integer.valueOf(PENDING).equals(application.getStatus())
                 || !Objects.equals(application.getVersion(), request.getVersion())) { throw conflict(); }
                 || !Objects.equals(application.getVersion(), request.getVersion())) { throw conflict(); }
         requireStall(stores.selectPosStoreById(application.getStoreId()));
         requireStall(stores.selectPosStoreById(application.getStoreId()));
+        // 旧版待审核申请若缺银行必填资料,不允许在新规则下直接通过;须驳回后补齐重提。
+        if (request.getApproved()) { requireMaterials(application); }
         application.setStatus(request.getApproved() ? APPROVED : REJECTED);
         application.setStatus(request.getApproved() ? APPROVED : REJECTED);
         application.setReviewedBy(reviewerId); application.setReviewedAt(new Date());
         application.setReviewedBy(reviewerId); application.setReviewedAt(new Date());
         application.setRejectReason(request.getApproved() ? null : reason);
         application.setRejectReason(request.getApproved() ? null : reason);
@@ -168,15 +182,18 @@ public class StallQualificationService {
         return List.copyOf(references);
         return List.copyOf(references);
     }
     }
     private void requireMaterials(StallQualificationApplication application) {
     private void requireMaterials(StallQualificationApplication application) {
-        if (application.getResponsibleName().isEmpty() || application.getContactPhone().isEmpty()
-                || application.getIdentityNumber().isEmpty() || application.getFoodRegistrationNumber().isEmpty()
-                || application.getIdentityImageUrls().isEmpty() || application.getStallImageUrls().isEmpty()
-                || application.getFoodRegistrationImageUrls().isEmpty()
-                || application.getStallNameSnapshot() == null || application.getStallNameSnapshot().isBlank()
-                || application.getStallAddressSnapshot() == null || application.getStallAddressSnapshot().isBlank()) {
+        if (blank(application.getResponsibleName()) || blank(application.getContactPhone())
+                || blank(application.getIdentityNumber())
+                || blank(application.getBankAccountName()) || blank(application.getBankName())
+                || blank(application.getBankAccountNo())
+                || empty(application.getIdentityImageUrls()) || empty(application.getStallImageUrls())
+                || empty(application.getBankProofImageUrls())
+                || blank(application.getStallNameSnapshot()) || blank(application.getStallAddressSnapshot())) {
             throw error("stall.qualification.materials.required");
             throw error("stall.qualification.materials.required");
         }
         }
     }
     }
+    private boolean blank(String value) { return value == null || value.isBlank(); }
+    private boolean empty(List<String> value) { return value == null || value.isEmpty(); }
     private String text(String value, int max) {
     private String text(String value, int max) {
         String normalized = value == null ? "" : value.trim();
         String normalized = value == null ? "" : value.trim();
         if (normalized.length() > max) { throw error("stall.qualification.input.invalid"); }
         if (normalized.length() > max) { throw error("stall.qualification.input.invalid"); }

+ 41 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java

@@ -2,15 +2,19 @@ package com.ruoyi.app.unifiedorder;
 
 
 import com.alibaba.fastjson2.JSONArray;
 import com.alibaba.fastjson2.JSONArray;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.app.flashdelivery.service.FlashDeliveryUserCardStops;
 import com.ruoyi.app.unifiedorder.dto.UnifiedOrderListItem;
 import com.ruoyi.app.unifiedorder.dto.UnifiedOrderListItem;
 import com.ruoyi.app.unifiedorder.dto.UnifiedOrderPageView;
 import com.ruoyi.app.unifiedorder.dto.UnifiedOrderPageView;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
 import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderStopMapper;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import java.util.ArrayList;
 import java.util.ArrayList;
@@ -21,6 +25,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Objects;
 import java.util.Set;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 
 /**
 /**
  * 统一订单列表查询:外卖与闪送两路各取 size+1 条,Java 按游标全序归并,读时聚合零写路径。
  * 统一订单列表查询:外卖与闪送两路各取 size+1 条,Java 按游标全序归并,读时聚合零写路径。
@@ -43,13 +48,16 @@ public class UnifiedOrderQueryService {
     private final PosOrderMapper posOrderMapper;
     private final PosOrderMapper posOrderMapper;
     private final FlashDeliveryOrderMapper flashOrderMapper;
     private final FlashDeliveryOrderMapper flashOrderMapper;
     private final PosStoreMapper posStoreMapper;
     private final PosStoreMapper posStoreMapper;
+    private final FlashDeliveryOrderStopMapper flashStopMapper;
 
 
     public UnifiedOrderQueryService(PosOrderMapper posOrderMapper,
     public UnifiedOrderQueryService(PosOrderMapper posOrderMapper,
                                     FlashDeliveryOrderMapper flashOrderMapper,
                                     FlashDeliveryOrderMapper flashOrderMapper,
-                                    PosStoreMapper posStoreMapper) {
+                                    PosStoreMapper posStoreMapper,
+                                    FlashDeliveryOrderStopMapper flashStopMapper) {
         this.posOrderMapper = posOrderMapper;
         this.posOrderMapper = posOrderMapper;
         this.flashOrderMapper = flashOrderMapper;
         this.flashOrderMapper = flashOrderMapper;
         this.posStoreMapper = posStoreMapper;
         this.posStoreMapper = posStoreMapper;
+        this.flashStopMapper = flashStopMapper;
     }
     }
 
 
     /** 查询统一列表:size 服务端钳制 1-50;cursor 为空表示首页。 */
     /** 查询统一列表:size 服务端钳制 1-50;cursor 为空表示首页。 */
@@ -79,11 +87,12 @@ public class UnifiedOrderQueryService {
 
 
         UnifiedOrderPageView view = new UnifiedOrderPageView();
         UnifiedOrderPageView view = new UnifiedOrderPageView();
         Map<Long, PosStore> stores = storesOf(displayed);
         Map<Long, PosStore> stores = storesOf(displayed);
+        Map<Long, List<FlashDeliveryOrderStop>> flashStops = flashStopRows(displayed);
         List<UnifiedOrderListItem> items = new ArrayList<>(displayed.size());
         List<UnifiedOrderListItem> items = new ArrayList<>(displayed.size());
         for (Row row : displayed) {
         for (Row row : displayed) {
             items.add(row.takeaway() != null
             items.add(row.takeaway() != null
                     ? takeawayItem(row.takeaway(), stores)
                     ? takeawayItem(row.takeaway(), stores)
-                    : flashItem(row.flash(), userId));
+                    : flashItem(row.flash(), userId, flashStops.get(row.flash().getId())));
         }
         }
         view.setList(items);
         view.setList(items);
         view.setHasMore(hasMore);
         view.setHasMore(hasMore);
@@ -204,7 +213,18 @@ public class UnifiedOrderQueryService {
         }
         }
     }
     }
 
 
-    private UnifiedOrderListItem flashItem(FlashDeliveryOrder order, Long userId) {
+    /** 展示页闪送 v2 多站点单一次 IN 批量取站点,避免逐单 N+1;外卖路与 v1 闪送不查。 */
+    private Map<Long, List<FlashDeliveryOrderStop>> flashStopRows(List<Row> displayed) {
+        List<Long> orderIds = displayed.stream().map(Row::flash).filter(Objects::nonNull)
+                .filter(order -> order.getDeliveryStopCount() != null)
+                .map(FlashDeliveryOrder::getId).toList();
+        if (orderIds.isEmpty()) return Map.of();
+        return flashStopMapper.selectList(new QueryWrapper<FlashDeliveryOrderStop>()
+                        .in("order_id", orderIds).orderByAsc("stop_order", "id")).stream()
+                .collect(Collectors.groupingBy(FlashDeliveryOrderStop::getOrderId));
+    }
+
+    private UnifiedOrderListItem flashItem(FlashDeliveryOrder order, Long userId, List<FlashDeliveryOrderStop> stopRows) {
         UnifiedOrderListItem item = new UnifiedOrderListItem();
         UnifiedOrderListItem item = new UnifiedOrderListItem();
         item.setSourceType("flash");
         item.setSourceType("flash");
         item.setRole(userId != null && userId.equals(order.getUserId()) ? "sender" : "receiver");
         item.setRole(userId != null && userId.equals(order.getUserId()) ? "sender" : "receiver");
@@ -230,6 +250,24 @@ public class UnifiedOrderQueryService {
         item.setPackageType(order.getPackageType());
         item.setPackageType(order.getPackageType());
         item.setQuantity(order.getQuantity());
         item.setQuantity(order.getQuantity());
         item.setTipAmount(order.getTipAmount());
         item.setTipAmount(order.getTipAmount());
+        // v2 多站点单补双进度计数与逐站列表;卡片脱敏口径与原闪送专属列表一致。
+        if (order.getDeliveryStopCount() != null) {
+            item.setPickupStopCount(order.getPickupStopCount());
+            item.setPickedUpStopCount(order.getPickedUpStopCount());
+            item.setDeliveryStopCount(order.getDeliveryStopCount());
+            item.setDeliveredStopCount(order.getDeliveredStopCount());
+            if (stopRows != null && !stopRows.isEmpty()) {
+                item.setStops(FlashDeliveryUserCardStops.of(stopRows));
+                // 收件人看他人多站单:卡片主位的 deliveryAddress 指向本人站点,逐站地址在 stops 全量返回。
+                if (!Objects.equals(userId, order.getUserId())) {
+                    stopRows.stream().filter(s -> "DELIVERY".equals(s.getStopType())
+                            && Objects.equals(userId, s.getReceiverUserId())).findFirst().ifPresent(own -> {
+                        item.setDeliveryAddress(own.getAddress());
+                        item.setDeliveryDetailAddress(own.getAddressDetail());
+                    });
+                }
+            }
+        }
         return item;
         return item;
     }
     }
 
 

+ 12 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java

@@ -2,7 +2,10 @@ package com.ruoyi.app.unifiedorder.dto;
 
 
 import lombok.Data;
 import lombok.Data;
 
 
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryStopView;
+
 import java.util.Date;
 import java.util.Date;
+import java.util.List;
 
 
 /**
 /**
  * 统一订单列表项:公共字段 + 外卖渲染块 + 闪送渲染块,前端按 sourceType 复用现有两种卡片。
  * 统一订单列表项:公共字段 + 外卖渲染块 + 闪送渲染块,前端按 sourceType 复用现有两种卡片。
@@ -58,7 +61,7 @@ public class UnifiedOrderListItem {
 
 
     /** 闪送状态:WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED。 */
     /** 闪送状态:WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED。 */
     private String status;
     private String status;
-    /** 闪送乐观锁版本(用户编辑待接单订单须回传,同 FlashDeliveryUserOrderListView.orderVersion)。 */
+    /** 闪送乐观锁版本(用户编辑待接单订单须回传,与订单详情的 orderVersion 同源)。 */
     private Integer orderVersion;
     private Integer orderVersion;
     /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
     /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
     private String serviceType;
     private String serviceType;
@@ -86,4 +89,12 @@ public class UnifiedOrderListItem {
     private Integer quantity;
     private Integer quantity;
     /** 小费。 */
     /** 小费。 */
     private Long tipAmount;
     private Long tipAmount;
+    /** 取货站总数及已取数;v2 多站点单返回,v1 闪送卡为空。 */
+    private Integer pickupStopCount;
+    private Integer pickedUpStopCount;
+    /** 收货站总数与已送达数;v2 多站点单返回,v1 闪送卡为空。 */
+    private Integer deliveryStopCount;
+    private Integer deliveredStopCount;
+    /** v2 逐站列表(用户顺序),寄件收件同口径全站文字地址;联系人、电话、坐标、凭证不落卡片。 */
+    private List<FlashDeliveryStopView> stops;
 }
 }

+ 27 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -39,6 +39,7 @@ import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.MobileSMS;
 import com.ruoyi.system.utils.MobileSMS;
+import com.ruoyi.app.user.dto.InfoUserResetPwdDto;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
 import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
@@ -1658,6 +1659,32 @@ public class InfoUserController extends BaseController {
         return toAjax(infoUserService.updateInfoUser(infoUser));
         return toAjax(infoUserService.updateInfoUser(infoUser));
     }
     }
 
 
+    /**
+     * 重置用户登录密码(独立于通用编辑,密文入库,与 App 端 RSA 公钥体系一致)
+     */
+    @PreAuthorize("@ss.hasPermi('infouser:user:resetPwd')")
+    @Log(title = "用户信息", businessType = BusinessType.UPDATE)
+    @PutMapping("/resetPwd")
+    public AjaxResult resetPwd(@RequestBody InfoUserResetPwdDto resetPwdDto) {
+        if (resetPwdDto.getUserId() == null) {
+            return error(MessageUtils.message("no.user.not.exist"));
+        }
+        if (resetPwdDto.getPassword() == null || resetPwdDto.getPassword().isEmpty()) {
+            return error(MessageUtils.message("no.user.password.not.null"));
+        }
+        InfoUser user = infoUserService.selectInfoUserByUserId(resetPwdDto.getUserId());
+        if (user == null) {
+            return error(MessageUtils.message("no.user.not.exist"));
+        }
+        if (MerchantAccountConstants.SUBACCOUNT_USER_TYPE.equals(user.getUserType())) {
+            return error(MessageUtils.message("merchant.subaccount.platform.managed"));
+        }
+        boolean updated = infoUserService.update(new LambdaUpdateWrapper<InfoUser>()
+                .eq(InfoUser::getUserId, user.getUserId())
+                .set(InfoUser::getPassword, resetPwdDto.getPassword()));
+        return updated ? success() : error(MessageUtils.message("no.system.error"));
+    }
+
     private boolean normalizeAuditRejectReason(InfoUser infoUser) {
     private boolean normalizeAuditRejectReason(InfoUser infoUser) {
         if (!"2".equals(infoUser.getAuditStatus())) {
         if (!"2".equals(infoUser.getAuditStatus())) {
             infoUser.setAuditRejectReason(null);
             infoUser.setAuditRejectReason(null);

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/InfoUserResetPwdDto.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+/**
+ * 平台管理端重置用户登录密码请求;password 为前端 RSA 公钥加密后的密文,原样入库
+ */
+@Data
+public class InfoUserResetPwdDto {
+    private Long userId;
+    private String password;
+}

+ 1 - 0
ruoyi-admin/src/main/resources/application.yml

@@ -38,6 +38,7 @@ newebpay:
   mpg-version: "2.3"
   mpg-version: "2.3"
 
 
 omgpay:
 omgpay:
+  environment: ${OMG_PAY_ENVIRONMENT:TEST}
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
   order-result-url: https://foodieapi.waimai-paotui.com/pay/omg/result
   order-result-url: https://foodieapi.waimai-paotui.com/pay/omg/result
   # 支付结果页固定跳回 App 支付成功页面,并由后端追加 ddId。
   # 支付结果页固定跳回 App 支付成功页面,并由后端追加 ddId。

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

@@ -123,6 +123,7 @@ no.message.push.message=訊息
 no.message.push.delivery.personnel.receiving.order=騎手已接單
 no.message.push.delivery.personnel.receiving.order=騎手已接單
 no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備餐
 no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備餐
 no.message.push.order.cancelled=訂單已取消
 no.message.push.order.cancelled=訂單已取消
+no.message.push.order.no.rider.line.cancelled=訂單因無騎手接單已取消,LINE Pay 退款處理中
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
 no.message.push.new.order=有新訂單
 no.message.push.recharge.success=充值成功
 no.message.push.recharge.success=充值成功
@@ -181,6 +182,8 @@ no.points.use.fail=積分抵扣未開啟
 no.points.not.exist=您的積分錢包不存在
 no.points.not.exist=您的積分錢包不存在
 no.message.push.delivery.personnel.qspsz.order=騎手配送中
 no.message.push.delivery.personnel.qspsz.order=騎手配送中
 no.message.push.delivery.personnel.qsysd.order=騎手已送達
 no.message.push.delivery.personnel.qsysd.order=騎手已送達
+no.message.push.flash.pickup.stop.completed=第 {0} 站已完成取貨,取貨進度 {1}/{2}
+no.message.push.flash.delivery.stop.completed=第 {0} 站已送達,送達進度 {1}/{2}
 no.wallet.noexist.userinfo=用戶資訊不存在
 no.wallet.noexist.userinfo=用戶資訊不存在
 no.user.state.no.audit=帳號狀態未審核
 no.user.state.no.audit=帳號狀態未審核
 no.system.busy.try.again=系統繁忙,請稍後重試
 no.system.busy.try.again=系統繁忙,請稍後重試
@@ -400,7 +403,6 @@ rider.accept.total.limit.reached=進行中的配送任務總數已達接單上
 flash.delivery.pickup.input.conflict=取貨點格式或舊物品欄位不可同時提交。
 flash.delivery.pickup.input.conflict=取貨點格式或舊物品欄位不可同時提交。
 flash.delivery.pickups.required=請提交全部取貨點及各站物品資訊。
 flash.delivery.pickups.required=請提交全部取貨點及各站物品資訊。
 flash.delivery.pickup.limit.exceeded=取貨點數量超過有效上限。
 flash.delivery.pickup.limit.exceeded=取貨點數量超過有效上限。
-flash.delivery.multi.pickup.disabled=暫未開放多個取貨點。
 flash.delivery.stops.invalid=訂單站點資料不完整。
 flash.delivery.stops.invalid=訂單站點資料不完整。
 flash.delivery.stop.invalid=該站點不屬於本訂單或不適用於此操作。
 flash.delivery.stop.invalid=該站點不屬於本訂單或不適用於此操作。
 flash.delivery.target.request.conflict=該請求編號已用於不同的目標調整。
 flash.delivery.target.request.conflict=該請求編號已用於不同的目標調整。
@@ -432,10 +434,16 @@ no.oauth.device.mismatch=设备确认凭证无效,请重新登录
 no.pay.switch.order.invalid=Đơn hàng không tồn tại hoặc không thể thanh toán
 no.pay.switch.order.invalid=Đơn hàng không tồn tại hoặc không thể thanh toán
 no.pay.switch.already.paid=Đơn hàng đã được thanh toán
 no.pay.switch.already.paid=Đơn hàng đã được thanh toán
 no.pay.switch.method.invalid=Phương thức thanh toán không hợp lệ
 no.pay.switch.method.invalid=Phương thức thanh toán không hợp lệ
-no.pay.switch.offline.restricted=Tiền mặt / thanh toán khi nhận hàng chỉ dành cho đơn tự lấy hoặc dùng tại chỗ
+no.pay.switch.offline.restricted=Đơn giao hàng không thể chuyển sang thanh toán bằng tiền mặt
 no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau
 no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau
 flash.delivery.billing.save.failed=Không thể ghi nhận thu nhập giao hàng nhanh, vui lòng thử lại
 flash.delivery.billing.save.failed=Không thể ghi nhận thu nhập giao hàng nhanh, vui lòng thử lại
 flash.delivery.billing.income=Thu nhập giao hàng nhanh (thanh toán trực tiếp cho tài xế)
 flash.delivery.billing.income=Thu nhập giao hàng nhanh (thanh toán trực tiếp cho tài xế)
+flash.delivery.stops.required=Vui lòng gửi ít nhất một điểm nhận và một điểm giao
+flash.delivery.stop.type.invalid=Loại điểm dừng không hợp lệ
+flash.delivery.stop.remark.too.long=Ghi chú của điểm dừng không được vượt quá 200 ký tự
+flash.delivery.stops.limit.exceeded=Số điểm nhận hoặc điểm giao vượt quá giới hạn
+flash.delivery.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
 # 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ơ
 stall.qualification.version.required=Vui lòng tải lại trước khi lưu: thiếu phiên bản hồ sơ

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

@@ -123,6 +123,7 @@ no.message.push.message=Message
 no.message.push.delivery.personnel.receiving.order=Rider has received the order
 no.message.push.delivery.personnel.receiving.order=Rider has received the order
 no.message.push.rider.accepted.merchant=A rider has accepted the order. Please confirm and prepare it
 no.message.push.rider.accepted.merchant=A rider has accepted the order. Please confirm and prepare it
 no.message.push.order.cancelled=The order has been cancelled
 no.message.push.order.cancelled=The order has been cancelled
+no.message.push.order.no.rider.line.cancelled=Your order was cancelled because no rider accepted it. Your LINE Pay refund is being processed.
 no.order.rider.accept.required=Please wait for a rider to accept the order before operating it
 no.order.rider.accept.required=Please wait for a rider to accept the order before operating it
 no.message.push.new.order=New order
 no.message.push.new.order=New order
 no.message.push.new.food.order=New food delivery order
 no.message.push.new.food.order=New food delivery order
@@ -183,6 +184,8 @@ no.points.use.fail=Points deduction not activated
 no.points.not.exist=Your points wallet does not exist
 no.points.not.exist=Your points wallet does not exist
 no.message.push.delivery.personnel.qspsz.order=Rider delivering
 no.message.push.delivery.personnel.qspsz.order=Rider delivering
 no.message.push.delivery.personnel.qsysd.order=Rider has delivered
 no.message.push.delivery.personnel.qsysd.order=Rider has delivered
+no.message.push.flash.pickup.stop.completed=Pickup completed at stop {0}. Pickup progress: {1}/{2}
+no.message.push.flash.delivery.stop.completed=Delivery completed at stop {0}. Delivery progress: {1}/{2}
 no.wallet.noexist.userinfo=User information does not exist
 no.wallet.noexist.userinfo=User information does not exist
 no.user.state.no.audit=Account status is not audited
 no.user.state.no.audit=Account status is not audited
 no.system.busy.try.again=System is busy, please try again later
 no.system.busy.try.again=System is busy, please try again later
@@ -397,7 +400,6 @@ rider.accept.total.limit.reached=The total active delivery task limit has been r
 flash.delivery.pickup.input.conflict=Do not combine pickup formats or legacy item fields.
 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.pickups.required=Submit every pickup with its item details.
 flash.delivery.pickup.limit.exceeded=Pickup count exceeds the configured limit.
 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.stops.invalid=Order station data is incomplete.
 flash.delivery.stop.invalid=This station is not a valid target for this order.
 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.request.conflict=This request ID was already used with different target details.
@@ -418,10 +420,16 @@ no.unifiedorder.param.invalid=Invalid query parameters
 no.pay.switch.order.invalid=Order not found or not payable
 no.pay.switch.order.invalid=Order not found or not payable
 no.pay.switch.already.paid=Order already paid
 no.pay.switch.already.paid=Order already paid
 no.pay.switch.method.invalid=Invalid payment method
 no.pay.switch.method.invalid=Invalid payment method
-no.pay.switch.offline.restricted=Cash / cash-on-delivery only for pickup or dine-in orders
+no.pay.switch.offline.restricted=Delivery orders cannot switch to cash payment
 no.pay.switch.failed=Failed to change payment method, please try again later
 no.pay.switch.failed=Failed to change payment method, please try again later
 flash.delivery.billing.save.failed=Failed to record flash delivery income, please try again
 flash.delivery.billing.save.failed=Failed to record flash delivery income, please try again
 flash.delivery.billing.income=Flash delivery income (paid directly to rider)
 flash.delivery.billing.income=Flash delivery income (paid directly to rider)
+flash.delivery.stops.required=Submit at least one pickup stop and one delivery stop
+flash.delivery.stop.type.invalid=Invalid stop type
+flash.delivery.stop.remark.too.long=Stop remark cannot exceed 200 characters
+flash.delivery.stops.limit.exceeded=Too many pickup or delivery stops
+flash.delivery.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
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=Refresh before saving: material version is missing
 stall.qualification.version.required=Refresh before saving: material version is missing

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

@@ -122,6 +122,7 @@ no.message.push.message=ข้อความ
 no.message.push.delivery.personnel.receiving.order=ไรเดอร์รับคำสั่งซื้อแล้ว
 no.message.push.delivery.personnel.receiving.order=ไรเดอร์รับคำสั่งซื้อแล้ว
 no.message.push.rider.accepted.merchant=ไรเดอร์รับคำสั่งซื้อแล้ว โปรดยืนยันและเตรียมอาหาร
 no.message.push.rider.accepted.merchant=ไรเดอร์รับคำสั่งซื้อแล้ว โปรดยืนยันและเตรียมอาหาร
 no.message.push.order.cancelled=ยกเลิกคำสั่งซื้อแล้ว
 no.message.push.order.cancelled=ยกเลิกคำสั่งซื้อแล้ว
+no.message.push.order.no.rider.line.cancelled=คำสั่งซื้อถูกยกเลิกเนื่องจากไม่มีไรเดอร์รับงาน ระบบกำลังดำเนินการคืนเงินผ่าน LINE Pay
 no.order.rider.accept.required=โปรดรอให้ไรเดอร์รับคำสั่งซื้อก่อนดำเนินการ
 no.order.rider.accept.required=โปรดรอให้ไรเดอร์รับคำสั่งซื้อก่อนดำเนินการ
 no.message.push.new.order=มีคำสั่งซื้อใหม่
 no.message.push.new.order=มีคำสั่งซื้อใหม่
 no.message.push.new.food.order=มีออเดอร์ส่งอาหารใหม่
 no.message.push.new.food.order=มีออเดอร์ส่งอาหารใหม่
@@ -182,6 +183,8 @@ no.points.use.fail=ยังไม่ได้เปิดใช้ส่วน
 no.points.not.exist=ไม่มีกระเป๋าคะแนนของคุณ
 no.points.not.exist=ไม่มีกระเป๋าคะแนนของคุณ
 no.message.push.delivery.personnel.qspsz.order=ไรเดอร์กำลังจัดส่ง
 no.message.push.delivery.personnel.qspsz.order=ไรเดอร์กำลังจัดส่ง
 no.message.push.delivery.personnel.qsysd.order=ไรเดอร์จัดส่งถึงแล้ว
 no.message.push.delivery.personnel.qsysd.order=ไรเดอร์จัดส่งถึงแล้ว
+no.message.push.flash.pickup.stop.completed=รับพัสดุที่จุด {0} แล้ว ความคืบหน้าการรับ: {1}/{2}
+no.message.push.flash.delivery.stop.completed=ส่งพัสดุถึงจุด {0} แล้ว ความคืบหน้าการส่ง: {1}/{2}
 no.wallet.noexist.userinfo=ไม่มีข้อมูลผู้ใช้
 no.wallet.noexist.userinfo=ไม่มีข้อมูลผู้ใช้
 no.user.state.no.audit=สถานะบัญชียังไม่ได้รับการตรวจสอบ
 no.user.state.no.audit=สถานะบัญชียังไม่ได้รับการตรวจสอบ
 no.system.busy.try.again=ระบบไม่ว่าง โปรดลองอีกครั้งภายหลัง
 no.system.busy.try.again=ระบบไม่ว่าง โปรดลองอีกครั้งภายหลัง
@@ -398,7 +401,6 @@ rider.accept.total.limit.reached=จำนวนงานจัดส่งท
 flash.delivery.pickup.input.conflict=ห้ามส่งรูปแบบจุดรับหรือข้อมูลสิ่งของแบบเดิมพร้อมกัน
 flash.delivery.pickup.input.conflict=ห้ามส่งรูปแบบจุดรับหรือข้อมูลสิ่งของแบบเดิมพร้อมกัน
 flash.delivery.pickups.required=กรุณาส่งจุดรับทั้งหมดพร้อมข้อมูลสิ่งของแต่ละจุด
 flash.delivery.pickups.required=กรุณาส่งจุดรับทั้งหมดพร้อมข้อมูลสิ่งของแต่ละจุด
 flash.delivery.pickup.limit.exceeded=จำนวนจุดรับเกินขีดจำกัดที่กำหนด
 flash.delivery.pickup.limit.exceeded=จำนวนจุดรับเกินขีดจำกัดที่กำหนด
-flash.delivery.multi.pickup.disabled=ยังไม่เปิดใช้การรับสินค้าหลายจุด
 flash.delivery.stops.invalid=ข้อมูลจุดแวะของคำสั่งซื้อไม่ครบถ้วน
 flash.delivery.stops.invalid=ข้อมูลจุดแวะของคำสั่งซื้อไม่ครบถ้วน
 flash.delivery.stop.invalid=จุดนี้ไม่อยู่ในคำสั่งซื้อหรือใช้กับการดำเนินการนี้ไม่ได้
 flash.delivery.stop.invalid=จุดนี้ไม่อยู่ในคำสั่งซื้อหรือใช้กับการดำเนินการนี้ไม่ได้
 flash.delivery.target.request.conflict=รหัสคำขอนี้ถูกใช้กับรายละเอียดการเปลี่ยนเป้าหมายอื่นแล้ว
 flash.delivery.target.request.conflict=รหัสคำขอนี้ถูกใช้กับรายละเอียดการเปลี่ยนเป้าหมายอื่นแล้ว
@@ -419,10 +421,16 @@ no.unifiedorder.param.invalid=พາรາມິเตอร์ทີ่กາ
 no.pay.switch.order.invalid=ไม่พบคำสั่งซื้อหรือไม่สามารถชำระเงินได้
 no.pay.switch.order.invalid=ไม่พบคำสั่งซื้อหรือไม่สามารถชำระเงินได้
 no.pay.switch.already.paid=ชำระเงินคำสั่งซื้อแล้ว
 no.pay.switch.already.paid=ชำระเงินคำสั่งซื้อแล้ว
 no.pay.switch.method.invalid=วิธีชำระเงินไม่ถูกต้อง
 no.pay.switch.method.invalid=วิธีชำระเงินไม่ถูกต้อง
-no.pay.switch.offline.restricted=เงินสด / ชำระเงินปลายทางใช้ได้เฉพาะคำสั่งซื้อแบบรับสินค้าหรือทานที่ร้านเท่านั้น
+no.pay.switch.offline.restricted=คำสั่งซื้อแบบจัดส่งไม่สามารถเปลี่ยนเป็นการชำระเงินสดได้
 no.pay.switch.failed=เปลี่ยนวิธีชำระเงินไม่สำเร็จ กรุณาลองใหม่ภายหลัง
 no.pay.switch.failed=เปลี่ยนวิธีชำระเงินไม่สำเร็จ กรุณาลองใหม่ภายหลัง
 flash.delivery.billing.save.failed=บันทึกรายได้จากการจัดส่งด่วนไม่สำเร็จ กรุณาลองอีกครั้ง
 flash.delivery.billing.save.failed=บันทึกรายได้จากการจัดส่งด่วนไม่สำเร็จ กรุณาลองอีกครั้ง
 flash.delivery.billing.income=รายได้จากการจัดส่งด่วน (ชำระให้ผู้ขับขี่โดยตรง)
 flash.delivery.billing.income=รายได้จากการจัดส่งด่วน (ชำระให้ผู้ขับขี่โดยตรง)
+flash.delivery.stops.required=โปรดระบุจุดรับสินค้าและจุดส่งอย่างน้อยจุดละหนึ่งจุด
+flash.delivery.stop.type.invalid=ประเภทจุดแวะไม่ถูกต้อง
+flash.delivery.stop.remark.too.long=หมายเหตุของจุดแวะต้องไม่เกิน 200 ตัวอักษร
+flash.delivery.stops.limit.exceeded=จำนวนจุดรับหรือจุดส่งเกินขีดจำกัด
+flash.delivery.stops.order.invalid=จุดแรกต้องเป็นจุดรับและจุดสุดท้ายต้องเป็นจุดส่ง
+flash.delivery.target.reason.too.long=เหตุผลในการปรับเปลี่ยนต้องไม่เกิน 500 ตัวอักษร
 
 
 # 034 Stall qualification: common material rules and public images
 # 034 Stall qualification: common material rules and public images
 stall.qualification.version.required=โปรดรีเฟรชก่อนบันทึก เนื่องจากขาดเวอร์ชันของข้อมูล
 stall.qualification.version.required=โปรดรีเฟรชก่อนบันทึก เนื่องจากขาดเวอร์ชันของข้อมูล

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

@@ -123,6 +123,7 @@ no.message.push.message=消息
 no.message.push.delivery.personnel.receiving.order=骑手已接单
 no.message.push.delivery.personnel.receiving.order=骑手已接单
 no.message.push.rider.accepted.merchant=骑手已接单,请及时确认并备餐
 no.message.push.rider.accepted.merchant=骑手已接单,请及时确认并备餐
 no.message.push.order.cancelled=订单已取消
 no.message.push.order.cancelled=订单已取消
+no.message.push.order.no.rider.line.cancelled=订单因无人接单已取消,LINE Pay 退款处理中
 no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.message.push.new.order=有新订单
 no.message.push.new.order=有新订单
 no.message.push.new.food.order=有新的外卖订单
 no.message.push.new.food.order=有新的外卖订单
@@ -183,6 +184,8 @@ no.points.use.fail=积分抵扣未开启
 no.points.not.exist=您的积分钱包不存在
 no.points.not.exist=您的积分钱包不存在
 no.message.push.delivery.personnel.qspsz.order=骑手配送中
 no.message.push.delivery.personnel.qspsz.order=骑手配送中
 no.message.push.delivery.personnel.qsysd.order=骑手已送达
 no.message.push.delivery.personnel.qsysd.order=骑手已送达
+no.message.push.flash.pickup.stop.completed=第 {0} 站已完成取货,取货进度 {1}/{2}
+no.message.push.flash.delivery.stop.completed=第 {0} 站已送达,送达进度 {1}/{2}
 no.wallet.noexist.userinfo=用户信息不存在
 no.wallet.noexist.userinfo=用户信息不存在
 no.user.state.no.audit=账号状态未审核
 no.user.state.no.audit=账号状态未审核
 no.system.busy.try.again=系统繁忙,请稍后重试
 no.system.busy.try.again=系统繁忙,请稍后重试
@@ -399,7 +402,6 @@ rider.accept.total.limit.reached=进行中的配送任务总数已达到接单
 flash.delivery.pickup.input.conflict=取货点格式或旧物品字段不可同时提交。
 flash.delivery.pickup.input.conflict=取货点格式或旧物品字段不可同时提交。
 flash.delivery.pickups.required=请提交全部取货点及各站物品信息。
 flash.delivery.pickups.required=请提交全部取货点及各站物品信息。
 flash.delivery.pickup.limit.exceeded=取货点数量超过有效上限。
 flash.delivery.pickup.limit.exceeded=取货点数量超过有效上限。
-flash.delivery.multi.pickup.disabled=暂未开放多个取货点。
 flash.delivery.stops.invalid=订单站点资料不完整。
 flash.delivery.stops.invalid=订单站点资料不完整。
 flash.delivery.stop.invalid=该站点不属于本订单或不适用于此操作。
 flash.delivery.stop.invalid=该站点不属于本订单或不适用于此操作。
 flash.delivery.target.request.conflict=该请求编号已用于不同的目标调整。
 flash.delivery.target.request.conflict=该请求编号已用于不同的目标调整。
@@ -420,10 +422,16 @@ no.unifiedorder.param.invalid=查询参数不合法
 no.pay.switch.order.invalid=订单不存在或当前不可支付
 no.pay.switch.order.invalid=订单不存在或当前不可支付
 no.pay.switch.already.paid=订单已支付,无法更换支付方式
 no.pay.switch.already.paid=订单已支付,无法更换支付方式
 no.pay.switch.method.invalid=支付方式无效
 no.pay.switch.method.invalid=支付方式无效
-no.pay.switch.offline.restricted=仅自取/堂食订单可更换为现金或货到付款
+no.pay.switch.offline.restricted=外送订单不能更换为现金支付
 no.pay.switch.failed=更换支付方式失败,请稍后重试
 no.pay.switch.failed=更换支付方式失败,请稍后重试
 flash.delivery.billing.save.failed=闪送骑手账单生成失败,请重试
 flash.delivery.billing.save.failed=闪送骑手账单生成失败,请重试
 flash.delivery.billing.income=闪送收入(款项直付骑手)
 flash.delivery.billing.income=闪送收入(款项直付骑手)
+flash.delivery.stops.required=请提交至少一个取货点和一个收货点
+flash.delivery.stop.type.invalid=站点类型不正确
+flash.delivery.stop.remark.too.long=站点备注不能超过200字
+flash.delivery.stops.limit.exceeded=取货点或收货点数量超出限制
+flash.delivery.stops.order.invalid=首站必须是取货点,末站必须是收货点
+flash.delivery.target.reason.too.long=调整原因不能超过500个字符
 
 
 # 034 Stall qualification: common material rules and private images
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=请刷新后再保存,缺少资料版本
 stall.qualification.version.required=请刷新后再保存,缺少资料版本

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

@@ -123,6 +123,7 @@ no.message.push.message=訊息
 no.message.push.delivery.personnel.receiving.order=騎手已接單
 no.message.push.delivery.personnel.receiving.order=騎手已接單
 no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備餐
 no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備餐
 no.message.push.order.cancelled=訂單已取消
 no.message.push.order.cancelled=訂單已取消
+no.message.push.order.no.rider.line.cancelled=訂單因無騎手接單已取消,LINE Pay 退款處理中
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
 no.message.push.new.order=有新訂單
 no.message.push.new.food.order=有新的外送訂單
 no.message.push.new.food.order=有新的外送訂單
@@ -183,6 +184,8 @@ no.points.use.fail=積分抵扣未開啟
 no.points.not.exist=您的積分錢包不存在
 no.points.not.exist=您的積分錢包不存在
 no.message.push.delivery.personnel.qspsz.order=騎手配送中
 no.message.push.delivery.personnel.qspsz.order=騎手配送中
 no.message.push.delivery.personnel.qsysd.order=騎手已送達
 no.message.push.delivery.personnel.qsysd.order=騎手已送達
+no.message.push.flash.pickup.stop.completed=第 {0} 站已完成取貨,取貨進度 {1}/{2}
+no.message.push.flash.delivery.stop.completed=第 {0} 站已送達,送達進度 {1}/{2}
 no.wallet.noexist.userinfo=用戶資訊不存在
 no.wallet.noexist.userinfo=用戶資訊不存在
 no.user.state.no.audit=帳號狀態未審核
 no.user.state.no.audit=帳號狀態未審核
 no.system.busy.try.again=系統繁忙,請稍後重試
 no.system.busy.try.again=系統繁忙,請稍後重試
@@ -398,7 +401,6 @@ rider.accept.total.limit.reached=進行中的配送任務總數已達接單上
 flash.delivery.pickup.input.conflict=取貨點格式或舊物品欄位不可同時提交。
 flash.delivery.pickup.input.conflict=取貨點格式或舊物品欄位不可同時提交。
 flash.delivery.pickups.required=請提交全部取貨點及各站物品資訊。
 flash.delivery.pickups.required=請提交全部取貨點及各站物品資訊。
 flash.delivery.pickup.limit.exceeded=取貨點數量超過有效上限。
 flash.delivery.pickup.limit.exceeded=取貨點數量超過有效上限。
-flash.delivery.multi.pickup.disabled=暫未開放多個取貨點。
 flash.delivery.stops.invalid=訂單站點資料不完整。
 flash.delivery.stops.invalid=訂單站點資料不完整。
 flash.delivery.stop.invalid=該站點不屬於本訂單或不適用於此操作。
 flash.delivery.stop.invalid=該站點不屬於本訂單或不適用於此操作。
 flash.delivery.target.request.conflict=該請求編號已用於不同的目標調整。
 flash.delivery.target.request.conflict=該請求編號已用於不同的目標調整。
@@ -419,10 +421,16 @@ no.unifiedorder.param.invalid=查詢參數不合法
 no.pay.switch.order.invalid=訂單不存在或當前不可支付
 no.pay.switch.order.invalid=訂單不存在或當前不可支付
 no.pay.switch.already.paid=訂單已付款,無法更換支付方式
 no.pay.switch.already.paid=訂單已付款,無法更換支付方式
 no.pay.switch.method.invalid=支付方式無效
 no.pay.switch.method.invalid=支付方式無效
-no.pay.switch.offline.restricted=僅自取/堂食訂單可更換為現金或貨到付款
+no.pay.switch.offline.restricted=外送訂單不能更換為現金支付
 no.pay.switch.failed=更換支付方式失敗,請稍後重試
 no.pay.switch.failed=更換支付方式失敗,請稍後重試
 flash.delivery.billing.save.failed=閃送騎手帳單產生失敗,請重試
 flash.delivery.billing.save.failed=閃送騎手帳單產生失敗,請重試
 flash.delivery.billing.income=閃送收入(款項直付騎手)
 flash.delivery.billing.income=閃送收入(款項直付騎手)
+flash.delivery.stops.required=請提交至少一個取貨點和一個收貨點
+flash.delivery.stop.type.invalid=站點類型不正確
+flash.delivery.stop.remark.too.long=站點備註不能超過200字
+flash.delivery.stops.limit.exceeded=取貨點或收貨點數量超出限制
+flash.delivery.stops.order.invalid=首站必須是取貨點,末站必須是收貨點
+flash.delivery.target.reason.too.long=調整原因不能超過500個字元
 
 
 # 034 Stall qualification: common material rules and private images
 # 034 Stall qualification: common material rules and private images
 stall.qualification.version.required=請重新整理後再儲存,缺少資料版本
 stall.qualification.version.required=請重新整理後再儲存,缺少資料版本

+ 94 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java

@@ -4,6 +4,7 @@ import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryOrderDetailView;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryOrderDetailView;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryStopDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteView;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteView;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
@@ -38,6 +39,66 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
 
 
 class FlashDeliveryControllerContractTest {
 class FlashDeliveryControllerContractTest {
+    @Test
+    void newStopDeliveryAcceptsAllBusinessParametersInBody() throws Exception {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders
+                .standaloneSetup(new FlashDeliveryRiderController(service)).build();
+        mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+                        .post("/system/flashDelivery/rider/orders/stops/deliver")
+                        .header("token", JwtUtil.token("8", "rider"))
+                        .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
+                        .content("{\"orderId\":10,\"stopId\":13,\"pinCode\":\"1111\",\"imageUrls\":[\"/profile/d.jpg\"]}"))
+                .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk());
+        var request = org.mockito.ArgumentCaptor.forClass(FlashDeliveryDeliverRequest.class);
+        verify(service).deliverAtStop(org.mockito.ArgumentMatchers.eq(8L), org.mockito.ArgumentMatchers.eq(10L),
+                org.mockito.ArgumentMatchers.eq(13L), request.capture());
+        org.junit.jupiter.api.Assertions.assertEquals("1111", request.getValue().getPinCode());
+        org.junit.jupiter.api.Assertions.assertEquals(java.util.List.of("/profile/d.jpg"), request.getValue().getImageUrls());
+        org.junit.jupiter.api.Assertions.assertEquals(13L, request.getValue().getStopId());
+    }
+
+    @Test
+    void newStopDeliveryRejectsMissingOrNonPositiveBodyIds() throws Exception {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders
+                .standaloneSetup(new FlashDeliveryRiderController(service))
+                .setControllerAdvice(new com.ruoyi.framework.web.exception.GlobalExceptionHandler()).build();
+        for (String body : java.util.List.of("{}", "{\"orderId\":0,\"stopId\":13}",
+                "{\"orderId\":-1,\"stopId\":13}", "{\"orderId\":10}",
+                "{\"orderId\":10,\"stopId\":0}", "{\"orderId\":10,\"stopId\":-1}")) {
+            mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+                            .post("/system/flashDelivery/rider/orders/stops/deliver")
+                            .header("token", JwtUtil.token("8", "rider"))
+                            .contentType(org.springframework.http.MediaType.APPLICATION_JSON).content(body))
+                    .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk())
+                    .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.code").value(500));
+        }
+        org.mockito.Mockito.verifyNoInteractions(service);
+    }
+
+    @Test
+    void newV2EndpointsUseBodyDtosAndHeaderAuthentication() throws Exception {
+        for (Method method : java.util.List.of(
+                FlashDeliveryUserController.class.getMethod("quoteV2", String.class,
+                        com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2QuoteRequest.class),
+                FlashDeliveryUserController.class.getMethod("createV2", String.class,
+                        com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2CreateRequest.class),
+                FlashDeliveryUserController.class.getMethod("quoteAddressV2", String.class,
+                        com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2AddressChangeRequest.class),
+                FlashDeliveryUserController.class.getMethod("updateAddressV2", String.class,
+                        com.ruoyi.app.flashdelivery.dto.FlashDeliveryV2AddressConfirmRequest.class),
+                FlashDeliveryRiderController.class.getMethod("deliverAtStop", String.class,
+                        FlashDeliveryStopDeliverRequest.class))) {
+            assertNotNull(method.getAnnotation(Auth.class));
+            assertNotNull(method.getParameters()[0].getAnnotation(RequestHeader.class));
+            assertNotNull(method.getParameters()[1].getAnnotation(RequestBody.class));
+            org.junit.jupiter.api.Assertions.assertEquals(2, method.getParameterCount());
+            assertTrue(java.util.Arrays.stream(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class)
+                    .value()).noneMatch(path -> path.contains("{")));
+        }
+    }
+
     @Test
     @Test
     void perStopFulfilmentUsesTokenExplicitPathsAndBodyDtos() throws Exception {
     void perStopFulfilmentUsesTokenExplicitPathsAndBodyDtos() throws Exception {
         for (Method method : java.util.List.of(
         for (Method method : java.util.List.of(
@@ -51,6 +112,15 @@ class FlashDeliveryControllerContractTest {
             assertNotNull(method.getParameters()[3].getAnnotation(RequestBody.class));
             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;
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
 
     @BeforeAll
     @BeforeAll
@@ -115,13 +185,10 @@ class FlashDeliveryControllerContractTest {
 
 
     @Test
     @Test
     void appOrderListsUseSimplePageAndRiderFoodOrderParameters() throws Exception {
     void appOrderListsUseSimplePageAndRiderFoodOrderParameters() throws Exception {
-        Method userOrders = FlashDeliveryUserController.class.getMethod("orders",
-                String.class, int.class, int.class, String.class);
-        assertNotNull(userOrders.getParameters()[0].getAnnotation(RequestHeader.class));
-        assertNotNull(userOrders.getParameters()[1].getAnnotation(RequestParam.class));
-        assertNotNull(userOrders.getParameters()[2].getAnnotation(RequestParam.class));
-        assertNotNull(userOrders.getParameters()[3].getAnnotation(RequestParam.class));
-        assertTrue(!userOrders.getParameters()[3].getAnnotation(RequestParam.class).required());
+        // 用户端闪送专属列表已删除(改用统一订单列表);骑手列表契约保持原样。
+        assertThrows(NoSuchMethodException.class,
+                () -> FlashDeliveryUserController.class.getMethod("orders",
+                        String.class, int.class, int.class, String.class));
 
 
         Method riderOrders = FlashDeliveryRiderController.class.getMethod("orders",
         Method riderOrders = FlashDeliveryRiderController.class.getMethod("orders",
                 String.class, int.class, int.class, String.class, BigDecimal.class, BigDecimal.class);
                 String.class, int.class, int.class, String.class, BigDecimal.class, BigDecimal.class);
@@ -138,6 +205,26 @@ class FlashDeliveryControllerContractTest {
                         String.class, int.class, int.class, String.class, String.class));
                         String.class, int.class, int.class, String.class, String.class));
     }
     }
 
 
+    @Test
+    void riderOrderCountEndpointUsesOptionalCoordinatesAndReturnsCounts() throws Exception {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        var counts = new com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderCountView();
+        when(service.riderOrderCounts(8L, BigDecimal.ONE, BigDecimal.TEN)).thenReturn(counts);
+
+        AjaxResult result = new FlashDeliveryRiderController(service)
+                .orderCount(JwtUtil.token("8", "rider"), BigDecimal.ONE, BigDecimal.TEN);
+
+        assertSame(counts, result.get(AjaxResult.DATA_TAG));
+
+        Method orderCount = FlashDeliveryRiderController.class.getMethod("orderCount",
+                String.class, BigDecimal.class, BigDecimal.class);
+        assertArrayEquals(new String[]{"/orderCount"}, orderCount.getAnnotation(GetMapping.class).value());
+        assertNotNull(orderCount.getParameters()[0].getAnnotation(RequestHeader.class));
+        for (int index = 1; index < orderCount.getParameterCount(); index++) {
+            assertTrue(!orderCount.getParameters()[index].getAnnotation(RequestParam.class).required());
+        }
+    }
+
     @Test
     @Test
     void appDetailAndAcceptPutDirectOrderDtoInAjaxData() {
     void appDetailAndAcceptPutDirectOrderDtoInAjaxData() {
         FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
         FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);

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

@@ -7,18 +7,6 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import java.util.stream.Stream;
 
 
 class FlashDeliveryRiderOrderListViewTest {
 class FlashDeliveryRiderOrderListViewTest {
-    @Test
-    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", "vehicleType", "status", "packageType",
-                "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
-                "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
-                "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
-                "estimatedDurationSeconds", "amount", "currency", "paymentStatus", "deliveredAt", "createTime", "orderVersion", "pickupStopCount", "pickedUpStopCount", "canUserCancel");
-        org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
-    }
-
     @Test
     @Test
     void riderListContainsOnlyFieldsRequiredByOrderCards() {
     void riderListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryRiderOrderListView.class.getDeclaredFields())
         Set<String> fields = Stream.of(FlashDeliveryRiderOrderListView.class.getDeclaredFields())
@@ -29,7 +17,7 @@ class FlashDeliveryRiderOrderListViewTest {
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",
                 "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",
                 "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",
                 "estimatedDurationSeconds", "baseDeliveryFee", "distanceFee", "urgentFee",
                 "estimatedDurationSeconds", "baseDeliveryFee", "distanceFee", "urgentFee",
-                "tipAmount", "amount", "currency", "paymentStatus", "createTime", "payType", "pickupStopCount", "pickedUpStopCount", "canUserCancel", "userNote");
+                "tipAmount", "amount", "currency", "paymentStatus", "createTime", "payType", "pickupStopCount", "pickedUpStopCount", "deliveryStopCount", "deliveredStopCount", "canUserCancel", "userNote", "stops");
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
         org.junit.jupiter.api.Assertions.assertEquals(expected, fields);
     }
     }
 
 

+ 57 - 45
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -266,33 +266,6 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
         verify(fixture.logMapper, never()).insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderLog.class));
     }
     }
 
 
-    @Test
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    void userListOnlyFiltersByOwnerAndReturnsCardFields() {
-        Fixture fixture = new Fixture();
-        FlashDeliveryOrder order = order(10L, "COMPLETED");
-        order.setWeightRange("OVER_5_TO_10_KG");
-        Page<FlashDeliveryOrder> source = new Page<>(1, 10, 1);
-        source.setRecords(List.of(order));
-        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
-                any(Wrapper.class))).thenReturn(source);
-
-        var result = fixture.service.userOrders(7L, 1, 10, null);
-
-        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
-                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
-        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
-        String sql = query.getValue().getSqlSegment().toLowerCase();
-        var parameters = query.getValue().getParamNameValuePairs().values();
-        assertTrue(sql.contains("user_id"));
-        assertFalse(sql.contains("status"));
-        assertFalse(sql.contains("service_type"));
-        assertTrue(parameters.contains(7L));
-        assertEquals("pickup road", result.getRecords().get(0).getPickupAddress());
-        assertEquals("OVER_5_TO_10_KG", result.getRecords().get(0).getWeightRange());
-        assertEquals(112L, result.getRecords().get(0).getAmount());
-    }
-
     @Test
     @Test
     void createDoesNotBindReceiverWhenNormalizedPhoneMatchesMultipleUsers() {
     void createDoesNotBindReceiverWhenNormalizedPhoneMatchesMultipleUsers() {
         Fixture fixture = new Fixture();
         Fixture fixture = new Fixture();
@@ -335,22 +308,6 @@ class FlashDeliveryApplicationServiceTest {
         assertNull(saved.getValue().getReceiverUserId());
         assertNull(saved.getValue().getReceiverUserId());
     }
     }
 
 
-    @Test
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    void receiverListFiltersByCreationTimeReceiverBinding() {
-        Fixture fixture = new Fixture();
-        when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
-                any(Wrapper.class))).thenReturn(new Page<FlashDeliveryOrder>());
-
-        fixture.service.userOrders(88L, 1, 10, "receiver");
-
-        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
-                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
-        verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
-        assertTrue(query.getValue().getSqlSegment().toLowerCase().contains("receiver_user_id"));
-        assertTrue(query.getValue().getParamNameValuePairs().values().contains(88L));
-    }
-
     @Test
     @Test
     @SuppressWarnings({"rawtypes", "unchecked"})
     @SuppressWarnings({"rawtypes", "unchecked"})
     void riderNewTaskUsesFoodOrderParametersAndReturnsPrototypeCardSummary() {
     void riderNewTaskUsesFoodOrderParametersAndReturnsPrototypeCardSummary() {
@@ -383,6 +340,9 @@ class FlashDeliveryApplicationServiceTest {
         assertTrue(result.getRecords().get(0).getPinRequired());
         assertTrue(result.getRecords().get(0).getPinRequired());
         // 待抢单卡片继续脱敏用户备注,接单前骑手不可见。
         // 待抢单卡片继续脱敏用户备注,接单前骑手不可见。
         assertNull(result.getRecords().get(0).getUserNote());
         assertNull(result.getRecords().get(0).getUserNote());
+        // v1 单点卡不附带逐站列表,也不触发站点批量查询。
+        assertNull(result.getRecords().get(0).getStops());
+        verify(fixture.stopMapper, never()).selectList(any());
         ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
         ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
                 ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
                 ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
         verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
         verify(fixture.orderMapper).selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class), query.capture());
@@ -1739,6 +1699,57 @@ class FlashDeliveryApplicationServiceTest {
         for (String status : statuses) assertTrue(parameters.contains(status), tab + ":" + status);
         for (String status : statuses) assertTrue(parameters.contains(status), tab + ":" + status);
     }
     }
 
 
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void riderOrderCountsReturnsBadgeCountsForAllListTabs() {
+        Fixture fixture = new Fixture();
+        rider(fixture, 8L);
+        when(fixture.orderMapper.selectCount(any(Wrapper.class))).thenReturn(3L);
+
+        com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderCountView view =
+                fixture.service.riderOrderCounts(8L, null, null);
+
+        assertEquals(3L, view.getNewTask());
+        assertEquals(3L, view.getToPickup());
+        assertEquals(3L, view.getDelivering());
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.QueryWrapper.class);
+        verify(fixture.orderMapper, times(3)).selectCount(query.capture());
+        // 角标与列表页签同口径:三个页签各查一次(toPickup 已接单、delivering 已取件、newTask 待抢单)
+        List<Object> allParams = new java.util.ArrayList<>();
+        boolean[] hasPendingGrab = {false};
+        for (com.baomidou.mybatisplus.core.conditions.query.QueryWrapper captured : query.getAllValues()) {
+            String sql = captured.getSqlSegment().toLowerCase();
+            allParams.addAll(captured.getParamNameValuePairs().values());
+            assertFalse(sql.contains("order by"));
+            if (sql.contains("rider_id is null")) hasPendingGrab[0] = true;
+        }
+        assertTrue(hasPendingGrab[0]);
+        assertTrue(allParams.contains("ACCEPTED"));
+        assertTrue(allParams.contains("PICKED_UP"));
+        assertTrue(allParams.contains("WAITING_ACCEPTANCE"));
+    }
+
+    @Test
+    void riderOrderCountsReportsZeroNewTaskWhenFlashNotEnabled() {
+        Fixture fixture = new Fixture();
+        InfoUser rider = new InfoUser();
+        rider.setUserId(8L);
+        rider.setUserType("2");
+        rider.setDeliveryType("FOOD");
+        when(fixture.userMapper.selectInfoUserByUserId(8L)).thenReturn(rider);
+        when(fixture.orderMapper.selectCount(any(Wrapper.class))).thenReturn(4L);
+
+        com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderCountView view =
+                fixture.service.riderOrderCounts(8L, null, null);
+
+        // 未开通闪送无可抢任务,newTask 角标计 0,仅统计本人取件中/配送中
+        assertEquals(0L, view.getNewTask());
+        assertEquals(4L, view.getToPickup());
+        assertEquals(4L, view.getDelivering());
+        verify(fixture.orderMapper, times(2)).selectCount(any(Wrapper.class));
+    }
+
     private void rider(Fixture fixture, Long riderId) {
     private void rider(Fixture fixture, Long riderId) {
         InfoUser rider = new InfoUser();
         InfoUser rider = new InfoUser();
         rider.setUserId(riderId);
         rider.setUserId(riderId);
@@ -2220,11 +2231,12 @@ class FlashDeliveryApplicationServiceTest {
                 pickup.setId(101L); pickup.setOrderId(10L); pickup.setStopType("PICKUP"); pickup.setStopOrder(0);
                 pickup.setId(101L); pickup.setOrderId(10L); pickup.setStopType("PICKUP"); pickup.setStopOrder(0);
                 pickup.setName("pickup contact"); pickup.setPhone("0911111111"); pickup.setAddress("pickup road");
                 pickup.setName("pickup contact"); pickup.setPhone("0911111111"); pickup.setAddress("pickup road");
                 pickup.setLatitude(new BigDecimal("25.0100")); pickup.setLongitude(new BigDecimal("121.5100"));
                 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())
                 pickup.setStatus(current != null && List.of("PICKED_UP", "DELIVERED", "COMPLETED").contains(current.getStatus())
                         ? "PICKED_UP" : "PENDING");
                         ? "PICKED_UP" : "PENDING");
                 var delivery = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();
                 var delivery = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();
-                delivery.setId(102L); delivery.setOrderId(10L); delivery.setStopType("DELIVERY"); delivery.setStopOrder(1); delivery.setStatus("PENDING");
+                delivery.setId(102L); delivery.setOrderId(10L); delivery.setStopType("DELIVERY"); delivery.setStopOrder(1);
+                delivery.setStatus(current != null && List.of("DELIVERED", "COMPLETED").contains(current.getStatus())
+                        ? "DELIVERED" : "PENDING");
                 return List.of(pickup, delivery);
                 return List.of(pickup, delivery);
             });
             });
             when(lockService.withLock(any(), any())).thenAnswer(invocation -> {
             when(lockService.withLock(any(), any())).thenAnswer(invocation -> {

+ 33 - 26
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); }
     @AfterAll static void restoreMessages() { new SpringUtils().postProcessBeanFactory(original); }
 
 
-    @Test void changedTargetRequiresDeclarationAndCannotBeBypassedByPickup() {
+    @Test void oldMultiPickupCanConfirmNonDefaultStationWithoutSelectingTarget() {
         Fixture f = new Fixture();
         Fixture f = new Fixture();
         assertEquals(11L, f.service.riderDetail(8L, 10L).getCurrentTargetStopId());
         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() {
     @Test void selectingBDoesNotCollectThenPickupReturnsToAAndDisablesCancellation() {
@@ -211,15 +220,15 @@ class FlashDeliveryMultiPickupTest {
                 .getAnnotation(org.springframework.transaction.annotation.Transactional.class));
                 .getAnnotation(org.springframework.transaction.annotation.Transactional.class));
     }
     }
 
 
-    @Test void disablingMultiPickupRejectsNewQuotesButDoesNotBlockExistingFulfilment() {
+    @Test void configuredLimitAllowsMultiPickupWithoutSwitchAndExistingFulfilment() {
         Fixture f = new Fixture();
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
         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)));
+            dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
+            when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
+            assertDoesNotThrow(() -> f.service.quote(quoteRequest(2)));
             f.service.pickup(8L, 10L, proof(11L));
             f.service.pickup(8L, 10L, proof(11L));
             assertEquals(1, f.order.getPickedUpStopCount());
             assertEquals(1, f.order.getPickedUpStopCount());
-            verify(f.routes, never()).calculate(anyList(), eq(1));
+            verify(f.routes).calculate(anyList(), eq(1));
         }
         }
     }
     }
 
 
@@ -237,20 +246,22 @@ class FlashDeliveryMultiPickupTest {
     @Test void limitsFailBeforeMapsAndUrgentMultiPickupUsesOrderedFullRoute() {
     @Test void limitsFailBeforeMapsAndUrgentMultiPickupUsesOrderedFullRoute() {
         Fixture f = new Fixture();
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
         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")));
+            dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
             FlashDeliveryQuoteRequest request = quoteRequest(2);
             FlashDeliveryQuoteRequest request = quoteRequest(2);
             when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
             when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(4000, 600, "ROUTE"));
             assertEquals(4000, f.service.quote(request).getDistanceMeters());
             assertEquals(4000, f.service.quote(request).getDistanceMeters());
             var points = org.mockito.ArgumentCaptor.forClass(List.class);
             var points = org.mockito.ArgumentCaptor.forClass(List.class);
             verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(3, points.getValue().size());
             verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(3, points.getValue().size());
-            for (int size : new int[] {0, 27}) {
+            for (int size : new int[] {0, 11}) {
                 assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(size)));
                 assertThrows(ServiceException.class, () -> f.service.quote(quoteRequest(size)));
             }
             }
-            assertEquals(26, f.service.home().getPickupStopLimit());
+            assertEquals(10, f.service.home().getPickupStopLimit());
+            assertEquals(Boolean.TRUE, f.service.home().getShowMultiPickup());
+            assertEquals(Boolean.TRUE, f.service.home().getShowUrgentOption());
             for (String invalid : List.of("", "0", "27", "-1", "1.5", "abc")) {
             for (String invalid : List.of("", "0", "27", "-1", "1.5", "abc")) {
                 dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary(invalid)));
                 dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary(invalid)));
                 assertEquals(1, f.service.home().getPickupStopLimit());
                 assertEquals(1, f.service.home().getPickupStopLimit());
+                assertEquals(Boolean.FALSE, f.service.home().getShowMultiPickup());
                 assertThrows(ServiceException.class, () -> f.service.quote(request));
                 assertThrows(ServiceException.class, () -> f.service.quote(request));
             }
             }
             verify(f.routes, times(1)).calculate(anyList(), eq(1));
             verify(f.routes, times(1)).calculate(anyList(), eq(1));
@@ -260,8 +271,7 @@ class FlashDeliveryMultiPickupTest {
     @Test void arrayInputRejectsLegacyFieldsAndValidatesEachIndependentItem() {
     @Test void arrayInputRejectsLegacyFieldsAndValidatesEachIndependentItem() {
         Fixture f = new Fixture();
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
         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")));
+            dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
             FlashDeliveryQuoteRequest request = quoteRequest(2); request.setPackageType("DOCUMENT");
             FlashDeliveryQuoteRequest request = quoteRequest(2); request.setPackageType("DOCUMENT");
             assertThrows(ServiceException.class, () -> f.service.quote(request));
             assertThrows(ServiceException.class, () -> f.service.quote(request));
             request.setPackageType(null); request.getPickups().get(1).setQuantity(0);
             request.setPackageType(null); request.getPickups().get(1).setQuantity(0);
@@ -270,13 +280,13 @@ class FlashDeliveryMultiPickupTest {
         }
         }
     }
     }
 
 
-    @Test void twentySixPickupsAreAcceptedAndAllTwentySevenRoutePointsRemainOrdered() {
+    @Test void tenPickupsAreAcceptedAndAllElevenRoutePointsRemainOrdered() {
         Fixture f = new Fixture();
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = multiEnabled()) {
         try (MockedStatic<DictUtils> dict = multiEnabled()) {
             when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(39000, 1800, "ROUTE"));
             when(f.routes.calculate(anyList(), eq(1))).thenReturn(new RouteDistance(39000, 1800, "ROUTE"));
-            assertEquals(39000, f.service.quote(quoteRequest(26)).getDistanceMeters());
+            assertEquals(39000, f.service.quote(quoteRequest(10)).getDistanceMeters());
             var points = org.mockito.ArgumentCaptor.forClass(List.class);
             var points = org.mockito.ArgumentCaptor.forClass(List.class);
-            verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(27, points.getValue().size());
+            verify(f.routes).calculate(points.capture(), eq(1)); assertEquals(11, points.getValue().size());
         }
         }
     }
     }
 
 
@@ -297,9 +307,6 @@ class FlashDeliveryMultiPickupTest {
             var saved = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrderStop.class);
             var saved = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrderStop.class);
             verify(f.stops, times(3)).insert(saved.capture());
             verify(f.stops, times(3)).insert(saved.capture());
             assertEquals(List.of("PICKUP", "PICKUP", "DELIVERY"), saved.getAllValues().stream().map(FlashDeliveryOrderStop::getStopType).toList());
             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);
             var order = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrder.class);
             verify(f.orders).insert(order.capture()); assertEquals(2, order.getValue().getPickupStopCount());
             verify(f.orders).insert(order.capture()); assertEquals(2, order.getValue().getPickupStopCount());
             assertEquals("DOCUMENT", order.getValue().getPackageType()); assertEquals(0, order.getValue().getPickedUpStopCount());
             assertEquals("DOCUMENT", order.getValue().getPackageType()); assertEquals(0, order.getValue().getPickedUpStopCount());
@@ -366,8 +373,7 @@ class FlashDeliveryMultiPickupTest {
 
 
     private static MockedStatic<DictUtils> multiEnabled() {
     private static MockedStatic<DictUtils> multiEnabled() {
         MockedStatic<DictUtils> dict = mockStatic(DictUtils.class);
         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")));
+        dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("10")));
         return dict;
         return dict;
     }
     }
 
 
@@ -385,6 +391,7 @@ class FlashDeliveryMultiPickupTest {
         for (int i = 0; i < count; i++) {
         for (int i = 0; i < count; i++) {
             FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
             FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
             org.springframework.beans.BeanUtils.copyProperties(stop(20L + i, i, "PICKUP"), pickup);
             org.springframework.beans.BeanUtils.copyProperties(stop(20L + i, i, "PICKUP"), pickup);
+            pickup.setPackageType(i % 2 == 0 ? "DOCUMENT" : "GIFT"); pickup.setQuantity(i + 1); pickup.setWeightRange("UP_TO_5_KG");
             pickups.add(pickup);
             pickups.add(pickup);
         }
         }
         request.setPickups(pickups);
         request.setPickups(pickups);
@@ -404,7 +411,7 @@ class FlashDeliveryMultiPickupTest {
         FlashDeliveryOrderStop s = new FlashDeliveryOrderStop(); s.setId(id); s.setOrderId(10L); s.setStopOrder(index);
         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.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.setAddress("address " + index); s.setLatitude(BigDecimal.valueOf(25 + index * .001)); s.setLongitude(BigDecimal.valueOf(121));
-        s.setPackageType("DOCUMENT"); s.setQuantity(index + 1); s.setWeightRange("UP_TO_5_KG"); return s;
+        return s;
     }
     }
     private static class Fixture {
     private static class Fixture {
         final FlashDeliveryOrderMapper orders = mock(FlashDeliveryOrderMapper.class);
         final FlashDeliveryOrderMapper orders = mock(FlashDeliveryOrderMapper.class);

+ 292 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java

@@ -1,5 +1,20 @@
 package com.ruoyi.app.flashdelivery.service;
 package com.ruoyi.app.flashdelivery.service;
 
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.app.push.ApnsPushSender;
+import com.ruoyi.app.push.BusinessPushService;
+import com.ruoyi.app.push.FcmPushSender;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEvent;
+import com.ruoyi.app.utils.event.PushEventListener;
+import com.ruoyi.common.utils.LocaleUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.framework.manager.AsyncManager;
+import com.ruoyi.system.domain.PushMessage;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPushMessageService;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.push.BusinessPushProperties;
 import com.ruoyi.app.push.BusinessPushProperties;
@@ -14,9 +29,20 @@ import com.ruoyi.system.mapper.RiderPositionMapper;
 import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.utils.DictUtils;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
 import org.mockito.MockedStatic;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.MessageSource;
+import org.springframework.context.support.ResourceBundleMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.util.List;
 import java.util.List;
+import java.util.ArrayList;
+import java.util.Locale;
+import java.util.Set;
+import java.util.TimerTask;
+import java.util.concurrent.ScheduledExecutorService;
 
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -29,6 +55,13 @@ import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mockStatic;
 
 
 class FlashDeliveryNotificationServiceTest {
 class FlashDeliveryNotificationServiceTest {
 
 
@@ -251,6 +284,265 @@ class FlashDeliveryNotificationServiceTest {
         verify(service, never()).sendToUser(any(), any(), anyString(), anyString(), any());
         verify(service, never()).sendToUser(any(), any(), anyString(), anyString(), any());
     }
     }
 
 
+    @Test
+    void pickupStopProgressIsQueuedOnlyAfterCommitAndSentOnlyToOrderSender() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            FlashDeliveryOrder order = order();
+            order.setStatus("ACCEPTED");
+            order.setReceiverUserId(88L);
+            order.setDeliveryPinCode("order-secret-pin");
+            FlashDeliveryOrderStop stop = stop("PICKUP", 2);
+            TransactionSynchronizationManager.initSynchronization();
+            try {
+                assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order, stop, 1, 2));
+                assertTrue(fixture.tasks.isEmpty());
+                assertTrue(fixture.messages.isEmpty());
+                assertTrue(fixture.lookedUpUsers.isEmpty());
+
+                TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit);
+                assertEquals(1, fixture.tasks.size());
+                assertTrue(fixture.messages.isEmpty());
+                assertEquals(List.of(7L), fixture.lookedUpUsers);
+                fixture.runTasks();
+            } finally {
+                TransactionSynchronizationManager.clearSynchronization();
+            }
+            assertEquals(1, fixture.messages.size());
+            PushMessage message = fixture.messages.get(0);
+            assertEquals(7L, message.getUserId());
+            assertEquals("FL-20260813-021", message.getDdId());
+            assertEquals(3, message.getPushType());
+            JSONObject body = JSON.parseObject(message.getBody());
+            assertEquals(Set.of("ddId", "state", "type", "pushType", "eventType", "stopId", "stopType",
+                    "stopOrder", "completedStopCount", "totalStopCount"), body.keySet());
+            assertEquals("FL-20260813-021", body.getString("ddId"));
+            assertEquals("ACCEPTED", body.getString("state"));
+            assertEquals(-1, body.getIntValue("type"));
+            assertEquals(3, body.getIntValue("pushType"));
+            assertEquals("FLASH_PICKUP_STOP_COMPLETED", body.getString("eventType"));
+            assertEquals(41L, body.getLong("stopId"));
+            assertEquals("PICKUP", body.getString("stopType"));
+            assertEquals(2, body.getIntValue("stopOrder"));
+            assertEquals(1, body.getIntValue("completedStopCount"));
+            assertEquals(2, body.getIntValue("totalStopCount"));
+            assertFalse(message.getBody().contains("secret"));
+            assertEquals(1, fixture.sentContents.size());
+            assertTrue(fixture.sentContents.get(0).contains("stop 3"));
+            assertTrue(fixture.sentContents.get(0).contains("1/2"));
+        }
+    }
+
+    @Test
+    void deliveryStopProgressKeepsRealPartialAndFinalOrderStatesWithoutWholeOrderScene() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            FlashDeliveryOrder partial = order();
+            partial.setStatus("PICKED_UP");
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(partial, stop("DELIVERY", 3), 1, 2));
+            FlashDeliveryOrder finished = order();
+            finished.setStatus("DELIVERED");
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(finished, stop("DELIVERY", 4), 2, 2));
+            fixture.runTasks();
+
+            assertEquals(2, fixture.messages.size());
+            List<JSONObject> bodies = fixture.messages.stream().map(message -> JSON.parseObject(message.getBody())).toList();
+            assertEquals(List.of("PICKED_UP", "DELIVERED"), bodies.stream().map(body -> body.getString("state")).toList());
+            assertEquals(List.of(1, 2), bodies.stream().map(body -> body.getIntValue("completedStopCount")).toList());
+            for (JSONObject body : bodies) {
+                assertEquals("FLASH_DELIVERY_STOP_COMPLETED", body.getString("eventType"));
+                assertEquals("DELIVERY", body.getString("stopType"));
+                assertEquals(2, body.getIntValue("totalStopCount"));
+                assertFalse(body.containsKey("eventScene"));
+            }
+            assertEquals(List.of(7L, 7L), fixture.messages.stream().map(PushMessage::getUserId).toList());
+        }
+    }
+
+    @Test
+    void rollbackDoesNotQueueOrPersistEitherStopProgressNotification() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            TransactionSynchronizationManager.initSynchronization();
+            try {
+                assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 0), 1, 2));
+                assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+                TransactionSynchronizationManager.getSynchronizations()
+                        .forEach(sync -> sync.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
+                fixture.runTasks();
+                assertTrue(fixture.lookedUpUsers.isEmpty());
+                assertTrue(fixture.tasks.isEmpty());
+                assertTrue(fixture.messages.isEmpty());
+                assertTrue(fixture.sentContents.isEmpty());
+            } finally {
+                TransactionSynchronizationManager.clearSynchronization();
+            }
+        }
+    }
+
+    @Test
+    void missingDeviceAddressStillPersistsStopProgressThroughRealPayPushEventPath() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            fixture.sender.setCid(null);
+            for (BusinessPushProperties.Mode mode : BusinessPushProperties.Mode.values()) {
+                fixture.properties.setMode(mode);
+                assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 0), 1, 2));
+                assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+                fixture.runTasks();
+            }
+
+            assertEquals(4, fixture.messages.size());
+            assertTrue(fixture.messages.stream().allMatch(message -> Long.valueOf(7L).equals(message.getUserId())
+                    && Integer.valueOf(3).equals(message.getPushType())
+                    && "FL-20260813-021".equals(message.getDdId())));
+            assertTrue(fixture.sentContents.isEmpty());
+        }
+    }
+
+    @Test
+    void stopProgressTextUsesSenderLocaleAndRouteNumberWithCompletedAndTotalCounts() {
+        List<Locale> locales = List.of(Locale.ROOT, Locale.TAIWAN, Locale.SIMPLIFIED_CHINESE,
+                Locale.US, Locale.forLanguageTag("th-TH"));
+        List<String> pickupTexts = List.of("第 3 站已完成取貨,取貨進度 1/2", "第 3 站已完成取貨,取貨進度 1/2",
+                "第 3 站已完成取货,取货进度 1/2", "Pickup completed at stop 3. Pickup progress: 1/2",
+                "รับพัสดุที่จุด 3 แล้ว ความคืบหน้าการรับ: 1/2");
+        List<String> deliveryTexts = List.of("第 5 站已送達,送達進度 2/2", "第 5 站已送達,送達進度 2/2",
+                "第 5 站已送达,送达进度 2/2", "Delivery completed at stop 5. Delivery progress: 2/2",
+                "ส่งพัสดุถึงจุด 5 แล้ว ความคืบหน้าการส่ง: 2/2");
+        for (int i = 0; i < locales.size(); i++) {
+            try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+                fixture.locale.when(() -> LocaleUtils.getUserLocale(7L)).thenReturn(locales.get(i));
+                assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 2), 1, 2));
+                assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 4), 2, 2));
+                fixture.runTasks();
+                assertEquals(2, fixture.messages.size());
+                assertTrue(fixture.messages.get(0).getContent().contains(pickupTexts.get(i)));
+                assertTrue(fixture.messages.get(1).getContent().contains(deliveryTexts.get(i)));
+                assertTrue(fixture.messages.stream().allMatch(message -> !message.getContent().contains("{0}")
+                        && message.getContent().contains("FL-20260813-021")));
+            }
+        }
+    }
+
+    @Test
+    void stopNotificationLookupAndSchedulingFailuresDoNotEscapeBusinessOperation() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            when(fixture.users.selectById(7L)).thenThrow(new IllegalStateException("lookup failed"));
+            assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 0), 1, 2));
+            assertTrue(fixture.tasks.isEmpty());
+            org.mockito.Mockito.doReturn(fixture.sender).when(fixture.users).selectById(7L);
+            doThrow(new IllegalStateException("executor unavailable")).when(fixture.manager).execute(any(TimerTask.class));
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+            assertTrue(fixture.messages.isEmpty());
+        }
+    }
+
+    @Test
+    void stopNotificationPersistenceFailureIsContainedAndNextProgressStillSends() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            fixture.persistenceFailure = true;
+            assertDoesNotThrow(() -> fixture.service.notifyPickupStop(order(), stop("PICKUP", 0), 1, 2));
+            assertDoesNotThrow(fixture::runTasks);
+            assertTrue(fixture.messages.isEmpty());
+            fixture.persistenceFailure = false;
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+            assertDoesNotThrow(fixture::runTasks);
+            assertEquals(1, fixture.messages.size());
+            assertEquals("FLASH_DELIVERY_STOP_COMPLETED", JSON.parseObject(fixture.messages.get(0).getBody()).getString("eventType"));
+        }
+    }
+
+    @Test
+    void externalStopPushFailureDoesNotLosePersistedProgressOrEscapeBusinessOperation() {
+        try (StopNotificationFixture fixture = new StopNotificationFixture()) {
+            doThrow(new IllegalStateException("network failure")).when(fixture.uni)
+                    .apppush(anyString(), anyString(), anyString(), anyString());
+            assertDoesNotThrow(() -> fixture.service.notifyDeliveryStop(order(), stop("DELIVERY", 2), 1, 2));
+            assertDoesNotThrow(fixture::runTasks);
+            assertEquals(1, fixture.messages.size());
+            assertEquals(7L, fixture.messages.get(0).getUserId());
+        }
+    }
+
+    private static FlashDeliveryOrderStop stop(String type, int routeOrder) {
+        FlashDeliveryOrderStop stop = new FlashDeliveryOrderStop();
+        stop.setId(41L);
+        stop.setOrderId(10L);
+        stop.setStopType(type);
+        stop.setStopOrder(routeOrder);
+        stop.setReceiverUserId(99L);
+        stop.setName("secret-contact");
+        stop.setPhone("secret-phone");
+        stop.setAddress("secret-address");
+        stop.setDeliveryPinCode("secret-pin");
+        return stop;
+    }
+
+    private static final class StopNotificationFixture implements AutoCloseable {
+        final InfoUserMapper users = mock(InfoUserMapper.class);
+        final InfoUser sender = new InfoUser();
+        final PayPush uni = mock(PayPush.class);
+        final BusinessPushProperties properties = new BusinessPushProperties();
+        final List<Long> lookedUpUsers = new ArrayList<>();
+        final List<TimerTask> tasks = new ArrayList<>();
+        final List<PushMessage> messages = new ArrayList<>();
+        final List<String> sentContents = new ArrayList<>();
+        final MockedStatic<SpringUtils> spring;
+        final MockedStatic<AsyncManager> async;
+        final MockedStatic<LocaleUtils> locale;
+        final AsyncManager manager;
+        final FlashDeliveryNotificationService service;
+        boolean persistenceFailure;
+
+        StopNotificationFixture() {
+            sender.setUserId(7L);
+            sender.setCid("sender-cid");
+            when(users.selectById(7L)).thenAnswer(call -> { lookedUpUsers.add(7L); return sender; });
+            IInfoUserService currentUsers = mock(IInfoUserService.class);
+            when(currentUsers.getById(7L)).thenReturn(sender);
+            BusinessPushService router = new BusinessPushService(properties, currentUsers,
+                    mock(FcmPushSender.class), mock(ApnsPushSender.class), uni);
+            IPushMessageService repository = mock(IPushMessageService.class);
+            doAnswer(call -> {
+                if (persistenceFailure) throw new IllegalStateException("message storage unavailable");
+                messages.add(call.getArgument(0));
+                return 1;
+            }).when(repository).insertPushMessage(any(PushMessage.class));
+            PushEventListener listener = new PushEventListener();
+            ReflectionTestUtils.setField(listener, "pushMessageService", repository);
+            PushEventService events = new PushEventService();
+            ApplicationEventPublisher publisher = event -> listener.onApplicationEvent((PushEvent) event);
+            ReflectionTestUtils.setField(events, "eventPublisher", publisher);
+            ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
+            messageSource.setBasename("i18n/messages");
+            messageSource.setDefaultEncoding("UTF-8");
+            messageSource.setFallbackToSystemLocale(false);
+            spring = mockStatic(SpringUtils.class);
+            spring.when(() -> SpringUtils.getBean("scheduledExecutorService")).thenReturn(mock(ScheduledExecutorService.class));
+            spring.when(() -> SpringUtils.getBean(MessageSource.class)).thenReturn(messageSource);
+            spring.when(() -> SpringUtils.getBean(BusinessPushService.class)).thenReturn(router);
+            manager = mock(AsyncManager.class);
+            doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(manager).execute(any(TimerTask.class));
+            async = mockStatic(AsyncManager.class);
+            async.when(AsyncManager::me).thenReturn(manager);
+            locale = mockStatic(LocaleUtils.class);
+            locale.when(() -> LocaleUtils.getUserLocale(7L)).thenReturn(Locale.US);
+            doAnswer(call -> { sentContents.add(call.getArgument(2)); return null; }).when(uni)
+                    .apppush(eq("sender-cid"), anyString(), anyString(), anyString());
+            service = new FlashDeliveryNotificationService(users, events);
+        }
+
+        void runTasks() {
+            List<TimerTask> pending = new ArrayList<>(tasks);
+            tasks.clear();
+            pending.forEach(TimerTask::run);
+        }
+
+        @Override
+        public void close() {
+            locale.close();
+            async.close();
+            spring.close();
+        }
+    }
+
     private static FlashDeliveryOrder order() {
     private static FlashDeliveryOrder order() {
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         order.setId(10L);
         order.setId(10L);

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

@@ -73,7 +73,7 @@ class FlashDeliveryRiderBillingTest {
         pickup.setStopType("PICKUP"); pickup.setStatus("PICKED_UP");
         pickup.setStopType("PICKUP"); pickup.setStatus("PICKED_UP");
         var delivery = new FlashDeliveryOrderStop();
         var delivery = new FlashDeliveryOrderStop();
         delivery.setId(12L); delivery.setOrderId(10L); delivery.setStopOrder(1);
         delivery.setId(12L); delivery.setOrderId(10L); delivery.setStopOrder(1);
-        delivery.setStopType("DELIVERY"); delivery.setStatus("PENDING");
+        delivery.setStopType("DELIVERY"); delivery.setStatus("DELIVERED");
         when(stops.selectList(any(Wrapper.class))).thenReturn(List.of(pickup, delivery));
         when(stops.selectList(any(Wrapper.class))).thenReturn(List.of(pickup, delivery));
         when(orders.transitionByParticipant(eq(10L), anyLong(), eq("DELIVERED"), eq("COMPLETED"), any()))
         when(orders.transitionByParticipant(eq(10L), anyLong(), eq("DELIVERED"), eq("COMPLETED"), any()))
                 .thenReturn(1, 0);
                 .thenReturn(1, 0);

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

@@ -0,0 +1,634 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
+import com.ruoyi.app.flashdelivery.route.RouteDistance;
+import com.ruoyi.app.order.RiderDeliveryAcceptanceService;
+import com.ruoyi.app.order.RiderDeliveryLockService;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.flash.*;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.flash.*;
+import org.junit.jupiter.api.*;
+import org.mockito.MockedStatic;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.test.util.ReflectionTestUtils;
+import com.ruoyi.common.utils.spring.SpringUtils;
+
+import java.math.BigDecimal;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** v2 多点取送:统一 stops 数组、订单级物品、逐站送达与取消门禁。 */
+class FlashDeliveryV2Test {
+    private static ConfigurableListableBeanFactory original;
+    @BeforeAll static void messages() {
+        original = (ConfigurableListableBeanFactory) ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
+        StaticMessageSource source = new StaticMessageSource(); source.setUseCodeAsDefaultMessage(true);
+        factory.registerSingleton("messageSource", source); new SpringUtils().postProcessBeanFactory(factory);
+    }
+    @AfterAll static void restoreMessages() { new SpringUtils().postProcessBeanFactory(original); }
+
+    private static void multiStopDicts(MockedStatic<DictUtils> dict) {
+        dict.when(() -> DictUtils.getDictCache("flash_pickup_stop_limit")).thenReturn(List.of(dictionary("3")));
+        dict.when(() -> DictUtils.getDictCache("flash_delivery_stop_limit")).thenReturn(List.of(dictionary("3")));
+    }
+
+    @Test void v2CreateSavesUnifiedStopsWithOrderLevelItemAndPerStopReceivers() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryV2CreateRequest request = v2Create();
+            FlashDeliveryQuoteView quote = f.service.quoteV2(request);
+            request.setPricingId(quote.getPricingId()); request.setPricingVersion(quote.getPricingVersion());
+            request.setQuotedBaseDeliveryFee(quote.getBaseDeliveryFee()); request.setQuotedDistanceFee(quote.getDistanceFee());
+            request.setQuotedUrgentFee(quote.getUrgentFee()); request.setQuotedAmount(quote.getAmount());
+            f.service.createV2(7L, request);
+            var saved = argThatStops(f);
+            assertEquals(List.of("PICKUP", "PICKUP", "DELIVERY", "DELIVERY"),
+                    saved.stream().map(FlashDeliveryOrderStop::getStopType).toList());
+            assertEquals(List.of(0, 1, 2, 3), saved.stream().map(FlashDeliveryOrderStop::getStopOrder).toList());
+            assertEquals(List.of("取月餅", "取牛奶", "送管理室", "送前台"),
+                    saved.stream().map(FlashDeliveryOrderStop::getRemark).toList());
+            // 按站收件人:两个收货站按各自电话分别固化;取货站不固化。
+            assertEquals(Arrays.asList(null, null, 31L, 32L), saved.stream().map(FlashDeliveryOrderStop::getReceiverUserId).toList());
+            // 启用收货码时各收货站独立生成。
+            assertNotNull(saved.get(2).getDeliveryPinCode());
+            assertNotNull(saved.get(3).getDeliveryPinCode());
+            assertNotEquals(saved.get(2).getDeliveryPinCode(), saved.get(3).getDeliveryPinCode());
+            var order = argThatOrder(f);
+            assertEquals(2, order.getDeliveryStopCount());
+            assertEquals(0, order.getDeliveredStopCount());
+            // 物品主体整单一次,落在订单级摘要字段。
+            assertEquals("FOOD_INGREDIENTS", order.getPackageType());
+            assertEquals(8, order.getQuantity());
+        }
+    }
+
+    @Test void v2RejectsDeliveryBeforePickupAndOverLimit() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryV2CreateRequest wrongOrder = v2Create();
+            wrongOrder.getStops().add(0, stop("DELIVERY", "0911111112", "台北市松山區B街2號", "d1"));
+            assertThrows(ServiceException.class, () -> f.service.createV2(7L, wrongOrder));
+
+            FlashDeliveryV2CreateRequest 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));
+            verify(f.orders, never()).insert(any(FlashDeliveryOrder.class));
+
+        }
+    }
+
+    @Test void homeDerivesMultiStopVisibilityFromLimitsAndAlwaysShowsUrgent() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryHomeView home = f.service.home();
+            assertEquals(3, home.getPickupStopLimit());
+            assertEquals(3, home.getDeliveryStopLimit());
+            assertEquals(Boolean.TRUE, home.getShowMultiPickup());
+            assertEquals(Boolean.TRUE, home.getShowMultiDropoff());
+            assertEquals(Boolean.TRUE, home.getShowUrgentOption());
+        }
+    }
+
+    @Test void v2QuoteRoutesThroughAllStopsInUserOrder() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryV2QuoteRequest request = v2Create();
+            FlashDeliveryQuoteView view = f.service.quoteV2(request);
+            assertEquals(5000, view.getDistanceMeters());
+            var points = f.capturedPoints;
+            assertEquals(4, points.size());
+            verify(f.routes).calculate(anyList(), eq(1));
+        }
+    }
+
+    @Test void 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 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);
+        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);
+        var stops = card.getStops();
+        // 逐站列表按用户顺序返回,取送类型与状态驱动卡片上的 ①②③ 序号和逐站进度。
+        assertEquals(List.of("PICKUP", "PICKUP", "DELIVERY", "DELIVERY"),
+                stops.stream().map(FlashDeliveryStopView::getStopType).toList());
+        assertEquals(List.of(0, 1, 2, 3), stops.stream().map(FlashDeliveryStopView::getStopOrder).toList());
+        assertEquals(List.of("address 0", "address 1", "address 2", "address 3"),
+                stops.stream().map(FlashDeliveryStopView::getAddress).toList());
+        assertEquals(List.of("PICKED_UP", "PICKED_UP", "PENDING", "PENDING"),
+                stops.stream().map(FlashDeliveryStopView::getStatus).toList());
+        // 卡片与详情同口径脱敏:文字地址可见,联系人、电话、坐标与凭证不落卡片。
+        assertNull(stops.get(0).getName()); assertNull(stops.get(0).getPhone());
+        assertNull(stops.get(0).getLongitude()); assertNull(stops.get(0).getLatitude());
+        assertTrue(stops.get(0).getImageUrls().isEmpty());
+    }
+
+    @Test void newTaskGrabCardsExposeStopAddressesOnly() {
+        Fixture f = new Fixture();
+        f.order.setStatus("WAITING_ACCEPTANCE"); f.order.setRiderId(null);
+        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, "newTask", null, null).getRecords().get(0);
+        // 待抢卡片同样给出逐站文字地址供骑手评估路线,但不泄露联系人、电话与坐标。
+        assertEquals(List.of("PICKUP", "PICKUP", "DELIVERY", "DELIVERY"),
+                card.getStops().stream().map(FlashDeliveryStopView::getStopType).toList());
+        assertNull(card.getStops().get(2).getName());
+        assertNull(card.getStops().get(2).getPhone());
+        assertNull(card.getStops().get(2).getLongitude());
+        assertNull(card.getUserNote());
+    }
+
+    @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 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");
+        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();
+        proof.setImageUrls(List.of("/profile/pickup.jpg"));
+        f.service.pickupAtStop(8L, 10L, 11L, proof);
+        f.service.pickupAtStop(8L, 10L, 11L, proof);
+        f.service.pickupAtStop(8L, 10L, 12L, proof);
+        verify(f.notifications).notifyPickupStop(same(f.order), same(f.rows.get(0)), eq(1), eq(2));
+        verify(f.notifications).notifyPickupStop(same(f.order), same(f.rows.get(1)), eq(2), eq(2));
+        verify(f.notifications, times(2)).notifyPickupStop(any(), any(), anyInt(), anyInt());
+        verify(f.notifications, never()).notifyPickedUp(any());
+    }
+
+    @Test void perStopDeliveryAdvancesOrderOnlyAfterLastStopAndBlocksCancel() {
+        Fixture f = new Fixture();
+        f.order.setStatus("PICKED_UP"); f.order.setPinRequired(false);
+        f.rows.forEach(row -> { if ("PICKUP".equals(row.getStopType())) row.setStatus("PICKED_UP"); });
+        when(f.orders.updateDeliveryFulfilment(any())).thenReturn(1);
+        FlashDeliveryDeliverRequest deliver = new FlashDeliveryDeliverRequest();
+        deliver.setImageUrls(List.of("/profile/d1.jpg"));
+        deliver.setStopId(13L);
+        f.service.deliverAtStop(8L, 10L, 13L, deliver);
+        assertEquals("DELIVERED", f.rows.get(2).getStatus());
+        assertEquals(1, f.order.getDeliveredStopCount());
+        assertEquals("PICKED_UP", f.order.getStatus());
+        // 部分送达后用户与平台都不能自助取消。
+        FlashDeliveryReasonRequest cancel = new FlashDeliveryReasonRequest(); cancel.setReason("changed mind");
+        assertThrows(ServiceException.class, () -> f.service.userCancel(7L, 10L, cancel));
+        verify(f.orders, never()).cancel(any(), any(), any(), any(), any(), any());
+        // 同站重试幂等:不重复写凭证、不重复推进。
+        f.service.deliverAtStop(8L, 10L, 13L, deliver);
+        verify(f.images, times(1)).insert(any(FlashDeliveryOrderImage.class));
+        // 末站完成后整单推进一次。
+        deliver.setStopId(14L);
+        f.service.deliverAtStop(8L, 10L, 14L, deliver);
+        assertEquals("DELIVERED", f.order.getStatus());
+        assertEquals(2, f.order.getDeliveredStopCount());
+        assertEquals(f.rows.get(3).getDeliveredAt(), f.order.getDeliveredAt());
+        verify(f.notifications, times(2)).notifyDeliveryStop(any(), any(), anyInt(), eq(2));
+        verify(f.notifications, never()).notifyDelivered(any());
+    }
+
+    @Test void perStopPinIsValidatedAgainstThatStopOnly() {
+        Fixture f = new Fixture();
+        f.order.setStatus("PICKED_UP"); f.order.setPinRequired(true); f.order.setDeliveryPinCode("0000");
+        f.rows.forEach(row -> { if ("PICKUP".equals(row.getStopType())) row.setStatus("PICKED_UP"); });
+        f.rows.get(2).setDeliveryPinCode("1111");
+        f.rows.get(3).setDeliveryPinCode("2222");
+        when(f.orders.updateDeliveryFulfilment(any())).thenReturn(1);
+        FlashDeliveryDeliverRequest deliver = new FlashDeliveryDeliverRequest();
+        deliver.setImageUrls(List.of("/profile/d1.jpg"));
+        deliver.setStopId(13L);
+        deliver.setPinCode("2222");
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(8L, 10L, 13L, deliver));
+        verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+        deliver.setPinCode("1111");
+        f.service.deliverAtStop(8L, 10L, 13L, deliver);
+        assertEquals("DELIVERED", f.rows.get(2).getStatus());
+    }
+
+
+    @Test void v2PinsAreReturnedPerStopOnlyToSenderAndMatchingReceiver() throws Exception {
+        Fixture f = ready(); receivers(f);
+        f.order.setPinRequired(true); f.order.setDeliveryPinCode("0000");
+        f.rows.get(2).setDeliveryPinCode("1111"); f.rows.get(3).setDeliveryPinCode("2222");
+        var json = new com.fasterxml.jackson.databind.ObjectMapper();
+        var sender = json.valueToTree(f.service.userDetail(7L, 10L));
+        assertEquals("1111", sender.at("/stops/2/deliveryPinCode").asText());
+        assertEquals("2222", sender.at("/stops/3/deliveryPinCode").asText());
+        assertTrue(sender.path("deliveryPinCode").isMissingNode() || sender.path("deliveryPinCode").isNull());
+        var receiver = json.valueToTree(assertDoesNotThrow(() -> f.service.userDetail(32L, 10L)));
+        assertEquals("2222", receiver.at("/stops/3/deliveryPinCode").asText());
+        assertTrue(receiver.at("/stops/2/deliveryPinCode").isMissingNode() || receiver.at("/stops/2/deliveryPinCode").isNull());
+        assertNull(f.service.userDetail(32L, 10L).getStops().get(2).getPhone());
+        // 他人收货站开放文字地址(与列表口径一致),联系人与坐标仍不返回。
+        assertEquals("address 2", f.service.userDetail(32L, 10L).getStops().get(2).getAddress());
+        assertEquals("0911111113", f.service.userDetail(32L, 10L).getDelivery().getPhone());
+        var rider = json.valueToTree(f.service.riderDetail(8L, 10L));
+        assertTrue(rider.at("/stops/2/deliveryPinCode").isMissingNode() || rider.at("/stops/2/deliveryPinCode").isNull());
+        assertThrows(ServiceException.class, () -> f.service.userDetail(99L, 10L));
+    }
+
+    @Test void legacyDeliveryRejectsV2OrdersIncludingSingleDropoffBeforeAnyWrite() {
+        for (int count : List.of(1, 2)) {
+            Fixture f = ready(); if (count == 1) f.rows.remove(3); f.order.setDeliveryStopCount(count);
+            when(f.orders.transitionByRider(any(), any(), any(), any(), any(), any())).thenReturn(1);
+            assertThrows(ServiceException.class, () -> f.service.deliver(8L, 10L, deliveryProof(13L)));
+            verify(f.orders, never()).transitionByRider(any(), any(), any(), any(), any(), any());
+            verify(f.images, never()).insert(any(FlashDeliveryOrderImage.class));
+            assertTrue(f.rows.stream().filter(row -> "DELIVERY".equals(row.getStopType())).allMatch(row -> "PENDING".equals(row.getStatus())));
+        }
+    }
+
+    @Test void legacyAddressQuoteRejectsV2BeforeCalculatingOrDeletingStops() {
+        Fixture f = addressFixture(); f.rows.remove(1); f.order.setPickupStopCount(1);
+        FlashDeliveryAddressChangeRequest request = new FlashDeliveryAddressChangeRequest(); request.setOrderVersion(0);
+        request.setPickup(address(f.rows.get(0))); request.setDelivery(address(f.rows.get(1)));
+        assertThrows(ServiceException.class, () -> f.service.quoteAddress(7L, 10L, request));
+        verify(f.routes, never()).calculate(any(), any(), any());
+        verify(f.stops, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
+        assertEquals(2, f.rows.stream().filter(row -> "DELIVERY".equals(row.getStopType())).count());
+    }
+
+    @Test void finalStopDeliveryRetriesRemainIdempotentAfterDeliveredAndCompleted() {
+        Fixture f = ready();
+        f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L));
+        f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L));
+        assertDoesNotThrow(() -> f.service.deliverAtStop(8L, 10L, 14L, deliveryProof(14L)));
+        f.order.setStatus("COMPLETED");
+        assertDoesNotThrow(() -> f.service.deliverAtStop(8L, 10L, 13L, deliveryProof(13L)));
+        verify(f.images, times(2)).insert(any(FlashDeliveryOrderImage.class));
+        verify(f.orders, times(2)).updateDeliveryFulfilment(any());
+        assertThrows(ServiceException.class, () -> f.service.deliverAtStop(9L, 10L, 14L, deliveryProof(14L)));
+    }
+
+    @Test void deliveryCanCompleteNonDefaultStopWithoutSelectingTarget() {
+        Fixture f = ready();
+        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("DELIVERED", f.order.getStatus());
+    }
+
+    @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.setConsentDeclared(false);
+        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());
+        assertNull(f.order.getCurrentTargetStopId());
+        assertEquals("PICKED_UP", f.order.getStatus());
+    }
+
+    @Test void incompleteDeliveryCannotBeConfirmedOrCompletedEvenIfOrderStatusWasWrong() {
+        Fixture f = ready(); f.order.setStatus("DELIVERED"); f.rows.get(2).setStatus("DELIVERED");
+        when(f.orders.transitionByParticipant(any(), any(), any(), any(), any())).thenReturn(1);
+        when(f.orders.transitionByStatus(any(), any(), any(), any(), any())).thenReturn(1);
+        assertThrows(ServiceException.class, () -> f.service.confirmReceipt(7L, 10L));
+        assertFalse(f.service.complete(1L, "ADMIN", 10L));
+        assertFalse(f.service.complete(null, "SYSTEM", 10L));
+        verify(f.billing, never()).insert(any(com.ruoyi.system.domain.UserBilling.class));
+    }
+
+    @Test void secondReceiverCanConfirmFullyDeliveredOrderAndBillOnlyOnce() {
+        Fixture f = ready(); receivers(f); f.order.setStatus("DELIVERED");
+        f.rows.stream().filter(row -> "DELIVERY".equals(row.getStopType())).forEach(row -> row.setStatus("DELIVERED"));
+        when(f.orders.transitionByParticipant(eq(10L), eq(32L), eq("DELIVERED"), eq("COMPLETED"), any())).thenAnswer(call -> {
+            if (!"DELIVERED".equals(f.order.getStatus())) return 0;
+            f.order.setStatus("COMPLETED"); return 1;
+        });
+        assertDoesNotThrow(() -> f.service.confirmReceipt(32L, 10L));
+        assertThrows(ServiceException.class, () -> f.service.confirmReceipt(32L, 10L));
+        verify(f.billing, times(1)).insert(any(com.ruoyi.system.domain.UserBilling.class));
+    }
+
+    @Test void v2AddressUpdatePreservesAllStopsItemsAndOriginalPricing() throws Exception {
+        Fixture f = addressFixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            var json = new com.fasterxml.jackson.databind.ObjectMapper();
+            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);
+            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());
+            body.put("quotedDistanceMeters", quote.getDistanceMeters()); body.put("quotedBaseDeliveryFee", quote.getBaseDeliveryFee());
+            body.put("quotedDistanceFee", quote.getDistanceFee()); body.put("quotedUrgentFee", quote.getUrgentFee()); body.put("quotedAmount", quote.getAmount());
+            Object confirm = json.treeToValue(body, confirmType);
+            when(f.orders.updateWaitingAddress(any(), eq(0))).thenReturn(1);
+            when(f.stops.delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenAnswer(call -> { f.rows.clear(); return 4; });
+            when(f.stops.insert(any(FlashDeliveryOrderStop.class))).thenAnswer(call -> { FlashDeliveryOrderStop row = call.getArgument(0); row.setId(20L + f.rows.size()); f.rows.add(row); return 1; });
+            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(32L, f.rows.get(3).getReceiverUserId());
+            assertNotNull(f.rows.get(1).getDeliveryPinCode());
+            assertEquals(1, f.order.getVersion());
+            verify(f.prices, never()).selectAtTime(anyString(), anyInt());
+        }
+    }
+
+    private static Fixture ready() {
+        Fixture f = new Fixture(); f.order.setStatus("PICKED_UP"); f.order.setPinRequired(false);
+        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);
+    }
+    private static FlashDeliveryDeliverRequest deliveryProof(Long stopId) {
+        FlashDeliveryDeliverRequest request = new FlashDeliveryDeliverRequest(); request.setStopId(stopId);
+        request.setImageUrls(List.of("/profile/d.jpg")); return request;
+    }
+    private static FlashDeliveryAddressRequest address(FlashDeliveryOrderStop row) {
+        FlashDeliveryAddressRequest request = new FlashDeliveryAddressRequest(); org.springframework.beans.BeanUtils.copyProperties(row, request); return request;
+    }
+    private static Fixture addressFixture() {
+        Fixture f = new Fixture(); f.order.setStatus("WAITING_ACCEPTANCE"); f.order.setRiderId(null); f.order.setPinRequired(true);
+        f.order.setServiceType("HELP_SEND"); f.order.setDeliveryType("NORMAL"); f.order.setVehicleType(1); f.order.setTipAmount(0L);
+        f.order.setPackageType("FOOD_INGREDIENTS"); f.order.setQuantity(8); f.order.setPricingId(1L); f.order.setPricingVersion(1);
+        f.order.setStartingDistance(BigDecimal.valueOf(3)); f.order.setStartingFare(80L); f.order.setDistance(BigDecimal.ONE);
+        f.order.setFreight(10L); f.order.setUrgentRate(BigDecimal.ZERO); f.order.setMinimumUrgentFee(0L); return f;
+    }
+
+    private List<FlashDeliveryOrderStop> argThatStops(Fixture f) {
+        var captor = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrderStop.class);
+        verify(f.stops, atLeast(4)).insert(captor.capture());
+        return captor.getAllValues();
+    }
+
+    private FlashDeliveryOrder argThatOrder(Fixture f) {
+        var captor = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+        verify(f.orders).insert(captor.capture());
+        return captor.getValue();
+    }
+
+    private static FlashDeliveryV2StopRequest stop(String type, String phone, String address, String remark) {
+        FlashDeliveryV2StopRequest stop = new FlashDeliveryV2StopRequest();
+        stop.setType(type); stop.setName("聯絡人"); stop.setPhone(phone);
+        stop.setAddress(address); stop.setAddressDetail("3樓"); stop.setRemark(remark);
+        int seq = Math.abs(address.hashCode()) % 100;
+        stop.setLatitude(BigDecimal.valueOf(25.03 + seq * .0001));
+        stop.setLongitude(BigDecimal.valueOf(121.56 + seq * .0001));
+        return stop;
+    }
+
+    private static FlashDeliveryV2CreateRequest v2Create() {
+        FlashDeliveryV2CreateRequest request = new FlashDeliveryV2CreateRequest();
+        request.setServiceType("HELP_SEND"); request.setDeliveryType("NORMAL"); request.setVehicleType(1);
+        request.setTipAmount(0L); request.setPackageType("FOOD_INGREDIENTS"); request.setQuantity(8);
+        request.setClientRequestId("v2-" + System.nanoTime());
+        List<FlashDeliveryV2StopRequest> stops = new ArrayList<>(List.of(
+                stop("PICKUP", "0911111110", "台北市大安區信義路三段56號", "取月餅"),
+                stop("PICKUP", "0911111111", "台北市信義區松高路11號", "取牛奶"),
+                stop("DELIVERY", "0911111112", "台北市松山區南京東路五段1號", "送管理室"),
+                stop("DELIVERY", "0911111113", "台北市中山區中山北路二段1號", "送前台")));
+        request.setStops(stops);
+        return request;
+    }
+
+    private static SysDictData dictionary(String value) { SysDictData d = new SysDictData(); d.setDictValue(value); return d; }
+
+    private static class Fixture {
+        final FlashDeliveryOrderMapper orders = mock(FlashDeliveryOrderMapper.class);
+        final FlashDeliveryOrderStopMapper stops = mock(FlashDeliveryOrderStopMapper.class);
+        final FlashDeliveryTargetAdjustmentMapper adjustments = mock(FlashDeliveryTargetAdjustmentMapper.class);
+        final FlashDeliveryOrderImageMapper images = mock(FlashDeliveryOrderImageMapper.class);
+        final FlashDeliveryOrderLogMapper logs = mock(FlashDeliveryOrderLogMapper.class);
+        final FlashDeliveryPricingMapper prices = mock(FlashDeliveryPricingMapper.class);
+        final InfoUserMapper users = mock(InfoUserMapper.class);
+        final FlashDeliveryRouteService routes = mock(FlashDeliveryRouteService.class);
+        final FlashDeliveryNotificationService notifications = mock(FlashDeliveryNotificationService.class);
+        final com.ruoyi.system.mapper.UserBillingMapper billing = mock(com.ruoyi.system.mapper.UserBillingMapper.class);
+        List<com.ruoyi.app.flashdelivery.route.GeoPoint> capturedPoints = new ArrayList<>();
+        final FlashDeliveryApplicationService service = new FlashDeliveryApplicationService(orders, prices, images, logs, users, mock(com.ruoyi.system.mapper.InfoBankCardMapper.class),
+                routes, new FlashDeliveryPricingCalculator(), mock(RiderDeliveryLockService.class), mock(RiderDeliveryAcceptanceService.class),
+                stops, adjustments, mock(com.ruoyi.system.service.PaymentMethodGateService.class),
+                notifications, billing);
+        final FlashDeliveryOrder order = new FlashDeliveryOrder();
+        final List<FlashDeliveryOrderStop> rows = new ArrayList<>(List.of(
+                stop(11L, 0, "PICKUP", "0911111110"), stop(12L, 1, "PICKUP", "0911111111"),
+                stop(13L, 2, "DELIVERY", "0911111112"), stop(14L, 3, "DELIVERY", "0911111113")));
+        Fixture() {
+            order.setOrderNo("FL10"); when(billing.insert(any(com.ruoyi.system.domain.UserBilling.class))).thenReturn(1);
+            order.setId(10L); order.setUserId(7L); order.setRiderId(8L); order.setStatus("ACCEPTED");
+            order.setVersion(0); order.setPickupStopCount(2); order.setPickedUpStopCount(0);
+            order.setDeliveryStopCount(2); order.setDeliveredStopCount(0);
+            order.setAmount(100L); order.setDeliveryMode("NOW"); order.setIsDisplay(true); order.setPayType("4");
+            InfoUser rider = new InfoUser(); rider.setUserId(8L); rider.setUserType("2"); rider.setDeliveryType("FLASH");
+            when(users.selectInfoUserByUserId(anyLong())).thenReturn(rider);
+            when(orders.selectById(10L)).thenReturn(order); when(orders.selectByIdForUpdate(10L)).thenReturn(order);
+            when(orders.selectByUserRequestId(anyLong(), anyString())).thenReturn(null);
+            when(orders.insert(any(FlashDeliveryOrder.class))).thenAnswer(i -> { ((FlashDeliveryOrder) i.getArgument(0)).setId(10L); return 1; });
+            when(stops.selectList(any())).thenReturn(rows);
+            when(stops.insert(any(FlashDeliveryOrderStop.class))).thenReturn(1);
+            when(stops.updateById(any(FlashDeliveryOrderStop.class))).thenReturn(1);
+            when(images.insert(any(FlashDeliveryOrderImage.class))).thenReturn(1);
+            when(orders.updateFulfilment(any())).thenReturn(1);
+            when(orders.updateDeliveryFulfilment(any())).thenReturn(1);
+            when(users.selectOrdinaryUserIdsByNormalizedPhone("0911111112")).thenReturn(List.of(31L));
+            when(users.selectOrdinaryUserIdsByNormalizedPhone("0911111113")).thenReturn(List.of(32L));
+            when(users.selectOrdinaryUserIdsByNormalizedPhone("0911111110")).thenReturn(List.of());
+            when(users.selectOrdinaryUserIdsByNormalizedPhone("0911111111")).thenReturn(List.of());
+            FlashDeliveryPricing price = new FlashDeliveryPricing(); price.setId(1L); price.setConfigVersion(1);
+            price.setStartingDistance(BigDecimal.valueOf(3)); price.setStartingFare(80L); price.setDistance(BigDecimal.ONE);
+            price.setFreight(10L); price.setUrgentRate(BigDecimal.ZERO); price.setMinimumUrgentFee(0L);
+            when(prices.selectAtTime(anyString(), anyInt())).thenReturn(List.of(price));
+            when(routes.calculate(anyList(), anyInt())).thenAnswer(i -> {
+                capturedPoints.addAll(i.getArgument(0));
+                return new RouteDistance(5000, 600, "ROUTE");
+            });
+            when(routes.calculate(any(com.ruoyi.app.flashdelivery.route.GeoPoint.class),
+                    any(com.ruoyi.app.flashdelivery.route.GeoPoint.class), anyInt()))
+                    .thenReturn(new RouteDistance(5000, 600, "ROUTE"));
+        }
+    }
+
+    private static FlashDeliveryOrderStop stop(Long id, int index, String type, String phone) {
+        FlashDeliveryOrderStop s = new FlashDeliveryOrderStop(); s.setId(id); s.setOrderId(10L); s.setStopOrder(index);
+        s.setStopType(type); s.setStatus("PENDING"); s.setName("name"); s.setPhone(phone);
+        s.setAddress("address " + index); s.setLatitude(BigDecimal.valueOf(25.03 + index * .001)); s.setLongitude(BigDecimal.valueOf(121.56));
+        return s;
+    }
+}

+ 22 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java

@@ -144,6 +144,28 @@ class OmgPaymentCreateServiceTest {
                 "KEY", "IV");
                 "KEY", "IV");
     }
     }
 
 
+    @Test
+    void productionCheckoutPersistsEnvironmentSnapshotBeforeReturningForm() {
+        OmgPaymentAttempt attempt = new OmgPaymentAttempt();
+        attempt.setId(9L);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(payableOrder());
+        when(credentials.getEnabledCredential(77L)).thenReturn(credential());
+        when(generator.generate()).thenReturn("OMG12345678901234567");
+        when(formFactory.create(anyString(), anyInt(), anyString(), anyString(), anyString(), anyString(),
+                any(OmgPaymentMethod.class))).thenReturn(new OmgPaymentForm(
+                OmgPaymentFormFactory.PRODUCTION_GATEWAY_URL, Map.of("CheckMacValue", "A".repeat(64))));
+        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString())).thenReturn(attempt);
+        when(attempts.markProductionEnvironment(9L)).thenReturn(1);
+
+        service.create(5L, "DD-1", OmgPaymentMethod.CREDIT);
+
+        var calls = inOrder(attempts);
+        calls.verify(attempts).createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString());
+        calls.verify(attempts).markProductionEnvironment(9L);
+    }
+
     @Test
     @Test
     void duplicateActiveRowWinsOverTradeNumberRetry() {
     void duplicateActiveRowWinsOverTradeNumberRetry() {
         when(attempts.selectOrderForUpdate("DD-1")).thenReturn(payableOrder());
         when(attempts.selectOrderForUpdate("DD-1")).thenReturn(payableOrder());

+ 12 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java

@@ -25,6 +25,18 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
 
 
 class OmgPaymentFormFactoryTest {
 class OmgPaymentFormFactoryTest {
+    @Test
+    void productionConfigurationUsesProductionCheckoutEndpoint() {
+        OmgPaymentProperties properties = properties();
+        properties.setEnvironment("PRODUCTION");
+        OmgPaymentFormFactory factory = new OmgPaymentFormFactory(properties,
+                new OmgCheckMacSigner(), Clock.fixed(Instant.parse("2026-10-15T07:00:00Z"), ZoneOffset.UTC));
+
+        OmgPaymentForm form = factory.create("DD-1", 100, "1000031", "KEY", "IV",
+                "OMG12345678901234567", OmgPaymentMethod.CREDIT);
+
+        assertEquals(OmgPaymentFormFactory.PRODUCTION_GATEWAY_URL, form.gatewayUrl());
+    }
     @Test
     @Test
     void buildsSignedCreditCardForm() {
     void buildsSignedCreditCardForm() {
         OmgPaymentProperties properties = properties();
         OmgPaymentProperties properties = properties();

+ 27 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java

@@ -83,11 +83,12 @@ class OmgPaymentNotifyServiceTest {
     }
     }
 
 
     @Test
     @Test
-    void successfulCallbackUsesSnapshotAndMarksOnlyPaymentFacts() {
+    void cancelledOrderKeepsPaymentFactAndCreatesRefundIntent() {
         OmgPaymentOrderSnapshot order = order(4L, 0L);
         OmgPaymentOrderSnapshot order = order(4L, 0L);
+        OmgPaymentRefundService refunds = mock(OmgPaymentRefundService.class);
+        ReflectionTestUtils.setField(service, "refundService", refunds);
         when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order);
         when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order);
         when(attempts.markPaid(any())).thenReturn(1);
         when(attempts.markPaid(any())).thenReturn(1);
-        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
 
 
         assertTrue(service.process(signedRequest(1, 0, 100)));
         assertTrue(service.process(signedRequest(1, 0, 100)));
 
 
@@ -97,7 +98,8 @@ class OmgPaymentNotifyServiceTest {
         assertEquals(1, facts.getValue().getRtnCode());
         assertEquals(1, facts.getValue().getRtnCode());
         assertEquals(0, facts.getValue().getSimulatePaid());
         assertEquals(0, facts.getValue().getSimulatePaid());
         verify(attempts).supersedeOtherCreated("DD-1", 7L);
         verify(attempts).supersedeOtherCreated("DD-1", 7L);
-        verify(attempts).markOrderPaid("DD-1");
+        verify(attempts, never()).markOrderPaid("DD-1");
+        verify(refunds).createRefundIntentForAttempt(7L, "LATE_PAYMENT");
     }
     }
 
 
     @Test
     @Test
@@ -114,6 +116,19 @@ class OmgPaymentNotifyServiceTest {
         assertEquals(1, facts.getValue().getSimulatePaid());
         assertEquals(1, facts.getValue().getSimulatePaid());
     }
     }
 
 
+    @Test
+    void signedCallbackPersistsCreditAuthorizationIdForRefund() {
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order(0L, 0L));
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
+
+        assertTrue(service.process(signedRequest(1, 0, 100, "1000031", "123456")));
+
+        var update = org.mockito.ArgumentCaptor.forClass(OmgPaymentAttempt.class);
+        verify(attempts).markPaid(update.capture());
+        assertEquals("123456", update.getValue().getCreditRefundId());
+    }
+
     @Test
     @Test
     void failureMarksAttemptFailedWithoutChangingOrder() {
     void failureMarksAttemptFailedWithoutChangingOrder() {
         when(attempts.markFailed(any())).thenReturn(1);
         when(attempts.markFailed(any())).thenReturn(1);
@@ -288,6 +303,11 @@ class OmgPaymentNotifyServiceTest {
     }
     }
 
 
     private OmgNotifyRequest signedRequest(int rtnCode, int simulatePaid, int amount, String merchantId) {
     private OmgNotifyRequest signedRequest(int rtnCode, int simulatePaid, int amount, String merchantId) {
+        return signedRequest(rtnCode, simulatePaid, amount, merchantId, null);
+    }
+
+    private OmgNotifyRequest signedRequest(int rtnCode, int simulatePaid, int amount,
+                                           String merchantId, String creditRefundId) {
         Map<String, String> fields = new LinkedHashMap<>();
         Map<String, String> fields = new LinkedHashMap<>();
         fields.put("MerchantID", merchantId);
         fields.put("MerchantID", merchantId);
         fields.put("MerchantTradeNo", "OMG123");
         fields.put("MerchantTradeNo", "OMG123");
@@ -301,6 +321,9 @@ class OmgPaymentNotifyServiceTest {
         fields.put("PaymentTypeChargeFee", "0");
         fields.put("PaymentTypeChargeFee", "0");
         fields.put("TradeDate", "2026/08/13 11:59:00");
         fields.put("TradeDate", "2026/08/13 11:59:00");
         fields.put("SimulatePaid", String.valueOf(simulatePaid));
         fields.put("SimulatePaid", String.valueOf(simulatePaid));
+        if (creditRefundId != null) {
+            fields.put("gwsr", creditRefundId);
+        }
         fields.put("CustomField1", "");
         fields.put("CustomField1", "");
         fields.put("CustomField2", "");
         fields.put("CustomField2", "");
         fields.put("CustomField3", "");
         fields.put("CustomField3", "");
@@ -317,6 +340,7 @@ class OmgPaymentNotifyServiceTest {
         order.setDdId("DD-1");
         order.setDdId("DD-1");
         order.setState(state);
         order.setState(state);
         order.setPayStatus(payStatus);
         order.setPayStatus(payStatus);
+        order.setPayType("2");
         return order;
         return order;
     }
     }
 }
 }

+ 14 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryServiceTest.java

@@ -45,7 +45,7 @@ class OmgPaymentQueryServiceTest {
 
 
     @Test
     @Test
     void paidGatewayResultCompensatesAQuitePossiblyLostCallback() {
     void paidGatewayResultCompensatesAQuitePossiblyLostCallback() {
-        when(gateway.query(anyMap())).thenReturn(signedResponse("1", Map.of("FutureEmptyField", "")));
+        when(gateway.query(anyMap())).thenReturn(signedResponse("1", Map.of("FutureEmptyField", "", "gwsr", "123456")));
         when(settlement.synchronizeVerifiedQuery(any())).thenReturn(OmgPaymentSettlementResult.PAID);
         when(settlement.synchronizeVerifiedQuery(any())).thenReturn(OmgPaymentSettlementResult.PAID);
 
 
         OmgQueryPaymentResponse response = service.query(5L, "DD-1");
         OmgQueryPaymentResponse response = service.query(5L, "DD-1");
@@ -55,7 +55,8 @@ class OmgPaymentQueryServiceTest {
         verify(settlement).synchronizeVerifiedQuery(argThat(facts ->
         verify(settlement).synchronizeVerifiedQuery(argThat(facts ->
                 facts.source() == OmgPaymentFactSource.QUERY
                 facts.source() == OmgPaymentFactSource.QUERY
                         && facts.merchantTradeNo().equals("OMG123")
                         && facts.merchantTradeNo().equals("OMG123")
-                        && facts.resultCode() == 1));
+                        && facts.resultCode() == 1
+                        && "123456".equals(facts.creditRefundId())));
         @SuppressWarnings("unchecked")
         @SuppressWarnings("unchecked")
         var request = org.mockito.ArgumentCaptor.forClass(Map.class);
         var request = org.mockito.ArgumentCaptor.forClass(Map.class);
         verify(gateway).query(request.capture());
         verify(gateway).query(request.capture());
@@ -78,6 +79,17 @@ class OmgPaymentQueryServiceTest {
         verify(attempts, never()).selectOrder(anyString());
         verify(attempts, never()).selectOrder(anyString());
     }
     }
 
 
+    @Test
+    void queryUsesAttemptEnvironmentSnapshot() {
+        attempt.setGatewayEnvironment("PRODUCTION");
+        when(gateway.query(anyMap(), org.mockito.ArgumentMatchers.eq("PRODUCTION")))
+                .thenReturn(signedResponse("0", Map.of()));
+
+        assertEquals("UNPAID", service.query(5L, "DD-1").status());
+
+        verify(gateway).query(anyMap(), org.mockito.ArgumentMatchers.eq("PRODUCTION"));
+    }
+
     @Test
     @Test
     void unpaidResultIsReadOnlyAndExplicitFailureIsSynchronized() {
     void unpaidResultIsReadOnlyAndExplicitFailureIsSynchronized() {
         when(gateway.query(anyMap())).thenReturn(signedResponse("0", Map.of()));
         when(gateway.query(anyMap())).thenReturn(signedResponse("0", Map.of()));

+ 95 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRefundServiceTest.java

@@ -1,6 +1,24 @@
 package com.ruoyi.app.omgpay;
 package com.ruoyi.app.omgpay;
 
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import java.sql.ResultSet;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.times;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
 
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -29,4 +47,81 @@ class OmgPaymentRefundServiceTest {
                 assertThrows(OmgPaymentBusinessException.class,
                 assertThrows(OmgPaymentBusinessException.class,
                         () -> service.refund(5L, "X".repeat(65))).getCode());
                         () -> service.refund(5L, "X".repeat(65))).getCode());
     }
     }
+
+    @Test
+    void testEnvironmentRecordsExpectedFailureWithoutCallingProductionGateway() {
+        JdbcTemplate jdbc = mock(JdbcTemplate.class);
+        OmgRefundGateway gateway = mock(OmgRefundGateway.class);
+        OmgPaymentRefundService refunds = new OmgPaymentRefundService(new OmgPaymentProperties(),
+                null, jdbc, null, gateway, null);
+
+        refunds.processOne(8L);
+
+        verify(jdbc).update(org.mockito.ArgumentMatchers.contains("TEST_ENVIRONMENT"),
+                org.mockito.ArgumentMatchers.eq(8L));
+        verifyNoInteractions(gateway);
+    }
+
+    @Test
+    void usesGatewayCreditStateToChooseOfficialAction() {
+        assertEquals("N", OmgPaymentRefundService.nextAction("已授權"));
+        assertEquals("E", OmgPaymentRefundService.nextAction("要關帳"));
+        assertEquals("R", OmgPaymentRefundService.nextAction("已關帳"));
+        assertEquals(null, OmgPaymentRefundService.nextAction("關帳中"));
+    }
+
+    @Test
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    void productionQueriesCreditBeforeActionAndDoesNotReplayUnchangedAction() throws Exception {
+        OmgPaymentProperties properties = new OmgPaymentProperties();
+        properties.setEnvironment("PRODUCTION");
+        properties.setCreditCheckCodes(Map.of("1000031", "123456"));
+        JdbcTemplate jdbc = mock(JdbcTemplate.class);
+        OmgRefundGateway gateway = mock(OmgRefundGateway.class);
+        OmgPaymentRefundService refunds = new OmgPaymentRefundService(properties, null, jdbc,
+                new OmgCheckMacSigner(), gateway, new OmgQueryResponseParser(),
+                Clock.fixed(Instant.parse("2026-10-15T07:00:00Z"), ZoneOffset.UTC));
+        when(jdbc.update(startsWith("UPDATE pos_order_omg_refund SET lease_until=DATE_ADD"), eq(8L)))
+                .thenReturn(1);
+        AtomicInteger reads = new AtomicInteger();
+        doAnswer(call -> {
+            RowMapper mapper = call.getArgument(1);
+            ResultSet rs = mock(ResultSet.class);
+            boolean first = reads.getAndIncrement() == 0;
+            when(rs.getLong(1)).thenReturn(7L);
+            when(rs.getString(2)).thenReturn(first ? "PENDING" : "ACTION_UNKNOWN");
+            when(rs.getString(3)).thenReturn(first ? null : "N");
+            when(rs.getString(4)).thenReturn(first ? null : "已授權");
+            return mapper.mapRow(rs, 0);
+        }).when(jdbc).queryForObject(startsWith("SELECT attempt_id"), any(RowMapper.class), eq(8L));
+        doAnswer(call -> {
+            RowMapper mapper = call.getArgument(1);
+            ResultSet rs = mock(ResultSet.class);
+            when(rs.getLong("id")).thenReturn(7L);
+            when(rs.getString("dd_id")).thenReturn("DD-1");
+            when(rs.getString("merchant_trade_no")).thenReturn("OMG123");
+            when(rs.getString("merchant_id")).thenReturn("1000031");
+            when(rs.getInt("amount")).thenReturn(100);
+            when(rs.getString("hash_key_snapshot")).thenReturn("KEY");
+            when(rs.getString("hash_iv_snapshot")).thenReturn("IV");
+            when(rs.getString("trade_no")).thenReturn("GW123");
+            when(rs.getString("credit_refund_id")).thenReturn("123");
+            when(rs.getString("gateway_environment")).thenReturn("PRODUCTION");
+            return mapper.mapRow(rs, 0);
+        }).when(jdbc).queryForObject(startsWith("SELECT id, dd_id"), any(RowMapper.class), eq(7L));
+        when(gateway.queryCredit(any())).thenReturn(
+                "{\"RtnMsg\":\"\",\"RtnValue\":{\"TradeID\":\"123\",\"amount\":\"100\",\"status\":\"已授權\"}}",
+                "{\"RtnMsg\":\"\",\"RtnValue\":{\"TradeID\":\"123\",\"amount\":\"100\",\"status\":\"已授權\"}}",
+                "{\"RtnMsg\":\"\",\"RtnValue\":{\"TradeID\":\"123\",\"amount\":\"100\",\"status\":\"已關帳\",\"close_data\":[{\"status\":\"銀行已追回\",\"amount\":\"100\"}]}}");
+        when(gateway.doAction(any())).thenReturn("MerchantID=1000031&MerchantTradeNo=OMG123&TradeNo=GW123&RtnCode=1");
+
+        refunds.processOne(8L);
+        refunds.processOne(8L);
+        refunds.processOne(8L);
+
+        verify(gateway, times(3)).queryCredit(any());
+        verify(gateway, times(1)).doAction(org.mockito.ArgumentMatchers.argThat(fields ->
+                "N".equals(((Map<?, ?>) fields).get("Action"))));
+        verify(jdbc).update(startsWith("UPDATE pos_order_omg_refund SET status='REFUNDED'"), eq(8L));
+    }
 }
 }

+ 1 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTest.java

@@ -207,6 +207,7 @@ class DeliveryOrderNotificationServiceTest {
         order.setDeliveryStatus(0L);
         order.setDeliveryStatus(0L);
         order.setPayStatus(1L);
         order.setPayStatus(1L);
         order.setAfterSaleStatus(0L);
         order.setAfterSaleStatus(0L);
+        order.setIsDisplay(true);
         order.setLongitude(new BigDecimal("121.5000"));
         order.setLongitude(new BigDecimal("121.5000"));
         order.setLatitude(new BigDecimal("25.0300"));
         order.setLatitude(new BigDecimal("25.0300"));
         return order;
         return order;

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

@@ -201,6 +201,7 @@ class OrderLifecycleServiceTest {
         order.setState(0L);
         order.setState(0L);
         order.setDeliveryStatus(0L);
         order.setDeliveryStatus(0L);
         order.setQsId(null);
         order.setQsId(null);
+        order.setIsDisplay(true);
         when(posOrderService.getById(10L)).thenReturn(order);
         when(posOrderService.getById(10L)).thenReturn(order);
         when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
         when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
 
 
@@ -231,6 +232,7 @@ class OrderLifecycleServiceTest {
         order.setState(0L);
         order.setState(0L);
         order.setDeliveryStatus(0L);
         order.setDeliveryStatus(0L);
         order.setQsId(null);
         order.setQsId(null);
+        order.setIsDisplay(true);
         when(posOrderService.getById(10L)).thenReturn(order);
         when(posOrderService.getById(10L)).thenReturn(order);
         when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
         when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
 
 

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

@@ -50,43 +50,6 @@ class OrderListPaymentFilterTest {
                 new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
                 new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
     }
     }
 
 
-    @Test
-    void userUnpaidTabIncludesOnlinePickupAndDineInOrders() {
-        IPosOrderService orders = mock(IPosOrderService.class);
-        UserOrderController controller = new UserOrderController();
-        ReflectionTestUtils.setField(controller, "posOrderService", orders);
-        when(orders.page(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>());
-
-        controller.orderList(tokenFor(909L), 1, 10, "unpaid", null);
-
-        LambdaQueryWrapper<PosOrder> query = capturedQuery(orders);
-        String sql = query.getSqlSegment();
-        Collection<Object> parameters = new ArrayList<>(query.getParamNameValuePairs().values());
-        assertTrue(sql.contains("pay_type IN"));
-        assertFalse(sql.contains("AND type ="));
-        assertEquals(1, Collections.frequency(parameters, "2"));
-        assertEquals(1, Collections.frequency(parameters, "3"));
-    }
-
-    @Test
-    void userActiveTabAllowsUnpaidPickupAndDineInOnlyForCashPayment() {
-        IPosOrderService orders = mock(IPosOrderService.class);
-        UserOrderController controller = new UserOrderController();
-        ReflectionTestUtils.setField(controller, "posOrderService", orders);
-        when(orders.page(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>());
-
-        controller.orderList(tokenFor(909L), 1, 10, "active", null);
-
-        LambdaQueryWrapper<PosOrder> query = capturedQuery(orders);
-        String sql = query.getSqlSegment();
-        Collection<Object> parameters = new ArrayList<>(query.getParamNameValuePairs().values());
-        assertTrue(sql.contains("(pay_status ="));
-        assertTrue(sql.contains("OR (pay_type IN"));
-        assertTrue(sql.contains("AND type IN"));
-        assertEquals(1, Collections.frequency(parameters, "1"));
-        assertEquals(1, Collections.frequency(parameters, "4"));
-    }
-
     @Test
     @Test
     void merchantPendingTabDoesNotExposeUnpaidOnlinePickupOrDineInOrders() {
     void merchantPendingTabDoesNotExposeUnpaidOnlinePickupOrDineInOrders() {
         IPosOrderService orders = mock(IPosOrderService.class);
         IPosOrderService orders = mock(IPosOrderService.class);

+ 65 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutServiceTest.java

@@ -0,0 +1,65 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.PosOrder;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PosOrderNoRiderTimeoutServiceTest {
+    @Test
+    void appointmentUsesLatestOfPaymentSlotStartAndActualRiderOpening() {
+        PosOrder order = readyOrder();
+        order.setDelryTime("2026-10-15 16:00-17:00");
+        order.setEffectiveAt(at(15, 0));
+        order.setDisplayTime(at(15, 0));
+
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(16, 29), 30));
+        assertTrue(PosOrderNoRiderTimeoutService.eligible(order, at(16, 30), 30));
+
+        order.setDisplayTime(at(16, 10));
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(16, 39), 30));
+        assertTrue(PosOrderNoRiderTimeoutService.eligible(order, at(16, 40), 30));
+    }
+
+    @Test
+    void unpaidHiddenAssignedAndSelfDeliveredOrdersNeverExpire() {
+        PosOrder order = readyOrder();
+        order.setEffectiveAt(at(15, 0));
+        order.setDisplayTime(at(15, 0));
+        order.setPayStatus(0L);
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(17, 0), 30));
+        order.setPayStatus(1L);
+        order.setIsDisplay(false);
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(17, 0), 30));
+        order.setIsDisplay(true);
+        order.setQsId(8L);
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(17, 0), 30));
+        order.setQsId(null);
+        order.setSelfDelivery(1);
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(17, 0), 30));
+    }
+
+    private static PosOrder readyOrder() {
+        PosOrder order = new PosOrder();
+        order.setDdId("DD-1");
+        order.setParentDdId("DD-1");
+        order.setType(0L);
+        order.setState(0L);
+        order.setDeliveryStatus(0L);
+        order.setPayStatus(1L);
+        order.setAfterSaleStatus(0L);
+        order.setIsDisplay(true);
+        order.setSelfDelivery(0);
+        return order;
+    }
+
+    private static Date at(int hour, int minute) {
+        return Date.from(LocalDateTime.of(2026, 10, 15, hour, minute)
+                .atZone(ZoneId.of("Asia/Taipei")).toInstant());
+    }
+}

+ 65 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java

@@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.app.service.UserService;
 import com.ruoyi.app.service.UserService;
+import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrder;
@@ -26,7 +28,9 @@ import org.springframework.test.util.ReflectionTestUtils;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.util.Collections;
 import java.util.Collections;
+import java.util.List;
 
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -35,6 +39,7 @@ import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.when;
 
 
@@ -153,4 +158,64 @@ class PosOrderQsOprateControllerTest {
                 () -> controller.deliverOrder(JwtUtil.token("88", "rider"), input));
                 () -> controller.deliverOrder(JwtUtil.token("88", "rider"), input));
         verify(orders, never()).saveOrUpdate(any(PosOrder.class));
         verify(orders, never()).saveOrUpdate(any(PosOrder.class));
     }
     }
+
+    @Test
+    void orderCountReturnsBadgeCountsUsingTheSameTabFiltersAsOrderList() {
+        PosOrderQsOprateController controller = new PosOrderQsOprateController();
+        IPosOrderService orders = mock(IPosOrderService.class);
+        UserService userService = mock(UserService.class);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "userService", userService);
+        InfoUser rider = new InfoUser();
+        rider.setUserId(88L);
+        rider.setUserType("2");
+        rider.setDeliveryType("FOOD");
+        when(userService.checkUserStatus(88L)).thenReturn(rider);
+        when(orders.count(any())).thenReturn(3L);
+
+        AjaxResult result = controller.orderCount(JwtUtil.token("88", "rider"), null, null);
+
+        JSONObject data = (JSONObject) result.get(AjaxResult.DATA_TAG);
+        assertEquals(3L, (long) data.getLong("newTask"));
+        assertEquals(3L, (long) data.getLong("toPickup"));
+        assertEquals(3L, (long) data.getLong("delivering"));
+        ArgumentCaptor<LambdaQueryWrapper<PosOrder>> wrapperCaptor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(orders, times(3)).count(wrapperCaptor.capture());
+        List<LambdaQueryWrapper<PosOrder>> wrappers = wrapperCaptor.getAllValues();
+        // 角标与列表页签同口径:newTask 限定待抢单,取件中/配送中限定本人订单
+        assertTrue(wrappers.get(0).getSqlSegment().toLowerCase().contains("qs_id is null"));
+        assertTrue(wrappers.get(0).getSqlSegment().toLowerCase().contains("self_delivery"));
+        assertTrue(wrappers.get(1).getSqlSegment().toLowerCase().contains("qs_id"));
+        assertTrue(wrappers.get(1).getParamNameValuePairs().containsValue(1L));
+        assertTrue(wrappers.get(2).getSqlSegment().toLowerCase().contains("qs_id"));
+        assertTrue(wrappers.get(2).getParamNameValuePairs().containsValue(2L));
+        // 计数查询不附加排序子句
+        for (LambdaQueryWrapper<PosOrder> wrapper : wrappers) {
+            assertFalse(wrapper.getSqlSegment().toLowerCase().contains("order by"));
+        }
+    }
+
+    @Test
+    void orderCountReportsZeroNewTaskWhenFoodDeliveryNotEnabled() {
+        PosOrderQsOprateController controller = new PosOrderQsOprateController();
+        IPosOrderService orders = mock(IPosOrderService.class);
+        UserService userService = mock(UserService.class);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "userService", userService);
+        InfoUser rider = new InfoUser();
+        rider.setUserId(88L);
+        rider.setUserType("2");
+        rider.setDeliveryType("FLASH");
+        when(userService.checkUserStatus(88L)).thenReturn(rider);
+        when(orders.count(any())).thenReturn(5L);
+
+        AjaxResult result = controller.orderCount(JwtUtil.token("88", "rider"), null, null);
+
+        JSONObject data = (JSONObject) result.get(AjaxResult.DATA_TAG);
+        // 未开通美食配送无可抢任务,newTask 角标计 0,仅统计本人取件中/配送中
+        assertEquals(0L, (long) data.getLong("newTask"));
+        assertEquals(5L, (long) data.getLong("toPickup"));
+        assertEquals(5L, (long) data.getLong("delivering"));
+        verify(orders, times(2)).count(any());
+    }
 }
 }

+ 0 - 54
ruoyi-admin/src/test/java/com/ruoyi/app/order/UserOrderTabFilterTest.java

@@ -1,54 +0,0 @@
-package com.ruoyi.app.order;
-
-import com.baomidou.mybatisplus.core.MybatisConfiguration;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
-import com.ruoyi.system.domain.PosOrder;
-import org.apache.ibatis.builder.MapperBuilderAssistant;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Test;
-
-import java.util.Collection;
-
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-/** 订单列表页签过滤单测(031 US7 T027):现金自取/堂食进 active、不进 unpaid。 */
-class UserOrderTabFilterTest {
-
-    @BeforeAll
-    static void initTableInfo() {
-        // Lambda 方法引用依赖 PosOrder 的 TableInfo 缓存
-        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
-    }
-
-    private static LambdaQueryWrapper<PosOrder> wrapperOf(String tab) {
-        LambdaQueryWrapper<PosOrder> wrapper = new LambdaQueryWrapper<>();
-        UserOrderController.applyTabFilter(wrapper, tab);
-        return wrapper;
-    }
-
-    @Test
-    void activeTabIncludesCashSelfPickupAndDineIn() {
-        LambdaQueryWrapper<PosOrder> wrapper = wrapperOf("active");
-        String sql = wrapper.getSqlSegment().toLowerCase();
-        Collection<Object> params = wrapper.getParamNameValuePairs().values();
-        assertTrue(sql.contains("pay_type in"));
-        assertTrue(params.contains("1"));
-        assertTrue(params.contains("4"));
-        assertTrue(sql.contains("type in"));
-        assertTrue(params.contains(1L));
-        assertTrue(params.contains(2L));
-    }
-
-    @Test
-    void unpaidTabExcludesCashOrders() {
-        LambdaQueryWrapper<PosOrder> wrapper = wrapperOf("unpaid");
-        String sql = wrapper.getSqlSegment().toLowerCase();
-        Collection<Object> params = wrapper.getParamNameValuePairs().values();
-        assertTrue(sql.contains("pay_type in"));
-        assertTrue(params.contains("2"));
-        assertTrue(params.contains("3"));
-        assertFalse(params.contains("4"), "待付款页签不得出现现金订单");
-    }
-}

+ 32 - 8
ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java

@@ -1,6 +1,9 @@
 package com.ruoyi.app.pay;
 package com.ruoyi.app.pay;
 
 
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
 import com.ruoyi.app.omgpay.OmgPaymentCreateOutcome;
 import com.ruoyi.app.omgpay.OmgPaymentCreateOutcome;
 import com.ruoyi.app.omgpay.OmgPaymentCreateService;
 import com.ruoyi.app.omgpay.OmgPaymentCreateService;
 import com.ruoyi.app.omgpay.OmgPaymentRetryService;
 import com.ruoyi.app.omgpay.OmgPaymentRetryService;
@@ -51,6 +54,7 @@ class PayMethodSwitchServiceTest {
 
 
     @BeforeAll
     @BeforeAll
     static void initializeMessages() {
     static void initializeMessages() {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
         originalBeanFactory = (ConfigurableListableBeanFactory)
         originalBeanFactory = (ConfigurableListableBeanFactory)
                 ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
                 ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
         // MessageUtils 取 key 本身,断言直接比对 i18n key
         // MessageUtils 取 key 本身,断言直接比对 i18n key
@@ -101,15 +105,13 @@ class PayMethodSwitchServiceTest {
         when(infoUserService.getById(7L)).thenReturn(user);
         when(infoUserService.getById(7L)).thenReturn(user);
         PosOrder order = baseOrder();
         PosOrder order = baseOrder();
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
-        when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
+        when(orderService.update(any(Wrapper.class))).thenReturn(true);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
                 omgPaymentRetryService, linePayService, orderLogHelper, infoUserService);
                 omgPaymentRetryService, linePayService, orderLogHelper, infoUserService);
 
 
         AjaxResult result = service.switchMethod(7L, request("dd-1", "1"));
         AjaxResult result = service.switchMethod(7L, request("dd-1", "1"));
 
 
-        ArgumentCaptor<PosOrder> saved = ArgumentCaptor.forClass(PosOrder.class);
-        verify(orderService).updateById(saved.capture());
-        assertEquals("1", saved.getValue().getPayType(), "应回写为目标方式货到付款");
+        verify(orderService).update(any(Wrapper.class));
         verify(orderLogHelper).log(eq("dd-1"), eq(4), eq(7L), eq("小明"), anyString());
         verify(orderLogHelper).log(eq("dd-1"), eq(4), eq(7L), eq("小明"), anyString());
         verify(linePayService, never()).create(any(), anyString());
         verify(linePayService, never()).create(any(), anyString());
         assertEquals("1", ((Map<?, ?>) result.get("data")).get("payType"));
         assertEquals("1", ((Map<?, ?>) result.get("data")).get("payType"));
@@ -129,7 +131,29 @@ class PayMethodSwitchServiceTest {
         ServiceException exception = assertThrows(ServiceException.class,
         ServiceException exception = assertThrows(ServiceException.class,
                 () -> service.switchMethod(7L, request("dd-1", "4")));
                 () -> service.switchMethod(7L, request("dd-1", "4")));
         assertEquals("no.pay.switch.offline.restricted", exception.getMessage());
         assertEquals("no.pay.switch.offline.restricted", exception.getMessage());
-        verify(orderService, never()).updateById(any(PosOrder.class));
+        verify(orderService, never()).update(any(Wrapper.class));
+    }
+
+    @Test
+    void switchUnpaidDeliveryLineOrderToCodSetsEffectiveFact() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setType(0L);
+        order.setDeliveryStatus(0L);
+        order.setAfterSaleStatus(0L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orders.update(any(Wrapper.class))).thenReturn(true);
+        PayMethodSwitchService service = new PayMethodSwitchService(orders,
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        AjaxResult result = service.switchMethod(7L, request("dd-1", "1"));
+
+        @SuppressWarnings("rawtypes")
+        ArgumentCaptor<Wrapper> updates = ArgumentCaptor.forClass(Wrapper.class);
+        verify(orders).update(updates.capture());
+        assertNotNull(updates.getValue());
+        assertEquals("1", ((Map<?, ?>) result.get("data")).get("payType"));
     }
     }
 
 
     @Test
     @Test
@@ -141,7 +165,7 @@ class PayMethodSwitchServiceTest {
                 .thenReturn(new OmgPaymentCreateOutcome(mock(OmgCreatePaymentResponse.class), 1L, "dd-1", 7L, 5L, 90, "abcde***fghi"));
                 .thenReturn(new OmgPaymentCreateOutcome(mock(OmgCreatePaymentResponse.class), 1L, "dd-1", 7L, 5L, 90, "abcde***fghi"));
         PosOrder order = baseOrder();
         PosOrder order = baseOrder();
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
-        when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
+        when(orderService.update(any(Wrapper.class))).thenReturn(true);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
                 omgPaymentRetryService, mock(LinePayService.class), mock(OrderLogHelper.class),
                 omgPaymentRetryService, mock(LinePayService.class), mock(OrderLogHelper.class),
                 mock(IInfoUserService.class));
                 mock(IInfoUserService.class));
@@ -151,7 +175,7 @@ class PayMethodSwitchServiceTest {
         // 顺序约束:先回写 payType 再拉起渠道;走 retry 路径:旧 Apple Pay 尝试仍激活时
         // 顺序约束:先回写 payType 再拉起渠道;走 retry 路径:旧 Apple Pay 尝试仍激活时
         // 替换重建而非 PAYMENT_ATTEMPT_EXISTS 死锁(组内 5->2 切换线上事故场景)
         // 替换重建而非 PAYMENT_ATTEMPT_EXISTS 死锁(组内 5->2 切换线上事故场景)
         InOrder inOrder = inOrder(orderService, omgPaymentRetryService);
         InOrder inOrder = inOrder(orderService, omgPaymentRetryService);
-        inOrder.verify(orderService).updateById(any(PosOrder.class));
+        inOrder.verify(orderService).update(any(Wrapper.class));
         inOrder.verify(omgPaymentRetryService).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
         inOrder.verify(omgPaymentRetryService).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
         Map<?, ?> data = (Map<?, ?>) result.get("data");
         Map<?, ?> data = (Map<?, ?>) result.get("data");
         assertEquals("2", data.get("payType"));
         assertEquals("2", data.get("payType"));
@@ -173,7 +197,7 @@ class PayMethodSwitchServiceTest {
 
 
         service.switchMethod(7L, request("dd-1", "2"));
         service.switchMethod(7L, request("dd-1", "2"));
 
 
-        verify(orderService, never()).updateById(any(PosOrder.class));
+        verify(orderService, never()).update(any(Wrapper.class));
         verify(omgPaymentRetryService, times(1)).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
         verify(omgPaymentRetryService, times(1)).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
     }
     }
 
 

+ 6 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationMapperTest.java

@@ -40,6 +40,10 @@ class StallQualificationMapperTest {
         BoundSql insert = sql(configuration, "insertApplication", application);
         BoundSql insert = sql(configuration, "insertApplication", application);
         assertTrue(insert.getParameterMappings().stream().anyMatch(parameter ->
         assertTrue(insert.getParameterMappings().stream().anyMatch(parameter ->
                 "identityImageUrls".equals(parameter.getProperty()) && parameter.getTypeHandler() instanceof StallImageListTypeHandler));
                 "identityImageUrls".equals(parameter.getProperty()) && parameter.getTypeHandler() instanceof StallImageListTypeHandler));
+        assertTrue(insert.getParameterMappings().stream().anyMatch(parameter ->
+                "bankProofImageUrls".equals(parameter.getProperty()) && parameter.getTypeHandler() instanceof StallImageListTypeHandler));
+        assertTrue(insert.getSql().contains("bank_account_no"));
+        assertTrue(sql(configuration, "updateDraft", application).getSql().contains("bank_account_no"));
         String review = sql(configuration, "reviewApplication", application).getSql();
         String review = sql(configuration, "reviewApplication", application).getSql();
         assertTrue(review.contains("status = 1 AND version = ?"));
         assertTrue(review.contains("status = 1 AND version = ?"));
         assertFalse(review.contains("identity_number")); assertFalse(review.contains("identity_image_urls"));
         assertFalse(review.contains("identity_number")); assertFalse(review.contains("identity_image_urls"));
@@ -55,6 +59,8 @@ class StallQualificationMapperTest {
             assertFalse(query.contains("identity_number")); assertFalse(query.contains("responsible_name"));
             assertFalse(query.contains("identity_number")); assertFalse(query.contains("responsible_name"));
             assertFalse(query.contains("contact_phone")); assertFalse(query.contains("image_urls"));
             assertFalse(query.contains("contact_phone")); assertFalse(query.contains("image_urls"));
         }
         }
+        assertTrue(list.contains("status != 0"));
+        assertTrue(sql(configuration, "countApplications", Map.of("status", 1)).getSql().contains("status != 0"));
         assertTrue(list.contains("LIMIT ? OFFSET ?")); assertTrue(market.contains("s.user_id = ?"));
         assertTrue(list.contains("LIMIT ? OFFSET ?")); assertTrue(market.contains("s.user_id = ?"));
     }
     }
 
 

+ 49 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/stall/qualification/StallQualificationServiceTest.java

@@ -53,7 +53,9 @@ class StallQualificationServiceTest {
         request.setVersion(0L); request.setResponsibleName(" owner "); request.setContactPhone("0912345678");
         request.setVersion(0L); request.setResponsibleName(" owner "); request.setContactPhone("0912345678");
         request.setIdentityNumber("A123456789"); request.setFoodRegistrationNumber("A-123456789-00000-0");
         request.setIdentityNumber("A123456789"); request.setFoodRegistrationNumber("A-123456789-00000-0");
         request.setIdentityImageUrls(List.of("8/id.jpg")); request.setStallImageUrls(List.of("8/stall.jpg"));
         request.setIdentityImageUrls(List.of("8/id.jpg")); request.setStallImageUrls(List.of("8/stall.jpg"));
-        request.setFoodRegistrationImageUrls(List.of("8/food.jpg")); return request;
+        request.setFoodRegistrationImageUrls(List.of("8/food.jpg"));
+        request.setBankAccountName("owner"); request.setBankName("bank"); request.setBankAccountNo("123456");
+        request.setBankProofImageUrls(List.of("8/bank.jpg")); return request;
     }
     }
 
 
     @Test void emptyDraftCanBeSavedButCannotBeSubmitted() {
     @Test void emptyDraftCanBeSavedButCannotBeSubmitted() {
@@ -76,6 +78,21 @@ class StallQualificationServiceTest {
         assertNotNull(application.getSubmittedAt());
         assertNotNull(application.getSubmittedAt());
     }
     }
 
 
+    @Test void foodRegistrationAndEmailAreOptionalButBankDetailsAreRequired() {
+        QualificationSaveRequest request = complete();
+        request.setFoodRegistrationNumber(null); request.setFoodRegistrationImageUrls(null);
+        StallQualificationApplication saved = service.save(20L, request, true).getCurrentApplication();
+        assertEquals("", saved.getFoodRegistrationNumber());
+        assertEquals(List.of(), saved.getFoodRegistrationImageUrls());
+        assertEquals("123456", saved.getBankAccountNo());
+
+        qualification.setVersion(0L); qualification.setCurrentApplicationId(null);
+        request.setBankProofImageUrls(null);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.save(20L, request, true));
+        }
+    }
+
     @Test void submitPreservesProductImagePathsInTheApplicationSnapshot() throws Exception {
     @Test void submitPreservesProductImagePathsInTheApplicationSnapshot() throws Exception {
         java.nio.file.Path image = directory.resolve("upload/2026/10/09/photo_001.png");
         java.nio.file.Path image = directory.resolve("upload/2026/10/09/photo_001.png");
         java.nio.file.Files.createDirectories(image.getParent());
         java.nio.file.Files.createDirectories(image.getParent());
@@ -85,11 +102,12 @@ class StallQualificationServiceTest {
         String reference = "/profile/upload/2026/10/09/photo_001.png";
         String reference = "/profile/upload/2026/10/09/photo_001.png";
         QualificationSaveRequest request = complete();
         QualificationSaveRequest request = complete();
         request.setIdentityImageUrls(List.of(reference)); request.setStallImageUrls(List.of(reference));
         request.setIdentityImageUrls(List.of(reference)); request.setStallImageUrls(List.of(reference));
-        request.setFoodRegistrationImageUrls(List.of(reference));
+        request.setFoodRegistrationImageUrls(List.of(reference)); request.setBankProofImageUrls(List.of(reference));
         StallQualificationApplication saved = actual.save(20L, request, true).getCurrentApplication();
         StallQualificationApplication saved = actual.save(20L, request, true).getCurrentApplication();
         assertEquals(List.of(reference), saved.getIdentityImageUrls());
         assertEquals(List.of(reference), saved.getIdentityImageUrls());
         assertEquals(List.of(reference), saved.getStallImageUrls());
         assertEquals(List.of(reference), saved.getStallImageUrls());
         assertEquals(List.of(reference), saved.getFoodRegistrationImageUrls());
         assertEquals(List.of(reference), saved.getFoodRegistrationImageUrls());
+        assertEquals(List.of(reference), saved.getBankProofImageUrls());
         assertEquals(1, saved.getStatus());
         assertEquals(1, saved.getStatus());
     }
     }
 
 
@@ -132,6 +150,14 @@ class StallQualificationServiceTest {
         }
         }
     }
     }
 
 
+    @Test void platformCannotListOrOpenDraftApplications() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.adminList(1, 10, 0, null));
+            when(mapper.selectApplication(31L)).thenReturn(application(0));
+            assertThrows(ServiceException.class, () -> service.adminApplication(31L));
+        }
+    }
+
     @Test void reviewApprovesOnlyPendingVersionAndPreservesOldApprovalOnRejection() {
     @Test void reviewApprovesOnlyPendingVersionAndPreservesOldApprovalOnRejection() {
         qualification.setCurrentApplicationId(30L); qualification.setApprovedApplicationId(11L);
         qualification.setCurrentApplicationId(30L); qualification.setApprovedApplicationId(11L);
         StallQualificationApplication pending = application(1);
         StallQualificationApplication pending = application(1);
@@ -162,9 +188,29 @@ class StallQualificationServiceTest {
         service.review(100L, 30L, request); assertEquals(30L, qualification.getApprovedApplicationId());
         service.review(100L, 30L, request); assertEquals(30L, qualification.getApprovedApplicationId());
     }
     }
 
 
+    @Test void oldPendingApplicationWithoutBankDetailsCannotBeApproved() {
+        qualification.setCurrentApplicationId(30L);
+        StallQualificationApplication pending = application(1);
+        pending.setBankAccountName(null); pending.setBankName(null); pending.setBankAccountNo(null);
+        pending.setBankProofImageUrls(null);
+        when(mapper.selectApplication(30L)).thenReturn(pending);
+        QualificationReviewRequest request = new QualificationReviewRequest();
+        request.setVersion(0L); request.setApproved(true);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            assertThrows(ServiceException.class, () -> service.review(100L, 30L, request));
+        }
+        verify(mapper, never()).reviewApplication(any());
+    }
+
     private StallQualificationApplication application(int status) {
     private StallQualificationApplication application(int status) {
         StallQualificationApplication value = new StallQualificationApplication();
         StallQualificationApplication value = new StallQualificationApplication();
         value.setId(30L); value.setQualificationId(1L); value.setStoreId(8L);
         value.setId(30L); value.setQualificationId(1L); value.setStoreId(8L);
-        value.setStatus(status); value.setVersion(0L); value.setApplicationVersion(1); return value;
+        value.setStatus(status); value.setVersion(0L); value.setApplicationVersion(1);
+        value.setResponsibleName("owner"); value.setContactPhone("0912"); value.setIdentityNumber("A123");
+        value.setIdentityImageUrls(List.of("8/id.jpg")); value.setStallImageUrls(List.of("8/stall.jpg"));
+        value.setBankAccountName("owner"); value.setBankName("bank"); value.setBankAccountNo("123");
+        value.setBankProofImageUrls(List.of("8/bank.jpg"));
+        value.setStallNameSnapshot("stall"); value.setStallAddressSnapshot("address");
+        return value;
     }
     }
 }
 }

+ 58 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryServiceTest.java

@@ -13,6 +13,8 @@ import com.ruoyi.system.domain.flash.FlashDeliveryStatus;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
 import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
+import com.ruoyi.system.mapper.flash.FlashDeliveryOrderStopMapper;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryStopView;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
@@ -44,8 +46,9 @@ class UnifiedOrderQueryServiceTest {
     private final PosOrderMapper posOrderMapper = mock(PosOrderMapper.class);
     private final PosOrderMapper posOrderMapper = mock(PosOrderMapper.class);
     private final FlashDeliveryOrderMapper flashOrderMapper = mock(FlashDeliveryOrderMapper.class);
     private final FlashDeliveryOrderMapper flashOrderMapper = mock(FlashDeliveryOrderMapper.class);
     private final PosStoreMapper posStoreMapper = mock(PosStoreMapper.class);
     private final PosStoreMapper posStoreMapper = mock(PosStoreMapper.class);
+    private final FlashDeliveryOrderStopMapper flashStopMapper = mock(FlashDeliveryOrderStopMapper.class);
     private final UnifiedOrderQueryService service =
     private final UnifiedOrderQueryService service =
-            new UnifiedOrderQueryService(posOrderMapper, flashOrderMapper, posStoreMapper);
+            new UnifiedOrderQueryService(posOrderMapper, flashOrderMapper, posStoreMapper, flashStopMapper);
 
 
     @BeforeAll
     @BeforeAll
     static void initializeTableMetadata() {
     static void initializeTableMetadata() {
@@ -55,6 +58,51 @@ class UnifiedOrderQueryServiceTest {
                 new MapperBuilderAssistant(new MybatisConfiguration(), ""), FlashDeliveryOrder.class);
                 new MapperBuilderAssistant(new MybatisConfiguration(), ""), FlashDeliveryOrder.class);
     }
     }
 
 
+    @Test
+    void flashMultiStopOrderCarriesCountsAndReceiverMaskedStops() {
+        FlashDeliveryOrder order = flash(31L, 3000L);
+        order.setUserId(7L);
+        order.setDeliveryAddress("first receiver address");
+        order.setDeliveryAddressDetail("first receiver floor");
+        order.setPickupStopCount(2); order.setPickedUpStopCount(1);
+        order.setDeliveryStopCount(2); order.setDeliveredStopCount(0);
+        doReturn(pageOf(List.of(order))).when(flashOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
+        when(flashStopMapper.selectList(any())).thenReturn(java.util.Arrays.asList(
+                stopRow(101L, 0, "PICKUP", null), stopRow(102L, 1, "PICKUP", null),
+                stopRow(103L, 2, "DELIVERY", 7L), stopRow(104L, 3, "DELIVERY", USER_ID)));
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10);
+
+        UnifiedOrderListItem item = view.getList().get(0);
+        assertEquals("receiver", item.getRole());
+        assertEquals(2, item.getPickupStopCount()); assertEquals(1, item.getPickedUpStopCount());
+        assertEquals(2, item.getDeliveryStopCount()); assertEquals(0, item.getDeliveredStopCount());
+        // 查看人是第 4 站收件人:全部站地址与备注同寄件人口径返回,联系人、电话、坐标不落卡片。
+        assertEquals(4, item.getStops().size());
+        assertEquals(List.of("address 0", "address 1", "address 2", "address 3"),
+                item.getStops().stream().map(FlashDeliveryStopView::getAddress).toList());
+        assertEquals(List.of("PICKUP", "PICKUP", "DELIVERY", "DELIVERY"),
+                item.getStops().stream().map(FlashDeliveryStopView::getStopType).toList());
+        assertNull(item.getStops().get(0).getName());
+        assertNull(item.getStops().get(0).getPhone());
+        // 卡片主位的收货地址仍指向本人站点。
+        assertEquals("address 3", item.getDeliveryAddress());
+
+        // 寄件人视角:全站文字地址可见,联系人、电话、坐标仍不落卡片。
+        FlashDeliveryOrder ownOrder = flash(31L, 3000L);
+        ownOrder.setPickupStopCount(2); ownOrder.setPickedUpStopCount(1);
+        ownOrder.setDeliveryStopCount(2); ownOrder.setDeliveredStopCount(0);
+        doReturn(pageOf(List.of(ownOrder))).when(flashOrderMapper).selectPage(any(), any());
+        UnifiedOrderListItem senderItem = service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10).getList().get(0);
+        assertEquals("sender", senderItem.getRole());
+        assertEquals(List.of("address 0", "address 1", "address 2", "address 3"),
+                senderItem.getStops().stream().map(FlashDeliveryStopView::getAddress).toList());
+        assertNull(senderItem.getStops().get(0).getName());
+        assertNull(senderItem.getStops().get(0).getPhone());
+        assertNull(senderItem.getStops().get(0).getLongitude());
+    }
+
     @Test
     @Test
     void twoPathsMergeInDisplayOrderAndReturnExactlySize() {
     void twoPathsMergeInDisplayOrderAndReturnExactlySize() {
         doReturn(pageOf(List.of(
         doReturn(pageOf(List.of(
@@ -421,6 +469,15 @@ class UnifiedOrderQueryServiceTest {
         return order;
         return order;
     }
     }
 
 
+    private static com.ruoyi.system.domain.flash.FlashDeliveryOrderStop stopRow(long id, int index, String type,
+                                                                                Long receiverUserId) {
+        com.ruoyi.system.domain.flash.FlashDeliveryOrderStop stop = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();
+        stop.setId(id); stop.setOrderId(31L); stop.setStopType(type); stop.setStopOrder(index);
+        stop.setStatus("PENDING"); stop.setAddress("address " + index);
+        stop.setReceiverUserId(receiverUserId);
+        return stop;
+    }
+
     private static <T> Page<T> pageOf(List<T> records) {
     private static <T> Page<T> pageOf(List<T> records) {
         Page<T> page = new Page<>(1, Math.max(records.size(), 1), false);
         Page<T> page = new Page<>(1, Math.max(records.size(), 1), false);
         page.setRecords(new ArrayList<>(records));
         page.setRecords(new ArrayList<>(records));

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

@@ -337,6 +337,10 @@ public class PosOrder {
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date displayTime;
     private Date displayTime;
 
 
+    /** 首次具备履约资格的服务端时间;重复付款和回调不覆盖。 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date effectiveAt;
+
     /**
     /**
      * 配送状态:0待接单,1骑手已接单,2配送中,3已送达(仅外送订单)
      * 配送状态:0待接单,1骑手已接单,2配送中,3已送达(仅外送订单)
      */
      */

+ 9 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/StallQualificationApplication.java

@@ -19,9 +19,18 @@ public class StallQualificationApplication {
     private String responsibleName;
     private String responsibleName;
     private String contactPhone;
     private String contactPhone;
     private String identityNumber;
     private String identityNumber;
+    private String email;
+    private String stallIntroduction;
+    private String bankAccountName;
+    private String bankName;
+    private String bankAccountNo;
     /** 商品上传返回的 /profile/upload 图片路径;兼容历史 storeId/UUID 私有附件引用。 */
     /** 商品上传返回的 /profile/upload 图片路径;兼容历史 storeId/UUID 私有附件引用。 */
     private List<String> identityImageUrls;
     private List<String> identityImageUrls;
     private List<String> stallImageUrls;
     private List<String> stallImageUrls;
+    private List<String> bankProofImageUrls;
+    private List<String> businessRegistrationImageUrls;
+    private List<String> directorScreenshotImageUrls;
+    private List<String> uboDeclarationImageUrls;
     private String foodRegistrationNumber;
     private String foodRegistrationNumber;
     private List<String> foodRegistrationImageUrls;
     private List<String> foodRegistrationImageUrls;
     private String stallNameSnapshot;
     private String stallNameSnapshot;

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

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

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

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

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

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

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java

@@ -29,6 +29,10 @@ public class OmgPaymentAttempt {
     private Integer attemptStatus;
     private Integer attemptStatus;
     private String activeDdId;
     private String activeDdId;
     private String tradeNo;
     private String tradeNo;
+    /** OMG gwsr,正式信用卡明细查询所需的授权单号。 */
+    private String creditRefundId;
+    /** 创建收银台时的网关环境,历史行默认 TEST。 */
+    private String gatewayEnvironment;
     private Integer rtnCode;
     private Integer rtnCode;
     private String rtnMsg;
     private String rtnMsg;
     private String paymentType;
     private String paymentType;

+ 2 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java

@@ -40,6 +40,8 @@ public interface OmgPaymentAttemptMapper {
 
 
     int insertCreated(OmgPaymentAttempt row);
     int insertCreated(OmgPaymentAttempt row);
 
 
+    int markProductionEnvironment(@Param("id") Long id);
+
     int markPaid(OmgPaymentAttempt row);
     int markPaid(OmgPaymentAttempt row);
 
 
     int markFailed(OmgPaymentAttempt row);
     int markFailed(OmgPaymentAttempt row);

+ 2 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java

@@ -34,6 +34,8 @@ public interface IOmgPaymentAttemptService {
                                     String merchantId, Integer amount,
                                     String merchantId, Integer amount,
                                     String hashKeySnapshot, String hashIvSnapshot);
                                     String hashKeySnapshot, String hashIvSnapshot);
 
 
+    int markProductionEnvironment(Long attemptId);
+
     int markPaid(OmgPaymentAttempt paymentFacts);
     int markPaid(OmgPaymentAttempt paymentFacts);
 
 
     int markFailed(OmgPaymentAttempt paymentFacts);
     int markFailed(OmgPaymentAttempt paymentFacts);

+ 5 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java

@@ -103,6 +103,11 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
         return row;
         return row;
     }
     }
 
 
+    @Override
+    public int markProductionEnvironment(Long attemptId) {
+        return mapper.markProductionEnvironment(attemptId);
+    }
+
     private static void validateCreatedFacts(String ddId, String merchantTradeNo, Long storeId,
     private static void validateCreatedFacts(String ddId, String merchantTradeNo, Long storeId,
                                              String merchantId, Integer amount,
                                              String merchantId, Integer amount,
                                              String hashKeySnapshot, String hashIvSnapshot) {
                                              String hashKeySnapshot, String hashIvSnapshot) {

+ 23 - 4
ruoyi-system/src/main/resources/mapper/chanting/StallQualificationMapper.xml

@@ -12,6 +12,10 @@
         <id property="id" column="id"/>
         <id property="id" column="id"/>
         <result property="identityImageUrls" column="identity_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
         <result property="identityImageUrls" column="identity_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
         <result property="stallImageUrls" column="stall_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
         <result property="stallImageUrls" column="stall_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+        <result property="bankProofImageUrls" column="bank_proof_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+        <result property="businessRegistrationImageUrls" column="business_registration_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+        <result property="directorScreenshotImageUrls" column="director_screenshot_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
+        <result property="uboDeclarationImageUrls" column="ubo_declaration_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
         <result property="foodRegistrationImageUrls" column="food_registration_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
         <result property="foodRegistrationImageUrls" column="food_registration_image_urls" typeHandler="com.ruoyi.system.handler.StallImageListTypeHandler"/>
     </resultMap>
     </resultMap>
     <insert id="ensureMaster">
     <insert id="ensureMaster">
@@ -29,12 +33,19 @@
     <insert id="insertApplication" useGeneratedKeys="true" keyProperty="id">
     <insert id="insertApplication" useGeneratedKeys="true" keyProperty="id">
         INSERT INTO stall_qualification_application
         INSERT INTO stall_qualification_application
         (qualification_id, store_id, application_version, status, responsible_name, contact_phone, identity_number,
         (qualification_id, store_id, application_version, status, responsible_name, contact_phone, identity_number,
-         identity_image_urls, stall_image_urls, food_registration_number, food_registration_image_urls,
+         email, stall_introduction, bank_account_name, bank_name, bank_account_no,
+         identity_image_urls, stall_image_urls, bank_proof_image_urls, business_registration_image_urls,
+         director_screenshot_image_urls, ubo_declaration_image_urls, food_registration_number, food_registration_image_urls,
          stall_name_snapshot, stall_address_snapshot, night_market_id_snapshot, submitted_by, submitted_at,
          stall_name_snapshot, stall_address_snapshot, night_market_id_snapshot, submitted_by, submitted_at,
          version, create_time, update_time)
          version, create_time, update_time)
         VALUES (#{qualificationId}, #{storeId}, #{applicationVersion}, #{status}, #{responsibleName}, #{contactPhone}, #{identityNumber},
         VALUES (#{qualificationId}, #{storeId}, #{applicationVersion}, #{status}, #{responsibleName}, #{contactPhone}, #{identityNumber},
+         #{email}, #{stallIntroduction}, #{bankAccountName}, #{bankName}, #{bankAccountNo},
          #{identityImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
          #{identityImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
-         #{stallImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler}, #{foodRegistrationNumber},
+         #{stallImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{bankProofImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{businessRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{directorScreenshotImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+         #{uboDeclarationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler}, #{foodRegistrationNumber},
          #{foodRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
          #{foodRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
          #{stallNameSnapshot}, #{stallAddressSnapshot}, #{nightMarketIdSnapshot}, #{submittedBy}, #{submittedAt}, 0, NOW(), NOW())
          #{stallNameSnapshot}, #{stallAddressSnapshot}, #{nightMarketIdSnapshot}, #{submittedBy}, #{submittedAt}, 0, NOW(), NOW())
     </insert>
     </insert>
@@ -42,8 +53,14 @@
     <update id="updateDraft">
     <update id="updateDraft">
         UPDATE stall_qualification_application SET status = #{status}, responsible_name = #{responsibleName},
         UPDATE stall_qualification_application SET status = #{status}, responsible_name = #{responsibleName},
         contact_phone = #{contactPhone}, identity_number = #{identityNumber},
         contact_phone = #{contactPhone}, identity_number = #{identityNumber},
+        email = #{email}, stall_introduction = #{stallIntroduction},
+        bank_account_name = #{bankAccountName}, bank_name = #{bankName}, bank_account_no = #{bankAccountNo},
         identity_image_urls = #{identityImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
         identity_image_urls = #{identityImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
         stall_image_urls = #{stallImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
         stall_image_urls = #{stallImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        bank_proof_image_urls = #{bankProofImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        business_registration_image_urls = #{businessRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        director_screenshot_image_urls = #{directorScreenshotImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
+        ubo_declaration_image_urls = #{uboDeclarationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
         food_registration_number = #{foodRegistrationNumber},
         food_registration_number = #{foodRegistrationNumber},
         food_registration_image_urls = #{foodRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
         food_registration_image_urls = #{foodRegistrationImageUrls,typeHandler=com.ruoyi.system.handler.StallImageListTypeHandler},
         stall_name_snapshot = #{stallNameSnapshot}, stall_address_snapshot = #{stallAddressSnapshot},
         stall_name_snapshot = #{stallNameSnapshot}, stall_address_snapshot = #{stallAddressSnapshot},
@@ -69,10 +86,12 @@
         LEFT JOIN stall_qualification_application a ON a.id = q.current_application_id
         LEFT JOIN stall_qualification_application a ON a.id = q.current_application_id
         WHERE s.user_id = #{marketId} AND s.is_stall = 1 AND s.del_flag = '0'
         WHERE s.user_id = #{marketId} AND s.is_stall = 1 AND s.del_flag = '0'
     </select>
     </select>
+    <!-- MyBatis-Plus 解析会吞掉 where 内静态文本与 if 之间的空白(线上曾拼出 0AND 语法错误),首条件必须同样用 if 包裹。 -->
     <sql id="filter">
     <sql id="filter">
         <where>
         <where>
-            <if test="status != null">status = #{status}</if>
-            <if test="storeId != null">AND store_id = #{storeId}</if>
+            <if test="true">status != 0</if>
+            <if test="status != null"> AND status = #{status}</if>
+            <if test="storeId != null"> AND store_id = #{storeId}</if>
         </where>
         </where>
     </sql>
     </sql>
     <select id="selectApplications" resultType="com.ruoyi.system.domain.StallQualificationSummary">
     <select id="selectApplications" resultType="com.ruoyi.system.domain.StallQualificationSummary">

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

@@ -77,6 +77,8 @@
         <result property="updateTime" column="update_time"/>
         <result property="updateTime" column="update_time"/>
         <result property="pickupStopCount" column="pickup_stop_count"/>
         <result property="pickupStopCount" column="pickup_stop_count"/>
         <result property="pickedUpStopCount" column="picked_up_stop_count"/>
         <result property="pickedUpStopCount" column="picked_up_stop_count"/>
+        <result property="deliveryStopCount" column="delivery_stop_count"/>
+        <result property="deliveredStopCount" column="delivered_stop_count"/>
         <result property="currentTargetStopId" column="current_target_stop_id"/>
         <result property="currentTargetStopId" column="current_target_stop_id"/>
         <result property="problemStopId" column="problem_stop_id"/>
         <result property="problemStopId" column="problem_stop_id"/>
         <result property="handlingResult" column="handling_result"/>
         <result property="handlingResult" column="handling_result"/>
@@ -100,6 +102,7 @@
     <update id="updateWaitingAddress">
     <update id="updateWaitingAddress">
         update flash_delivery_order
         update flash_delivery_order
         set pickup_stop_count = #{order.pickupStopCount}, picked_up_stop_count = 0, current_target_stop_id = null,
         set pickup_stop_count = #{order.pickupStopCount}, picked_up_stop_count = 0, current_target_stop_id = null,
+            delivery_stop_count = #{order.deliveryStopCount}, delivered_stop_count = 0,
             package_type = #{order.packageType}, quantity = #{order.quantity},
             package_type = #{order.packageType}, quantity = #{order.quantity},
             weight_range = #{order.weightRange}, specification = #{order.specification},
             weight_range = #{order.weightRange}, specification = #{order.specification},
             pickup_name = #{order.pickupName}, pickup_phone = #{order.pickupPhone},
             pickup_name = #{order.pickupName}, pickup_phone = #{order.pickupPhone},
@@ -142,13 +145,18 @@
             <choose><when test="timeColumn == 'picked_up_at'">picked_up_at=#{now},</when><otherwise>delivered_at=#{now},</otherwise></choose>
             <choose><when test="timeColumn == 'picked_up_at'">picked_up_at=#{now},</when><otherwise>delivered_at=#{now},</otherwise></choose>
             update_time=#{now}, version = version + 1
             update_time=#{now}, version = version + 1
         where id=#{id} and rider_id=#{riderId} and status=#{expectedStatus}
         where id=#{id} and rider_id=#{riderId} and status=#{expectedStatus}
+          and delivery_stop_count is null
     </update>
     </update>
 
 
     <!-- 用户确认收货必须同时满足订单归属和已送达状态。 -->
     <!-- 用户确认收货必须同时满足订单归属和已送达状态。 -->
     <update id="transitionByParticipant">
     <update id="transitionByParticipant">
         update flash_delivery_order
         update flash_delivery_order
         set status=#{nextStatus}, completed_at=#{now}, update_time=#{now}, version = version + 1
         set status=#{nextStatus}, completed_at=#{now}, update_time=#{now}, version = version + 1
-        where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}) and status=#{expectedStatus}
+        where id=#{id} and (user_id=#{userId} or receiver_user_id = #{userId}
+              or exists (select 1 from flash_delivery_order_stop s where s.order_id=flash_delivery_order.id
+                  and s.stop_type='DELIVERY' and s.receiver_user_id = #{userId})) and status=#{expectedStatus}
+          and not exists (select 1 from flash_delivery_order_stop s where s.order_id=flash_delivery_order.id
+              and s.stop_type='DELIVERY' and s.status &lt;&gt; 'DELIVERED')
     </update>
     </update>
 
 
     <!-- 评分条件更新:仅寄件人的完成态订单、已接骑手且从未评价才成功,并发重复提交返回 0。 -->
     <!-- 评分条件更新:仅寄件人的完成态订单、已接骑手且从未评价才成功,并发重复提交返回 0。 -->
@@ -170,6 +178,8 @@
         update flash_delivery_order
         update flash_delivery_order
         set status=#{nextStatus}, completed_at=#{now}, update_time=#{now}, version = version + 1
         set status=#{nextStatus}, completed_at=#{now}, update_time=#{now}, version = version + 1
         where id=#{id} and status=#{expectedStatus}
         where id=#{id} and status=#{expectedStatus}
+          and not exists (select 1 from flash_delivery_order_stop s where s.order_id=flash_delivery_order.id
+              and s.stop_type='DELIVERY' and s.status &lt;&gt; 'DELIVERED')
     </update>
     </update>
 
 
     <!-- 取消仅取件前;服务行锁和站点检查之外再加条件,防止旧入口绕过部分取件事实。 -->
     <!-- 取消仅取件前;服务行锁和站点检查之外再加条件,防止旧入口绕过部分取件事实。 -->
@@ -206,6 +216,16 @@
           and status = 'ACCEPTED' and version = #{order.version}
           and status = 'ACCEPTED' and version = #{order.version}
     </update>
     </update>
 
 
+    <!-- 交错送达可能仍处于待取件;行锁内按骑手、可履约状态和版本推进,整单完成时写送达时间。 -->
+    <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 in ('ACCEPTED', 'PICKED_UP') and version = #{order.version}
+    </update>
+
     <!-- 预约时段结束仍无人接单时,由任务分批收口;只读取仍可取消的待接订单。 -->
     <!-- 预约时段结束仍无人接单时,由任务分批收口;只读取仍可取消的待接订单。 -->
     <select id="selectExpiredScheduled" resultMap="FlashDeliveryOrderResult">
     <select id="selectExpiredScheduled" resultMap="FlashDeliveryOrderResult">
         select * from flash_delivery_order
         select * from flash_delivery_order

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

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

+ 13 - 4
ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml

@@ -7,7 +7,8 @@
         store_id AS storeId, merchant_id AS merchantId, amount,
         store_id AS storeId, merchant_id AS merchantId, amount,
         hash_key_snapshot AS hashKeySnapshot, hash_iv_snapshot AS hashIvSnapshot,
         hash_key_snapshot AS hashKeySnapshot, hash_iv_snapshot AS hashIvSnapshot,
         attempt_status AS attemptStatus, active_dd_id AS activeDdId,
         attempt_status AS attemptStatus, active_dd_id AS activeDdId,
-        trade_no AS tradeNo, rtn_code AS rtnCode, rtn_msg AS rtnMsg,
+        trade_no AS tradeNo, credit_refund_id AS creditRefundId,
+        gateway_environment AS gatewayEnvironment, rtn_code AS rtnCode, rtn_msg AS rtnMsg,
         payment_type AS paymentType, payment_date AS paymentDate, trade_date AS tradeDate,
         payment_type AS paymentType, payment_date AS paymentDate, trade_date AS tradeDate,
         payment_type_charge_fee AS paymentTypeChargeFee, simulate_paid AS simulatePaid,
         payment_type_charge_fee AS paymentTypeChargeFee, simulate_paid AS simulatePaid,
         last_notify_time AS lastNotifyTime,
         last_notify_time AS lastNotifyTime,
@@ -101,10 +102,16 @@
            #{nextQueryTime}, #{queryCount}, #{createTime}, #{updateTime})
            #{nextQueryTime}, #{queryCount}, #{createTime}, #{updateTime})
     </insert>
     </insert>
 
 
+    <update id="markProductionEnvironment">
+        UPDATE pos_order_omg_attempt SET gateway_environment='PRODUCTION'
+        WHERE id=#{id} AND gateway_environment='TEST' AND attempt_status=0
+    </update>
+
     <!-- PAID is irreversible; a late success may upgrade FAILED or SUPERSEDED. -->
     <!-- PAID is irreversible; a late success may upgrade FAILED or SUPERSEDED. -->
     <update id="markPaid">
     <update id="markPaid">
         UPDATE pos_order_omg_attempt
         UPDATE pos_order_omg_attempt
-        SET attempt_status = 1, trade_no = #{tradeNo}, rtn_code = #{rtnCode}, rtn_msg = #{rtnMsg},
+        SET attempt_status = 1, trade_no = #{tradeNo}, credit_refund_id = #{creditRefundId},
+            rtn_code = #{rtnCode}, rtn_msg = #{rtnMsg},
             payment_type = #{paymentType}, payment_date = #{paymentDate}, trade_date = #{tradeDate},
             payment_type = #{paymentType}, payment_date = #{paymentDate}, trade_date = #{tradeDate},
             payment_type_charge_fee = #{paymentTypeChargeFee}, simulate_paid = #{simulatePaid},
             payment_type_charge_fee = #{paymentTypeChargeFee}, simulate_paid = #{simulatePaid},
             last_notify_time = #{lastNotifyTime}, update_time = #{updateTime}
             last_notify_time = #{lastNotifyTime}, update_time = #{updateTime}
@@ -134,8 +141,10 @@
 
 
     <!-- Payment notification owns only the payment fact; no order/delivery state is changed here. -->
     <!-- Payment notification owns only the payment fact; no order/delivery state is changed here. -->
     <update id="markOrderPaid">
     <update id="markOrderPaid">
-        UPDATE pos_order SET pay_status = 1
-        WHERE dd_id = #{ddId} AND pay_status != 1
+        UPDATE pos_order SET pay_status = 1,
+            effective_at = CASE WHEN type = 0 AND state != 4 THEN COALESCE(effective_at, NOW()) ELSE effective_at END
+        WHERE dd_id = #{ddId} AND pay_status = 0 AND pay_type IN ('2', '5')
+          AND state IN (0, 1, 2)
     </update>
     </update>
 
 
     <select id="countOtherPaidAttempts" resultType="int">
     <select id="countOtherPaidAttempts" resultType="int">

+ 7 - 2
ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml

@@ -58,13 +58,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="isAccepted" column="is_accepted" />
         <result property="isAccepted" column="is_accepted" />
         <result property="isDisplay" column="is_display" />
         <result property="isDisplay" column="is_display" />
         <result property="displayTime" column="display_time" />
         <result property="displayTime" column="display_time" />
+        <result property="effectiveAt" column="effective_at" />
         <result property="deliveryStatus" column="delivery_status" />
         <result property="deliveryStatus" column="delivery_status" />
         <result property="payStatus" column="pay_status" />
         <result property="payStatus" column="pay_status" />
         <result property="afterSaleStatus" column="after_sale_status" />
         <result property="afterSaleStatus" column="after_sale_status" />
     </resultMap>
     </resultMap>
 
 
     <sql id="selectPosOrderVo">
     <sql id="selectPosOrderVo">
-        select id, dd_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,order_source,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,delivery_status,pay_status,after_sale_status from pos_order
+        select id, dd_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,order_source,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,effective_at,delivery_status,pay_status,after_sale_status from pos_order
     </sql>
     </sql>
 
 
     <select id="selectPosOrderList" parameterType="PosOrder" resultMap="PosOrderResult">
     <select id="selectPosOrderList" parameterType="PosOrder" resultMap="PosOrderResult">
@@ -193,6 +194,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="isAccepted != null">is_accepted,</if>
             <if test="isAccepted != null">is_accepted,</if>
             <if test="isDisplay != null">is_display,</if>
             <if test="isDisplay != null">is_display,</if>
             <if test="displayTime != null">display_time,</if>
             <if test="displayTime != null">display_time,</if>
+            <if test="effectiveAt != null">effective_at,</if>
             <if test="deliveryStatus != null">delivery_status,</if>
             <if test="deliveryStatus != null">delivery_status,</if>
             <if test="payStatus != null">pay_status,</if>
             <if test="payStatus != null">pay_status,</if>
             <if test="afterSaleStatus != null">after_sale_status,</if>
             <if test="afterSaleStatus != null">after_sale_status,</if>
@@ -237,6 +239,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="isAccepted != null">#{isAccepted},</if>
             <if test="isAccepted != null">#{isAccepted},</if>
             <if test="isDisplay != null">#{isDisplay},</if>
             <if test="isDisplay != null">#{isDisplay},</if>
             <if test="displayTime != null">#{displayTime},</if>
             <if test="displayTime != null">#{displayTime},</if>
+            <if test="effectiveAt != null">#{effectiveAt},</if>
             <if test="deliveryStatus != null">#{deliveryStatus},</if>
             <if test="deliveryStatus != null">#{deliveryStatus},</if>
             <if test="payStatus != null">#{payStatus},</if>
             <if test="payStatus != null">#{payStatus},</if>
             <if test="afterSaleStatus != null">#{afterSaleStatus},</if>
             <if test="afterSaleStatus != null">#{afterSaleStatus},</if>
@@ -287,6 +290,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="isAccepted != null">is_accepted = #{isAccepted},</if>
             <if test="isAccepted != null">is_accepted = #{isAccepted},</if>
             <if test="isDisplay != null">is_display = #{isDisplay},</if>
             <if test="isDisplay != null">is_display = #{isDisplay},</if>
             <if test="displayTime != null">display_time = #{displayTime},</if>
             <if test="displayTime != null">display_time = #{displayTime},</if>
+            <if test="effectiveAt != null">effective_at = #{effectiveAt},</if>
             <if test="deliveryStatus != null">delivery_status = #{deliveryStatus},</if>
             <if test="deliveryStatus != null">delivery_status = #{deliveryStatus},</if>
             <if test="payStatus != null">pay_status = #{payStatus},</if>
             <if test="payStatus != null">pay_status = #{payStatus},</if>
             <if test="afterSaleStatus != null">after_sale_status = #{afterSaleStatus},</if>
             <if test="afterSaleStatus != null">after_sale_status = #{afterSaleStatus},</if>
@@ -376,7 +380,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </where>
         </where>
     </select>
     </select>
     <update id="markLinePaid">
     <update id="markLinePaid">
-        UPDATE pos_order SET pay_status = 1
+        UPDATE pos_order SET pay_status = 1,
+            effective_at = CASE WHEN type = 0 AND state != 4 THEN COALESCE(effective_at, NOW()) ELSE effective_at END
         WHERE dd_id = #{ddId} AND pay_type = '3' AND pay_status = 0
         WHERE dd_id = #{ddId} AND pay_type = '3' AND pay_status = 0
           AND state IN (0, 1, 2, 4)
           AND state IN (0, 1, 2, 4)
           AND EXISTS (SELECT 1 FROM pos_order_line_payment p
           AND EXISTS (SELECT 1 FROM pos_order_line_payment p

+ 43 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/StallQualificationMapperMpRenderTest.java

@@ -0,0 +1,43 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.MybatisXMLMapperBuilder;
+import org.apache.ibatis.mapping.BoundSql;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 摊位审核 filter 的 MyBatis-Plus 渲染回归:线上运行时走 MybatisConfiguration/MybatisXMLMapperBuilder,
+ * 纯空白文本节点会被丢弃,静态 status != 0 与动态 AND 可能贴死成 0AND(2026-10-16 线上事故)。
+ * 原生 Configuration 的测试保留空白、无法暴露该问题,必须用 MP 解析链路验证。
+ */
+class StallQualificationMapperMpRenderTest {
+
+    @Test
+    void filterKeepsSeparatorUnderMybatisPlusParsing() throws Exception {
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        String resource = "mapper/chanting/StallQualificationMapper.xml";
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            assertNotNull(input);
+            new MybatisXMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        String namespace = "com.ruoyi.system.mapper.StallQualificationMapper.";
+        BoundSql select = configuration.getMappedStatement(namespace + "selectApplications")
+                .getBoundSql(Map.of("status", 1, "limit", 10, "offset", 0));
+        String collapsed = select.getSql().replaceAll("\\s+", " ");
+        assertFalse(collapsed.contains("0AND"), "MP 解析下静态与动态条件贴死: " + collapsed);
+        assertTrue(collapsed.contains("status != 0 AND status = ?"), "MP 解析下丢失分隔空白: " + collapsed);
+
+        BoundSql withStore = configuration.getMappedStatement(namespace + "selectApplications")
+                .getBoundSql(Map.of("status", 2, "storeId", 7L, "limit", 10, "offset", 0));
+        String collapsedStore = withStore.getSql().replaceAll("\\s+", " ");
+        assertFalse(collapsedStore.contains("?AND"), "MP 解析下动态条件之间贴死: " + collapsedStore);
+        assertTrue(collapsedStore.contains("AND store_id = ?"), collapsedStore);
+    }
+}

+ 44 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/StallQualificationMapperXmlTest.java

@@ -0,0 +1,44 @@
+package com.ruoyi.system.mapper;
+
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 摊位资料审核列表 filter 片段渲染回归:status 基础条件与动态条件之间必须保留分隔空白。
+ */
+class StallQualificationMapperXmlTest {
+
+    private Configuration loadMapper() throws Exception {
+        String resource = "mapper/chanting/StallQualificationMapper.xml";
+        Configuration configuration = new Configuration();
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        return configuration;
+    }
+
+    @Test
+    void filterKeepsSeparatorBetweenBaseAndDynamicConditions() throws Exception {
+        Configuration configuration = loadMapper();
+        String namespace = "com.ruoyi.system.mapper.StallQualificationMapper.";
+        String sql = configuration.getMappedStatement(namespace + "selectApplications")
+                .getBoundSql(java.util.Map.of("status", 1, "limit", 10, "offset", 0)).getSql();
+        System.out.println("RENDERED SQL >>> " + sql + " <<<");
+        String collapsed = sql.replaceAll("\\s+", " ").toLowerCase();
+        assertFalse(collapsed.contains("0and"), "条件之间丢失分隔空白: " + collapsed);
+        assertTrue(collapsed.contains("status != 0 and status = ?"), collapsed);
+        assertTrue(collapsed.contains("limit ? offset ?"), collapsed);
+
+        String withStore = configuration.getMappedStatement(namespace + "selectApplications")
+                .getBoundSql(java.util.Map.of("status", 2, "storeId", 7L, "limit", 10, "offset", 0)).getSql();
+        String collapsedStore = withStore.replaceAll("\\s+", " ").toLowerCase();
+        assertFalse(collapsedStore.contains("?and"), "动态条件之间丢失分隔空白: " + collapsedStore);
+        assertTrue(collapsedStore.contains("and store_id = ?"), collapsedStore);
+    }
+}

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

@@ -18,6 +18,41 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 
 class FlashDeliveryMapperXmlTest {
 class FlashDeliveryMapperXmlTest {
 
 
+
+    @Test
+    void deliveryTargetAndCompletionSqlEnforcePerStopFacts() 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.";
+        FlashDeliveryOrder order = new FlashDeliveryOrder(); order.setId(10L); order.setVersion(2);
+        var delivery = configuration.getMappedStatement(namespace + "updateDeliveryFulfilment")
+                .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();
+        assertTrue(sql.contains("s.receiver_user_id = ?"));
+        assertTrue(sql.contains("flash_delivery_order_stop"));
+        assertTrue(sql.contains("not exists"));
+        assertTrue(sql.contains("s.status <> 'delivered'"));
+        var edit = configuration.getMappedStatement(namespace + "updateWaitingAddress")
+                .getBoundSql(java.util.Map.of("order", order, "expectedVersion", 2));
+        assertTrue(edit.getParameterMappings().stream().anyMatch(mapping -> mapping.getProperty().equals("order.deliveryStopCount")));
+        var complete = configuration.getMappedStatement(namespace + "transitionByStatus")
+                .getBoundSql(java.util.Map.of("id", 10L, "expectedStatus", "DELIVERED", "nextStatus", "COMPLETED", "now", new java.util.Date()));
+        assertTrue(complete.getSql().toLowerCase().contains("not exists"));
+    }
+
     @Test
     @Test
     void stationFulfilmentLocksOrderAndPersistsExplicitTargetReset() throws Exception {
     void stationFulfilmentLocksOrderAndPersistsExplicitTargetReset() throws Exception {
         String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";
         String resource = "mapper/flash/FlashDeliveryOrderMapper.xml";
@@ -51,7 +86,7 @@ class FlashDeliveryMapperXmlTest {
             Set<String> properties = configuration.getResultMap("com.ruoyi.system.mapper.flash." + name + "Mapper." + name + "Result")
             Set<String> properties = configuration.getResultMap("com.ruoyi.system.mapper.flash." + name + "Mapper." + name + "Result")
                     .getResultMappings().stream().map(ResultMapping::getProperty).collect(Collectors.toSet());
                     .getResultMappings().stream().map(ResultMapping::getProperty).collect(Collectors.toSet());
             if (name.equals("FlashDeliveryOrderStop")) {
             if (name.equals("FlashDeliveryOrderStop")) {
-                assertTrue(properties.containsAll(Set.of("orderId", "stopType", "stopOrder", "packageType", "quantity", "weightRange", "specification", "status", "pickedUpAt")));
+                assertTrue(properties.containsAll(Set.of("orderId", "stopType", "stopOrder", "remark", "receiverUserId", "deliveryPinCode", "status", "pickedUpAt")));
             } else {
             } else {
                 assertTrue(properties.containsAll(Set.of("fromStopId", "toStopId", "riderId", "clientRequestId", "orderVersion", "reason", "consentDeclared", "createTime")));
                 assertTrue(properties.containsAll(Set.of("fromStopId", "toStopId", "riderId", "clientRequestId", "orderVersion", "reason", "consentDeclared", "createTime")));
             }
             }

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

@@ -247,3 +247,8 @@ Task T022: "骑手端 PosOrderQsOprateController.orderList()"
 - [x] T042 为商家全部页签、空页签及未传页签补充接单过滤回归,修改前 8 个新增用例均因缺少条件失败。
 - [x] T042 为商家全部页签、空页签及未传页签补充接单过滤回归,修改前 8 个新增用例均因缺少条件失败。
 - [x] T043 在 `orderList` 分页前统一限制外送订单须 `deliveryStatus IN (1,2,3)` 且 `qsId` 非空,保留自取/堂食、支付和门店权限条件。
 - [x] T043 在 `orderList` 分页前统一限制外送订单须 `deliveryStatus IN (1,2,3)` 且 `qsId` 非空,保留自取/堂食、支付和门店权限条件。
 - [x] T044 使用 JDK 21 运行 `OrderListPaymentFilterTest`、`PosOrderShOprateControllerTest`、`PosOrderQsOprateControllerTest` 定向测试,23/23 通过;未执行真实数据库分页联调和完整回归。
 - [x] T044 使用 JDK 21 运行 `OrderListPaymentFilterTest`、`PosOrderShOprateControllerTest`、`PosOrderQsOprateControllerTest` 定向测试,23/23 通过;未执行真实数据库分页联调和完整回归。
+
+## 2026-10-14 增量:骑手端美食订单角标计数
+
+- [x] T045 新增 `GET /system/orderQsOprate/orderCount`,一次返回美食订单 newTask/toPickup/delivering 三个页签计数;抽取 orderList 页签查询条件共用(计数跳过排序子句),newTask 过滤口径与列表一致(含可选距离上限与美食配送类型门禁,未开通计 0)。
+- [x] T046 补充 PosOrderQsOprateControllerTest 角标计数定向测试;JDK 21 定向测试 131 项通过。App 联调未执行。

+ 2 - 0
specs/016-omg-payment/data-model.md

@@ -1,5 +1,7 @@
 # Data Model: OMG(歐買尬/FunPoint)AIO 支付接入
 # Data Model: OMG(歐買尬/FunPoint)AIO 支付接入
 
 
+> **2026-10-15 现行补充**:本文件保留 016 初版数据模型供历史追溯,下面的旧支付流水和退款表 DDL 不是现行迁移脚本。现行支付尝试为 `pos_order_omg_attempt`,退款意图为按 `attempt_id` 唯一的 `pos_order_omg_refund`;`effective_at`、环境快照及完整增量 SQL 见 [037 规格](../037-order-no-rider-auto-cancel/spec.md)与 `updatesql/sql.md` 的 2026-10-15 节。
+
 **Feature**: specs/016-omg-payment/spec.md
 **Feature**: specs/016-omg-payment/spec.md
 **Date**: 2026-07-29
 **Date**: 2026-07-29
 
 

+ 12 - 5
specs/016-omg-payment/frontend-integration.md

@@ -1,6 +1,8 @@
 # OMG(歐買尬/FunPoint)支付 — 前端接入文档
 # OMG(歐買尬/FunPoint)支付 — 前端接入文档
 
 
-**适用端**:客户 App(uni-app) | **后端分支**:`OmgPayController` 已完成 | **日期**:2026-08-14
+> **2026-10-15 现行补充**:本页保留 016 初期接入说明;退款请求和返回语义以更新后的 §3.4 为准。无人接单自动取消与退款由后端任务处理,前端展示订单及退款状态即可。后端计时、测试环境限制见 [037 规格](../037-order-no-rider-auto-cancel/spec.md)。
+
+**适用端**:客户 App(uni-app) | **后端入口**:`OmgPaymentController` | **初版日期**:2026-08-14
 
 
 ---
 ---
 
 
@@ -151,23 +153,28 @@ App 返回后用 Header `token` 和 JSON `{"orderId":"..."}` 调用。接口只
 
 
 ### 3.4 退款(取消已支付订单)— `POST /pay/omg/refund`
 ### 3.4 退款(取消已支付订单)— `POST /pay/omg/refund`
 
 
-**用途**:用户取消已支付的信用卡/Apple Pay 订单时退款。
+**用途**:订单已取消且 OMG 实收后,登记退款意图。接口成功只表示意图已接受,正式环境由后台任务按网关状态异步退款;前端不能立即显示“已退款”。无人接单自动取消时由后端自行登记,前端无需主动调用本接口。
 
 
 | 项 | 内容 |
 | 项 | 内容 |
 |----|------|
 |----|------|
 | Method | `POST` |
 | Method | `POST` |
 | Header | `token: <用户JWT>` |
 | Header | `token: <用户JWT>` |
-| 入参 | query:`orderid` = ddId |
+| 入参 | JSON body:`{"orderId":"订单号"}` |
 | 鉴权 | 订单本人 |
 | 鉴权 | 订单本人 |
 
 
 **请求**
 **请求**
 ```
 ```
-POST {baseURL}/pay/omg/refund?orderid=DD202608060001
+POST {baseURL}/pay/omg/refund
 Header: token: eyJ...
 Header: token: eyJ...
+Content-Type: application/json
+
+{"orderId":"DD202608060001"}
 ```
 ```
 
 
 **返回**
 **返回**
-| code=200 | 信用卡/Apple Pay 退刷成功,`pos_order.pay_status` 置 2 |
+| code=200 | 已登记退款意图;需继续查询订单/退款状态,不能据此认定资金已退回 |
+
+测试环境没有 OMG 信用卡真实退款能力,接口会返回不可退款错误;测试退款意图不会被标记为成功。正式环境需配置商户 CreditCheckCode 并完成真实网关联调,详见 [037 计划](../037-order-no-rider-auto-cancel/plan.md)。
 
 
 ---
 ---
 
 

+ 3 - 1
specs/016-omg-payment/omg-official-api-ref.md

@@ -1,5 +1,7 @@
 # OMG(歐買尬/FunPoint)全方位金流 官方接口参考
 # OMG(歐買尬/FunPoint)全方位金流 官方接口参考
 
 
+> **2026-10-15 项目现状**:本页保留当时的官方接口摘录;下文“本项目只用 Action=R”等旧实现说明已过时。现行代码先查询信用卡状态,按状态执行 N、E 后 N 或 R;测试环境不支持实际退款。实现与部署口径见 [037 规格](../037-order-no-rider-auto-cancel/spec.md)。
+
 > **来源**:OMG 官方介接技术文件 https://developers.omg.com.tw/payment/aio/
 > **来源**:OMG 官方介接技术文件 https://developers.omg.com.tw/payment/aio/
 > **文档版本**:V 1.5.3(2026-07,首页标注,茂為歐買尬數位科技)
 > **文档版本**:V 1.5.3(2026-07,首页标注,茂為歐買尬數位科技)
 > **抓取日期**:2026-08-12
 > **抓取日期**:2026-08-12
@@ -15,7 +17,7 @@
 | 正式 | `https://payment.funpoint.com.tw` |
 | 正式 | `https://payment.funpoint.com.tw` |
 | 测试 | `https://payment-stage.funpoint.com.tw` |
 | 测试 | `https://payment-stage.funpoint.com.tw` |
 
 
-本项目配置(`application.yml` → `omg.base-url`):测试期指向 stage。
+本项目当前通过 `omgpay.environment` 选择 TEST(默认)或 PRODUCTION,按创建支付尝试时的环境快照查询交易;退款还要求正式环境及对应商户的 CreditCheckCode。
 
 
 凭证:门店级 `pos_store_omg`(merchant_id / hash_key / hash_iv / store_id),CheckMacValue 用 HashKey/HashIV 签名。
 凭证:门店级 `pos_store_omg`(merchant_id / hash_key / hash_iv / store_id),CheckMacValue 用 HashKey/HashIV 签名。
 
 

+ 2 - 0
specs/016-omg-payment/plan.md

@@ -1,5 +1,7 @@
 # Implementation Plan: OMG(歐買尬/FunPoint)AIO 支付接入
 # Implementation Plan: OMG(歐買尬/FunPoint)AIO 支付接入
 
 
+> **2026-10-15 现行补充**:本文件记录 016 初期设计,以下旧支付流水、退款表与 stage 退款步骤不再代表当前实现。现行支付尝试使用 `pos_order_omg_attempt`;无人接单取消、正式环境自动退款和部署边界见 [037 规格](../037-order-no-rider-auto-cancel/spec.md)、[037 计划](../037-order-no-rider-auto-cancel/plan.md)及 `updatesql/sql.md` 的 2026-10-15 节。测试环境无法实际退款,不能据此标记退款成功。
+
 **Branch**: `(待创建,暂未建分支/未提交)` | **Date**: 2026-07-29 | **Spec**: [spec.md](spec.md)
 **Branch**: `(待创建,暂未建分支/未提交)` | **Date**: 2026-07-29 | **Spec**: [spec.md](spec.md)
 
 
 **Input**: Feature specification from `/specs/016-omg-payment/spec.md`
 **Input**: Feature specification from `/specs/016-omg-payment/spec.md`

+ 2 - 0
specs/016-omg-payment/quickstart.md

@@ -1,5 +1,7 @@
 # Quickstart: OMG(歐買尬/FunPoint)AIO 支付接入
 # Quickstart: OMG(歐買尬/FunPoint)AIO 支付接入
 
 
+> **2026-10-15 现行补充**:以下为 016 初期验证草案,旧 `pos_order_omg_payment` DDL 和“默认 R 退刷”步骤不得按现行部署流程执行。当前使用 `pos_order_omg_attempt` 与幂等退款意图;按网关信用卡状态选择 N、E 后 N 或 R。测试环境退款不可用,正式环境的部署与验收口径见 [037 规格](../037-order-no-rider-auto-cancel/spec.md)及 `updatesql/sql.md` 的 2026-10-15 节。
+
 **Feature**: specs/016-omg-payment/spec.md
 **Feature**: specs/016-omg-payment/spec.md
 **Date**: 2026-07-29
 **Date**: 2026-07-29
 
 

+ 2 - 0
specs/016-omg-payment/spec.md

@@ -1,5 +1,7 @@
 # Feature Specification: OMG 支付接入(订单在线支付)
 # Feature Specification: OMG 支付接入(订单在线支付)
 
 
+> **2026-10-15 现行补充**:本文件保留 016 初期需求与决策记录;支付尝试、退款及测试/正式环境边界已由后续实现调整。现行无人接单自动取消与 OMG 正式退款规格见 [037 规格](../037-order-no-rider-auto-cancel/spec.md),迁移 SQL 见 `updatesql/sql.md` 的 2026-10-15 节。
+
 **Feature Branch**: `(待创建,暂未建分支/未提交)`
 **Feature Branch**: `(待创建,暂未建分支/未提交)`
 
 
 **Created**: 2026-07-29
 **Created**: 2026-07-29

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

@@ -173,11 +173,11 @@
 
 
 ### 下单、报价与待接单编辑
 ### 下单、报价与待接单编辑
 
 
-- `home` 增加 `pickupStopLimit`、`showMultiPickup`、`showUrgentOption`。上限来自 `flash_pickup_stop_limit`,有效值 1..26,缺失或非法回退 1;`flash_show_multi_pickup` 关闭时只能提交一个取货点。
+- `home` 下发 `pickupStopLimit`、`deliveryStopLimit`(有效值 1..10,缺失或非法回退 1)。兼容字段 `showMultiPickup`、`showMultiDropoff` 分别由对应上限是否大于 1 派生;`showUrgentOption` 固定为 true,加急功能始终可用。无独立多点或加急展示字典开关。
 - 报价、创建新增 `pickups` 数组。每项直接包含地址字段(`name,phone,address,addressDetail,city,area,handoffMethod,latitude,longitude`)及独立物品字段(`packageType,quantity,weightRange,specification`),不再提交订单级物品。
 - 报价、创建新增 `pickups` 数组。每项直接包含地址字段(`name,phone,address,addressDetail,city,area,handoffMethod,latitude,longitude`)及独立物品字段(`packageType,quantity,weightRange,specification`),不再提交订单级物品。
 - 原 `pickup` 加订单级物品的单点请求仍可提交;与 `pickups` 同传或多点数组与订单级物品混传均拒绝。新订单一律保存取货及收货站点,不提供缺失站点历史订单回退。
 - 原 `pickup` 加订单级物品的单点请求仍可提交;与 `pickups` 同传或多点数组与订单级物品混传均拒绝。新订单一律保存取货及收货站点,不提供缺失站点历史订单回退。
 - 地址报价/确认使用完整 `pickups` 和唯一 `delivery`。多点不得仅传旧 `pickup`;旧单点编辑保留原物品。待接单调整站点顺序必须重新报价确认;接单后访问顺序调整不走改址,不重算金额。
 - 地址报价/确认使用完整 `pickups` 和唯一 `delivery`。多点不得仅传旧 `pickup`;旧单点编辑保留原物品。待接单调整站点顺序必须重新报价确认;接单后访问顺序调整不走改址,不重算金额。
-- 用户顺序用于整线地图报价,最多 26 个取货点及 1 个收货点,不优化或省略中途点;地图不可用时逐段直线求和,整线限制 40 公里。仅追加小费不调用路线、不重新校验开关及点数上限。
+- 用户顺序用于整线地图报价,最多 10 个取货点及 1 个收货点,不优化或省略中途点;地图不可用时逐段直线求和,整线限制 40 公里。仅追加小费不调用路线、不重新校验点数上限。
 
 
 ### 逐站履约
 ### 逐站履约
 
 
@@ -185,9 +185,9 @@
 |---|---|---|
 |---|---|---|
 | POST | `/system/flashDelivery/rider/orders/{id}/pickup` | `stopId?,imageUrls`;单点可省略站点 ID,多点必传;实际交接后提交至少 1 张图片 |
 | 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}/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`,用户取消必须拒绝。取件、目标调整和用户/平台取消先锁订单行后读取最新站点,事务内统一保存状态、版本、凭证及日志。
 全部取货点完成才进入 `PICKED_UP`;`pickedUpAt` 是最后一个实际完成站点的时间。仅部分已取时订单仍为 `ACCEPTED`,用户取消必须拒绝。取件、目标调整和用户/平台取消先锁订单行后读取最新站点,事务内统一保存状态、版本、凭证及日志。
 
 
@@ -227,3 +227,65 @@
 
 
 - `GET /system/flashDelivery/rider/orders` 摘要新增 `userNote`:骑手本人任务页签(`toPickup/delivering/completed/cancelled`)返回订单备注,无需进详情;`newTask` 待抢单卡片按接单前脱敏规则固定为 `null`,与详情接口的备注可见性一致。
 - `GET /system/flashDelivery/rider/orders` 摘要新增 `userNote`:骑手本人任务页签(`toPickup/delivering/completed/cancelled`)返回订单备注,无需进详情;`newTask` 待抢单卡片按接单前脱敏规则固定为 `null`,与详情接口的备注可见性一致。
 
 
+
+## 2026-10-14:多点取送 v2 创建契约(新增;旧接口过渡期保留,确认待实施)
+
+### v2 报价与创建
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/system/flashDelivery/v2/quote` | 统一结构报价:订单级物品 + `stops[]`(至少 1 取、1 收,允许交错) |
+| POST | `/system/flashDelivery/v2/orders` | 按 v2 报价创建订单,服务端复算费用与路线 |
+
+请求结构(要点):取货点与收货点放在同一个 `stops[]` 数组,顺序即用户添加顺序、也是骑手路线顺序,元素以 `type`(`PICKUP`/`DELIVERY`)区分:
+
+```json
+{
+  "stops": [
+    {"type":"PICKUP","name":"李先生","phone":"17788889999","address":"台北市大安區信義路三段56號","addressDetail":"10樓","latitude":25.0330,"longitude":121.5654,"remark":"取 4 盒月餅"},
+    {"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":"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 公里上限不变。旧单点订单的市/区结构化字段仅随旧接口与旧订单继续存在。
+
+响应要点:报价返回整线距离、时长与费用明细(结构同现有报价);创建返回订单详情,`stops[].id` 是后续履约所需的站点 ID,另有状态与备注。服务端按收货站联系人电话固化 `receiverUserId`(可空),详情不返回该内部 ID。启用收货码时每个收货站生成独立 `deliveryPinCode`;发起人可见全部,已绑定收件人仅可见自己的站点,骑手不可见。v2 订单级 `deliveryPinCode` 为空。列表和详情返回 `deliveryStopCount`、`deliveredStopCount`。
+
+### 骑手逐站送达
+
+| 方法 | 路径 | 说明 |
+| --- | --- | --- |
+| POST | `/system/flashDelivery/rider/orders/stops/deliver` | body:`{"orderId":10,"stopId":13,"pinCode":"4821","imageUrls":[...]}`;订单与收货站 ID 必填正整数,图片至少一张;启用 PIN 时必须提交该站收货码 |
+
+全部收货站确认送达后才推进主状态 DELIVERED;随后确认收货、平台完成或系统自动完成进入 COMPLETED 时才生成一笔骑手收入账单。任一收货站已送达后订单进入禁止取消状态(用户与平台同口径)。旧整单送达接口拒绝全部 v2 订单,只有一个收货站的 v2 订单也使用此逐站接口并提供 `stopId`。
+
+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 角标。
+
+每个取货站或收货站首次确认成功且事务提交后,向发起人推送一次逐站进度并写入消息中心。消息 payload 使用 `FLASH_PICKUP_STOP_COMPLETED` 或 `FLASH_DELIVERY_STOP_COMPLETED`,包含 `ddId,state,stopId,stopType,stopOrder,completedStopCount,totalStopCount`;`stopOrder` 为从 0 开始的路线下标,文案中站号从 1 开始。重试、失败和回滚不重复通知;最后一站不叠发同阶段整单提醒。逐站收件人通知本阶段暂缓。
+
+### v2 待接单改址
+
+| 方法 | 路径 | body | 说明 |
+| --- | --- | --- | --- |
+| POST | `/system/flashDelivery/v2/orders/address/quote` | `orderId,orderVersion,stops` | 完整站点重新报价,沿用订单级物品和原运价快照 |
+| POST | `/system/flashDelivery/v2/orders/address` | 上述字段及 `quotedDistanceMeters,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount` | 核对最新报价和订单版本,同一事务替换全部站点 |
+
+`stops` 必须包含完整的取货与收货站数组,顺序、字段及站点数量限制同 v2 创建。保存成功后以返回详情刷新全部站点 ID 和收货码;旧站点 ID 不再用于履约。旧改址接口仅保留旧订单行为,遇到 v2 订单会拒绝,不会删减收货站。
+
+### 废弃与退役计划
+
+- 自 v2 上线起,`POST /system/flashDelivery/quote`、`POST /system/flashDelivery/orders`(含旧单点 `pickup`、逐站物品 `pickups`、混传校验)及旧送达接口标注废弃:代码 `@Deprecated` + 本契约标注"过渡期保留";行为冻结,不接收新功能,既有线上 App 不受影响。
+- 退役删除为独立收尾批次,前置条件:用户/骑手 App 两端全部切换 v2 并通过真实环境联调;删除范围含旧结构 DTO 与互斥校验、对应测试、本契约废弃段落及 `pickupStopLimit`/`showMultiPickup` 等被 v2 字段取代的首页配置。
+- 数据无迁移:v2 订单与旧订单共用站点表;`remark`、按站收件人与按站收货码仅 v2 新订单写入。
+
+### 2026-10-14:新增接口业务参数统一放 body
+
+本次新增的 `/v2/quote`、`/v2/orders`、逐站送达及 v2 改址接口采用该规则;GET 的简单 query/路径参数及已有旧接口保持原样(含 LINE Pay 创建、取件、目标调整、改址和平台接口)。`token` 继续使用请求头,不放 body。
+
+新逐站送达请求 DTO:`FlashDeliveryStopDeliverRequest`。`orderId` 与 `stopId` 只从 body 取得,缺失或非正数返回国际化业务错误;单收货站也必须传 `stopId`。旧整单送达仍为 `/rider/orders/{id}/deliver`,仅供旧订单过渡使用。

Некоторые файлы не были показаны из-за большого количества измененных файлов