Эх сурвалжийг харах

调整闪送为取货站独立货品和商品图片

将货品字段移至取货站,打通报价、创建、改址及详情展示,收货站不设置货品信息。同步规格、前端接入文档、人工迁移脚本和定向测试。
qmj 6 цаг одооноос
parent
commit
16c6ab4956
32 өөрчлөгдсөн 448 нэмэгдсэн , 228 устгасан
  1. 19 16
      docs/flash-delivery-app-api.md
  2. 11 16
      docs/flash-delivery-v2-frontend-handoff.md
  3. 0 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderDetailView.java
  4. 0 8
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  5. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPickupRequest.java
  6. 0 8
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  7. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryStopView.java
  8. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2AddressChangeRequest.java
  9. 0 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2CreateRequest.java
  10. 2 16
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2QuoteRequest.java
  11. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryV2StopRequest.java
  12. 59 77
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  13. 4 6
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java
  14. 0 4
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java
  15. 2 3
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  16. 1 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  17. 29 22
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  18. 4 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryMultiPickupTest.java
  19. 31 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPickupItemContractTest.java
  20. 97 8
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryV2Test.java
  21. 0 6
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  22. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrderStop.java
  23. 0 6
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  24. 4 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderStopMapper.xml
  25. 8 8
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  26. 8 9
      specs/024-flash-delivery/contracts/api.md
  27. 10 2
      specs/024-flash-delivery/data-model.md
  28. 7 0
      specs/024-flash-delivery/plan.md
  29. 3 3
      specs/024-flash-delivery/quickstart.md
  30. 8 0
      specs/024-flash-delivery/spec.md
  31. 10 0
      specs/024-flash-delivery/tasks.md
  32. 102 0
      updatesql/sql.md

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

@@ -255,7 +255,7 @@ https://api.example.com/profile/upload/2026/09/01/example.jpg
 
 - 只接受 `http://` 或 `https://` URL,必须包含主机名。
 - 单个 URL 最长 1000 个字符。
-- 寄件图片 `senderImageUrls` 可不传,最多 9 张。
+- 商品图片放在各取货站 `senderImageUrls`,可不传,每站最多 9 张。旧单取货站请求仍可使用根级 `senderImageUrls`;多取货站按站提交。
 - 取件图片和送达图片各至少 1 张、最多 9 张。
 
 ## 6. 公共数据对象
@@ -304,10 +304,10 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 | `serviceType` | string | 业务场景:`HELP_SEND` 或 `HELP_PICKUP` |
 | `deliveryType` | string | 配送等级:`NORMAL` 或 `URGENT` |
 | `status` | string | 当前状态 |
-| `packageType` | string | 包裹类型 |
-| `quantity` | integer | 物品数量,正整数 |
-| `weightRange` | string | 用户选择的四档重量范围,取值见 3.4 |
-| `specification` | string/null | 体积或规格说明,最长 255 字符 |
+| `stops[].packageType` | string | 取货站包裹类型 |
+| `stops[].quantity` | integer | 取货站物品数量,新订单正整数;历史缺省可空 |
+| `stops[].weightRange` | string | 用户选择的四档重量范围,取值见 3.4 |
+| `stops[].specification` | string/null | 体积或规格说明,最长 255 字符 |
 | `deliveryMode` | string | `NOW` 或 `SCHEDULED` |
 | `scheduledPickupStartAt` | datetime/null | 预约开始时间 |
 | `scheduledPickupEndAt` | datetime/null | 预约结束时间 |
@@ -333,9 +333,9 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 | `createTime` | datetime | 创建时间 |
 | `updateTime` | datetime | 更新时间 |
 
-用户列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。用户列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。
+用户列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,stops,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。用户列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。
 
-骑手列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,distanceFee,urgentFee,tipAmount,amount,currency,payType,createTime`。`newTask` 分页响应额外返回 `nearbyTaskCount` 和 `highestOrderAmount`,不返回内部加急比例、最低加急费或骑手收入字段。
+骑手列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,stops,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,distanceFee,urgentFee,tipAmount,amount,currency,payType,createTime`。`newTask` 分页响应额外返回 `nearbyTaskCount` 和 `highestOrderAmount`,不返回内部加急比例、最低加急费或骑手收入字段。
 
 ### 6.3 App 完整详情对象
 
@@ -348,10 +348,6 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
   "serviceType": "HELP_SEND",
   "deliveryType": "URGENT",
   "status": "ACCEPTED",
-  "packageType": "DOCUMENT",
-  "quantity": 1,
-  "weightRange": "UP_TO_5_KG",
-  "specification": "30 x 20 x 10 cm",
   "deliveryMode": "NOW",
   "scheduledPickupStartAt": null,
   "scheduledPickupEndAt": null,
@@ -377,8 +373,9 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
   "cancelReason": null,
   "createTime": "2026-09-01T10:00:00+08:00",
   "updateTime": "2026-09-01T10:20:00+08:00",
-  "senderImageUrls": [
-    "https://api.example.com/profile/upload/sender-1.jpg"
+  "stops": [
+    {"id":201,"stopType":"PICKUP","stopOrder":0,"packageType":"DOCUMENT","quantity":1,"weightRange":"UP_TO_5_KG","specification":"30 x 20 x 10 cm","senderImageUrls":["https://api.example.com/profile/upload/sender-1.jpg"],"imageUrls":[]},
+    {"id":202,"stopType":"DELIVERY","stopOrder":1,"senderImageUrls":[],"imageUrls":[]}
   ],
   "pickupImageUrls": [],
   "deliveryImageUrls": [],
@@ -395,7 +392,7 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 ```
 
 - 用户创建、详情及编辑成功的响应还包含 `orderVersion`;骑手响应不包含。编辑前从用户详情取得最新版本,每次成功后替换本地版本。
-- `senderImageUrls`、`pickupImageUrls` 和 `deliveryImageUrls` 分别表示寄件、取件和送达图片数组。
+- `stops[].senderImageUrls` 是取货站商品图片,`stops[].imageUrls` 是本站骑手凭证;根级 `pickupImageUrls` 和 `deliveryImageUrls` 为取件、送达凭证汇总。列表不返回商品图片,详情按权限返回。
 - App 详情不返回原始状态日志;页面使用 `status`、`acceptedAt`、`pickedUpAt`、`deliveredAt`、`completedAt` 和 `cancelledAt` 展示进度。
 - `deliveryPinCode` 只向订单发件人和当前绑定的收件人返回,并且只在启用 PIN 时出现;骑手响应永不返回此字段。
 - `rider` 只向用户返回。订单未接单时为 `null`。骑手对象不包含真实电话号码,联系入口使用 `imUserId` 打开 IM 会话,原型中的通话按钮没有接口支撑。
@@ -1123,7 +1120,7 @@ POST /system/address/{id}/top
 
 ### 下单与编辑
 
-首页读取 `pickupStopLimit` 控制取货点数量;兼容字段 `showMultiPickup` 由上限是否大于 1 派生,`showUrgentOption` 固定为 true,加急始终展示。用户可新增取货点、填写每站独立物品,并在报价前排序;最终 `pickups` 数组顺序即报价顺序。每个数组项直接包含地址字段和 `packageType/quantity/weightRange/specification`。数组模式不再传旧 `pickup` 或订单级物品。唯一 `delivery` 保持原格式。
+首页读取 `pickupStopLimit` 控制取货点数量;兼容字段 `showMultiPickup` 由上限是否大于 1 派生,`showUrgentOption` 固定为 true,加急始终展示。用户可新增取货点、填写每站独立物品,并在报价前排序;最终 `pickups` 数组顺序即报价顺序。每个数组项直接包含地址字段和 `packageType/quantity/weightRange/specification/senderImageUrls`。数组模式不再传旧 `pickup` 或订单级物品。唯一 `delivery` 保持原格式。
 
 ```json
 {
@@ -1205,7 +1202,7 @@ 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 订单会拒绝,旧订单的参数和行为保持原样。
+报价 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` 打开订单详情,以最新详情展示站点进度。逐站收件人通知记录为后续需求,本轮暂缓。
 
@@ -1218,3 +1215,9 @@ v2 报价、创建和待接单完整改址的 `stops[]` 按客户顺序提交:
 五个 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。
+
+## 取货站货品与商品图片
+
+新 App 的报价、创建、待接单改址统一按 [v2 前端接入文档](flash-delivery-v2-frontend-handoff.md) 提交完整 `stops`。每个 PICKUP 独立填写 `packageType`、正整数 `quantity`、可选 `weightRange`、`specification` 和最多 9 张 `senderImageUrls`;DELIVERY 不填写。商品图片使用已有上传接口返回的 URL。
+
+新旧订单详情均从取货站读取货品,根级不返回货品字段和商品图片;骑手接单前隐藏商品图片,履约凭证 `imageUrls` 独立保留。历史站点货品可为空,编辑时补齐必填值。上线需开发者手动完成 SQL 文档 2026-11-09 的结构及历史资料迁移。

+ 11 - 16
docs/flash-delivery-v2-frontend-handoff.md

@@ -1,12 +1,12 @@
 # 闪送 v2 多点取送前端对接说明
 
-更新时间:2026-10-15。本文对应当前仓库中的后端实现,供用户端和骑手端接入;联调前需确认服务端部署版本和 [10 月 14 日数据库执行说明](../updatesql/sql.md)。10 月 15 日交错取送与进度调整无新增字段、无新增 SQL。
+更新时间:2026-11-09。本文供用户端和骑手端接入。联调前需确认服务端已部署本版接口,并完成 [数据库执行说明](../updatesql/sql.md) 中 2026-11-09 的取货站货品迁移。
 
 ## 本次要调整的页面和流程
 
 | 页面/场景 | 前端调整 |
 | --- | --- |
-| 用户填写闪送订单 | 按客户顺序编辑一个 `stops` 数组,首站取货、末站收货,中间可以交错取送;物品信息只填写一次。根据 `/home` 的取货点和收货点数量上限控制可添加数量。 |
+| 用户填写闪送订单 | 按客户顺序编辑一个 `stops` 数组,首站取货、末站收货,中间可以交错取送;每个取货站独立填写货品信息和商品图片,收货站不填写。根据 `/home` 的取货点和收货点数量上限控制可添加数量。 |
 | 报价与下单 | 使用 `/v2/quote` 和 `/v2/orders`。创建时把本次报价的配置 ID、版本和费用原样回传;创建请求号在重试时保持一致。 |
 | 用户/收件人订单列表和详情 | 展示取货、收货两个进度;站点使用 `stops[].id`,不可把数组下标当成站点 ID。逐站收货码按权限展示。 |
 | 骑手履约 | 保留原五个 Tab 和名称,卡片增加取件、送达双进度。直接从 `stops[]` 选择待完成站,移除“目前目标/设为目前目标”;每站上传凭证并刷新详情。逐站送达使用固定路径的新接口,`orderId`、`stopId`、PIN 和照片全部放 body。 |
@@ -26,24 +26,20 @@
   "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":"PICKUP","name":"取件人甲","phone":"0911000001","address":"台北市信义区甲路1号","addressDetail":"1楼","longitude":121.5601,"latitude":25.0301,"remark":"取文件袋甲","packageType":"DOCUMENT","quantity":1,"weightRange":"UP_TO_5_KG","specification":"文件袋","senderImageUrls":["/profile/document-a.jpg"]},
+    {"type":"PICKUP","name":"取件人乙","phone":"0911000002","address":"台北市信义区乙路2号","longitude":121.5602,"latitude":25.0302,"remark":"取文件袋乙","packageType":"DOCUMENT","quantity":2,"senderImageUrls":[]},
     {"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":"PICKUP","name":"取件人戊","phone":"0911000005","address":"台北市大安区戊路5号","longitude":121.5605,"latitude":25.0305,"remark":"取给丁的文件","packageType":"DOCUMENT","quantity":1,"senderImageUrls":[]},
     {"type":"DELIVERY","name":"收件人丁","phone":"0911000004","address":"台北市大安区丁路4号","longitude":121.5604,"latitude":25.0304,"remark":"交前台"}
   ]
 }
 ```
 
-至少一个 `PICKUP` 和一个 `DELIVERY`,首站取货、末站收货,中间允许交错;默认路线及报价顺序就是数组顺序,不要把站点按取/送重新分组。同一地址或坐标不能重复。每站分别填写姓名、电话、完整地址和经纬度,可选门牌、交接方式、最长 200 字备注。客户在备注写清本站取什么/送什么,骑手负责核对,不需要提交取货站与收货站关联关系。v2 站点不要提交旧结构中的逐站物品字段;`packageType`、正整数 `quantity`、可选 `weightRange` 和 `specification` 只在订单级出现一次。`SCHEDULED` 订单还需提交 `scheduledPickupStartAt`、`scheduledPickupEndAt`,`NOW` 订单无需提交这两个字段。
+至少一个 `PICKUP` 和一个 `DELIVERY`,首站取货、末站收货,中间允许交错;默认路线及报价顺序就是数组顺序,不要把站点按取/送重新分组。同一地址或坐标不能重复。每站分别填写姓名、电话、完整地址和经纬度,可选门牌、交接方式、最长 200 字备注。客户在备注写清本站取什么/送什么,骑手负责核对,不需要提交取货站与收货站关联关系。每个 `PICKUP` 必填 `packageType` 和正整数 `quantity`,可选 `weightRange`、`specification`(最长 255 字)和 `senderImageUrls`(最多 9 张)。`DELIVERY` 不提交这些字段。商品图片先上传,再将 URL 数组放到所属取货站;未上传可省略或传空数组。订单根级不提交货品字段或 `senderImageUrls`。`SCHEDULED` 订单还需提交 `scheduledPickupStartAt`、`scheduledPickupEndAt`,`NOW` 订单无需提交这两个字段。
 
 创建订单使用 `POST /system/flashDelivery/v2/orders`。body 包含报价时的业务字段和完整 `stops`,再增加:
 
@@ -57,7 +53,6 @@
   "quotedUrgentFee": 0,
   "quotedAmount": 90,
   "pinRequired": true,
-  "senderImageUrls": [],
   "userNote": "请注意文件袋"
 }
 ```
@@ -70,7 +65,7 @@
 - 用户详情:`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` 推算逐站进度。
+详情 `data.stops` 按客户原路线顺序返回。请求站点字段叫 `type`,响应字段叫 `stopType`;响应的 `id` 才是后续履约所需的 `stopId`,`stopOrder` 从 0 开始。每站有 `status`、`remark`、`pickedUpAt`/`deliveredAt` 和 `imageUrls`。货品从取货站的 `packageType`、`quantity`、`weightRange`、`specification` 展示,详情的 `senderImageUrls` 是该站用户上传的商品图片;`imageUrls` 是骑手取货/送达凭证,不可混用。收货站货品字段为空、商品图片为空数组。历史订单部分取货站可能没有货品资料,页面按空值展示;编辑时需补齐各取货站必填项。列表只展示货品文字,图片到详情读取;未接单骑手看不到商品图片。列表与详情提供 `pickupStopCount`、`pickedUpStopCount`、`deliveryStopCount`、`deliveredStopCount`。未全部取完时整单仍为 `ACCEPTED`,即使有站点已送达;全部取完为 `PICKED_UP`;全部取送站完成为 `DELIVERED`;确认收货或平台/系统完成后为 `COMPLETED`。页面不要只靠整单 `status` 推算逐站进度。
 
 ### 骑手 Tab 保持原样,只增加进度
 
@@ -113,10 +108,10 @@ 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}]}
+{"orderId":10,"orderVersion":0,"stops":[{"type":"PICKUP","name":"...","phone":"...","address":"...","longitude":121.56,"latitude":25.03,"packageType":"DOCUMENT","quantity":1,"senderImageUrls":["/profile/document-a.jpg"]},{"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 订单,旧订单仍沿用原入口。
+确认保存调用 `POST /system/flashDelivery/v2/orders/address`,body 保留相同的 `orderId`、`orderVersion`、**完整** `stops`,再回传报价 `data` 中的 `distanceMeters`→`quotedDistanceMeters`、`baseDeliveryFee`→`quotedBaseDeliveryFee`、`distanceFee`→`quotedDistanceFee`、`urgentFee`→`quotedUrgentFee`、`amount`→`quotedAmount`。每个取货站同时提交完整货品和商品图片,省略或清空 `senderImageUrls` 会移除该站原商品图片;配送方式、小费和原运价快照沿用订单保存值。报价或版本变化时重新获取并让用户确认。保存成功后以返回详情更新全部 `stops[].id` 和各站收货码;旧站点 ID 已失效。旧改址入口会拒绝 v2 订单,旧订单仍沿用原入口。
 
 ## 发起人逐站通知
 
@@ -143,8 +138,8 @@ Content-Type: application/json
 
 1. 单取单送和多取多送都走 v2 报价/创建;收货站只有一站时,送达仍提供 `stopId`。
 2. 验证取1→取2→送1→取3→送2,以及骑手自主改为先取完再送;送1后 `ACCEPTED`、进度 2/3 与 1/2,取3后 `PICKED_UP`,送2后 `DELIVERED`。五个 Tab 和名称不变,不能提前确认完成或记账。
-3. 修改第二个收货站后,其他站、订单级物品和原运价仍在;保存后旧站点 ID/PIN 不再使用。
+3. 两个取货站填写不同类型、数量和商品图片,创建后详情应分别对应;收货站不显示货品表单。修改第二个收货站时仍提交完整取货站货品和图片,保存后图片关联新站点 ID,旧站点 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 说明执行。
+现有单点、支付及其他旧接口以 [原闪送 App API 文档](flash-delivery-app-api.md) 为准。数据库结构和执行步骤见 [SQL 说明](../updatesql/sql.md) 的 2026-11-09 章节;本次迁移需由开发者手动执行。

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

@@ -15,8 +15,6 @@ public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
     /** 用户编辑及中单骑手选择目标使用的版本;接单前不返回。 */
     @JsonInclude(JsonInclude.Include.NON_NULL)
     private Integer orderVersion;
-    /** 寄件图片 URL 列表,用户下单时上传,可为空。 */
-    private List<String> senderImageUrls = List.of();
     /** 取件凭证图片 URL 列表,骑手上传。 */
     private List<String> pickupImageUrls = List.of();
     /** 送达凭证图片 URL 列表,骑手上传。 */

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

@@ -19,14 +19,6 @@ public class FlashDeliveryOrderView {
     private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
-    /** 物品类别(DOCUMENT、GIFT 等 9 种)。 */
-    private String packageType;
-    /** 物品数量。 */
-    private Integer quantity;
-    /** 重量范围(UP_TO_5_KG 等 4 档)。 */
-    private String weightRange;
-    /** 物品规格说明。 */
-    private String specification;
     /** 取件方式:NOW=立即、SCHEDULED=预约。 */
     private String deliveryMode;
     /** 预约取件时间窗开始时间,立即单为空。 */

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

@@ -17,4 +17,6 @@ public class FlashDeliveryPickupRequest extends FlashDeliveryAddressRequest {
     private String weightRange;
     /** 本站物品尺寸或规格,选填,最长255字符。 */
     private String specification;
+    /** 本取货站商品图片,选填,最多9张。 */
+    private java.util.List<String> senderImageUrls;
 }

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

@@ -19,14 +19,6 @@ public class FlashDeliveryRiderOrderListView {
     private Integer vehicleType;
     /** 订单状态,取值见 FlashDeliveryStatus。 */
     private String status;
-    /** 物品类别。 */
-    private String packageType;
-    /** 物品数量。 */
-    private Integer quantity;
-    /** 重量范围。 */
-    private String weightRange;
-    /** 物品规格说明。 */
-    private String specification;
     /** 取件方式:NOW=立即、SCHEDULED=预约。 */
     private String deliveryMode;
     /** 预约取件时间窗开始时间,立即单为空。 */

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

@@ -28,4 +28,14 @@ public class FlashDeliveryStopView {
     private Date deliveredAt;
     /** 本站实际取货或送达凭证,接单前不向骑手返回。 */
     private List<String> imageUrls = List.of();
+    /** 本取货站的物品类别。收货站不填写。 */
+    private String packageType;
+    /** 本取货站物品数量,正整数。 */
+    private Integer quantity;
+    /** 本取货站重量范围,选填。 */
+    private String weightRange;
+    /** 本取货站规格,选填,最长255字。 */
+    private String specification;
+    /** 用户上传的本站商品图片,最多9张;与骑手凭证 imageUrls 分开。 */
+    private List<String> senderImageUrls = List.of();
 }

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

@@ -3,7 +3,7 @@ package com.ruoyi.app.flashdelivery.dto;
 import java.util.List;
 import lombok.Data;
 
-/** v2 待接单改址:只提交完整站点,订单级物品与运价沿用原订单。 */
+/** v2 待接单改址:提交完整站点及取货站物品,配送配置与运价沿用原订单。 */
 @Data
 public class FlashDeliveryV2AddressChangeRequest {
     private Long orderId;

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

@@ -3,7 +3,6 @@ package com.ruoyi.app.flashdelivery.dto;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
-import java.util.List;
 
 /** v2 创建闪送订单请求;服务端按最新条件重新报价,回传报价不一致时拒绝创建。 */
 @Data
@@ -33,8 +32,6 @@ public class FlashDeliveryV2CreateRequest extends FlashDeliveryV2QuoteRequest {
     /** 是否启用交付收货码,选填;默认 true,启用时按收货站逐站生成。 */
     private Boolean pinRequired;
 
-    /** 寄件图片地址,选填,最多 9 张。 */
-    private List<String> senderImageUrls;
 
     /** 用户对本次闪送的备注,选填,最长 500 个字符。 */
     private String userNote;

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

@@ -1,13 +1,12 @@
 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 数组。
+ * v2 多点取送报价请求:货品由各PICKUP站独立设置,取货点与收货点统一放在 stops 数组。
  * 与旧报价结构互斥;旧接口过渡期保留,见 FR-122。
  */
 @Data
@@ -21,19 +20,6 @@ public class FlashDeliveryV2QuoteRequest {
     /** 配送车型,必填: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;
 
@@ -51,7 +37,7 @@ public class FlashDeliveryV2QuoteRequest {
 
     /**
      * 统一站点数组,必填,顺序即用户添加顺序与骑手路线顺序。
-     * 至少 1 个取货站与 1 个收货站,取货站必须全部位于收货站之前。
+     * 至少 1 个取货站与 1 个收货站,首站取货、末站收货,中间可交错取送。
      */
     private List<FlashDeliveryV2StopRequest> stops;
 }

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

@@ -12,4 +12,15 @@ public class FlashDeliveryV2StopRequest extends FlashDeliveryAddressRequest {
 
     /** 选填备注,去除首尾空格后最长 200 字,告知骑手该站取什么或送什么。 */
     private String remark;
+    /** 本取货站的物品类别。收货站不填写。 */
+    private String packageType;
+    /** 本取货站物品数量,正整数。 */
+    @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = FlashDeliveryQuantityDeserializer.class)
+    private Integer quantity;
+    /** 本取货站重量范围,选填。 */
+    private String weightRange;
+    /** 本取货站规格,选填,最长255字。 */
+    private String specification;
+    /** 用户上传的本站商品图片,最多9张;与骑手凭证 imageUrls 分开。 */
+    private java.util.List<String> senderImageUrls = java.util.List.of();
 }

+ 59 - 77
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -211,6 +211,11 @@ public class FlashDeliveryApplicationService {
         boolean pinRequired = request.getPinRequired() == null || request.getPinRequired();
         String payType = normalizePayType(request.getPayType());
         List<String> senderImages = validateProofUrls(request.getSenderImageUrls(), false);
+        if (!senderImages.isEmpty()) {
+            if (quote.pickups().size() != 1 || quote.pickups().get(0).getSenderImageUrls() != null
+                    && !quote.pickups().get(0).getSenderImageUrls().isEmpty()) throw fail("flash.delivery.pickup.input.conflict");
+            quote.pickups().get(0).setSenderImageUrls(senderImages);
+        }
         FlashDeliveryOrder order = new FlashDeliveryOrder();
         order.setOrderNo(generateOrderNo());
         order.setClientRequestId(requestId);
@@ -272,7 +277,6 @@ public class FlashDeliveryApplicationService {
             }
         }
         saveStops(order, quote.pickups(), request.getDelivery(), now);
-        saveImages(order.getId(), "SENDER", "USER", userId, senderImages, now);
         writeLog(order.getId(), null, WAITING_ACCEPTANCE, "USER", userId, null, now);
         if (Boolean.TRUE.equals(order.getIsDisplay())) {
             notificationService.notifyAvailable(order);
@@ -349,6 +353,7 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.state.changed");
         }
         // 条件更新成功后持有订单行锁至提交,抢单不会读到仅修改一半的地址。
+        imageMapper.delete(new QueryWrapper<FlashDeliveryOrderImage>().eq("order_id", orderId).eq("proof_type", "SENDER"));
         stopMapper.delete(new QueryWrapper<FlashDeliveryOrderStop>().eq("order_id", orderId));
         saveStops(order, pickups, request.getDelivery(), now);
         order.setVersion(order.getVersion() + 1);
@@ -430,7 +435,7 @@ public class FlashDeliveryApplicationService {
         return view;
     }
 
-    /** v2 待接单改址报价;订单级物品、配送方式与原运价快照保持不变。 */
+    /** 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());
@@ -451,8 +456,7 @@ public class FlashDeliveryApplicationService {
             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));
+        copyPickupSummary(order, pickupAdapters(stops.stream().filter(s -> "PICKUP".equals(s.getType())).toList()));
         FlashDeliveryV2StopRequest firstDelivery = stops.stream().filter(s -> "DELIVERY".equals(s.getType())).findFirst().orElseThrow();
         copyAddress(firstDelivery, order, false);
         order.setReceiverUserId(resolveReceiverUserId(firstDelivery.getPhone()));
@@ -468,6 +472,7 @@ public class FlashDeliveryApplicationService {
         if (orderMapper.updateWaitingAddress(order, request.getOrderVersion()) != 1) {
             throw fail("flash.delivery.state.changed");
         }
+        imageMapper.delete(new QueryWrapper<FlashDeliveryOrderImage>().eq("order_id", order.getId()).eq("proof_type", "SENDER"));
         stopMapper.delete(new QueryWrapper<FlashDeliveryOrderStop>().eq("order_id", order.getId()));
         saveStopsV2(order, stops, Boolean.TRUE.equals(order.getPinRequired()), now);
         order.setVersion(order.getVersion() + 1);
@@ -480,7 +485,7 @@ public class FlashDeliveryApplicationService {
         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;
@@ -491,7 +496,7 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.tip.invalid");
         }
         List<FlashDeliveryPickupRequest> pickups = pickupAdapters(stops.stream()
-                .filter(s -> "PICKUP".equals(s.getType())).toList(), orderItems);
+                .filter(s -> "PICKUP".equals(s.getType())).toList());
         FlashDeliveryQuoteView view = quoteView(quoteAdapter(orderItems),
                 new QuoteContext(pricing, route, breakdown, pickups));
         view.setOrderVersion(order.getVersion());
@@ -790,9 +795,9 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    // ==== v2 多点取送统一契约(2026-10-14):统一 stops 数组、订单级物品、独立计价 ====
+    // ==== v2 多点取送统一契约(2026-10-14):统一 stops 数组、取货站独立物品、独立计价 ====
 
-    /** v2 实时报价:统一站点数组顺序即路线顺序,物品主体在订单级,独立计价方法。 */
+    /** v2 实时报价:统一站点数组顺序即路线顺序,物品按取货站设置,独立计价方法。 */
     public FlashDeliveryQuoteView quoteV2(FlashDeliveryV2QuoteRequest request) {
         QuoteContext context = calculateQuoteV2(request);
         return quoteView(quoteAdapter(request), context);
@@ -823,7 +828,6 @@ public class FlashDeliveryApplicationService {
         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();
@@ -888,7 +892,6 @@ public class FlashDeliveryApplicationService {
             }
         }
         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);
@@ -896,16 +899,6 @@ public class FlashDeliveryApplicationService {
         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");
@@ -913,7 +906,6 @@ public class FlashDeliveryApplicationService {
         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);
@@ -925,19 +917,14 @@ public class FlashDeliveryApplicationService {
                 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));
+        return new QuoteContext(pricing, route, breakdown, pickupAdapters(pickups));
     }
 
-    /** v2 物品主体在订单级;内部取货站适配器统一携带订单级物品供既有校验与摘要复用。 */
-    private List<FlashDeliveryPickupRequest> pickupAdapters(List<FlashDeliveryV2StopRequest> pickups,
-                                                            FlashDeliveryV2QuoteRequest request) {
+    /** v2 取货站适配器携带本站独立物品和图片。 */
+    private List<FlashDeliveryPickupRequest> pickupAdapters(List<FlashDeliveryV2StopRequest> pickups) {
         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();
     }
@@ -946,10 +933,22 @@ public class FlashDeliveryApplicationService {
     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())) {
+            if (stop == null || !"PICKUP".equals(stop.getType()) && !"DELIVERY".equals(stop.getType())) {
                 throw fail("flash.delivery.stop.type.invalid");
             }
             validateAddress(stop);
+            if ("PICKUP".equals(stop.getType())) {
+                if (!PACKAGE_TYPES.contains(Objects.toString(stop.getPackageType(), ""))) {
+                    throw fail("flash.delivery.package.type.invalid");
+                }
+                if (stop.getQuantity() == null || stop.getQuantity() <= 0
+                        || hasText(stop.getWeightRange()) && !WEIGHT_RANGES.contains(stop.getWeightRange())
+                        || tooLong(stop.getSpecification(), 255)) throw fail("flash.delivery.item.invalid");
+                validateProofUrls(stop.getSenderImageUrls(), false);
+            } else if (stop.getPackageType() != null || stop.getQuantity() != null || stop.getWeightRange() != null
+                    || stop.getSpecification() != null || stop.getSenderImageUrls() != null && !stop.getSenderImageUrls().isEmpty()) {
+                throw fail("flash.delivery.item.invalid");
+            }
             if (tooLong(stop.getRemark(), 200)) throw fail("flash.delivery.stop.remark.too.long");
         }
         List<FlashDeliveryV2StopRequest> pickups = stops.stream()
@@ -996,10 +995,6 @@ public class FlashDeliveryApplicationService {
         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());
@@ -1029,6 +1024,10 @@ public class FlashDeliveryApplicationService {
             row.setHandoffMethod(trimToNull(stop.getHandoffMethod()));
             row.setLongitude(stop.getLongitude()); row.setLatitude(stop.getLatitude());
             row.setRemark(trimToNull(stop.getRemark()));
+            if ("PICKUP".equals(stop.getType())) {
+                row.setPackageType(stop.getPackageType()); row.setQuantity(stop.getQuantity());
+                row.setWeightRange(trimToNull(stop.getWeightRange())); row.setSpecification(trimToNull(stop.getSpecification()));
+            }
             row.setOrderId(order.getId()); row.setStopType(stop.getType()); row.setStopOrder(i);
             row.setStatus("PENDING"); row.setCreateTime(now);
             if ("DELIVERY".equals(stop.getType())) {
@@ -1042,6 +1041,10 @@ public class FlashDeliveryApplicationService {
                 }
             }
             if (stopMapper.insert(row) != 1) throw fail("flash.delivery.state.changed");
+            if ("PICKUP".equals(stop.getType())) {
+                saveStationImages(order.getId(), row.getId(), "SENDER", "USER", order.getUserId(),
+                        validateProofUrls(stop.getSenderImageUrls(), false), now);
+            }
         }
     }
 
@@ -1392,22 +1395,6 @@ public class FlashDeliveryApplicationService {
         return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
     }
 
-    /** 状态、图片和日志处于同一事务;任一图片写入失败会回滚本次业务操作。 */
-    private void saveImages(Long orderId, String proofType, String operatorType,
-                            Long operatorId, List<String> urls, Date now) {
-        for (int index = 0; index < urls.size(); index++) {
-            FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
-            image.setOrderId(orderId);
-            image.setProofType(proofType);
-            image.setImageUrl(urls.get(index));
-            image.setSortOrder(index);
-            image.setOperatorType(operatorType);
-            image.setOperatorId(operatorId);
-            image.setCreateTime(now);
-            imageMapper.insert(image);
-        }
-    }
-
     /** 校验凭证图片 URL:必填时 1 至 9 张,每项为合法 URI 且不超过 1000 字符。 */
     private List<String> validateProofUrls(List<String> values, boolean required) {
         if (values == null || values.isEmpty()) {
@@ -1532,10 +1519,9 @@ public class FlashDeliveryApplicationService {
     }
 
     /** 订单实体转骑手端任务卡片,可附带骑手到取件点的直线距离。 */
-    /** 列表页一次 IN 批量取回 v2 多站点单的站点,避免逐单 N+1;单点单卡片不需要逐站展示。 */
+    /** 列表页一次 IN 批量取回所有闪送订单站点及货品,避免逐单 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>()
@@ -1558,10 +1544,6 @@ public class FlashDeliveryApplicationService {
         view.setDeliveryStopCount(expectedDeliveryStopCount(order));
         view.setDeliveredStopCount(deliveredStopCount(order));
         view.setCanUserCancel(canUserCancel(order));
-        view.setPackageType(order.getPackageType());
-        view.setQuantity(order.getQuantity());
-        view.setWeightRange(order.getWeightRange());
-        view.setSpecification(order.getSpecification());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -1585,8 +1567,8 @@ public class FlashDeliveryApplicationService {
         view.setCreateTime(order.getCreateTime());
         // 备注仅对中单骑手本人可见;newTask 待抢卡片保持脱敏为空。
         view.setUserNote(Objects.equals(riderId, order.getRiderId()) ? order.getUserNote() : null);
-        // v2 多站点单卡片逐站展示取送地址与状态;与详情同口径脱敏联系人、电话、坐标和凭证。
-        if (order.getDeliveryStopCount() != null && stopRows != null && !stopRows.isEmpty()) {
+        // 闪送卡片逐站展示货品、取送地址与状态;与详情同口径脱敏联系人、电话、坐标和凭证。
+        if (stopRows != null && !stopRows.isEmpty()) {
             view.setStops(stopViews(stopRows, false, List.of()));
         }
         return view;
@@ -1600,17 +1582,14 @@ public class FlashDeliveryApplicationService {
         return view;
     }
 
-    /** 按凭证类型把图片分组为寄件、取件、送达三组 URL。 */
+    /** 按凭证类型汇总骑手取件、送达图片;商品图片按取货站返回。 */
     private void groupImageUrls(FlashDeliveryOrderDetailView view, List<FlashDeliveryOrderImage> images) {
-        List<String> sender = new ArrayList<>();
         List<String> pickup = new ArrayList<>();
         List<String> delivery = new ArrayList<>();
         for (FlashDeliveryOrderImage image : images) {
-            if ("SENDER".equals(image.getProofType())) sender.add(image.getImageUrl());
-            else if ("PICKUP".equals(image.getProofType())) pickup.add(image.getImageUrl());
+            if ("PICKUP".equals(image.getProofType())) pickup.add(image.getImageUrl());
             else if ("DELIVERY".equals(image.getProofType())) delivery.add(image.getImageUrl());
         }
-        view.setSenderImageUrls(sender);
         view.setPickupImageUrls(pickup);
         view.setDeliveryImageUrls(delivery);
     }
@@ -1647,10 +1626,6 @@ public class FlashDeliveryApplicationService {
         view.setDeliveryStopCount(expectedDeliveryStopCount(order));
         view.setDeliveredStopCount(deliveredStopCount(order));
         view.setCanUserCancel(canUserCancel(order));
-        view.setPackageType(order.getPackageType());
-        view.setQuantity(order.getQuantity());
-        view.setWeightRange(order.getWeightRange());
-        view.setSpecification(order.getSpecification());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -2046,9 +2021,9 @@ public class FlashDeliveryApplicationService {
         FlashDeliveryPickupRequest pickup = new FlashDeliveryPickupRequest();
         BeanUtils.copyProperties(pickups.get(0), pickup);
         BeanUtils.copyProperties(request.getPickup(), pickup);
-        // 站点不再保存逐站物品;单点改址回退时物品取订单级摘要,与统一契约一致。
-        pickup.setPackageType(order.getPackageType()); pickup.setQuantity(order.getQuantity());
-        pickup.setWeightRange(order.getWeightRange()); pickup.setSpecification(order.getSpecification());
+        pickup.setSenderImageUrls(imageMapper.selectList(new QueryWrapper<FlashDeliveryOrderImage>()
+                .eq("order_id", order.getId()).eq("stop_id", pickups.get(0).getId()).eq("proof_type", "SENDER")
+                .orderByAsc("sort_order", "id")).stream().map(FlashDeliveryOrderImage::getImageUrl).toList());
         return List.of(pickup);
     }
 
@@ -2067,6 +2042,7 @@ public class FlashDeliveryApplicationService {
             if (pickup.getQuantity() == null || pickup.getQuantity() <= 0
                     || hasText(pickup.getWeightRange()) && !WEIGHT_RANGES.contains(pickup.getWeightRange())
                     || tooLong(pickup.getSpecification(), 255)) throw fail("flash.delivery.item.invalid");
+            validateProofUrls(pickup.getSenderImageUrls(), false);
             if (sameAddressText(pickup, delivery)
                     || pickup.getLatitude().compareTo(delivery.getLatitude()) == 0
                     && pickup.getLongitude().compareTo(delivery.getLongitude()) == 0) {
@@ -2140,31 +2116,33 @@ public class FlashDeliveryApplicationService {
         } catch (Exception ignored) { return null; }
     }
 
-    /** 订单旧字段仅维护首站兼容摘要,不代表全部站点物品。 */
+    /** 订单仅维护首站地址摘要,货品以取货站为准。 */
     private void copyPickupSummary(FlashDeliveryOrder order, List<FlashDeliveryPickupRequest> pickups) {
         FlashDeliveryPickupRequest first = pickups.get(0);
         copyAddress(first, order, true);
-        order.setPackageType(first.getPackageType()); order.setQuantity(first.getQuantity());
-        order.setWeightRange(first.getWeightRange()); order.setSpecification(trimToNull(first.getSpecification()));
         order.setPickupStopCount(pickups.size());
     }
 
     /** 调用方已创建订单或持有订单行锁,全部站点与订单在同一事务保存。 */
     private void saveStops(FlashDeliveryOrder order, List<FlashDeliveryPickupRequest> pickups,
                            FlashDeliveryAddressRequest delivery, Date now) {
-        for (int i = 0; i < pickups.size(); i++) insertStop(order.getId(), pickups.get(i), "PICKUP", i, now);
-        insertStop(order.getId(), delivery, "DELIVERY", pickups.size(), now);
+        for (int i = 0; i < pickups.size(); i++) insertStop(order, pickups.get(i), "PICKUP", i, now);
+        insertStop(order, delivery, "DELIVERY", pickups.size(), now);
     }
 
-    private void insertStop(Long orderId, FlashDeliveryAddressRequest address, String type, int index, Date now) {
+    private void insertStop(FlashDeliveryOrder order, FlashDeliveryAddressRequest address, String type, int index, Date now) {
         FlashDeliveryOrderStop stop = new FlashDeliveryOrderStop();
         BeanUtils.copyProperties(address, stop);
         stop.setName(address.getName().trim()); stop.setPhone(address.getPhone().trim());
         stop.setAddress(address.getAddress().trim()); stop.setAddressDetail(trimToNull(address.getAddressDetail()));
         stop.setHandoffMethod(trimToNull(address.getHandoffMethod()));
-        stop.setOrderId(orderId); stop.setStopType(type); stop.setStopOrder(index);
+        stop.setOrderId(order.getId()); stop.setStopType(type); stop.setStopOrder(index);
         stop.setStatus("PENDING"); stop.setCreateTime(now);
         if (stopMapper.insert(stop) != 1) throw fail("flash.delivery.state.changed");
+        if (address instanceof FlashDeliveryPickupRequest pickup) {
+            saveStationImages(order.getId(), stop.getId(), "SENDER", "USER", order.getUserId(),
+                    validateProofUrls(pickup.getSenderImageUrls(), false), now);
+        }
     }
 
     /** 事务内使用当前读,避免骑手资料查询建立的旧快照遮蔽锁等待期间的履约变化;GET 不加锁。 */
@@ -2373,6 +2351,10 @@ public class FlashDeliveryApplicationService {
             FlashDeliveryStopView view = new FlashDeliveryStopView();
             BeanUtils.copyProperties(stop, view);
             view.setDeliveryPinCode(null);
+            if (sensitive && "PICKUP".equals(stop.getStopType())) {
+                view.setSenderImageUrls(images.stream().filter(i -> Objects.equals(i.getStopId(), stop.getId())
+                        && "SENDER".equals(i.getProofType())).map(FlashDeliveryOrderImage::getImageUrl).toList());
+            }
             if (sensitive) view.setImageUrls(images.stream().filter(i -> Objects.equals(i.getStopId(), stop.getId())
                             && Objects.equals(i.getProofType(), stop.getStopType()))
                     .map(FlashDeliveryOrderImage::getImageUrl).toList());

+ 4 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java

@@ -213,10 +213,10 @@ public class UnifiedOrderQueryService {
         }
     }
 
-    /** 展示页闪送 v2 多站点单一次 IN 批量取站点,避免逐单 N+1;外卖路与 v1 闪送不查。 */
+    /** 展示页闪送订单一次 IN 批量取站点及货品,避免逐单 N+1;外卖不查。 */
     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)
+                .filter(order -> order.getId() != null)
                 .map(FlashDeliveryOrder::getId).toList();
         if (orderIds.isEmpty()) return Map.of();
         return flashStopMapper.selectList(new QueryWrapper<FlashDeliveryOrderStop>()
@@ -247,11 +247,9 @@ public class UnifiedOrderQueryService {
         item.setPickupDetailAddress(order.getPickupAddressDetail());
         item.setDeliveryAddress(order.getDeliveryAddress());
         item.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
-        item.setPackageType(order.getPackageType());
-        item.setQuantity(order.getQuantity());
         item.setTipAmount(order.getTipAmount());
-        // v2 多站点单补双进度计数与逐站列表;卡片脱敏口径与原闪送专属列表一致。
-        if (order.getDeliveryStopCount() != null) {
+        // 闪送订单补进度计数与逐站列表;卡片脱敏口径与原闪送专属列表一致。
+        if (stopRows != null && !stopRows.isEmpty()) {
             item.setPickupStopCount(order.getPickupStopCount());
             item.setPickedUpStopCount(order.getPickedUpStopCount());
             item.setDeliveryStopCount(order.getDeliveryStopCount());

+ 0 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java

@@ -83,10 +83,6 @@ public class UnifiedOrderListItem {
     private String deliveryAddress;
     /** 收件详细地址。 */
     private String deliveryDetailAddress;
-    /** 物品类别。 */
-    private String packageType;
-    /** 物品数量。 */
-    private Integer quantity;
     /** 小费。 */
     private Long tipAmount;
     /** 取货站总数及已取数;v2 多站点单返回,v1 闪送卡为空。 */

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

@@ -18,8 +18,7 @@ class FlashDeliveryOrderViewTest {
                 "pricingVersion", "cancelOperatorId", "cancelOperatorType", "version")) {
             assertFalse(fields.contains(forbidden), forbidden);
         }
-        for (String required : Set.of("serviceType", "deliveryType", "vehicleType", "packageType", "quantity",
-                "weightRange", "specification", "deliveryMode",
+        for (String required : Set.of("serviceType", "deliveryType", "vehicleType", "deliveryMode",
                 "scheduledPickupStartAt", "pinRequired", "pickup", "delivery",
                 "baseDeliveryFee", "distanceFee", "urgentFee", "tipAmount", "amount")) {
             assertTrue(fields.contains(required), required);
@@ -33,7 +32,7 @@ class FlashDeliveryOrderViewTest {
         for (String forbidden : Set.of("order", "images", "logs")) {
             assertFalse(fields.contains(forbidden), forbidden);
         }
-        for (String required : Set.of("senderImageUrls", "pickupImageUrls",
+        for (String required : Set.of("stops", "pickupImageUrls",
                 "deliveryImageUrls", "rider", "user", "deliveryPinCode")) {
             assertTrue(fields.contains(required), required);
         }

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

@@ -11,8 +11,7 @@ class FlashDeliveryRiderOrderListViewTest {
     void riderListContainsOnlyFieldsRequiredByOrderCards() {
         Set<String> fields = Stream.of(FlashDeliveryRiderOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
-        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "vehicleType", "status", "packageType",
-                "quantity", "weightRange", "specification", "deliveryMode",
+        Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "vehicleType", "status", "deliveryMode",
                 "scheduledPickupStartAt", "scheduledPickupEndAt",
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",
                 "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",

+ 29 - 22
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -200,10 +200,10 @@ class FlashDeliveryApplicationServiceTest {
         verify(fixture.orderMapper).insert(saved.capture());
         assertEquals("SCHEDULED", saved.getValue().getDeliveryMode());
         assertEquals("NORMAL", saved.getValue().getDeliveryType());
-        assertEquals("DOCUMENT", saved.getValue().getPackageType());
-        assertEquals(1, saved.getValue().getQuantity());
-        assertEquals("UP_TO_5_KG", saved.getValue().getWeightRange());
-        assertEquals("30 x 20 x 10 cm", saved.getValue().getSpecification());
+        assertEquals("DOCUMENT", fixture.savedStops.get(0).getPackageType());
+        assertEquals(1, fixture.savedStops.get(0).getQuantity());
+        assertEquals("UP_TO_5_KG", fixture.savedStops.get(0).getWeightRange());
+        assertEquals("30 x 20 x 10 cm", fixture.savedStops.get(0).getSpecification());
         assertEquals(88L, saved.getValue().getReceiverUserId());
         assertEquals(1L, saved.getValue().getPricingId());
         assertEquals("00:00", saved.getValue().getPricingStartTime());
@@ -314,7 +314,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         rider(fixture, 8L);
         FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
-        order.setWeightRange("OVER_10_TO_15_KG");
+        fixture.pickupWeightRange = "OVER_10_TO_15_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),
@@ -335,14 +335,15 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(168L, result.getHighestOrderAmount());
         assertEquals("pickup road", result.getRecords().get(0).getPickupAddress());
         assertEquals("delivery road", result.getRecords().get(0).getDeliveryAddress());
-        assertEquals("OVER_10_TO_15_KG", result.getRecords().get(0).getWeightRange());
+        assertEquals("OVER_10_TO_15_KG", result.getRecords().get(0).getStops().get(0).getWeightRange());
         assertTrue(result.getRecords().get(0).getPickupDistanceMeters() > 0);
         assertTrue(result.getRecords().get(0).getPinRequired());
         // 待抢单卡片继续脱敏用户备注,接单前骑手不可见。
         assertNull(result.getRecords().get(0).getUserNote());
-        // v1 单点卡不附带逐站列表,也不触发站点批量查询。
-        assertNull(result.getRecords().get(0).getStops());
-        verify(fixture.stopMapper, never()).selectList(any());
+        // 新旧订单均从站点读取货品;待抢卡不返回商品图片。
+        assertEquals(2, result.getRecords().get(0).getStops().size());
+        assertTrue(result.getRecords().get(0).getStops().get(0).getSenderImageUrls().isEmpty());
+        verify(fixture.stopMapper).selectList(any());
         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());
@@ -516,7 +517,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         rider(fixture, 8L);
         FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
-        order.setWeightRange("OVER_15_TO_20_KG");
+        fixture.pickupWeightRange = "OVER_15_TO_20_KG";
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
 
         var result = fixture.service.riderDetail(8L, 10L);
@@ -526,7 +527,7 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals("pickup door", result.getPickup().getAddressDetail());
         assertEquals("delivery road", result.getDelivery().getAddress());
         assertEquals("delivery door", result.getDelivery().getAddressDetail());
-        assertEquals("OVER_15_TO_20_KG", result.getWeightRange());
+        assertEquals("OVER_15_TO_20_KG", result.getStops().get(0).getWeightRange());
         assertNull(result.getPickup().getName());
         assertNull(result.getPickup().getPhone());
         assertNull(result.getPickup().getLongitude());
@@ -538,7 +539,7 @@ class FlashDeliveryApplicationServiceTest {
         assertNull(result.getDeliveryPinCode());
         assertNull(result.getUserNote());
         assertNull(result.getUser());
-        assertTrue(result.getSenderImageUrls().isEmpty());
+        assertTrue(result.getStops().get(0).getSenderImageUrls().isEmpty());
         assertTrue(result.getPickupImageUrls().isEmpty());
         assertTrue(result.getDeliveryImageUrls().isEmpty());
         verify(fixture.userMapper, never()).selectInfoUserByUserId(7L);
@@ -551,7 +552,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         FlashDeliveryOrder order = order(10L, ACCEPTED);
         order.setRiderId(null);
-        order.setWeightRange("OVER_5_TO_10_KG");
+        fixture.pickupWeightRange = "OVER_5_TO_10_KG";
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
         when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of(
                 image("SENDER", "https://example.com/sender.jpg"),
@@ -561,11 +562,11 @@ class FlashDeliveryApplicationServiceTest {
         var result = fixture.service.userDetail(7L, 10L);
 
         assertEquals(10L, result.getId());
-        assertEquals(List.of("https://example.com/sender.jpg"), result.getSenderImageUrls());
+        assertEquals(List.of("https://example.com/sender.jpg"), result.getStops().get(0).getSenderImageUrls());
         assertEquals(List.of("https://example.com/pickup.jpg"), result.getPickupImageUrls());
         assertEquals(List.of("https://example.com/delivery.jpg"), result.getDeliveryImageUrls());
         assertEquals("4821", result.getDeliveryPinCode());
-        assertEquals("OVER_5_TO_10_KG", result.getWeightRange());
+        assertEquals("OVER_5_TO_10_KG", result.getStops().get(0).getWeightRange());
         verify(fixture.logMapper, never()).selectList(any());
     }
 
@@ -1110,7 +1111,8 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         FlashDeliveryOrder order = order(10L, "ACCEPTED");order.setPickedUpStopCount(0);order.setPickupStopCount(1);
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
-        var pickup = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();pickup.setId(101L);pickup.setStopType("PICKUP");pickup.setStatus("PICKED_UP");
+        var pickup = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();pickup.setPackageType("DOCUMENT"); pickup.setQuantity(1); pickup.setWeightRange("UP_TO_5_KG");
+                pickup.setId(101L);pickup.setStopType("PICKUP");pickup.setStatus("PICKED_UP");
         var delivery = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();delivery.setId(102L);delivery.setStopType("DELIVERY");
         when(fixture.stopMapper.selectList(any())).thenReturn(List.of(pickup,delivery));
         when(fixture.orderMapper.update(any(), any())).thenReturn(1);
@@ -1553,8 +1555,8 @@ class FlashDeliveryApplicationServiceTest {
 
             ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
             verify(fixture.orderMapper).insert(saved.capture());
-            assertEquals(weightRange, saved.getValue().getWeightRange());
-            assertEquals(weightRange, result.getWeightRange());
+            assertEquals(weightRange, fixture.savedStops.get(0).getWeightRange());
+            assertEquals(weightRange, result.getStops().get(0).getWeightRange());
         }
     }
 
@@ -1775,9 +1777,6 @@ class FlashDeliveryApplicationServiceTest {
         order.setDeliveryType("NORMAL");
         order.setStatus(status);
         order.setIsDisplay(true);
-        order.setPackageType("DOCUMENT");
-        order.setQuantity(1);
-        order.setWeightRange("UP_TO_5_KG");
         order.setDeliveryMode("NOW");
         order.setPinRequired(true);
         order.setDeliveryPinCode("4821");
@@ -1815,6 +1814,7 @@ class FlashDeliveryApplicationServiceTest {
     private FlashDeliveryOrderImage image(String proofType, String url) {
         FlashDeliveryOrderImage image = new FlashDeliveryOrderImage();
         image.setProofType(proofType);
+        if ("SENDER".equals(proofType)) image.setStopId(101L);
         image.setImageUrl(url);
         return image;
     }
@@ -2190,6 +2190,8 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     private static class Fixture {
+        String pickupWeightRange = "UP_TO_5_KG";
+        final java.util.List<com.ruoyi.system.domain.flash.FlashDeliveryOrderStop> savedStops = new java.util.ArrayList<>();
         final com.ruoyi.system.mapper.flash.FlashDeliveryOrderStopMapper stopMapper = mock(com.ruoyi.system.mapper.flash.FlashDeliveryOrderStopMapper.class);
         final com.ruoyi.system.mapper.flash.FlashDeliveryTargetAdjustmentMapper adjustmentMapper = mock(com.ruoyi.system.mapper.flash.FlashDeliveryTargetAdjustmentMapper.class);
         final FlashDeliveryOrderMapper orderMapper = mock(FlashDeliveryOrderMapper.class);
@@ -2222,12 +2224,17 @@ class FlashDeliveryApplicationServiceTest {
                 if (current != null && current.getPickupStopCount() == null) current.setPickupStopCount(1);
                 return current;
             });
-            when(stopMapper.insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderStop.class))).thenReturn(1);
+            when(stopMapper.insert(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderStop.class))).thenAnswer(i -> {
+                com.ruoyi.system.domain.flash.FlashDeliveryOrderStop row = i.getArgument(0);
+                row.setId(101L + savedStops.size()); savedStops.add(row); return 1;
+            });
             when(stopMapper.updateById(any(com.ruoyi.system.domain.flash.FlashDeliveryOrderStop.class))).thenReturn(1);
             when(imageMapper.insert(any(FlashDeliveryOrderImage.class))).thenReturn(1);
             when(stopMapper.selectList(any())).thenAnswer(i -> {
+                if (!savedStops.isEmpty()) return savedStops;
                 FlashDeliveryOrder current = orderMapper.selectById(10L);
                 var pickup = new com.ruoyi.system.domain.flash.FlashDeliveryOrderStop();
+                pickup.setPackageType("DOCUMENT"); pickup.setQuantity(1); pickup.setWeightRange(pickupWeightRange);
                 pickup.setId(101L); pickup.setOrderId(10L); pickup.setStopType("PICKUP"); pickup.setStopOrder(0);
                 pickup.setName("pickup contact"); pickup.setPhone("0911111111"); pickup.setAddress("pickup road");
                 pickup.setLatitude(new BigDecimal("25.0100")); pickup.setLongitude(new BigDecimal("121.5100"));

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

@@ -309,7 +309,7 @@ class FlashDeliveryMultiPickupTest {
             assertEquals(List.of("PICKUP", "PICKUP", "DELIVERY"), saved.getAllValues().stream().map(FlashDeliveryOrderStop::getStopType).toList());
             var order = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrder.class);
             verify(f.orders).insert(order.capture()); assertEquals(2, order.getValue().getPickupStopCount());
-            assertEquals("DOCUMENT", order.getValue().getPackageType()); assertEquals(0, order.getValue().getPickedUpStopCount());
+            assertEquals("DOCUMENT", saved.getAllValues().get(0).getPackageType()); assertEquals(0, order.getValue().getPickedUpStopCount());
         }
     }
 
@@ -332,7 +332,9 @@ class FlashDeliveryMultiPickupTest {
             when(f.orders.updateWaitingAddress(any(), eq(0))).thenReturn(1);
             when(f.stops.insert(any(FlashDeliveryOrderStop.class))).thenReturn(1);
             f.service.updateAddress(7L, 10L, confirmation);
-            assertEquals(2, f.order.getQuantity());
+            var goods = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrderStop.class);
+            verify(f.stops, times(3)).insert(goods.capture());
+            assertEquals(2, goods.getAllValues().get(0).getQuantity());
             var sequence = inOrder(f.orders, f.stops);
             sequence.verify(f.orders).updateWaitingAddress(any(), eq(0));
             sequence.verify(f.stops).delete(any()); sequence.verify(f.stops, times(3)).insert(any(FlashDeliveryOrderStop.class));

+ 31 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPickupItemContractTest.java

@@ -0,0 +1,31 @@
+package com.ruoyi.app.flashdelivery.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.domain.flash.FlashDeliveryOrderStop;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+class FlashDeliveryPickupItemContractTest {
+    @Test void itemFieldsBelongToStopInsteadOfOrder() throws Exception {
+        for (String field : new String[]{"packageType", "quantity", "weightRange", "specification"}) {
+            assertNotNull(FlashDeliveryOrderStop.class.getDeclaredField(field));
+            assertNotNull(FlashDeliveryV2StopRequest.class.getDeclaredField(field));
+            assertNotNull(FlashDeliveryStopView.class.getDeclaredField(field));
+            assertThrows(NoSuchFieldException.class, () -> FlashDeliveryOrder.class.getDeclaredField(field));
+            assertThrows(NoSuchFieldException.class, () -> FlashDeliveryV2QuoteRequest.class.getDeclaredField(field));
+        }
+    }
+
+    @Test void pickupJsonAcceptsIndependentItemAndImages() throws Exception {
+        ObjectMapper json = new ObjectMapper();
+        var request = json.readValue("{\"type\":\"PICKUP\",\"packageType\":\"GIFT\",\"quantity\":2,\"senderImageUrls\":[\"https://example.com/item.jpg\"]}", FlashDeliveryV2StopRequest.class);
+        var result = json.readTree(json.writeValueAsString(request));
+        assertEquals("GIFT", result.get("packageType").asText());
+        assertEquals(2, result.get("quantity").asInt());
+        assertEquals(1, result.get("senderImageUrls").size());
+        var fractional = json.valueToTree(json.readValue("{\"quantity\":1.5}", FlashDeliveryV2StopRequest.class));
+        assertTrue(fractional.get("quantity").isNull());
+    }
+}

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

@@ -43,7 +43,7 @@ class FlashDeliveryV2Test {
         dict.when(() -> DictUtils.getDictCache("flash_delivery_stop_limit")).thenReturn(List.of(dictionary("3")));
     }
 
-    @Test void v2CreateSavesUnifiedStopsWithOrderLevelItemAndPerStopReceivers() {
+    @Test void v2CreateSavesIndependentPickupItemsAndPerStopReceivers() {
         Fixture f = new Fixture();
         try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
             multiStopDicts(dict);
@@ -68,9 +68,9 @@ class FlashDeliveryV2Test {
             var order = argThatOrder(f);
             assertEquals(2, order.getDeliveryStopCount());
             assertEquals(0, order.getDeliveredStopCount());
-            // 物品主体整单一次,落在订单级摘要字段。
-            assertEquals("FOOD_INGREDIENTS", order.getPackageType());
-            assertEquals(8, order.getQuantity());
+            // 物品在取货站独立保存。
+            assertEquals("FOOD_INGREDIENTS", saved.get(0).getPackageType());
+            assertEquals(8, saved.get(0).getQuantity());
         }
     }
 
@@ -477,6 +477,8 @@ class FlashDeliveryV2Test {
             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);
+            interleavedStops.get(2).setPackageType("GIFT"); interleavedStops.get(2).setQuantity(2);
+            interleavedStops.get(2).setSenderImageUrls(List.of("https://example.com/keep.jpg"));
             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);
@@ -489,15 +491,99 @@ class FlashDeliveryV2Test {
             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("FOOD_INGREDIENTS", argThatStops(f).get(0).getPackageType()); assertEquals(8, argThatStops(f).get(0).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());
+            var photo = org.mockito.ArgumentCaptor.forClass(FlashDeliveryOrderImage.class);
+            verify(f.images).insert(photo.capture());
+            assertEquals(f.rows.get(2).getId(), photo.getValue().getStopId());
+            assertEquals("https://example.com/keep.jpg", photo.getValue().getImageUrl());
+            assertEquals("GIFT", f.rows.get(2).getPackageType()); assertEquals(2, f.rows.get(2).getQuantity());
+            verify(f.images).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
             verify(f.prices, never()).selectAtTime(anyString(), anyInt());
         }
     }
 
+    @Test void pickupsKeepDifferentGoodsAndImagesAndDeliveryHasNeither() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            FlashDeliveryV2CreateRequest request = v2Create();
+            request.getStops().get(0).setSenderImageUrls(List.of("https://example.com/a.jpg"));
+            request.getStops().get(1).setPackageType("GIFT");
+            request.getStops().get(1).setQuantity(2);
+            request.getStops().get(1).setSpecification("  two boxes  ");
+            request.getStops().get(1).setSenderImageUrls(List.of("/profile/b.jpg", "/profile/c.jpg"));
+            var 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.rows.clear();
+            when(f.stops.insert(any(FlashDeliveryOrderStop.class))).thenAnswer(i -> {
+                FlashDeliveryOrderStop row = i.getArgument(0); row.setId(100L + row.getStopOrder()); f.rows.add(row); return 1;
+            });
+            List<FlashDeliveryOrderImage> photos = new ArrayList<>();
+            when(f.images.insert(any(FlashDeliveryOrderImage.class))).thenAnswer(i -> { photos.add(i.getArgument(0)); return 1; });
+            when(f.images.selectList(any())).thenReturn(photos);
+            var detail = f.service.createV2(7L, request);
+            assertEquals(List.of("FOOD_INGREDIENTS", "GIFT"), detail.getStops().subList(0, 2).stream().map(FlashDeliveryStopView::getPackageType).toList());
+            assertEquals(List.of(8, 2), detail.getStops().subList(0, 2).stream().map(FlashDeliveryStopView::getQuantity).toList());
+            assertEquals("two boxes", detail.getStops().get(1).getSpecification());
+            assertEquals(List.of("https://example.com/a.jpg"), detail.getStops().get(0).getSenderImageUrls());
+            assertEquals(List.of("/profile/b.jpg", "/profile/c.jpg"), detail.getStops().get(1).getSenderImageUrls());
+            assertTrue(detail.getStops().get(0).getImageUrls().isEmpty());
+            assertEquals(List.of(100L, 101L, 101L), photos.stream().map(FlashDeliveryOrderImage::getStopId).toList());
+            assertTrue(photos.stream().allMatch(i -> "SENDER".equals(i.getProofType()) && Long.valueOf(7L).equals(i.getOperatorId())));
+            for (var delivery : detail.getStops().subList(2, 4)) {
+                assertNull(delivery.getPackageType()); assertNull(delivery.getQuantity());
+                assertTrue(delivery.getSenderImageUrls().isEmpty());
+            }
+            List<FlashDeliveryStopView> hidden = ReflectionTestUtils.invokeMethod(f.service, "stopViews", f.rows, false, photos);
+            assertTrue(hidden.stream().allMatch(v -> v.getSenderImageUrls().isEmpty()));
+            assertEquals("GIFT", hidden.get(1).getPackageType());
+        }
+    }
+
+    @Test void pickupItemsAndImagesAreValidatedBeforeRouting() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            var request = v2Create();
+            request.getStops().get(1).setQuantity(null);
+            assertThrows(ServiceException.class, () -> f.service.quoteV2(request));
+            request.getStops().get(1).setQuantity(1);
+            request.getStops().get(1).setPackageType("INVALID");
+            assertThrows(ServiceException.class, () -> f.service.quoteV2(request));
+            request.getStops().get(1).setPackageType("GIFT");
+            request.getStops().get(1).setSenderImageUrls(Collections.nCopies(10, "https://example.com/a.jpg"));
+            assertThrows(ServiceException.class, () -> f.service.quoteV2(request));
+            request.getStops().get(1).setSenderImageUrls(List.of("not a url"));
+            assertThrows(ServiceException.class, () -> f.service.quoteV2(request));
+            request.getStops().get(1).setSenderImageUrls(List.of());
+            request.getStops().get(2).setQuantity(1);
+            assertThrows(ServiceException.class, () -> f.service.quoteV2(request));
+            request.getStops().get(2).setQuantity(null);
+            request.getStops().get(2).setSenderImageUrls(List.of("https://example.com/delivery.jpg"));
+            assertThrows(ServiceException.class, () -> f.service.quoteV2(request));
+            verifyNoInteractions(f.routes);
+        }
+    }
+
+    @Test void eachPickupCanHaveNineImagesWithoutChangingQuote() {
+        Fixture f = new Fixture();
+        try (MockedStatic<DictUtils> dict = mockStatic(DictUtils.class)) {
+            multiStopDicts(dict);
+            var request = v2Create();
+            long amount = f.service.quoteV2(request).getAmount();
+            request.getStops().get(0).setSenderImageUrls(Collections.nCopies(9, "https://example.com/a.jpg"));
+            request.getStops().get(1).setSenderImageUrls(Collections.nCopies(9, "https://example.com/b.jpg"));
+            request.getStops().get(1).setQuantity(20);
+            assertEquals(amount, f.service.quoteV2(request).getAmount());
+        }
+    }
+
     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"));
@@ -527,7 +613,7 @@ class FlashDeliveryV2Test {
     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.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;
     }
@@ -546,6 +632,7 @@ class FlashDeliveryV2Test {
 
     private static FlashDeliveryV2StopRequest stop(String type, String phone, String address, String remark) {
         FlashDeliveryV2StopRequest stop = new FlashDeliveryV2StopRequest();
+        if ("PICKUP".equals(type)) { stop.setPackageType("FOOD_INGREDIENTS"); stop.setQuantity(8); }
         stop.setType(type); stop.setName("聯絡人"); stop.setPhone(phone);
         stop.setAddress(address); stop.setAddressDetail("3樓"); stop.setRemark(remark);
         int seq = Math.abs(address.hashCode()) % 100;
@@ -557,7 +644,7 @@ class FlashDeliveryV2Test {
     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.setTipAmount(0L);
         request.setClientRequestId("v2-" + System.nanoTime());
         List<FlashDeliveryV2StopRequest> stops = new ArrayList<>(List.of(
                 stop("PICKUP", "0911111110", "台北市大安區信義路三段56號", "取月餅"),
@@ -602,7 +689,9 @@ class FlashDeliveryV2Test {
             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.insert(any(FlashDeliveryOrderStop.class))).thenAnswer(i -> {
+                FlashDeliveryOrderStop row = i.getArgument(0); row.setId(100L + row.getStopOrder()); return 1;
+            });
             when(stops.updateById(any(FlashDeliveryOrderStop.class))).thenReturn(1);
             when(images.insert(any(FlashDeliveryOrderImage.class))).thenReturn(1);
             when(orders.updateFulfilment(any())).thenReturn(1);

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

@@ -38,12 +38,6 @@ public class FlashDeliveryOrder {
     private Boolean canAdminCancel;
     /** 是否已向骑手开放展示;立即单创建即开放,预约单到达时间窗后开放。 */
     private Boolean isDisplay;
-    /** 用户申报的包裹类别和重量范围。 */
-    private String packageType;
-    private Integer quantity;
-    private String weightRange;
-    /** 物品体积、尺寸等规格说明。 */
-    private String specification;
     /** NOW 为立即配送,SCHEDULED 为预约取件时段。 */
     private String deliveryMode;
     /** 预约取件时间窗开始时间,仅 SCHEDULED 模式有值。 */

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

@@ -38,4 +38,9 @@ public class FlashDeliveryOrderStop {
     private Date pickedUpAt;
     private Date deliveredAt;
     private Date createTime;
+    /** 仅PICKUP取货站保存货品快照;历史缺省和收货站为空。 */
+    private String packageType;
+    private Integer quantity;
+    private String weightRange;
+    private String specification;
 }

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

@@ -14,10 +14,6 @@
         <result property="status" column="status"/>
         <!-- canAdminCancel 是服务层按站点计算的只读字段,不映射/写入数据库。 -->
         <result property="isDisplay" column="is_display"/>
-        <result property="packageType" column="package_type"/>
-        <result property="quantity" column="quantity"/>
-        <result property="weightRange" column="weight_range"/>
-        <result property="specification" column="specification"/>
         <result property="deliveryMode" column="delivery_mode"/>
         <result property="scheduledPickupStartAt" column="scheduled_pickup_start_at"/>
         <result property="scheduledPickupEndAt" column="scheduled_pickup_end_at"/>
@@ -103,8 +99,6 @@
         update flash_delivery_order
         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},
-            weight_range = #{order.weightRange}, specification = #{order.specification},
             pickup_name = #{order.pickupName}, pickup_phone = #{order.pickupPhone},
             pickup_address = #{order.pickupAddress}, pickup_address_detail = #{order.pickupAddressDetail},
             pickup_city = #{order.pickupCity}, pickup_area = #{order.pickupArea},

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

@@ -14,6 +14,10 @@
         <result property="handoffMethod" column="handoff_method"/>
         <result property="longitude" column="longitude"/>
         <result property="latitude" column="latitude"/>
+        <result property="packageType" column="package_type"/>
+        <result property="quantity" column="quantity"/>
+        <result property="weightRange" column="weight_range"/>
+        <result property="specification" column="specification"/>
         <result property="remark" column="remark"/>
         <result property="receiverUserId" column="receiver_user_id"/>
         <result property="deliveryPinCode" column="delivery_pin_code"/>

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

@@ -86,7 +86,8 @@ class FlashDeliveryMapperXmlTest {
             Set<String> properties = configuration.getResultMap("com.ruoyi.system.mapper.flash." + name + "Mapper." + name + "Result")
                     .getResultMappings().stream().map(ResultMapping::getProperty).collect(Collectors.toSet());
             if (name.equals("FlashDeliveryOrderStop")) {
-                assertTrue(properties.containsAll(Set.of("orderId", "stopType", "stopOrder", "remark", "receiverUserId", "deliveryPinCode", "status", "pickedUpAt")));
+                assertTrue(properties.containsAll(Set.of("orderId", "stopType", "stopOrder", "remark", "receiverUserId", "deliveryPinCode", "status", "pickedUpAt",
+                        "packageType", "quantity", "weightRange", "specification")));
             } else {
                 assertTrue(properties.containsAll(Set.of("fromStopId", "toStopId", "riderId", "clientRequestId", "orderVersion", "reason", "consentDeclared", "createTime")));
             }
@@ -171,11 +172,6 @@ class FlashDeliveryMapperXmlTest {
                 .getResultMappings().stream().filter(item -> "amount".equals(item.getProperty()))
                 .findFirst().orElseThrow();
         assertEquals("amount", amount.getColumn());
-        ResultMapping weightRange = configuration
-                .getResultMap("com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.FlashDeliveryOrderResult")
-                .getResultMappings().stream().filter(item -> "weightRange".equals(item.getProperty()))
-                .findFirst().orElseThrow();
-        assertEquals("weight_range", weightRange.getColumn());
         Set<String> mappedProperties = configuration
                 .getResultMap("com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.FlashDeliveryOrderResult")
                 .getResultMappings().stream()
@@ -184,10 +180,14 @@ class FlashDeliveryMapperXmlTest {
         assertTrue(mappedProperties.containsAll(Set.of(
                 "receiverUserId",
                 "isDisplay",
-                "deliveryType", "quantity", "weightRange", "specification",
+                "deliveryType",
                 "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
                 "startingDistance", "startingFare", "distance", "freight", "distanceFee",
                 "baseDeliveryFee", "urgentRate", "minimumUrgentFee", "urgentFee", "tipAmount")));
+        assertFalse(mappedProperties.contains("packageType"));
+        assertFalse(mappedProperties.contains("quantity"));
+        assertFalse(mappedProperties.contains("weightRange"));
+        assertFalse(mappedProperties.contains("specification"));
         assertFalse(mappedProperties.contains("totalWeightKg"));
         assertFalse(mappedProperties.contains("packageSize"));
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
@@ -202,7 +202,7 @@ class FlashDeliveryMapperXmlTest {
         assertTrue(xml.contains("is_display = true"));
         assertTrue(xml.contains("scheduled_pickup_end_at &lt;= #{now}"));
         assertTrue(xml.contains("cancel_reason='SCHEDULE_EXPIRED'"));
-        assertTrue(xml.contains("package_type"));
+        assertFalse(xml.contains("package_type"));
         assertTrue(xml.contains("delivery_pin_code"));
         assertTrue(xml.contains("delivered_at &lt;="));
         assertTrue(xml.contains("version = version + 1"));

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

@@ -234,7 +234,7 @@
 
 | 方法 | 路径 | 说明 |
 | --- | --- | --- |
-| POST | `/system/flashDelivery/v2/quote` | 统一结构报价:订单级物品 + `stops[]`(至少 1 取、1 收,允许交错) |
+| POST | `/system/flashDelivery/v2/quote` | 统一结构报价:取货站独立物品 + `stops[]`(至少 1 取、1 收,允许交错) |
 | POST | `/system/flashDelivery/v2/orders` | 按 v2 报价创建订单,服务端复算费用与路线 |
 
 请求结构(要点):取货点与收货点放在同一个 `stops[]` 数组,顺序即用户添加顺序、也是骑手路线顺序,元素以 `type`(`PICKUP`/`DELIVERY`)区分:
@@ -242,18 +242,17 @@
 ```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":"PICKUP","name":"李先生","phone":"17788889999","address":"台北市大安區信義路三段56號","addressDetail":"10樓","latitude":25.0330,"longitude":121.5654,"remark":"取 4 盒月餅","packageType":"FOOD_INGREDIENTS","quantity":4,"weightRange":"UP_TO_5_KG","senderImageUrls":["/profile/mooncake.jpg"]},
+    {"type":"PICKUP","name":"王小姐","phone":"17788880000","address":"台北市信義區松高路11號","addressDetail":"1樓門市","latitude":25.0410,"longitude":121.5670,"remark":"取 2 箱牛奶","packageType":"FOOD_INGREDIENTS","quantity":2,"senderImageUrls":[]},
     {"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 公里上限不变。旧单点订单的市/区结构化字段仅随旧接口与旧订单继续存在。
+地址模型与 Lalamove 一致:站点只存 `address` 完整地址文本(台湾地址为连续文字,不做城市/区结构化拆分)、可选 `addressDetail`(樓層/室)与必填经纬度;路线与计价以经纬度为准,地址文本用于展示和骑手导航。每个 PICKUP 独立提交 `packageType,quantity,weightRange?,specification?,senderImageUrls?`;类型和正整数数量必填,规格最长 255 字,商品图片最多 9 张;DELIVERY 不设置这些字段,误传非空货品信息返回业务错误;`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`。
+响应要点:货品由 `stops[]` 的取货站返回,详情 `stops[].senderImageUrls` 是商品图片,`stops[].imageUrls` 是骑手凭证;列表和未接单骑手不返回商品图片。订单根级不返回四个货品字段及寄件图片。历史站点货品可为空。报价返回整线距离、时长与费用明细(结构同现有报价);创建返回订单详情,`stops[].id` 是后续履约所需的站点 ID,另有状态与备注。服务端按收货站联系人电话固化 `receiverUserId`(可空),详情不返回该内部 ID。启用收货码时每个收货站生成独立 `deliveryPinCode`;发起人可见全部,已绑定收件人仅可见自己的站点,骑手不可见。v2 订单级 `deliveryPinCode` 为空。列表和详情返回 `deliveryStopCount`、`deliveredStopCount`。
 
 ### 骑手逐站送达
 
@@ -273,16 +272,16 @@
 
 | 方法 | 路径 | body | 说明 |
 | --- | --- | --- | --- |
-| POST | `/system/flashDelivery/v2/orders/address/quote` | `orderId,orderVersion,stops` | 完整站点重新报价,沿用订单级物品和原运价快照 |
+| 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 订单会拒绝,不会删减收货站。
+`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 新订单写入。
+- v2 订单与旧订单共用站点表;`remark`、按站收件人与按站收货码仅 v2 新订单写入。
 
 ### 2026-10-14:新增接口业务参数统一放 body
 

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

@@ -2,7 +2,7 @@
 
 ## `flash_delivery_order`
 
-核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、创建人 `user_id`、创建时按收件手机号唯一匹配并固化的可空 `receiver_user_id`、`rider_id`、帮送/帮取 `service_type`、普通/加急 `delivery_type`、`vehicle_type`(1=机车、2=轿车)、`status`;`package_type`、正整数 `quantity`、可空的四档 `weight_range`、可空 `specification`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee`、`base_delivery_fee`、`urgent_rate`、`minimum_urgent_fee`、`urgent_fee`、`tip_amount` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
+核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、创建人 `user_id`、创建时按收件手机号唯一匹配并固化的可空 `receiver_user_id`、`rider_id`、帮送/帮取 `service_type`、普通/加急 `delivery_type`、`vehicle_type`(1=机车、2=轿车)、`status`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee`、`base_delivery_fee`、`urgent_rate`、`minimum_urgent_fee`、`urgent_fee`、`tip_amount` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
 
 索引:订单号唯一、用户幂等号唯一、待抢排序、创建人列表、收件人列表、骑手列表、自动完成扫描。`receiver_user_id` 只保存创建当时唯一匹配的普通用户;空值和历史订单不补写,用户手机号变更也不回写。
 
@@ -12,7 +12,7 @@
 
 ## `flash_delivery_order_image`
 
-保存 `order_id`、`proof_type`(SENDER/PICKUP/DELIVERY)、`image_url`、`sort_order`、`operator_type`、`operator_id`、`create_time`。
+保存 `order_id`、`stop_id`(所属站点)、`proof_type`(SENDER 商品图片 / PICKUP 取件凭证 / DELIVERY 送达凭证)、`image_url`、`sort_order`、`operator_type`、`operator_id`、`create_time`。
 
 ## `flash_delivery_order_log`
 
@@ -65,3 +65,11 @@
 - v2(`delivery_stop_count` 非空)站点 `stop_order` 保留客户交错顺序,目标从全体 PENDING 站中按原序选取;骑手可选择其他待完成站,操作审计保留,`reason` 与 `consent_declared` 不再是 v2 改序前置条件。旧单原规则不变。
 - 主状态 ACCEPTED 覆盖所有未全部取完的阶段,可以同时具有非零 `delivered_stop_count`;全部取完才进入 PICKED_UP。最后一个未完成收货站送达前必须已全部取完,全部站完成进入 DELIVERED,后续完成结算不变。
 - 卡片及详情复用 `picked_up_stop_count/pickup_stop_count` 与 `delivered_stop_count/delivery_stop_count` 双进度,Tab 及角标按原主状态和订单数量统计。未新增表、字段、取送关联或数据迁移。
+
+## 2026-11-09:取货站货品及商品图片
+
+- 货品唯一来源为 `flash_delivery_order_stop` 的 `package_type VARCHAR(32)`、`quantity INT`、`weight_range VARCHAR(24)`、`specification VARCHAR(255)`;四列允许 NULL,仅 PICKUP 使用。新请求要求各取货站类型和正整数数量必填;DELIVERY 四列为空。
+- `flash_delivery_order` 移除上述四列及实体字段。旧接口单点输入由服务适配到取货站,主表不再保存货品摘要。
+- 商品图片复用 `flash_delivery_order_image`,`proof_type=SENDER` 且 `stop_id` 为所属取货站。每站最多 9 张,排序各自独立,不与骑手凭证混用。
+- 待接单改址同事务替换完整站点与 SENDER 图片,照片关联新站点 ID。旧单点仅改地址时保留该取货站商品图片。
+- 手动迁移将历史主表货品及未分站 SENDER 图片只挂到原顺序第一个 PICKUP,不复制到其他站;其他历史取货站可空。迁移、核对、最后删除主表四列分步执行,见 `updatesql/sql.md`。不得凭历史聚合数据推断多站分配。

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

@@ -819,3 +819,10 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 1. 在 `FlashDeliveryV2Test` 和 `FlashDeliveryMultiPickupTest` 先证明新旧订单可直接凭 `stopId` 确认非默认取货站及可送达的收货站,无需先调用目标接口;保留未取货禁止送达、漏取禁止末站送达、骑手权限、按站 PIN/图片和重复确认测试。
 2. `FlashDeliveryApplicationService` 对新旧订单移除取货和逐站送达的当前目标一致性校验,目标调整接口的原因/同意声明也不再作为切换前置条件。目标接口标记 `@Deprecated`,`currentTargetStopId` 及目标接口仅兼容旧 App 的可选导航提示;确认后清空已选目标并回到原序最早未完成站,不重排路线或重算运费。
 3. 更新 024 规格、契约、任务与 App 交接文档;运行 JDK 21 定向测试、完整后端测试和模块构建,核对差异与换行。无 SQL;骑手 App 源码不在本仓库,页面移除“设为目前目标”由 App 接入方依据文档完成。
+
+## 2026-11-09 取货站货品迁移计划
+
+1. 将实体和 Mapper 货品列从主表移到站点表,v2 站点请求/响应沿用四个货品名称,并接入逐站 senderImageUrls;收货站拒绝非空货品值。
+2. 调整报价、创建、改址、详情和列表;报价校验每个取货站,落库和图片关联同事务完成,保持路线、权限、价格和骑手凭证流程。旧单点请求继续适配,新的 v2 整单货品字段不再接受。
+3. 更新已有测试与契约,补充两站不同货品和图片、收货不填/误填、参数边界、改址保留、详情脱敏及字段迁移检查;运行 JDK21 闪送定向测试、模块构建,检查差异。
+4. 在 updatesql/sql.md 提供人工迁移脚本,更新当前前端接入约定;不创建分支,不执行SQL,不修改缺失的骑手App。

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

@@ -1,10 +1,10 @@
 # 闪送 API 联调快速开始
 
-## 当前 v2:交错取送与双进度(2026-10-15)
+## 当前 v2:取货站货品、图片与双进度(2026-11-09)
 
-新 App 对接以 [v2 前端交接文档](../../docs/flash-delivery-v2-frontend-handoff.md) 为准,以下早期步骤保留作旧契约验收参考。v2 `stops[]` 首站取货、末站收货,中间按客户原序交错,不重排。骑手可自主通过目标接口选择其他待完成取/送站(版本、请求幂等号必填,原因/同意声明可省略);取送仍核验当前目标和本站凭证/PIN。
+新 App 对接以 [v2 前端交接文档](../../docs/flash-delivery-v2-frontend-handoff.md) 为准,以下早期步骤保留作旧契约验收参考。v2 `stops[]` 首站取货、末站收货,中间按客户原序交错,不重排。每个 PICKUP 独立提交类型、数量、可选重量、规格及商品图片,DELIVERY 不提交。开发者先按 SQL 文档完成 2026-11-09 迁移。骑手直接按待完成站 ID 取送,核验本站凭证/PIN,无需设置目标。
 
-验收取1→取2→送1→取3→送2:送1后 ACCEPTED,列表和详情同时显示已取2/3、已送1/2,仍归原待取件 `toPickup`;取3后 PICKED_UP,归原配送中 `delivering`;送2后 DELIVERED,归原已送达 `completed`。骑手先选择取3再送的方式也应可用,原站序和运费不变。未取过货不能送;仍有待取货站不能确认最后一个未完成收货站;同站重试不重复凭证/进度/通知,不提前记账。新任务、待取件、配送中、已送达、已取消五个 Tab、名称和角标订单数不变,无新增字段或 SQL。
+验收取1→取2→送1→取3→送2:送1后 ACCEPTED,列表和详情同时显示已取2/3、已送1/2,仍归原待取件 `toPickup`;取3后 PICKED_UP,归原配送中 `delivering`;送2后 DELIVERED,归原已送达 `completed`。骑手先选择取3再送的方式也应可用,原站序和运费不变。未取过货不能送;仍有待取货站不能确认最后一个未完成收货站;同站重试不重复凭证/进度/通知,不提前记账。新任务、待取件、配送中、已送达、已取消五个 Tab、名称和角标订单数不变,取货站货品字段及历史数据需按本次 SQL 手动迁移;真实数据库与 App 联调仍需执行。
 
 ## 原单点与旧契约步骤
 

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

@@ -448,3 +448,11 @@
 - FR-129:v2 客户提交的 `stops[]` 顺序用于报价、保存及默认导航,不自动按类型重排。首站为取货、末站为收货,中间允许取送交错,例如取1→取2→送1→取3→送2;下单与待接单改址共用校验。客户通过每站备注说明物品,骑手负责核对实际取送,不新增取货站与收货站强制关联字段。
 - FR-130:v2 骑手可自主确认任意未完成取货/收货站,不必先设为当前目标;旧订单亦不限制取货站顺序。目标接口过期,仅兼容旧客户端的可选导航提示,原因和同意声明均非必需。尚未取过货不能送达;存在未取货站时不能确认最后一个未完成收货站,以防漏取提前结束。每站取送仍需对应类型、所属骑手、凭证及按站 PIN 校验;不修改原站点顺序和运费。
 - FR-131:现有五个 Tab、名称、参数和订单计数保持:`newTask` 新任务、`toPickup` 待取件、`delivering` 配送中、`completed` 已送达、`cancelled` 已取消。取货未全部完成时主状态保持 ACCEPTED,即使部分收货站已送达;全部取完后为 PICKED_UP;全部取送站完成后才为 DELIVERED,后续确认收货/自动完成和记账沿用原流程。卡片与详情同时显示已取件 `pickedUpStopCount/pickupStopCount`、已送达 `deliveredStopCount/deliveryStopCount`,如“已取件 2/3 · 已送达 1/2”;Tab 角标计订单而非站点,一单仅属于一个 Tab。App 由前端人员实现,本仓库提供接入文档,不修改 App 源码。
+
+## 2026-11-09 取货站独立货品(当前约定)
+
+只在 PICKUP 站点保存 packageType、quantity、weightRange、specification,沿用原订单级字段名称、类型及业务含义。类型和正整数数量必填,重量与规格选填;DELIVERY 不填写货品信息。v2 请求和响应不再使用整单货品字段,主表移除对应持久化字段。
+
+每个取货站支持 senderImageUrls,沿用现有图片 URL 格式和每站最多9张校验;使用图片表的 SENDER + stop_id 关联,不混用骑手 PICKUP/DELIVERY 凭证。改址提交完整站点和货品、图片,更新后图片重新绑定新站点;保留原订单运价快照、车型及路线计价规则。详情逐站返回货品与图片,接单前不向骑手返回商品图片,列表不额外加载图片。
+
+旧单点请求允许适配到首取货站;旧整单图片仅可用于单取货站,避免多站歧义。历史整单货品及无站点 SENDER 图片迁移至首个取货站,其他旧站货品缺省,不复制数量。SQL 仅提供脚本,先核对缺失站点和旧数据再迁移并删除主表列,不直接执行。

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

@@ -306,3 +306,13 @@ Phase 30 本地验证记录:服务/SQL 测试均确认过预期 RED;修正
 - [x] T145 JDK 21 完整后端测试、模块构建、差异/换行检查。
 
 Phase 31 验证:新旧订单取送顺序及目标接口契约测试先出现预期 RED;JDK 21 执行 `mvn -q -pl ruoyi-admin -am test` 成功,本轮 admin 报告 136 类/1061 项、0 失败/错误/跳过;`mvn -q -pl ruoyi-admin -am -DskipTests package` 成功,差异空白检查通过。无 SQL;骑手 App 源码不在本仓库,真实设备和数据库联调未执行。
+
+## Phase 32:取货站独立货品和商品图片(2026-11-09)
+
+- [x] T146 迁移主表四个货品字段到取货站实体、Mapper及v2接口,收货站不设置。
+- [x] T147 接入逐取货站商品图片,打通报价、创建、改址、列表和详情及脱敏。
+- [x] T148 提供历史迁移SQL、更新前端文档和定向测试,执行JDK21验证并记录限制。
+
+验证记录:JDK 21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=*FlashDelivery*Test,UnifiedOrderQueryServiceTest -Dsurefire.failIfNoSpecifiedTests=false package`,退出码 0;system 9 项、admin 272 项定向测试全部通过,模块打包成功。覆盖两取货站不同货品/图片、收货站误传、数量反序列化、图片上限/URL、改址重绑及待抢脱敏。差异检查通过;本次没有新增用户可见文案,复用已有四语言业务错误。前端通过现有上传接口取得商品图片 URL,页面按接入文档调整。
+
+未执行项:SQL 迁移、真实 MySQL 事务/并发、真实地图和 App 联调、全量后端回归。SQL 仅写入 `updatesql/sql.md`,上线前须由开发者按步骤执行。使用现有分支提交与推送,结果以 Git 记录为准;未创建分支。

+ 102 - 0
updatesql/sql.md

@@ -2579,3 +2579,105 @@ ALTER TABLE `pos_order_invoice_print_job`
 ```
 
 后台每10秒小批扫描已开立纸本(含存量),通过发票行锁防止重复生成;文件准备失败按1/5/15/60分钟退避,不修改开票状态、不占打印次数。无需插入sys_job记录。PDF/图片保存在上述表中,不公开暴露文件目录。
+
+## 2026-11-09 闪送取货站独立货品和商品图片(024 Phase32)
+
+将主表 `package_type/quantity/weight_range/specification` 移至取货站;商品图片复用图片表 `stop_id`,`proof_type=SENDER`。本段仅登记,未执行,由开发者备份后手动执行。执行期间停止闪送写入并停用旧服务,核对成功、删除主表列、部署配套新服务后再开放;新旧版本不能混跑。
+
+### 1. 结构与数据预检
+
+以下增量适用于已执行 2026-10-14 多点取送结构的环境:主表有四个货品字段、站点表无四个货品字段、图片表已有 `stop_id`。若已有站点货品列或真实逐站数据,先核对实际结构,不能重复 ADD 或覆盖历史逐站资料。
+
+```sql
+SHOW COLUMNS FROM flash_delivery_order;
+SHOW COLUMNS FROM flash_delivery_order_stop;
+SHOW COLUMNS FROM flash_delivery_order_image;
+
+-- 必须为 0 行。缺少取货站的历史订单需人工核对并恢复站点后再继续。
+SELECT o.id, o.order_no FROM flash_delivery_order o
+WHERE NOT EXISTS (
+  SELECT 1 FROM flash_delivery_order_stop s WHERE s.order_id=o.id AND s.stop_type='PICKUP'
+);
+
+-- 必须为 0 行:已有归属的商品图片必须属于本订单的取货站。
+SELECT i.id, i.order_id, i.stop_id FROM flash_delivery_order_image i
+LEFT JOIN flash_delivery_order_stop s ON s.id=i.stop_id AND s.order_id=i.order_id
+WHERE i.proof_type='SENDER' AND i.stop_id IS NOT NULL
+  AND (s.id IS NULL OR s.stop_type<>'PICKUP');
+```
+
+### 2. 增加站点列并迁移历史资料
+
+历史主表无法还原各站分配。原货品和未归属站点的 SENDER 图片仅保留在按 `stop_order,id` 排序的第一个 PICKUP;其他历史取货站保持 NULL,收货站不填写。查看历史空值不应推断数量,编辑时补全各取货站必填资料。脚本不可在新服务写入后重跑。
+
+```sql
+ALTER TABLE flash_delivery_order_stop
+  ADD COLUMN package_type VARCHAR(32) NULL COMMENT '取货站物品类别,收货站为空',
+  ADD COLUMN quantity INT NULL COMMENT '取货站物品数量,正整数',
+  ADD COLUMN weight_range VARCHAR(24) NULL COMMENT '取货站物品重量范围',
+  ADD COLUMN specification VARCHAR(255) NULL COMMENT '取货站物品规格';
+
+-- 以下迁移、校验及提交在同一数据库会话执行。
+CREATE TEMPORARY TABLE tmp_flash_first_pickup (
+  order_id BIGINT NOT NULL PRIMARY KEY,
+  stop_id BIGINT NOT NULL UNIQUE
+);
+INSERT INTO tmp_flash_first_pickup(order_id,stop_id)
+SELECT s.order_id,s.id FROM flash_delivery_order_stop s
+LEFT JOIN flash_delivery_order_stop prior
+  ON prior.order_id=s.order_id AND prior.stop_type='PICKUP'
+  AND (prior.stop_order<s.stop_order OR (prior.stop_order=s.stop_order AND prior.id<s.id))
+WHERE s.stop_type='PICKUP' AND prior.id IS NULL;
+
+START TRANSACTION;
+UPDATE flash_delivery_order_stop s
+JOIN tmp_flash_first_pickup f ON f.stop_id=s.id
+JOIN flash_delivery_order o ON o.id=f.order_id
+SET s.package_type=o.package_type, s.quantity=o.quantity,
+    s.weight_range=o.weight_range, s.specification=o.specification;
+
+UPDATE flash_delivery_order_image i
+JOIN tmp_flash_first_pickup f ON f.order_id=i.order_id
+SET i.stop_id=f.stop_id
+WHERE i.proof_type='SENDER' AND i.stop_id IS NULL;
+```
+
+### 3. 核对后手动提交
+
+以下三项均须为 0 行;否则执行 `ROLLBACK` 并排查,不得删除主表列。同一站合并后若有超过 9 张历史商品照片,另行人工核对,不直接删除照片。
+
+```sql
+SELECT o.id FROM flash_delivery_order o
+LEFT JOIN tmp_flash_first_pickup f ON f.order_id=o.id
+LEFT JOIN flash_delivery_order_stop s ON s.id=f.stop_id
+WHERE s.id IS NULL
+  OR NOT (s.package_type <=> o.package_type)
+  OR NOT (s.quantity <=> o.quantity)
+  OR NOT (s.weight_range <=> o.weight_range)
+  OR NOT (s.specification <=> o.specification);
+
+SELECT i.id FROM flash_delivery_order_image i
+LEFT JOIN flash_delivery_order_stop s ON s.id=i.stop_id AND s.order_id=i.order_id
+WHERE i.proof_type='SENDER' AND (s.id IS NULL OR s.stop_type<>'PICKUP');
+
+SELECT id FROM flash_delivery_order_stop WHERE stop_type='DELIVERY'
+  AND (package_type IS NOT NULL OR quantity IS NOT NULL OR weight_range IS NOT NULL OR specification IS NOT NULL);
+
+-- 人工确认核对通过后单独执行:
+COMMIT;
+DROP TEMPORARY TABLE tmp_flash_first_pickup;
+```
+
+### 4. 最后删除主表旧列
+
+备份、迁移及核对通过后才执行以下独立步骤。随后部署配套代码并恢复服务;回退旧代码时必须从备份恢复原结构及资料,不能仅回退应用。
+
+```sql
+ALTER TABLE flash_delivery_order
+  DROP COLUMN package_type,
+  DROP COLUMN quantity,
+  DROP COLUMN weight_range,
+  DROP COLUMN specification;
+```
+
+图片表无需新列或索引,复用 `(order_id,stop_id,proof_type)`。骑手的 PICKUP/DELIVERY 凭证不参与本次数据更新。