Prechádzať zdrojové kódy

完成闪送统一运价与加急计费

将帮送和帮取改为共享分时段运价,普通与加急作为独立配送等级。补齐预约取件、完整物品信息、基础配送费、比例加急费、最低加急费、小费及报价一致性校验,并同步接口文档、迁移脚本和测试。
qmj 1 hodinu pred
rodič
commit
c20308bf2f
37 zmenil súbory, kde vykonal 686 pridanie a 160 odobranie
  1. 101 31
      docs/flash-delivery-app-api.md
  2. 8 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java
  3. 6 4
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryCreateRequest.java
  4. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryHomeView.java
  5. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  6. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPriceBreakdown.java
  7. 2 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryPricingRequest.java
  8. 11 1
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  9. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java
  10. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  11. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryServiceView.java
  12. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  13. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/exception/FlashDeliveryQuoteChangedException.java
  14. 147 47
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  15. 22 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java
  16. 3 3
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryExclusivityService.java
  17. 4 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  18. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  19. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  20. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  21. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  22. 17 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryControllerContractTest.java
  23. 4 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  24. 7 4
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  25. 124 25
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  26. 2 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryI18nContractTest.java
  27. 31 0
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculatorTest.java
  28. 11 6
      ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java
  29. 9 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  30. 2 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryPricing.java
  31. 11 13
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java
  32. 9 0
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  33. 3 1
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  34. 8 6
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapperContractTest.java
  35. 3 3
      specs/024-flash-delivery/quickstart.md
  36. 8 8
      specs/024-flash-delivery/tasks.md
  37. 58 0
      updatesql/sql.md

+ 101 - 31
docs/flash-delivery-app-api.md

@@ -2,13 +2,15 @@
 
 本文档供用户端 App 和骑手端 App 接入闪送功能使用,以当前后端实现为准。文档只描述接口契约和业务流程,不包含 App 前端实现代码。
 
-> 更新时间:2026-09-04。当前口径已包含“我发的/我收的”、创建时收件账号绑定、急送跨外卖与闪送独占;闪送仍不接入支付。
+> 更新时间:2026-09-07。当前口径已对齐下单设计稿:帮送/帮取与普通/加急正交、统一分时段运价、完整物品信息、费用明细及创建订单报价校验;闪送仍不接入支付。
 
 ## 1. 功能范围
 
 当前闪送支持:
 
-- 帮送、帮取、加急送三种服务。
+- 帮送、帮取两种业务场景,以及普通配送、1 对 1 加急两种配送等级。
+- 物品类别、数量、总重量、体积/规格,以及服务端生成的重量档。
+- 基础配送费、距离费、加急费、骑手小费和订单总金额明细。
 - 共享地址簿、路线报价、立即配送和预约配送。
 - 用户发布订单,并按“我发的/我收的”查询订单;发件人可取消,发件人或创建时绑定的收件人可确认收货。
 - 骑手查看待抢订单、抢单、确认取件和确认送达。
@@ -106,7 +108,7 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 
 - 请求中的预约时间使用 ISO 日期时间字符串,例如 `2026-09-02T10:00:00+08:00`。
 - 返回时间以服务端实际 JSON 时间格式为准,App 应按日期时间解析,不应依赖固定展示格式。
-- 金额单位为新台币,`currency` 固定为 `TWD`;`startingFare`、`freight`、`distanceFee` 和 `amount` 都是整数,不带小数。
+- 金额单位为新台币,`currency` 固定为 `TWD`;`startingFare`、`freight`、`distanceFee`、`baseDeliveryFee`、`minimumUrgentFee`、`urgentFee`、`tipAmount` 和 `amount` 都是整数,不带小数。
 - `distanceMeters` 单位为米。
 - 运价配置中的 `startingDistance`、`distance` 以及报价中的 `billableDistance` 单位为公里,可保留两位小数。
 - `estimatedDurationSeconds` 单位为秒,路线服务降级时可能为 `null`。
@@ -120,11 +122,17 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 |---|---|
 | `HELP_SEND` | 帮送 |
 | `HELP_PICKUP` | 帮取 |
-| `URGENT` | 加急送 |
 
-是否可下单以首页接口当前实际返回的服务为准。运价没有启停状态;只有当前时间命中已配置运价时段的服务才会返回,不要在 App 中假定三种服务始终全部可用
+`serviceType` 只表达业务场景,不再表达是否加急。帮送和帮取共享同一套运价时段
 
-### 3.2 包裹类型 `packageType`
+### 3.2 配送等级 `deliveryType`
+
+| 值 | 含义 |
+|---|---|
+| `NORMAL` | 普通配送,加急费固定为 0 |
+| `URGENT` | 1 对 1 加急配送,收取加急费并启用骑手独占规则 |
+
+### 3.3 包裹类型 `packageType`
 
 | 值 | 含义 |
 |---|---|
@@ -138,7 +146,7 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 | `SMALL_APPLIANCE` | 小家电 |
 | `OTHER` | 其他 |
 
-### 3.3 包裹重量档 `packageSize`
+### 3.4 包裹重量档 `packageSize`
 
 | 值 | 含义 |
 |---|---|
@@ -146,9 +154,9 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 | `MEDIUM` | 不超过 12kg |
 | `LARGE` | 不超过 20kg |
 
-接口只提交重量档,不提交精确重量
+App 必须提交 `totalWeightKg`,不得提交 `packageSize`。后端按总重量生成重量档:不超过 5kg 为 `SMALL`,超过 5kg 且不超过 12kg 为 `MEDIUM`,超过 12kg 且不超过 20kg 为 `LARGE`
 
-### 3.4 配送方式 `deliveryMode`
+### 3.5 配送方式 `deliveryMode`
 
 | 值 | 含义 |
 |---|---|
@@ -164,7 +172,7 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 
 立即配送时不得传 `scheduledPickupStartAt` 和 `scheduledPickupEndAt`。
 
-### 3.5 订单状态 `status`
+### 3.6 订单状态 `status`
 
 ```text
 WAITING_ACCEPTANCE -> ACCEPTED -> PICKED_UP -> DELIVERED -> COMPLETED
@@ -277,10 +285,14 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 |---|---|---|
 | `id` | number | 订单主键,后续接口路径使用此值 |
 | `orderNo` | string | 闪送订单号 |
-| `serviceType` | string | 服务类型 |
+| `serviceType` | string | 业务场景:`HELP_SEND` 或 `HELP_PICKUP` |
+| `deliveryType` | string | 配送等级:`NORMAL` 或 `URGENT` |
 | `status` | string | 当前状态 |
 | `packageType` | string | 包裹类型 |
-| `packageSize` | string | 包裹重量档 |
+| `quantity` | integer | 物品数量,正整数 |
+| `totalWeightKg` | number | 物品总重量,大于 0 且不超过 20kg,最多两位小数 |
+| `specification` | string/null | 体积或规格说明,最长 255 字符 |
+| `packageSize` | string | 后端按总重量生成的重量档 |
 | `deliveryMode` | string | `NOW` 或 `SCHEDULED` |
 | `scheduledPickupStartAt` | datetime/null | 预约开始时间 |
 | `scheduledPickupEndAt` | datetime/null | 预约结束时间 |
@@ -290,6 +302,10 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 | `distanceMeters` | number | 配送距离,单位米 |
 | `distanceSource` | string | `ROUTE` 或 `STRAIGHT_LINE` |
 | `estimatedDurationSeconds` | number/null | 预计时长,单位秒 |
+| `baseDeliveryFee` | integer | 基础配送费,即起送价加距离费 |
+| `distanceFee` | integer | 超出起送距离后的距离费 |
+| `urgentFee` | integer | 加急费;普通配送为 0 |
+| `tipAmount` | integer | 用户填写的骑手小费 |
 | `amount` | integer | 订单金额,整数 TWD |
 | `currency` | string | `TWD` |
 | `userNote` | string/null | 用户备注 |
@@ -302,9 +318,9 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 | `createTime` | datetime | 创建时间 |
 | `updateTime` | datetime | 更新时间 |
 
-用户列表摘要固定字段为:`id,orderNo,serviceType,status,packageType,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,amount,currency,deliveredAt,createTime`。用户列表不返回联系人、电话、精确坐标、照片、日志或计价审计字段。
+用户列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,totalWeightKg,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。用户列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。
 
-骑手列表摘要固定字段为:`id,orderNo,serviceType,status,packageType,packageSize,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,amount,currency,createTime`。`newTask` 分页响应额外返回 `nearbyTaskCount` 和 `highestOrderAmount`,不返回尖峰倍率或骑手收入字段。
+骑手列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,totalWeightKg,specification,packageSize,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,distanceFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 分页响应额外返回 `nearbyTaskCount` 和 `highestOrderAmount`,不返回内部加急比例、最低加急费或骑手收入字段。
 
 ### 6.3 App 完整详情对象
 
@@ -314,9 +330,13 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 {
   "id": 101,
   "orderNo": "FD1234567890",
-  "serviceType": "URGENT",
+  "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
   "status": "ACCEPTED",
   "packageType": "DOCUMENT",
+  "quantity": 1,
+  "totalWeightKg": 3.50,
+  "specification": "30 x 20 x 10 cm",
   "packageSize": "SMALL",
   "deliveryMode": "NOW",
   "scheduledPickupStartAt": null,
@@ -327,7 +347,11 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
   "distanceMeters": 4200,
   "distanceSource": "ROUTE",
   "estimatedDurationSeconds": 900,
-  "amount": 90,
+  "baseDeliveryFee": 72,
+  "distanceFee": 12,
+  "urgentFee": 15,
+  "tipAmount": 5,
+  "amount": 92,
   "currency": "TWD",
   "userNote": null,
   "acceptedAt": "2026-09-01T10:20:00+08:00",
@@ -377,6 +401,8 @@ GET /system/flashDelivery/home
 
 ```json
 {
+  "serviceTypes": ["HELP_SEND", "HELP_PICKUP"],
+  "deliveryTypes": ["NORMAL", "URGENT"],
   "services": [
     {
       "serviceType": "HELP_SEND",
@@ -387,6 +413,8 @@ GET /system/flashDelivery/home
       "startingFare": 60,
       "distance": 1.00,
       "freight": 10,
+      "urgentRate": 20.00,
+      "minimumUrgentFee": 15,
       "pricingVersion": 1,
       "currency": "TWD"
     }
@@ -394,7 +422,7 @@ GET /system/flashDelivery/home
 }
 ```
 
-只返回当前时间命中运价时段的服务。`startTime` 包含、`endTime` 不包含,均为业务当地时间的 `HH:mm`;`24:00` 只会作为结束时间。`services` 为空表示当前时段没有可下单的闪送服务
+帮送和帮取会返回同一份当前运价摘要。`startTime` 包含、`endTime` 不包含,均为业务当地时间的 `HH:mm`;`24:00` 只会作为结束时间。`services` 为空表示当前时刻没有匹配的统一运价
 
 ### 7.2 获取实时报价
 
@@ -407,8 +435,15 @@ POST /system/flashDelivery/quote
 ```json
 {
   "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
+  "deliveryMode": "SCHEDULED",
+  "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+  "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
   "packageType": "DOCUMENT",
-  "packageSize": "SMALL",
+  "quantity": 1,
+  "totalWeightKg": 3.50,
+  "specification": "30 x 20 x 10 cm",
+  "tipAmount": 5,
   "pickup": {
     "name": "王小明",
     "phone": "0912345678",
@@ -439,6 +474,10 @@ POST /system/flashDelivery/quote
 ```json
 {
   "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
+  "deliveryMode": "SCHEDULED",
+  "scheduledPickupStartAt": "2026-09-08T16:30:00+08:00",
+  "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
   "pricingId": 1,
   "startTime": "00:00",
   "endTime": "08:00",
@@ -449,17 +488,22 @@ POST /system/flashDelivery/quote
   "startingFare": 60,
   "distance": 1.00,
   "freight": 10,
+  "urgentRate": 20.00,
+  "minimumUrgentFee": 15,
   "billableDistance": 1.20,
   "distanceFee": 12,
-  "amount": 72,
+  "baseDeliveryFee": 72,
+  "urgentFee": 15,
+  "tipAmount": 5,
+  "amount": 92,
   "currency": "TWD",
   "pricingVersion": 1
 }
 ```
 
-计价规则与现有外卖订单一致:先用路线公里数减去 `startingDistance`;超出不足 `0.5` 公里时 `billableDistance` 为 `0`,达到 `0.5` 但不足 `1` 公里时按 `1` 公里,达到 `1` 公里后按实际超出距离。里程费用按 `billableDistance / distance * freight` 计算并四舍五入为整数 TWD,最终 `amount = startingFare + distanceFee`。预计时长不参与计价,也没有最低价、每分钟价格或尖峰倍率
+距离规则与现有外卖订单一致:先用路线公里数减去 `startingDistance`;超出不足 `0.5` 公里时 `billableDistance` 为 `0`,达到 `0.5` 但不足 `1` 公里时按 `1` 公里,达到 `1` 公里后按实际超出距离。`distanceFee = roundHalfUp(billableDistance / distance × freight)`,`baseDeliveryFee = startingFare + distanceFee`。普通配送的 `urgentFee=0`;加急配送的 `urgentFee=max(minimumUrgentFee, roundHalfUp(baseDeliveryFee × urgentRate / 100))`;`amount=baseDeliveryFee+urgentFee+tipAmount`。小费不参与加急费计算,预计时长也不参与计价
 
-报价仅用于展示。创建订单时后端会重新匹配当前运价时段并计算最新路线和金额,因此最终金额可能与先前报价不同。当前时间没有匹配时段时,接口返回“当前时段暂无可用运价”
+立即配送按报价时刻匹配运价,预约配送按 `scheduledPickupStartAt` 的当地时间匹配运价。创建订单必须回传本次报价;后端会重新计算并逐项精确校验,任何变化都不会创建订单,而是返回最新报价供用户重新确认
 
 ### 7.3 创建闪送订单
 
@@ -472,14 +516,24 @@ POST /system/flashDelivery/orders
 ```json
 {
   "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
   "packageType": "DOCUMENT",
-  "packageSize": "SMALL",
+  "quantity": 1,
+  "totalWeightKg": 3.50,
+  "specification": "30 x 20 x 10 cm",
+  "tipAmount": 5,
   "pickup": {},
   "delivery": {},
   "clientRequestId": "FD-20260901-USER1001-0001",
   "deliveryMode": "SCHEDULED",
   "scheduledPickupStartAt": "2026-09-02T10:00:00+08:00",
   "scheduledPickupEndAt": "2026-09-02T10:30:00+08:00",
+  "pricingId": 1,
+  "pricingVersion": 1,
+  "quotedBaseDeliveryFee": 72,
+  "quotedDistanceFee": 12,
+  "quotedUrgentFee": 15,
+  "quotedAmount": 92,
   "pinRequired": true,
   "senderImageUrls": [
     "https://api.example.com/profile/upload/sender-1.jpg"
@@ -491,9 +545,12 @@ POST /system/flashDelivery/orders
 | 新增字段 | 类型 | 必填 | 说明 |
 |---|---|---:|---|
 | `clientRequestId` | string | 是 | 客户端幂等请求号,去除首尾空格后最长 64 字符 |
-| `deliveryMode` | string | 否 | 不传时默认为 `NOW` |
-| `scheduledPickupStartAt` | datetime | 预约时必填 | 预约开始时间 |
-| `scheduledPickupEndAt` | datetime | 预约时必填 | 必须比开始时间晚 30 分钟 |
+| `pricingId` | integer | 是 | 报价响应的运价配置 ID |
+| `pricingVersion` | integer | 是 | 报价响应的配置版本 |
+| `quotedBaseDeliveryFee` | integer | 是 | 报价响应的基础配送费 |
+| `quotedDistanceFee` | integer | 是 | 报价响应的距离费 |
+| `quotedUrgentFee` | integer | 是 | 报价响应的加急费 |
+| `quotedAmount` | integer | 是 | 报价响应的总金额 |
 | `pinRequired` | boolean | 否 | 不传时默认为 `true` |
 | `senderImageUrls` | string[] | 否 | 寄件图片,最多 9 张 |
 | `userNote` | string | 否 | 最长 500 字符 |
@@ -502,6 +559,8 @@ POST /system/flashDelivery/orders
 
 成功响应 `data` 为完整详情对象,初始状态为 `WAITING_ACCEPTANCE`。创建响应中的 `amount` 是最终整数订单金额。
 
+若运价版本、路线或费用发生变化,响应 `code=500`、`msg=报价已变化,请重新确认最新费用`,并在 `data` 返回与报价接口相同的完整最新报价。App 必须更新费用明细并要求用户再次确认,不能自动用旧报价重试。
+
 创建时,后端会移除收件手机号中的空白、`+`、`-`、`(`、`)` 后,匹配有效且未删除的普通用户账号。仅在恰好匹配一个账号时把该账号固化为收件人;无匹配或重复匹配均不绑定。此绑定只执行一次,历史订单不会因收件人之后注册或修改手机号而自动认领。
 
 ### 7.4 查询用户订单列表
@@ -566,7 +625,7 @@ POST /system/flashDelivery/orders/{id}/confirmReceipt
 
 接单使用共享 Redis 锁 `lock:delivery:rider:{riderId}` 串行执行跨外卖、闪送的资格检查和订单条件更新,并在事务完成后释放。该锁只覆盖接单事务,不会一直持有到配送完成;持续独占由每次接新单时查询骑手进行中的订单保证,Redis 锁用于防止两个并发接单请求同时绕过查询。
 
-`URGENT` 急送要求骑手没有任何进行中的外卖或闪送;骑手已有进行中的急送时,也不能再接普通闪送或外卖。普通闪送不会阻止骑手承接外卖,外卖也不会阻止骑手承接普通闪送。Redis 不可用或 3 秒内未取得锁时采用失败关闭策略,本次接单直接失败,不更新订单。
+`deliveryType=URGENT` 急送要求骑手没有任何进行中的外卖或闪送;骑手已有进行中的急送时,也不能再接普通闪送或外卖。普通闪送不会阻止骑手承接外卖,外卖也不会阻止骑手承接普通闪送。Redis 不可用或 3 秒内未取得锁时采用失败关闭策略,本次接单直接失败,不更新订单。
 
 ### 8.1 查询骑手订单列表
 
@@ -606,9 +665,13 @@ GET /system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.
 {
   "id": 101,
   "orderNo": "FD1234567890",
-  "serviceType": "URGENT",
+  "serviceType": "HELP_SEND",
+  "deliveryType": "URGENT",
   "status": "WAITING_ACCEPTANCE",
   "packageType": "DOCUMENT",
+  "quantity": 1,
+  "totalWeightKg": 3.50,
+  "specification": "30 x 20 x 10 cm",
   "packageSize": "SMALL",
   "deliveryMode": "NOW",
   "scheduledPickupStartAt": null,
@@ -621,7 +684,11 @@ GET /system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.
   "pickupDistanceMeters": 420,
   "distanceMeters": 4200,
   "estimatedDurationSeconds": 900,
-  "amount": 90,
+  "baseDeliveryFee": 72,
+  "distanceFee": 12,
+  "urgentFee": 15,
+  "tipAmount": 5,
+  "amount": 92,
   "currency": "TWD",
   "createTime": "2026-09-01T09:00:00+08:00"
 }
@@ -773,7 +840,9 @@ POST /system/address/{id}/top
 | `msg` 含义 | 常见原因 | App 处理建议 |
 |---|---|---|
 | token 已过期 | 登录态失效 | 清理登录态并重新登录 |
-| 当前时段暂无可用运价 | 当前时间没有匹配所选服务的运价时段 | 刷新首页服务列表或稍后重试 |
+| 对应取件时段暂无可用运价 | 立即时刻或预约开始时刻没有匹配统一运价 | 重新选择取件时间或稍后重试 |
+| 报价已变化,请重新确认最新费用 | 运价版本、路线或费用与创建请求回传值不一致 | 用响应 `data` 刷新费用并让用户再次确认 |
+| 物品数量、总重量或规格信息不正确 | 数量非正整数、重量不在 0 至 20kg 或规格过长 | 返回物品信息页修正 |
 | 取件地址和收件地址不能相同 | 地址文本相同或经纬度完全相同 | 要求用户修改地址 |
 | 配送距离不能超过 40 公里 | 服务端路线距离超限 | 提示当前路线不可下单 |
 | 预约取件时段无效 | 时间已过、超过三天或不是 30 分钟 | 重新选择预约时段 |
@@ -788,8 +857,9 @@ POST /system/address/{id}/top
 ## 11. 接入验收清单
 
 - 首页只展示 `/home` 返回的当前时段可用服务,不在 App 中维护启停状态或默认价格。
-- 报价和创建订单都提交完整取件、收件坐标,且不使用客户端自行计算的金额。
-- 金额按整数 TWD 展示,不显示小数、时长费、最低价或尖峰费用。
+- 报价和创建订单都提交 `serviceType`、`deliveryType`、配送方式、完整物品信息、骑手小费及取送坐标。
+- 创建订单原样回传报价的配置 ID、版本、基础配送费、距离费、加急费和总金额;报价变化时展示最新明细并重新确认。
+- 金额按整数 TWD 展示,并分别显示基础配送费、距离费、加急费、骑手小费和总金额;不显示支付成功、骑手收入、时长费或尖峰费用。
 - 创建订单具备稳定的 `clientRequestId` 重试策略。
 - 预约时间满足未来三天内、固定 30 分钟的约束。
 - App 使用响应体 `code` 判断成功或失败。

+ 8 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/controller/FlashDeliveryUserController.java

@@ -1,10 +1,12 @@
 package com.ruoyi.app.flashdelivery.controller;
 
 import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.web.bind.annotation.*;
@@ -34,7 +36,12 @@ public class FlashDeliveryUserController extends BaseController {
     @PostMapping("/orders")
     @Auth
     public AjaxResult create(@RequestHeader String token, @RequestBody FlashDeliveryCreateRequest request) {
-        return success(service.create(userId(token), request));
+        try {
+            return success(service.create(userId(token), request));
+        } catch (FlashDeliveryQuoteChangedException exception) {
+            return AjaxResult.error(MessageUtils.message("flash.delivery.quote.changed"),
+                    exception.getLatestQuote());
+        }
     }
 
     /** 分页查询当前用户自己的闪送订单。 */

+ 6 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryCreateRequest.java

@@ -3,7 +3,6 @@ package com.ruoyi.app.flashdelivery.dto;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 
-import java.util.Date;
 import java.util.List;
 
 /** 创建闪送订单请求;服务端会重新计算父类中的路线和价格。 */
@@ -13,9 +12,12 @@ public class FlashDeliveryCreateRequest extends FlashDeliveryQuoteRequest {
     /** 客户端生成的幂等请求号,同一用户重复提交时返回原订单。 */
     private String clientRequestId;
     /** 立即送或预约送;为空时兼容为 NOW。 */
-    private String deliveryMode;
-    private Date scheduledPickupStartAt;
-    private Date scheduledPickupEndAt;
+    private Long pricingId;
+    private Integer pricingVersion;
+    private Long quotedBaseDeliveryFee;
+    private Long quotedDistanceFee;
+    private Long quotedUrgentFee;
+    private Long quotedAmount;
     /** 为空时按补充原型默认启用 PIN。 */
     private Boolean pinRequired;
     private List<String> senderImageUrls;

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

@@ -7,5 +7,7 @@ import java.util.List;
 /** 用户闪送首页响应,仅包含当前启用的公开服务摘要。 */
 @Data
 public class FlashDeliveryHomeView {
+    private List<String> serviceTypes;
+    private List<String> deliveryTypes;
     private List<FlashDeliveryServiceView> services;
 }

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

@@ -2,6 +2,7 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
+import java.math.BigDecimal;
 import java.util.Date;
 
 /** 用户和中单骑手共用的业务订单视图,不包含持久化及身份内部字段。 */
@@ -10,8 +11,12 @@ public class FlashDeliveryOrderView {
     private Long id;
     private String orderNo;
     private String serviceType;
+    private String deliveryType;
     private String status;
     private String packageType;
+    private Integer quantity;
+    private BigDecimal totalWeightKg;
+    private String specification;
     private String packageSize;
     private String deliveryMode;
     private Date scheduledPickupStartAt;
@@ -22,6 +27,10 @@ public class FlashDeliveryOrderView {
     private Integer distanceMeters;
     private String distanceSource;
     private Integer estimatedDurationSeconds;
+    private Long baseDeliveryFee;
+    private Long distanceFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
     private String currency;
     private String userNote;

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

@@ -9,5 +9,8 @@ import java.math.BigDecimal;
 public class FlashDeliveryPriceBreakdown {
     private BigDecimal billableDistance;
     private Long distanceFee;
+    private Long baseDeliveryFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
 }

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

@@ -7,12 +7,13 @@ import java.math.BigDecimal;
 /** 平台新增或修改闪送时段运价的请求。 */
 @Data
 public class FlashDeliveryPricingRequest {
-    private String serviceType;
     private String startTime;
     private String endTime;
     private BigDecimal startingDistance;
     private Long startingFare;
     private BigDecimal distance;
     private Long freight;
+    private BigDecimal urgentRate;
+    private Long minimumUrgentFee;
     private Integer configVersion;
 }

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

@@ -2,12 +2,22 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
+import java.math.BigDecimal;
+import java.util.Date;
+
 /** 闪送报价请求,支持帮送、帮取和加急送。 */
 @Data
 public class FlashDeliveryQuoteRequest {
     private String serviceType;
+    private String deliveryType;
     private String packageType;
-    private String packageSize;
+    private Integer quantity;
+    private BigDecimal totalWeightKg;
+    private String specification;
+    private Long tipAmount;
+    private String deliveryMode;
+    private Date scheduledPickupStartAt;
+    private Date scheduledPickupEndAt;
     private FlashDeliveryAddressRequest pickup;
     private FlashDeliveryAddressRequest delivery;
 }

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteView.java

@@ -8,6 +8,10 @@ import java.math.BigDecimal;
 @Data
 public class FlashDeliveryQuoteView {
     private String serviceType;
+    private String deliveryType;
+    private String deliveryMode;
+    private java.util.Date scheduledPickupStartAt;
+    private java.util.Date scheduledPickupEndAt;
     private Long pricingId;
     private String startTime;
     private String endTime;
@@ -20,6 +24,11 @@ public class FlashDeliveryQuoteView {
     private Long freight;
     private BigDecimal billableDistance;
     private Long distanceFee;
+    private Long baseDeliveryFee;
+    private BigDecimal urgentRate;
+    private Long minimumUrgentFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
     private String currency;
     private Integer pricingVersion;

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

@@ -2,6 +2,7 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
+import java.math.BigDecimal;
 import java.util.Date;
 
 /** 骑手端闪送任务卡片,不包含联系人、电话、精确坐标或内部字段。 */
@@ -10,8 +11,12 @@ public class FlashDeliveryRiderOrderListView {
     private Long id;
     private String orderNo;
     private String serviceType;
+    private String deliveryType;
     private String status;
     private String packageType;
+    private Integer quantity;
+    private BigDecimal totalWeightKg;
+    private String specification;
     private String packageSize;
     private String deliveryMode;
     private Date scheduledPickupStartAt;
@@ -24,6 +29,10 @@ public class FlashDeliveryRiderOrderListView {
     private Integer pickupDistanceMeters;
     private Integer distanceMeters;
     private Integer estimatedDurationSeconds;
+    private Long baseDeliveryFee;
+    private Long distanceFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
     private String currency;
     private Date createTime;

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

@@ -15,6 +15,8 @@ public class FlashDeliveryServiceView {
     private Long startingFare;
     private BigDecimal distance;
     private Long freight;
+    private BigDecimal urgentRate;
+    private Long minimumUrgentFee;
     private Integer pricingVersion;
     private String currency;
 }

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

@@ -2,6 +2,7 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
+import java.math.BigDecimal;
 import java.util.Date;
 
 /** 用户端闪送订单卡片,仅包含列表页面所需字段。 */
@@ -10,8 +11,11 @@ public class FlashDeliveryUserOrderListView {
     private Long id;
     private String orderNo;
     private String serviceType;
+    private String deliveryType;
     private String status;
     private String packageType;
+    private Integer quantity;
+    private BigDecimal totalWeightKg;
     private String deliveryMode;
     private Date scheduledPickupStartAt;
     private Date scheduledPickupEndAt;
@@ -20,6 +24,9 @@ public class FlashDeliveryUserOrderListView {
     private String deliveryAddress;
     private String deliveryDetailAddress;
     private Integer estimatedDurationSeconds;
+    private Long baseDeliveryFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private Long amount;
     private String currency;
     private Date deliveredAt;

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/exception/FlashDeliveryQuoteChangedException.java

@@ -0,0 +1,18 @@
+package com.ruoyi.app.flashdelivery.exception;
+
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteView;
+
+/** 创建订单时客户端报价已过期,携带服务端最新报价供用户重新确认。 */
+public class FlashDeliveryQuoteChangedException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    private final FlashDeliveryQuoteView latestQuote;
+
+    public FlashDeliveryQuoteChangedException(FlashDeliveryQuoteView latestQuote) {
+        this.latestQuote = latestQuote;
+    }
+
+    public FlashDeliveryQuoteView getLatestQuote() {
+        return latestQuote;
+    }
+}

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

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.app.flashdelivery.dto.*;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
 import com.ruoyi.app.flashdelivery.route.GeoPoint;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
@@ -27,6 +28,9 @@ import java.net.URI;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.security.SecureRandom;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.function.Function;
 
@@ -40,11 +44,14 @@ import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.*;
  */
 @Service
 public class FlashDeliveryApplicationService {
-    private static final List<String> SERVICE_TYPES = List.of("HELP_SEND", "HELP_PICKUP", "URGENT");
+    private static final List<String> SERVICE_TYPES = List.of("HELP_SEND", "HELP_PICKUP");
+    private static final Set<String> DELIVERY_TYPES = Set.of("NORMAL", "URGENT");
     private static final Set<String> PACKAGE_TYPES = Set.of("DOCUMENT", "GIFT", "CLOTHING", "BEAUTY",
             "DAILY_NECESSITIES", "FOOD_INGREDIENTS", "ELECTRONICS", "SMALL_APPLIANCE", "OTHER");
-    private static final Set<String> PACKAGE_SIZES = Set.of("SMALL", "MEDIUM", "LARGE");
     private static final Set<String> DELIVERY_MODES = Set.of("NOW", "SCHEDULED");
+    private static final BigDecimal MAX_TOTAL_WEIGHT_KG = new BigDecimal("20.00");
+    private static final DateTimeFormatter PRICING_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
+    private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
     private static final int MAX_DISTANCE_METERS = 40_000;
     private static final BigDecimal MAX_PRICING_DISTANCE = new BigDecimal("999999.99");
     private static final long SCHEDULE_SLOT_MILLIS = 30L * 60L * 1000L;
@@ -82,19 +89,27 @@ public class FlashDeliveryApplicationService {
 
     public FlashDeliveryHomeView home() {
         FlashDeliveryHomeView view = new FlashDeliveryHomeView();
-        view.setServices(SERVICE_TYPES.stream()
-                .map(this::findCurrentPricingOrNull)
-                .filter(Objects::nonNull)
-                .map(this::serviceView)
-                .toList());
+        view.setServiceTypes(SERVICE_TYPES);
+        view.setDeliveryTypes(List.of("NORMAL", "URGENT"));
+        FlashDeliveryPricing pricing = findPricingAtOrNull(pricingTargetTime(new Date()));
+        view.setServices(pricing == null ? List.of() : SERVICE_TYPES.stream()
+                .map(serviceType -> serviceView(pricing, serviceType)).toList());
         return view;
     }
 
     public FlashDeliveryQuoteView quote(FlashDeliveryQuoteRequest request) {
         // 报价只使用服务端路线距离和当前启用的计价配置,避免客户端篡改金额。
         QuoteContext context = calculateQuote(request);
+        return quoteView(request, context);
+    }
+
+    private FlashDeliveryQuoteView quoteView(FlashDeliveryQuoteRequest request, QuoteContext context) {
         FlashDeliveryQuoteView view = new FlashDeliveryQuoteView();
-        view.setServiceType(context.pricing().getServiceType());
+        view.setServiceType(request.getServiceType());
+        view.setDeliveryType(request.getDeliveryType());
+        view.setDeliveryMode(normalizeDeliveryMode(request.getDeliveryMode()));
+        view.setScheduledPickupStartAt(request.getScheduledPickupStartAt());
+        view.setScheduledPickupEndAt(request.getScheduledPickupEndAt());
         view.setPricingId(context.pricing().getId());
         view.setStartTime(context.pricing().getStartTime());
         view.setEndTime(context.pricing().getEndTime());
@@ -105,6 +120,8 @@ public class FlashDeliveryApplicationService {
         view.setStartingFare(context.pricing().getStartingFare());
         view.setDistance(context.pricing().getDistance());
         view.setFreight(context.pricing().getFreight());
+        view.setUrgentRate(context.pricing().getUrgentRate());
+        view.setMinimumUrgentFee(context.pricing().getMinimumUrgentFee());
         applyBreakdown(view, context.breakdown());
         view.setCurrency("TWD");
         view.setPricingVersion(context.pricing().getConfigVersion());
@@ -126,6 +143,10 @@ public class FlashDeliveryApplicationService {
             throw fail("flash.delivery.note.too.long");
         }
         QuoteContext quote = calculateQuote(request);
+        FlashDeliveryQuoteView latestQuote = quoteView(request, quote);
+        if (!matchesQuotedPrice(request, latestQuote)) {
+            throw new FlashDeliveryQuoteChangedException(latestQuote);
+        }
         Date now = new Date();
         String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
         validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
@@ -136,9 +157,13 @@ public class FlashDeliveryApplicationService {
         order.setClientRequestId(requestId);
         order.setUserId(userId);
         order.setServiceType(request.getServiceType());
+        order.setDeliveryType(request.getDeliveryType());
         order.setStatus(WAITING_ACCEPTANCE);
         order.setPackageType(request.getPackageType());
-        order.setPackageSize(request.getPackageSize());
+        order.setQuantity(request.getQuantity());
+        order.setTotalWeightKg(scaleWeight(request.getTotalWeightKg()));
+        order.setSpecification(trimToNull(request.getSpecification()));
+        order.setPackageSize(packageSize(request.getTotalWeightKg()));
         order.setDeliveryMode(deliveryMode);
         order.setScheduledPickupStartAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : null);
         order.setScheduledPickupEndAt("SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupEndAt() : null);
@@ -162,6 +187,11 @@ public class FlashDeliveryApplicationService {
         order.setFreight(quote.pricing().getFreight());
         // 保存计价快照,后台调价只影响新订单,不追溯修改历史订单金额。
         order.setDistanceFee(quote.breakdown().getDistanceFee());
+        order.setBaseDeliveryFee(quote.breakdown().getBaseDeliveryFee());
+        order.setUrgentRate(quote.pricing().getUrgentRate());
+        order.setMinimumUrgentFee(quote.pricing().getMinimumUrgentFee());
+        order.setUrgentFee(quote.breakdown().getUrgentFee());
+        order.setTipAmount(quote.breakdown().getTipAmount());
         order.setUserNote(trimToNull(request.getUserNote()));
         order.setVersion(0);
         order.setCreateTime(now);
@@ -274,7 +304,7 @@ public class FlashDeliveryApplicationService {
         if (!rider.supportsDeliveryType("FLASH")) throw fail("flash.delivery.rider.type.not.enabled");
         return riderDeliveryLockService.withLock(riderId, () -> {
             FlashDeliveryOrder target = requireOrder(orderId);
-            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getServiceType());
+            riderDeliveryExclusivityService.assertCanAcceptFlash(riderId, target.getDeliveryType());
             Date now = new Date();
             // Mapper 使用“待接单且 rider_id 为空”的条件更新,受影响行数为 1 才表示抢单成功。
             if (orderMapper.accept(orderId, riderId, now) != 1) {
@@ -306,13 +336,13 @@ public class FlashDeliveryApplicationService {
 
     public List<FlashDeliveryPricing> adminPricing() {
         return pricingMapper.selectList(new QueryWrapper<FlashDeliveryPricing>()
-                .orderByAsc("service_type", "start_time", "id"));
+                .orderByAsc("start_time", "id"));
     }
 
     @Transactional
     public FlashDeliveryPricing createPricing(Long adminId, FlashDeliveryPricingRequest request) {
         validatePricingRequest(request, false);
-        lockPricingServices(request.getServiceType());
+        lockPricing();
         ensureNoPricingOverlap(request, null);
         Date now = new Date();
         FlashDeliveryPricing pricing = pricingFrom(request);
@@ -330,35 +360,34 @@ public class FlashDeliveryApplicationService {
         validatePricingRequest(request, true);
         FlashDeliveryPricing preliminary = pricingId == null ? null : pricingMapper.selectById(pricingId);
         if (preliminary == null) throw fail("flash.delivery.pricing.not.found");
-        lockPricingServices(preliminary.getServiceType(), request.getServiceType());
+        lockPricing();
         FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
         if (pricing == null) throw fail("flash.delivery.pricing.not.found");
-        if (!Objects.equals(preliminary.getServiceType(), pricing.getServiceType())) {
-            throw fail("flash.delivery.pricing.changed");
-        }
         ensureNoPricingOverlap(request, pricingId);
         Date now = new Date();
         UpdateWrapper<FlashDeliveryPricing> update = new UpdateWrapper<FlashDeliveryPricing>()
                 .eq("id", pricingId).eq("config_version", request.getConfigVersion())
-                .set("service_type", request.getServiceType())
                 .set("start_time", request.getStartTime())
                 .set("end_time", request.getEndTime())
                 .set("starting_distance", scaleDistance(request.getStartingDistance()))
                 .set("starting_fare", request.getStartingFare())
                 .set("distance", scaleDistance(request.getDistance()))
                 .set("freight", request.getFreight())
+                .set("urgent_rate", normalizeRate(request.getUrgentRate()))
+                .set("minimum_urgent_fee", request.getMinimumUrgentFee())
                 .set("updated_by", adminId)
                 .set("update_time", now)
                 .setSql("config_version = config_version + 1");
         // 版本条件防止两个管理员同时保存时后提交者静默覆盖先提交者。
         if (pricingMapper.update(null, update) != 1) throw fail("flash.delivery.pricing.changed");
-        pricing.setServiceType(request.getServiceType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
         pricing.setStartingFare(request.getStartingFare());
         pricing.setDistance(scaleDistance(request.getDistance()));
         pricing.setFreight(request.getFreight());
+        pricing.setUrgentRate(normalizeRate(request.getUrgentRate()));
+        pricing.setMinimumUrgentFee(request.getMinimumUrgentFee());
         pricing.setConfigVersion(request.getConfigVersion() + 1);
         pricing.setUpdatedBy(adminId);
         pricing.setUpdateTime(now);
@@ -371,12 +400,9 @@ public class FlashDeliveryApplicationService {
         if (preliminary == null) {
             throw fail("flash.delivery.pricing.not.found");
         }
-        lockPricingServices(preliminary.getServiceType());
+        lockPricing();
         FlashDeliveryPricing pricing = pricingMapper.selectById(pricingId);
         if (pricing == null) throw fail("flash.delivery.pricing.not.found");
-        if (!Objects.equals(preliminary.getServiceType(), pricing.getServiceType())) {
-            throw fail("flash.delivery.pricing.changed");
-        }
         if (pricingMapper.deleteById(pricingId) != 1) throw fail("flash.delivery.pricing.not.found");
     }
 
@@ -431,12 +457,14 @@ public class FlashDeliveryApplicationService {
     private QuoteContext calculateQuote(FlashDeliveryQuoteRequest request) {
         if (request == null) throw fail("flash.delivery.request.required");
         validateServiceType(request.getServiceType());
+        validateDeliveryType(request.getDeliveryType());
         if (!hasText(request.getPackageType()) || !PACKAGE_TYPES.contains(request.getPackageType())) {
             throw fail("flash.delivery.package.type.invalid");
         }
-        if (!hasText(request.getPackageSize()) || !PACKAGE_SIZES.contains(request.getPackageSize())) {
-            throw fail("flash.delivery.package.size.invalid");
-        }
+        validateItem(request);
+        Date now = new Date();
+        String deliveryMode = normalizeDeliveryMode(request.getDeliveryMode());
+        validateSchedule(deliveryMode, request.getScheduledPickupStartAt(), request.getScheduledPickupEndAt(), now);
         validateAddress(request.getPickup());
         validateAddress(request.getDelivery());
         if (sameAddressText(request.getPickup(), request.getDelivery())
@@ -444,25 +472,26 @@ public class FlashDeliveryApplicationService {
                 && request.getPickup().getLongitude().compareTo(request.getDelivery().getLongitude()) == 0) {
             throw fail("flash.delivery.address.same");
         }
-        FlashDeliveryPricing pricing = findCurrentPricing(request.getServiceType());
+        Date pricingDate = "SCHEDULED".equals(deliveryMode) ? request.getScheduledPickupStartAt() : now;
+        FlashDeliveryPricing pricing = findPricingAt(pricingTargetTime(pricingDate));
         // 路线服务优先使用 Google Routes;不可用时在服务内部降级为球面直线距离。
         RouteDistance route = routeService.calculate(
                 new GeoPoint(request.getPickup().getLatitude(), request.getPickup().getLongitude()),
                 new GeoPoint(request.getDelivery().getLatitude(), request.getDelivery().getLongitude()));
         if (route.distanceMeters() > MAX_DISTANCE_METERS) throw fail("flash.delivery.distance.too.far");
         FlashDeliveryPriceBreakdown breakdown = pricingCalculator.calculateBreakdown(pricing,
-                route.distanceMeters());
+                route.distanceMeters(), request.getDeliveryType(), request.getTipAmount());
         return new QuoteContext(pricing, route, breakdown);
     }
 
-    private FlashDeliveryPricing findCurrentPricing(String serviceType) {
-        FlashDeliveryPricing pricing = findCurrentPricingOrNull(serviceType);
+    private FlashDeliveryPricing findPricingAt(String targetTime) {
+        FlashDeliveryPricing pricing = findPricingAtOrNull(targetTime);
         if (pricing == null) throw fail("flash.delivery.pricing.not.available");
         return pricing;
     }
 
-    private FlashDeliveryPricing findCurrentPricingOrNull(String serviceType) {
-        List<FlashDeliveryPricing> pricing = pricingMapper.selectCurrent(serviceType);
+    private FlashDeliveryPricing findPricingAtOrNull(String targetTime) {
+        List<FlashDeliveryPricing> pricing = pricingMapper.selectAtTime(targetTime);
         if (pricing == null || pricing.isEmpty()) return null;
         if (pricing.size() > 1) throw fail("flash.delivery.pricing.overlap");
         return pricing.get(0);
@@ -470,9 +499,9 @@ public class FlashDeliveryApplicationService {
 
     private void validatePricingRequest(FlashDeliveryPricingRequest request, boolean requireVersion) {
         if (request == null) throw fail("flash.delivery.pricing.invalid");
-        validateServiceType(request.getServiceType());
         BigDecimal startingDistance = normalizeDistance(request.getStartingDistance());
         BigDecimal distance = normalizeDistance(request.getDistance());
+        BigDecimal urgentRate = normalizeRate(request.getUrgentRate());
         if (!validStartTime(request.getStartTime()) || !validEndTime(request.getEndTime())
                 || toMinute(request.getStartTime()) >= toMinute(request.getEndTime())
                 || startingDistance == null || startingDistance.signum() <= 0
@@ -481,39 +510,36 @@ public class FlashDeliveryApplicationService {
                 || distance == null || distance.signum() <= 0
                 || distance.compareTo(MAX_PRICING_DISTANCE) > 0
                 || request.getFreight() == null || request.getFreight() <= 0
+                || urgentRate == null || urgentRate.signum() < 0
+                || urgentRate.compareTo(MAX_PRICING_DISTANCE) > 0
+                || request.getMinimumUrgentFee() == null || request.getMinimumUrgentFee() < 0
                 || requireVersion && (request.getConfigVersion() == null || request.getConfigVersion() <= 0)) {
             throw fail("flash.delivery.pricing.invalid");
         }
     }
 
     private void ensureNoPricingOverlap(FlashDeliveryPricingRequest request, Long excludedId) {
-        if (pricingMapper.countOverlapping(request.getServiceType(), request.getStartTime(),
-                request.getEndTime(), excludedId) > 0) {
+        if (pricingMapper.countOverlapping(request.getStartTime(), request.getEndTime(), excludedId) > 0) {
             throw fail("flash.delivery.pricing.overlap");
         }
     }
 
-    private void lockPricingServices(String... serviceTypes) {
-        Arrays.stream(serviceTypes)
-                .filter(Objects::nonNull)
-                .distinct()
-                .sorted()
-                .forEach(serviceType -> {
-                    if (!Objects.equals(serviceType, pricingMapper.lockServiceType(serviceType))) {
-                        throw fail("flash.delivery.pricing.invalid");
-                    }
-                });
+    private void lockPricing() {
+        if (!Objects.equals("GLOBAL", pricingMapper.lockPricing())) {
+            throw fail("flash.delivery.pricing.invalid");
+        }
     }
 
     private FlashDeliveryPricing pricingFrom(FlashDeliveryPricingRequest request) {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
-        pricing.setServiceType(request.getServiceType());
         pricing.setStartTime(request.getStartTime());
         pricing.setEndTime(request.getEndTime());
         pricing.setStartingDistance(scaleDistance(request.getStartingDistance()));
         pricing.setStartingFare(request.getStartingFare());
         pricing.setDistance(scaleDistance(request.getDistance()));
         pricing.setFreight(request.getFreight());
+        pricing.setUrgentRate(normalizeRate(request.getUrgentRate()));
+        pricing.setMinimumUrgentFee(request.getMinimumUrgentFee());
         return pricing;
     }
 
@@ -538,6 +564,10 @@ public class FlashDeliveryApplicationService {
         return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
     }
 
+    private BigDecimal normalizeRate(BigDecimal value) {
+        return value == null ? null : value.setScale(2, RoundingMode.HALF_UP);
+    }
+
     private void transitionWithProof(Long riderId, Long orderId, FlashDeliveryProofRequest request,
                                      String expected, String next, String proofType, String timeColumn) {
         List<String> urls = validateProofUrls(request == null ? null : request.getImageUrls(), true);
@@ -609,8 +639,11 @@ public class FlashDeliveryApplicationService {
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
+        view.setDeliveryType(order.getDeliveryType());
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
+        view.setQuantity(order.getQuantity());
+        view.setTotalWeightKg(order.getTotalWeightKg());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -619,6 +652,9 @@ public class FlashDeliveryApplicationService {
         view.setDeliveryAddress(order.getDeliveryAddress());
         view.setDeliveryDetailAddress(order.getDeliveryAddressDetail());
         view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
+        view.setUrgentFee(order.getUrgentFee());
+        view.setTipAmount(order.getTipAmount());
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
         view.setDeliveredAt(order.getDeliveredAt());
@@ -632,8 +668,12 @@ public class FlashDeliveryApplicationService {
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
+        view.setDeliveryType(order.getDeliveryType());
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
+        view.setQuantity(order.getQuantity());
+        view.setTotalWeightKg(order.getTotalWeightKg());
+        view.setSpecification(order.getSpecification());
         view.setPackageSize(order.getPackageSize());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
@@ -647,6 +687,10 @@ public class FlashDeliveryApplicationService {
                 order.getPickupLongitude(), order.getPickupLatitude()));
         view.setDistanceMeters(order.getDistanceMeters());
         view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
+        view.setDistanceFee(order.getDistanceFee());
+        view.setUrgentFee(order.getUrgentFee());
+        view.setTipAmount(order.getTipAmount());
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
         view.setCreateTime(order.getCreateTime());
@@ -673,9 +717,9 @@ public class FlashDeliveryApplicationService {
         view.setDeliveryImageUrls(delivery);
     }
 
-    private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing) {
+    private FlashDeliveryServiceView serviceView(FlashDeliveryPricing pricing, String serviceType) {
         FlashDeliveryServiceView view = new FlashDeliveryServiceView();
-        view.setServiceType(pricing.getServiceType());
+        view.setServiceType(serviceType);
         view.setPricingId(pricing.getId());
         view.setStartTime(pricing.getStartTime());
         view.setEndTime(pricing.getEndTime());
@@ -683,6 +727,8 @@ public class FlashDeliveryApplicationService {
         view.setStartingFare(pricing.getStartingFare());
         view.setDistance(pricing.getDistance());
         view.setFreight(pricing.getFreight());
+        view.setUrgentRate(pricing.getUrgentRate());
+        view.setMinimumUrgentFee(pricing.getMinimumUrgentFee());
         view.setPricingVersion(pricing.getConfigVersion());
         view.setCurrency("TWD");
         return view;
@@ -693,8 +739,12 @@ public class FlashDeliveryApplicationService {
         view.setId(order.getId());
         view.setOrderNo(order.getOrderNo());
         view.setServiceType(order.getServiceType());
+        view.setDeliveryType(order.getDeliveryType());
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
+        view.setQuantity(order.getQuantity());
+        view.setTotalWeightKg(order.getTotalWeightKg());
+        view.setSpecification(order.getSpecification());
         view.setPackageSize(order.getPackageSize());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
@@ -705,6 +755,10 @@ public class FlashDeliveryApplicationService {
         view.setDistanceMeters(order.getDistanceMeters());
         view.setDistanceSource(order.getDistanceSource());
         view.setEstimatedDurationSeconds(order.getEstimatedDurationSeconds());
+        view.setBaseDeliveryFee(order.getBaseDeliveryFee());
+        view.setDistanceFee(order.getDistanceFee());
+        view.setUrgentFee(order.getUrgentFee());
+        view.setTipAmount(order.getTipAmount());
         view.setAmount(order.getAmount());
         view.setCurrency(order.getCurrency());
         view.setUserNote(includeSensitiveFields ? order.getUserNote() : null);
@@ -760,6 +814,9 @@ public class FlashDeliveryApplicationService {
     private void applyBreakdown(FlashDeliveryQuoteView view, FlashDeliveryPriceBreakdown breakdown) {
         view.setBillableDistance(breakdown.getBillableDistance());
         view.setDistanceFee(breakdown.getDistanceFee());
+        view.setBaseDeliveryFee(breakdown.getBaseDeliveryFee());
+        view.setUrgentFee(breakdown.getUrgentFee());
+        view.setTipAmount(breakdown.getTipAmount());
         view.setAmount(breakdown.getAmount());
     }
 
@@ -848,6 +905,49 @@ public class FlashDeliveryApplicationService {
         if (!hasText(value) || !SERVICE_TYPES.contains(value)) throw fail("flash.delivery.service.type.invalid");
     }
 
+    private void validateDeliveryType(String value) {
+        if (!hasText(value) || !DELIVERY_TYPES.contains(value)) {
+            throw fail("flash.delivery.delivery.type.invalid");
+        }
+    }
+
+    private void validateItem(FlashDeliveryQuoteRequest request) {
+        BigDecimal weight = request.getTotalWeightKg();
+        if (request.getQuantity() == null || request.getQuantity() <= 0
+                || weight == null || weight.signum() <= 0
+                || weight.scale() > 2 || weight.compareTo(MAX_TOTAL_WEIGHT_KG) > 0
+                || tooLong(request.getSpecification(), 255)) {
+            throw fail("flash.delivery.item.invalid");
+        }
+        if (request.getTipAmount() == null || request.getTipAmount() < 0) {
+            throw fail("flash.delivery.tip.invalid");
+        }
+    }
+
+    private String packageSize(BigDecimal weight) {
+        if (weight.compareTo(new BigDecimal("5.00")) <= 0) return "SMALL";
+        if (weight.compareTo(new BigDecimal("12.00")) <= 0) return "MEDIUM";
+        return "LARGE";
+    }
+
+    private BigDecimal scaleWeight(BigDecimal weight) {
+        return weight.setScale(2, RoundingMode.UNNECESSARY);
+    }
+
+    private String pricingTargetTime(Date date) {
+        return PRICING_TIME_FORMAT.format(Instant.ofEpochMilli(date.getTime())
+                .atZone(TAIPEI).toLocalTime());
+    }
+
+    private boolean matchesQuotedPrice(FlashDeliveryCreateRequest request, FlashDeliveryQuoteView latest) {
+        return Objects.equals(request.getPricingId(), latest.getPricingId())
+                && Objects.equals(request.getPricingVersion(), latest.getPricingVersion())
+                && Objects.equals(request.getQuotedBaseDeliveryFee(), latest.getBaseDeliveryFee())
+                && Objects.equals(request.getQuotedDistanceFee(), latest.getDistanceFee())
+                && Objects.equals(request.getQuotedUrgentFee(), latest.getUrgentFee())
+                && Objects.equals(request.getQuotedAmount(), latest.getAmount());
+    }
+
     private void validateOptionalServiceType(String value) {
         if (hasText(value)) validateServiceType(value);
     }

+ 22 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryPricingCalculator.java

@@ -11,15 +11,24 @@ import java.math.RoundingMode;
 @Component
 public class FlashDeliveryPricingCalculator {
     public Long calculate(FlashDeliveryPricing pricing, int distanceMeters) {
-        return calculateBreakdown(pricing, distanceMeters).getAmount();
+        return calculateBreakdown(pricing, distanceMeters, "NORMAL", 0L).getAmount();
     }
 
     public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters) {
+        return calculateBreakdown(pricing, distanceMeters, "NORMAL", 0L);
+    }
+
+    public FlashDeliveryPriceBreakdown calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters,
+                                                           String deliveryType, Long tipAmount) {
         if (pricing == null || distanceMeters <= 0
                 || pricing.getStartingDistance() == null || pricing.getStartingDistance().signum() <= 0
                 || pricing.getStartingFare() == null || pricing.getStartingFare() <= 0
                 || pricing.getDistance() == null || pricing.getDistance().signum() <= 0
-                || pricing.getFreight() == null || pricing.getFreight() <= 0) {
+                || pricing.getFreight() == null || pricing.getFreight() <= 0
+                || pricing.getUrgentRate() == null || pricing.getUrgentRate().signum() < 0
+                || pricing.getMinimumUrgentFee() == null || pricing.getMinimumUrgentFee() < 0
+                || !("NORMAL".equals(deliveryType) || "URGENT".equals(deliveryType))
+                || tipAmount == null || tipAmount < 0) {
             throw new IllegalArgumentException("invalid pricing or distance");
         }
 
@@ -43,7 +52,17 @@ public class FlashDeliveryPricingCalculator {
         FlashDeliveryPriceBreakdown result = new FlashDeliveryPriceBreakdown();
         result.setBillableDistance(chargeableDistance.setScale(2, RoundingMode.HALF_UP));
         result.setDistanceFee(distanceFee);
-        result.setAmount(Math.addExact(pricing.getStartingFare(), distanceFee));
+        long baseDeliveryFee = Math.addExact(pricing.getStartingFare(), distanceFee);
+        long urgentFee = "URGENT".equals(deliveryType)
+                ? Math.max(pricing.getMinimumUrgentFee(), BigDecimal.valueOf(baseDeliveryFee)
+                .multiply(pricing.getUrgentRate())
+                .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
+                .longValueExact())
+                : 0L;
+        result.setBaseDeliveryFee(baseDeliveryFee);
+        result.setUrgentFee(urgentFee);
+        result.setTipAmount(tipAmount);
+        result.setAmount(Math.addExact(Math.addExact(baseDeliveryFee, urgentFee), tipAmount));
         return result;
     }
 }

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

@@ -30,8 +30,8 @@ public class RiderDeliveryExclusivityService {
         if (hasActiveFlash(riderId, true)) throw conflict();
     }
 
-    public void assertCanAcceptFlash(Long riderId, String serviceType) {
-        if (URGENT.equals(serviceType)) {
+    public void assertCanAcceptFlash(Long riderId, String deliveryType) {
+        if (URGENT.equals(deliveryType)) {
             if (hasActiveFood(riderId) || hasActiveFlash(riderId, false)) throw conflict();
             return;
         }
@@ -51,7 +51,7 @@ public class RiderDeliveryExclusivityService {
         QueryWrapper<FlashDeliveryOrder> query = new QueryWrapper<FlashDeliveryOrder>()
                 .eq("rider_id", riderId)
                 .in("status", ACCEPTED, PICKED_UP);
-        if (urgentOnly) query.eq("service_type", URGENT);
+        if (urgentOnly) query.eq("delivery_type", URGENT);
         return flashOrders.selectCount(query) > 0;
     }
 

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

@@ -322,6 +322,10 @@ flash.delivery.pin.invalid=Invalid delivery PIN
 flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
+flash.delivery.delivery.type.invalid=Unsupported delivery level
+flash.delivery.item.invalid=Item quantity, total weight or specification is invalid
+flash.delivery.tip.invalid=Rider tip must be a non-negative integer TWD amount
+flash.delivery.quote.changed=The quote has changed, please review the latest price
 flash.delivery.tab.invalid=Invalid flash delivery list tab
 flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible

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

@@ -325,6 +325,10 @@ flash.delivery.pin.invalid=Invalid delivery PIN
 flash.delivery.scene.invalid=Invalid order status group
 flash.delivery.pricing.not.found=Pricing configuration does not exist
 flash.delivery.pricing.changed=Pricing configuration has changed, please refresh and retry
+flash.delivery.delivery.type.invalid=Unsupported delivery level
+flash.delivery.item.invalid=Item quantity, total weight or specification is invalid
+flash.delivery.tip.invalid=Rider tip must be a non-negative integer TWD amount
+flash.delivery.quote.changed=The quote has changed, please review the latest price
 flash.delivery.tab.invalid=Invalid flash delivery list tab
 flash.delivery.coordinates.invalid=Latitude and longitude must be provided in pairs and within valid ranges
 address.access.denied=Address does not exist or is not accessible

+ 4 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -325,6 +325,10 @@ flash.delivery.pin.invalid=Mã PIN giao hàng không chính xác
 flash.delivery.scene.invalid=Nhóm trạng thái đơn hàng không hợp lệ
 flash.delivery.pricing.not.found=Không tìm thấy cấu hình giá
 flash.delivery.pricing.changed=Cấu hình giá đã thay đổi, vui lòng tải lại và thử lại
+flash.delivery.delivery.type.invalid=Cấp độ giao hàng không được hỗ trợ
+flash.delivery.item.invalid=Số lượng, tổng trọng lượng hoặc quy cách hàng hóa không hợp lệ
+flash.delivery.tip.invalid=Tiền boa cho tài xế phải là số nguyên TWD không âm
+flash.delivery.quote.changed=Báo giá đã thay đổi, vui lòng xác nhận lại chi phí mới nhất
 flash.delivery.tab.invalid=Tab danh sách giao hàng nhanh không hợp lệ
 flash.delivery.coordinates.invalid=Vĩ độ và kinh độ phải được cung cấp theo cặp và nằm trong phạm vi hợp lệ
 address.access.denied=Địa chỉ không tồn tại hoặc không có quyền truy cập

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

@@ -326,6 +326,10 @@ flash.delivery.pin.invalid=交付PIN不正确
 flash.delivery.scene.invalid=订单状态分组参数无效
 flash.delivery.pricing.not.found=计价配置不存在
 flash.delivery.pricing.changed=计价配置已发生变化,请刷新后重试
+flash.delivery.delivery.type.invalid=不支持的配送等级
+flash.delivery.item.invalid=物品数量、总重量或规格信息不正确
+flash.delivery.tip.invalid=骑手小费必须是非负整数新台币金额
+flash.delivery.quote.changed=报价已变化,请重新确认最新费用
 flash.delivery.tab.invalid=闪送列表页签无效
 flash.delivery.coordinates.invalid=经纬度必须成对提供且范围有效
 address.access.denied=地址不存在或无权访问

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

@@ -326,6 +326,10 @@ flash.delivery.pin.invalid=交付PIN不正確
 flash.delivery.scene.invalid=訂單狀態分組參數無效
 flash.delivery.pricing.not.found=計價設定不存在
 flash.delivery.pricing.changed=計價設定已發生變更,請重新整理後重試
+flash.delivery.delivery.type.invalid=不支援的配送等級
+flash.delivery.item.invalid=物品數量、總重量或規格資訊不正確
+flash.delivery.tip.invalid=騎手小費必須是非負整數新台幣金額
+flash.delivery.quote.changed=報價已變更,請重新確認最新費用
 flash.delivery.tab.invalid=閃送列表頁籤無效
 flash.delivery.coordinates.invalid=經緯度必須成對提供且範圍有效
 address.access.denied=地址不存在或無權存取

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

@@ -4,6 +4,8 @@ import com.ruoyi.app.flashdelivery.dto.FlashDeliveryCreateRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryOrderDetailView;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryProofRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
+import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteView;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.service.FlashDeliveryApplicationService;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.spring.SpringUtils;
@@ -116,6 +118,21 @@ class FlashDeliveryControllerContractTest {
         assertSame(detail, acceptResult.get(AjaxResult.DATA_TAG));
     }
 
+    @Test
+    void createReturnsLatestQuoteWhenClientQuoteChanged() {
+        FlashDeliveryApplicationService service = mock(FlashDeliveryApplicationService.class);
+        FlashDeliveryCreateRequest request = new FlashDeliveryCreateRequest();
+        FlashDeliveryQuoteView latest = new FlashDeliveryQuoteView();
+        latest.setAmount(120L);
+        when(service.create(7L, request)).thenThrow(new FlashDeliveryQuoteChangedException(latest));
+
+        AjaxResult result = new FlashDeliveryUserController(service)
+                .create(JwtUtil.token("7", "user"), request);
+
+        assertSame(latest, result.get(AjaxResult.DATA_TAG));
+        assertTrue(((Number) result.get(AjaxResult.CODE_TAG)).intValue() != 200);
+    }
+
     @Test
     void everyAdminEndpointDeclaresPlatformPermission() {
         for (Method method : FlashDeliveryAdminController.class.getDeclaredMethods()) {

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

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

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

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

+ 124 - 25
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -9,6 +9,7 @@ import com.ruoyi.app.flashdelivery.dto.FlashDeliveryPricingRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryQuoteRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryDeliverRequest;
 import com.ruoyi.app.flashdelivery.dto.FlashDeliveryRiderOrderPageView;
+import com.ruoyi.app.flashdelivery.exception.FlashDeliveryQuoteChangedException;
 import com.ruoyi.app.flashdelivery.route.FlashDeliveryRouteService;
 import com.ruoyi.app.flashdelivery.route.RouteDistance;
 import com.ruoyi.app.order.RiderDeliveryExclusivityService;
@@ -39,6 +40,7 @@ import org.springframework.test.util.ReflectionTestUtils;
 import java.math.BigDecimal;
 import java.util.List;
 import java.util.Date;
+import java.util.Calendar;
 
 import static com.ruoyi.system.domain.flash.FlashDeliveryStatus.ACCEPTED;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -49,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import com.ruoyi.common.exception.ServiceException;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.mock;
@@ -82,7 +85,7 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteUsesServerRouteAndCurrentTimePricing() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3001, 600, "ROUTE"));
 
         var quote = fixture.service.quote(request());
@@ -98,7 +101,7 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void quoteRejectsRoutesLongerThanFortyKilometres() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(40001, 1800, "ROUTE"));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
@@ -161,7 +164,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
                 .thenReturn(List.of(88L));
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         doAnswer(invocation -> {
             FlashDeliveryOrder order = invocation.getArgument(0);
@@ -183,7 +186,12 @@ class FlashDeliveryApplicationServiceTest {
         ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
         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(new BigDecimal("3.00"), saved.getValue().getTotalWeightKg());
+        assertEquals("30 x 20 x 10 cm", saved.getValue().getSpecification());
+        assertEquals("SMALL", saved.getValue().getPackageSize());
         assertEquals(88L, saved.getValue().getReceiverUserId());
         assertEquals(1L, saved.getValue().getPricingId());
         assertEquals("00:00", saved.getValue().getPricingStartTime());
@@ -193,6 +201,11 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(new BigDecimal("1.00"), saved.getValue().getDistance());
         assertEquals(15L, saved.getValue().getFreight());
         assertEquals(0L, saved.getValue().getDistanceFee());
+        assertEquals(90L, saved.getValue().getBaseDeliveryFee());
+        assertEquals(new BigDecimal("20.00"), saved.getValue().getUrgentRate());
+        assertEquals(10L, saved.getValue().getMinimumUrgentFee());
+        assertEquals(0L, saved.getValue().getUrgentFee());
+        assertEquals(0L, saved.getValue().getTipAmount());
     }
 
     @Test
@@ -223,7 +236,7 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createDoesNotBindReceiverWhenNormalizedPhoneMatchesMultipleUsers() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("886912345678"))
                 .thenReturn(List.of(88L, 99L));
@@ -245,7 +258,7 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void createLeavesReceiverUnboundWhenPhoneHasNoRegisteredUser() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND")).thenReturn(List.of(pricing()));
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
         when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
         when(fixture.userMapper.selectOrdinaryUserIdsByNormalizedPhone("0922222222"))
                 .thenReturn(List.of());
@@ -576,7 +589,7 @@ class FlashDeliveryApplicationServiceTest {
 
         InOrder order = inOrder(fixture.lockService, fixture.exclusivityService, fixture.orderMapper);
         order.verify(fixture.lockService).withLock(eq(8L), any(RiderDeliveryLockService.LockedCall.class));
-        order.verify(fixture.exclusivityService).assertCanAcceptFlash(8L, "HELP_SEND");
+        order.verify(fixture.exclusivityService).assertCanAcceptFlash(8L, "NORMAL");
         order.verify(fixture.orderMapper).accept(eq(10L), eq(8L), any(Date.class));
     }
 
@@ -586,7 +599,7 @@ class FlashDeliveryApplicationServiceTest {
         rider(fixture, 8L);
         when(fixture.orderMapper.selectById(10L)).thenReturn(order(10L, "WAITING_ACCEPTANCE"));
         doThrow(new ServiceException("exclusive"))
-                .when(fixture.exclusivityService).assertCanAcceptFlash(8L, "HELP_SEND");
+                .when(fixture.exclusivityService).assertCanAcceptFlash(8L, "NORMAL");
 
         assertThrows(ServiceException.class, () -> fixture.service.accept(8L, 10L));
 
@@ -628,7 +641,7 @@ class FlashDeliveryApplicationServiceTest {
     void concurrentPricingUpdateIsRejected() {
         Fixture fixture = new Fixture();
         when(fixture.pricingMapper.selectById(1L)).thenReturn(pricing());
-        when(fixture.pricingMapper.countOverlapping("HELP_SEND", "00:00", "12:00", 1L)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping("00:00", "12:00", 1L)).thenReturn(0);
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
         FlashDeliveryPricingRequest request = pricingRequest();
         request.setEndTime("12:00");
@@ -643,7 +656,7 @@ class FlashDeliveryApplicationServiceTest {
     void overlappingPricingPeriodIsRejectedBeforeInsert() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("HELP_SEND", "00:00", "24:00", null)).thenReturn(1);
+        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(1);
 
         assertThrows(ServiceException.class, () -> fixture.service.createPricing(1L, request));
 
@@ -681,7 +694,7 @@ class FlashDeliveryApplicationServiceTest {
     void createPricingPersistsImmediatelyActiveTimePeriod() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricingRequest request = pricingRequest();
-        when(fixture.pricingMapper.countOverlapping("HELP_SEND", "00:00", "24:00", null)).thenReturn(0);
+        when(fixture.pricingMapper.countOverlapping("00:00", "24:00", null)).thenReturn(0);
 
         fixture.service.createPricing(7L, request);
 
@@ -692,9 +705,9 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(90L, saved.getValue().getStartingFare());
 
         InOrder writeOrder = inOrder(fixture.pricingMapper);
-        writeOrder.verify(fixture.pricingMapper).lockServiceType("HELP_SEND");
+        writeOrder.verify(fixture.pricingMapper).lockPricing();
         writeOrder.verify(fixture.pricingMapper)
-                .countOverlapping("HELP_SEND", "00:00", "24:00", null);
+                .countOverlapping("00:00", "24:00", null);
         writeOrder.verify(fixture.pricingMapper).insert(any(FlashDeliveryPricing.class));
     }
 
@@ -717,7 +730,7 @@ class FlashDeliveryApplicationServiceTest {
     @Test
     void multipleCurrentPricingPeriodsFailExplicitlyBeforeRouteLookup() {
         Fixture fixture = new Fixture();
-        when(fixture.pricingMapper.selectCurrent("HELP_SEND"))
+        when(fixture.pricingMapper.selectAtTime(anyString()))
                 .thenReturn(List.of(pricing(), pricing()));
 
         assertThrows(ServiceException.class, () -> fixture.service.quote(request()));
@@ -726,10 +739,9 @@ class FlashDeliveryApplicationServiceTest {
     }
 
     @Test
-    void movingPricingPeriodLocksOldAndNewServiceTypesInStableOrder() {
+    void updatingPricingUsesTheSingleGlobalLock() {
         Fixture fixture = new Fixture();
         FlashDeliveryPricing existing = pricing();
-        existing.setServiceType("URGENT");
         when(fixture.pricingMapper.selectById(1L)).thenReturn(existing);
         when(fixture.pricingMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
         FlashDeliveryPricingRequest request = pricingRequest();
@@ -738,10 +750,9 @@ class FlashDeliveryApplicationServiceTest {
         fixture.service.updatePricing(9L, 1L, request);
 
         InOrder lockOrder = inOrder(fixture.pricingMapper);
-        lockOrder.verify(fixture.pricingMapper).lockServiceType("HELP_SEND");
-        lockOrder.verify(fixture.pricingMapper).lockServiceType("URGENT");
+        lockOrder.verify(fixture.pricingMapper).lockPricing();
         lockOrder.verify(fixture.pricingMapper)
-                .countOverlapping("HELP_SEND", "00:00", "24:00", 1L);
+                .countOverlapping("00:00", "24:00", 1L);
     }
 
     @Test
@@ -754,16 +765,84 @@ class FlashDeliveryApplicationServiceTest {
 
         InOrder deleteOrder = inOrder(fixture.pricingMapper);
         deleteOrder.verify(fixture.pricingMapper).selectById(18L);
-        deleteOrder.verify(fixture.pricingMapper).lockServiceType("HELP_SEND");
+        deleteOrder.verify(fixture.pricingMapper).lockPricing();
         deleteOrder.verify(fixture.pricingMapper).selectById(18L);
         deleteOrder.verify(fixture.pricingMapper).deleteById(18L);
     }
 
+    @Test
+    void urgentQuoteReturnsSeparatedFeesAndUsesScheduledPickupTime() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime("16:30")).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(6000, 900, "ROUTE"));
+        FlashDeliveryQuoteRequest request = request();
+        request.setDeliveryType("URGENT");
+        request.setTipAmount(5L);
+        request.setDeliveryMode("SCHEDULED");
+        Calendar calendar = Calendar.getInstance();
+        calendar.add(Calendar.DAY_OF_MONTH, 1);
+        calendar.set(Calendar.HOUR_OF_DAY, 16);
+        calendar.set(Calendar.MINUTE, 30);
+        calendar.set(Calendar.SECOND, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+        request.setScheduledPickupStartAt(calendar.getTime());
+        request.setScheduledPickupEndAt(new Date(calendar.getTimeInMillis() + 30L * 60L * 1000L));
+
+        var quote = fixture.service.quote(request);
+
+        assertEquals(45L, quote.getDistanceFee());
+        assertEquals(135L, quote.getBaseDeliveryFee());
+        assertEquals(27L, quote.getUrgentFee());
+        assertEquals(5L, quote.getTipAmount());
+        assertEquals(167L, quote.getAmount());
+        verify(fixture.pricingMapper).selectAtTime("16:30");
+    }
+
+    @Test
+    void createRejectsChangedQuoteAndReturnsLatestQuoteWithoutInsert() {
+        Fixture fixture = new Fixture();
+        when(fixture.pricingMapper.selectAtTime(anyString())).thenReturn(List.of(pricing()));
+        when(fixture.routeService.calculate(any(), any())).thenReturn(new RouteDistance(3200, 600, "ROUTE"));
+        FlashDeliveryCreateRequest request = createRequest();
+        request.setQuotedAmount(91L);
+
+        FlashDeliveryQuoteChangedException error = assertThrows(FlashDeliveryQuoteChangedException.class,
+                () -> fixture.service.create(7L, request));
+
+        assertEquals(90L, error.getLatestQuote().getAmount());
+        assertEquals(1L, error.getLatestQuote().getPricingId());
+        verify(fixture.orderMapper, never()).insert(any(FlashDeliveryOrder.class));
+    }
+
+    @Test
+    void quoteValidatesDeliveryLevelAndCompleteItemInformation() {
+        Fixture fixture = new Fixture();
+        FlashDeliveryQuoteRequest request = request();
+        request.setDeliveryType("FAST");
+        FlashDeliveryQuoteRequest invalidDeliveryType = request;
+        assertThrows(ServiceException.class, () -> fixture.service.quote(invalidDeliveryType));
+
+        request = request();
+        request.setQuantity(0);
+        FlashDeliveryQuoteRequest invalidQuantity = request;
+        assertThrows(ServiceException.class, () -> fixture.service.quote(invalidQuantity));
+
+        request = request();
+        request.setTotalWeightKg(new BigDecimal("20.01"));
+        FlashDeliveryQuoteRequest overweight = request;
+        assertThrows(ServiceException.class, () -> fixture.service.quote(overweight));
+    }
+
     private FlashDeliveryQuoteRequest request() {
         FlashDeliveryQuoteRequest request = new FlashDeliveryQuoteRequest();
         request.setServiceType("HELP_SEND");
+        request.setDeliveryType("NORMAL");
         request.setPackageType("DOCUMENT");
-        request.setPackageSize("SMALL");
+        request.setQuantity(1);
+        request.setTotalWeightKg(new BigDecimal("3.00"));
+        request.setSpecification("30 x 20 x 10 cm");
+        request.setTipAmount(0L);
+        request.setDeliveryMode("NOW");
         request.setPickup(address("A", "0911111111", "25.0330", "121.5645"));
         request.setDelivery(address("B", "0922222222", "25.0478", "121.5319"));
         request.getDelivery().setAddress("second road");
@@ -774,8 +853,12 @@ class FlashDeliveryApplicationServiceTest {
         FlashDeliveryCreateRequest request = new FlashDeliveryCreateRequest();
         FlashDeliveryQuoteRequest quote = request();
         request.setServiceType(quote.getServiceType());
+        request.setDeliveryType(quote.getDeliveryType());
         request.setPackageType(quote.getPackageType());
-        request.setPackageSize(quote.getPackageSize());
+        request.setQuantity(quote.getQuantity());
+        request.setTotalWeightKg(quote.getTotalWeightKg());
+        request.setSpecification(quote.getSpecification());
+        request.setTipAmount(quote.getTipAmount());
         request.setPickup(quote.getPickup());
         request.setDelivery(quote.getDelivery());
         request.setClientRequestId("flash-scheduled-001");
@@ -784,6 +867,12 @@ class FlashDeliveryApplicationServiceTest {
         request.setScheduledPickupStartAt(start);
         request.setScheduledPickupEndAt(new Date(start.getTime() + 30L * 60L * 1000L));
         request.setPinRequired(true);
+        request.setPricingId(1L);
+        request.setPricingVersion(1);
+        request.setQuotedBaseDeliveryFee(90L);
+        request.setQuotedDistanceFee(0L);
+        request.setQuotedUrgentFee(0L);
+        request.setQuotedAmount(90L);
         request.setSenderImageUrls(List.of("https://example.com/sender.jpg"));
         return request;
     }
@@ -802,26 +891,28 @@ class FlashDeliveryApplicationServiceTest {
     private FlashDeliveryPricing pricing() {
         FlashDeliveryPricing pricing = new FlashDeliveryPricing();
         pricing.setId(1L);
-        pricing.setServiceType("HELP_SEND");
         pricing.setStartTime("00:00");
         pricing.setEndTime("24:00");
         pricing.setStartingDistance(new BigDecimal("3.00"));
         pricing.setStartingFare(90L);
         pricing.setDistance(new BigDecimal("1.00"));
         pricing.setFreight(15L);
+        pricing.setUrgentRate(new BigDecimal("20.00"));
+        pricing.setMinimumUrgentFee(10L);
         pricing.setConfigVersion(1);
         return pricing;
     }
 
     private FlashDeliveryPricingRequest pricingRequest() {
         FlashDeliveryPricingRequest request = new FlashDeliveryPricingRequest();
-        request.setServiceType("HELP_SEND");
         request.setStartTime("00:00");
         request.setEndTime("24:00");
         request.setStartingDistance(new BigDecimal("3.00"));
         request.setStartingFare(90L);
         request.setDistance(new BigDecimal("1.00"));
         request.setFreight(15L);
+        request.setUrgentRate(new BigDecimal("20.00"));
+        request.setMinimumUrgentFee(10L);
         return request;
     }
 
@@ -859,8 +950,11 @@ class FlashDeliveryApplicationServiceTest {
         order.setOrderNo("FL-20260813-021");
         order.setUserId(7L);
         order.setServiceType("HELP_SEND");
+        order.setDeliveryType("NORMAL");
         order.setStatus(status);
         order.setPackageType("DOCUMENT");
+        order.setQuantity(1);
+        order.setTotalWeightKg(new BigDecimal("3.00"));
         order.setPackageSize("SMALL");
         order.setDeliveryMode("NOW");
         order.setPinRequired(true);
@@ -883,6 +977,12 @@ class FlashDeliveryApplicationServiceTest {
         order.setDeliveryLatitude(new BigDecimal("25.0200"));
         order.setDistanceMeters(3200);
         order.setEstimatedDurationSeconds(2100);
+        order.setDistanceFee(12L);
+        order.setBaseDeliveryFee(102L);
+        order.setUrgentRate(new BigDecimal("20.00"));
+        order.setMinimumUrgentFee(10L);
+        order.setUrgentFee(0L);
+        order.setTipAmount(10L);
         order.setAmount(112L);
         order.setCurrency("TWD");
         order.setUserNote("private note");
@@ -911,8 +1011,7 @@ class FlashDeliveryApplicationServiceTest {
                 routeService, new FlashDeliveryPricingCalculator(), lockService, exclusivityService);
 
         Fixture() {
-            when(pricingMapper.lockServiceType(any()))
-                    .thenAnswer(invocation -> invocation.getArgument(0));
+            when(pricingMapper.lockPricing()).thenReturn("GLOBAL");
             when(lockService.withLock(any(), any())).thenAnswer(invocation -> {
                 RiderDeliveryLockService.LockedCall<?> call = invocation.getArgument(1);
                 return call.call();

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

@@ -26,6 +26,8 @@ class FlashDeliveryI18nContractTest {
             "flash.delivery.pricing.invalid", "flash.delivery.pricing.not.available",
             "flash.delivery.pricing.overlap", "flash.delivery.pricing.not.found",
             "flash.delivery.pricing.changed",
+            "flash.delivery.delivery.type.invalid", "flash.delivery.item.invalid",
+            "flash.delivery.tip.invalid", "flash.delivery.quote.changed",
             "flash.delivery.rider.exclusive.conflict",
             "address.access.denied", "address.data.invalid");
 

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

@@ -52,6 +52,35 @@ class FlashDeliveryPricingCalculatorTest {
         assertEquals(601L, calculator.calculateBreakdown(pricing, 4201).getDistanceFee());
     }
 
+    @Test
+    void calculatesUrgentFeeFromBaseDeliveryFeeAndAddsTipSeparately() {
+        var breakdown = calculator.calculateBreakdown(pricing(), 6000, "URGENT", 5L);
+
+        assertEquals(45L, breakdown.getDistanceFee());
+        assertEquals(135L, breakdown.getBaseDeliveryFee());
+        assertEquals(27L, breakdown.getUrgentFee());
+        assertEquals(5L, breakdown.getTipAmount());
+        assertEquals(167L, breakdown.getAmount());
+    }
+
+    @Test
+    void urgentFeeUsesMinimumAndRoundsPercentageHalfUp() {
+        FlashDeliveryPricing pricing = pricing();
+        pricing.setUrgentRate(new BigDecimal("12.50"));
+        pricing.setMinimumUrgentFee(20L);
+        assertEquals(20L, calculator.calculateBreakdown(pricing, 3000, "URGENT", 0L).getUrgentFee());
+
+        pricing.setMinimumUrgentFee(0L);
+        assertEquals(11L, calculator.calculateBreakdown(pricing, 3000, "URGENT", 0L).getUrgentFee());
+    }
+
+    @Test
+    void normalDeliveryNeverChargesUrgentFee() {
+        var breakdown = calculator.calculateBreakdown(pricing(), 3000, "NORMAL", 8L);
+        assertEquals(0L, breakdown.getUrgentFee());
+        assertEquals(98L, breakdown.getAmount());
+    }
+
     @Test
     void rejectsInvalidDistanceOrPricing() {
         assertThrows(IllegalArgumentException.class, () -> calculator.calculateBreakdown(pricing(), 0));
@@ -90,6 +119,8 @@ class FlashDeliveryPricingCalculatorTest {
         pricing.setStartingFare(90L);
         pricing.setDistance(new BigDecimal("1.00"));
         pricing.setFreight(15L);
+        pricing.setUrgentRate(new BigDecimal("20.00"));
+        pricing.setMinimumUrgentFee(10L);
         return pricing;
     }
 }

+ 11 - 6
ruoyi-admin/src/test/java/com/ruoyi/app/order/RiderDeliveryExclusivityServiceTest.java

@@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.verify;
 
@@ -71,7 +72,7 @@ class RiderDeliveryExclusivityServiceTest {
         Fixture fixture = new Fixture();
         when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(1L);
         assertThrows(ServiceException.class,
-                () -> fixture.service.assertCanAcceptFlash(123L, "HELP_SEND"));
+                () -> fixture.service.assertCanAcceptFlash(123L, "NORMAL"));
     }
 
     @Test
@@ -96,20 +97,24 @@ class RiderDeliveryExclusivityServiceTest {
         when(fixture.flashOrders.selectCount(any(Wrapper.class))).thenReturn(0L);
 
         fixture.service.assertCanAcceptFlash(123L, "URGENT");
+        fixture.service.assertCanAcceptFood(123L);
 
         ArgumentCaptor<Wrapper> foodQuery = ArgumentCaptor.forClass(Wrapper.class);
         ArgumentCaptor<Wrapper> flashQuery = ArgumentCaptor.forClass(Wrapper.class);
         verify(fixture.foodOrders).count(foodQuery.capture());
-        verify(fixture.flashOrders).selectCount(flashQuery.capture());
+        verify(fixture.flashOrders, times(2)).selectCount(flashQuery.capture());
         String foodSql = foodQuery.getValue().getSqlSegment().toLowerCase();
-        String flashSql = flashQuery.getValue().getSqlSegment().toLowerCase();
+        String allFlashSql = flashQuery.getAllValues().get(0).getSqlSegment().toLowerCase();
+        String urgentFlashSql = flashQuery.getAllValues().get(1).getSqlSegment().toLowerCase();
         assertTrue(foodSql.contains("qs_id"));
         assertTrue(foodSql.contains("delivery_status"));
         assertTrue(foodSql.contains("state"));
         assertTrue(foodSql.contains("after_sale_status"));
-        assertTrue(flashSql.contains("rider_id"));
-        assertTrue(flashSql.contains("status"));
-        assertTrue(!flashSql.contains("service_type"));
+        assertTrue(allFlashSql.contains("rider_id"));
+        assertTrue(allFlashSql.contains("status"));
+        assertTrue(urgentFlashSql.contains("delivery_type"));
+        assertTrue(!allFlashSql.contains("service_type"));
+        assertTrue(!urgentFlashSql.contains("service_type"));
     }
 
     private static class Fixture {

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

@@ -23,9 +23,13 @@ public class FlashDeliveryOrder {
     /** 抢单前为空,原子抢单成功后绑定且不允许骑手主动放弃。 */
     private Long riderId;
     private String serviceType;
+    private String deliveryType;
     private String status;
     /** 用户申报的包裹类别和机车可载重量档。 */
     private String packageType;
+    private Integer quantity;
+    private BigDecimal totalWeightKg;
+    private String specification;
     private String packageSize;
     /** NOW 为立即配送,SCHEDULED 为预约取件时段。 */
     private String deliveryMode;
@@ -74,6 +78,11 @@ public class FlashDeliveryOrder {
     private BigDecimal distance;
     private Long freight;
     private Long distanceFee;
+    private Long baseDeliveryFee;
+    private BigDecimal urgentRate;
+    private Long minimumUrgentFee;
+    private Long urgentFee;
+    private Long tipAmount;
     private String userNote;
     /** 订单条件更新版本号,用于记录并发状态变化次数。 */
     private Integer version;

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

@@ -14,13 +14,14 @@ import java.util.Date;
 public class FlashDeliveryPricing {
     @TableId(type = IdType.AUTO)
     private Long id;
-    private String serviceType;
     private String startTime;
     private String endTime;
     private BigDecimal startingDistance;
     private Long startingFare;
     private BigDecimal distance;
     private Long freight;
+    private BigDecimal urgentRate;
+    private Long minimumUrgentFee;
     /** 管理员并发更新使用的乐观版本号。 */
     private Integer configVersion;
     private Long updatedBy;

+ 11 - 13
ruoyi-system/src/main/java/com/ruoyi/system/mapper/flash/FlashDeliveryPricingMapper.java

@@ -10,34 +10,32 @@ import java.util.List;
 /** 闪送时段运价持久化接口。 */
 public interface FlashDeliveryPricingMapper extends BaseMapper<FlashDeliveryPricing> {
     @Select("""
-            select service_type
+            select lock_key
             from flash_delivery_pricing_lock
-            where service_type = #{serviceType}
+            where lock_key = 'GLOBAL'
             for update
             """)
-    String lockServiceType(@Param("serviceType") String serviceType);
+    String lockPricing();
 
     @Select("""
-            select id, service_type, start_time, end_time, starting_distance, starting_fare,
-                   distance, freight, config_version, updated_by, create_time, update_time
+            select id, start_time, end_time, starting_distance, starting_fare,
+                   distance, freight, urgent_rate, minimum_urgent_fee,
+                   config_version, updated_by, create_time, update_time
             from flash_delivery_pricing
-            where service_type = #{serviceType}
-              and time(start_time) <= current_time()
-              and time(end_time) > current_time()
+            where start_time <= #{targetTime}
+              and end_time > #{targetTime}
             order by start_time desc
             """)
-    List<FlashDeliveryPricing> selectCurrent(@Param("serviceType") String serviceType);
+    List<FlashDeliveryPricing> selectAtTime(@Param("targetTime") String targetTime);
 
     @Select("""
             select count(1)
             from flash_delivery_pricing
-            where service_type = #{serviceType}
-              and start_time < #{endTime}
+            where start_time < #{endTime}
               and end_time > #{startTime}
               and (#{excludedId} is null or id <> #{excludedId})
             """)
-    int countOverlapping(@Param("serviceType") String serviceType,
-                         @Param("startTime") String startTime,
+    int countOverlapping(@Param("startTime") String startTime,
                          @Param("endTime") String endTime,
                          @Param("excludedId") Long excludedId);
 }

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

@@ -9,8 +9,12 @@
         <result property="receiverUserId" column="receiver_user_id"/>
         <result property="riderId" column="rider_id"/>
         <result property="serviceType" column="service_type"/>
+        <result property="deliveryType" column="delivery_type"/>
         <result property="status" column="status"/>
         <result property="packageType" column="package_type"/>
+        <result property="quantity" column="quantity"/>
+        <result property="totalWeightKg" column="total_weight_kg"/>
+        <result property="specification" column="specification"/>
         <result property="packageSize" column="package_size"/>
         <result property="deliveryMode" column="delivery_mode"/>
         <result property="scheduledPickupStartAt" column="scheduled_pickup_start_at"/>
@@ -49,6 +53,11 @@
         <result property="distance" column="distance"/>
         <result property="freight" column="freight"/>
         <result property="distanceFee" column="distance_fee"/>
+        <result property="baseDeliveryFee" column="base_delivery_fee"/>
+        <result property="urgentRate" column="urgent_rate"/>
+        <result property="minimumUrgentFee" column="minimum_urgent_fee"/>
+        <result property="urgentFee" column="urgent_fee"/>
+        <result property="tipAmount" column="tip_amount"/>
         <result property="userNote" column="user_note"/>
         <result property="version" column="version"/>
         <result property="acceptedAt" column="accepted_at"/>

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

@@ -60,8 +60,10 @@ class FlashDeliveryMapperXmlTest {
                 .collect(Collectors.toSet());
         assertTrue(mappedProperties.containsAll(Set.of(
                 "receiverUserId",
+                "deliveryType", "quantity", "totalWeightKg", "specification",
                 "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
-                "startingDistance", "startingFare", "distance", "freight", "distanceFee")));
+                "startingDistance", "startingFare", "distance", "freight", "distanceFee",
+                "baseDeliveryFee", "urgentRate", "minimumUrgentFee", "urgentFee", "tipAmount")));
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
         assertTrue(xml.contains("rider_id is null"));
         assertTrue(xml.contains("scheduled_pickup_start_at &lt;= #{now}"));

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

@@ -13,8 +13,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 class FlashDeliveryPricingMapperContractTest {
 
     @Test
-    void serviceTypeLockUsesDatabaseRowLock() throws Exception {
-        Method method = FlashDeliveryPricingMapper.class.getMethod("lockServiceType", String.class);
+    void globalPricingLockUsesDatabaseRowLock() throws Exception {
+        Method method = FlashDeliveryPricingMapper.class.getMethod("lockPricing");
         String sql = sql(method);
 
         assertTrue(sql.contains("flash_delivery_pricing_lock"));
@@ -23,22 +23,24 @@ class FlashDeliveryPricingMapperContractTest {
 
     @Test
     void currentPeriodUsesInclusiveStartExclusiveEndAndDoesNotHideDuplicates() throws Exception {
-        Method method = FlashDeliveryPricingMapper.class.getMethod("selectCurrent", String.class);
+        Method method = FlashDeliveryPricingMapper.class.getMethod("selectAtTime", String.class);
         String sql = sql(method);
 
-        assertTrue(sql.contains("time(start_time) <= current_time()"));
-        assertTrue(sql.contains("time(end_time) > current_time()"));
+        assertTrue(sql.contains("start_time <= #{targettime}"));
+        assertTrue(sql.contains("end_time > #{targettime}"));
+        assertFalse(sql.contains("service_type"));
         assertFalse(sql.contains("limit"));
     }
 
     @Test
     void overlapQueryAllowsAdjacentPeriodsIncludingEndOfDay() throws Exception {
         Method method = FlashDeliveryPricingMapper.class.getMethod(
-                "countOverlapping", String.class, String.class, String.class, Long.class);
+                "countOverlapping", String.class, String.class, Long.class);
         String sql = sql(method);
 
         assertTrue(sql.contains("start_time < #{endtime}"));
         assertTrue(sql.contains("end_time > #{starttime}"));
+        assertFalse(sql.contains("service_type"));
     }
 
     private String sql(Method method) {

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

@@ -1,11 +1,11 @@
 # 闪送 API 联调快速开始
 
-1. 开发者人工执行 `updatesql/sql.md` 中 2026-08-31 闪送迁移,并在字典 `sys_google_routes_key` 配置仅供后端使用、已启用 Google Routes API 的独立密钥不得复用匿名接口可读取的 `sys_googlemap_key`。
+1. 开发者人工执行 `updatesql/sql.md` 中既有闪送迁移及 2026-09-07 统一运价增量,核对每个时段的加急比例和最低加急费;并在字典 `sys_google_routes_key` 配置仅供后端使用、已启用 Google Routes API 的独立密钥不得复用匿名接口可读取的 `sys_googlemap_key`。
 2. 启动 `ruoyi-admin`;用户和骑手请求携带现有 `token` 请求头,平台请求使用后台权限。
-3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;创建时服务端重新报价,并按标准化收件手机号唯一匹配普通用户。匹配结果固化在新订单中,不追溯历史订单。
+3. 调用 `/system/flashDelivery/home`、`/quote`、`/orders`;报价提交帮送/帮取业务场景、普通/加急配送等级、立即/预约取件时段、完整物品信息和骑手小费。创建时回传报价 ID、版本及各项金额,服务端复算不一致时返回最新报价且不创建订单;收件账号匹配结果固化在新订单中,不追溯历史订单。
 4. 用户通过 `/system/flashDelivery/orders?role=sender` 查看“我发的”,通过 `role=receiver` 查看“我收的”;省略 `role` 时默认 `sender`。发件人与固化收件人均可查看详情和确认收货,只有发件人可以取消。
 5. 骑手从 `/system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.5&latitude=25.0` 拉取待抢任务并调用 `/accept`;五个页签分别为 `newTask`(待接单)、`toPickup`(已接单)、`delivering`(已取件)、`completed`(已送达/已完成)和 `cancelled`(已取消)。`newTask` 的经纬度仅用于附近范围、取件点距离和排序,闪送没有 `refund` 页签。接单前只展示完整文字地址,隐藏联系人、电话、实际 PIN、精确坐标和履约图片;图片先走现有上传接口,再把 URL 传给 `/pickup` 和 `/deliver`。
-6. 急送接单与外卖、普通闪送共用骑手级 Redis 锁;急送进行期间骑手不能承接其他配送,接急送前骑手也不能有任何进行中配送。
+6. `deliveryType=URGENT` 的急送接单与外卖、普通闪送共用骑手级 Redis 锁;急送进行期间骑手不能承接其他配送,接急送前骑手也不能有任何进行中配送。
 7. 发布成功页使用静态文案“发布后等待附近骑手接单”,不展示动态附近骑手数或承诺接单时间。闪送不接入支付,金额保持整数 TWD。
 8. 用户 `/confirmReceipt` 完成;送达满 24 小时未操作则自动完成。
 9. 完整字段见 [API 契约](contracts/api.md)。

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

@@ -108,11 +108,11 @@
 ## Phase 13:设计稿费用与物品信息对齐
 
 - [x] T062 修正规格、设计、数据模型和 API 契约,确认帮送/帮取与普通/加急正交、统一分时段运价、比例加急费、最低加急费、小费、预约计价时刻、报价回传校验及完整物品字段
-- [ ] T063 先扩展计价器、运价 Mapper 和订单 XML 测试,覆盖比例加急费、最低值、小费、全局时段与新增快照字段并确认旧实现红灯
-- [ ] T064 修改运价/订单实体、Mapper、XML 和计价器,移除按服务类型分价并使数据层与计价测试转绿
-- [ ] T065 先扩展应用服务、Controller、DTO 和独占测试,覆盖完整请求响应、立即/预约计价、前端报价逐项校验、最新报价错误、物品校验及 `deliveryType=URGENT` 并确认旧实现红灯
-- [ ] T066 实现报价、创建、列表、详情和独占逻辑,保存业务场景、配送等级、物品与完整费用快照,使 App 定向测试转绿
-- [ ] T067 先更新平台后端契约和 `foodie-admin-vue` 闪送测试,确认旧三服务运价页面红灯;再实现统一时段配置、加急比例、最低加急费、订单明细及四语言
-- [ ] T068 在 `updatesql/sql.md` 追加迁移 SQL,不执行数据库;同步五语言后端错误、App 接入文档和 quickstart
-- [ ] T069 使用 JDK 21 运行后端定向测试与模块构建,运行平台前端定向测试、ESLint、生产构建及两个仓库差异检查
-- [ ] T070 核对后端与平台前端暂存范围,分别使用中文提交并记录 SHA 与未执行项
+- [x] T063 扩展计价器、运价 Mapper 和订单 XML 测试,覆盖比例加急费、最低值、小费、全局时段与新增快照字段;按用户要求集中实现后统一验证,未单独执行旧实现红灯
+- [x] T064 修改运价/订单实体、Mapper、XML 和计价器,移除按服务类型分价并使数据层与计价测试转绿
+- [x] T065 扩展应用服务、Controller、DTO 和独占测试,覆盖完整请求响应、立即/预约计价、前端报价逐项校验、最新报价错误、物品校验及 `deliveryType=URGENT`;按用户要求未单独执行旧实现红灯
+- [x] T066 实现报价、创建、列表、详情和独占逻辑,保存业务场景、配送等级、物品与完整费用快照,使 App 定向测试转绿
+- [x] T067 更新平台后端契约和 `foodie-admin-vue` 闪送测试,实现统一时段配置、加急比例、最低加急费、订单明细及四语言;按用户要求未单独执行旧页面红灯
+- [x] T068 在 `updatesql/sql.md` 追加迁移 SQL,不执行数据库;同步五语言后端错误、App 接入文档和 quickstart
+- [x] T069 使用 JDK 21 完成后端 69 项定向测试和模块构建、平台前端 8 项定向测试及两个仓库差异检查;用户要求立即提交后,不再执行平台 ESLint 与生产构建
+- [x] T070 核对后端与平台前端暂存范围,分别使用中文提交;SHA 与未执行项记录在最终交付中

+ 58 - 0
updatesql/sql.md

@@ -1438,3 +1438,61 @@ ALTER TABLE flash_delivery_order
   ADD COLUMN receiver_user_id BIGINT NULL COMMENT '创建时匹配的收件用户ID' AFTER user_id,
   ADD KEY idx_flash_receiver_list (receiver_user_id, create_time);
 ```
+
+## 2026-09-07 闪送统一运价、加急费用与完整物品信息(024-flash-delivery)
+
+> 用途:帮送和帮取改为共享一套分时段运价;普通/加急改用独立 `delivery_type`,并补齐数量、总重量、规格、基础配送费、加急费和骑手小费快照。脚本只记录,由开发者备份并人工执行。迁移会保留优先级最高的一组旧服务运价(帮送优先),上线前必须在平台核对每个时段的加急比例和最低加急费。
+
+```sql
+CREATE TABLE flash_delivery_pricing_backup_20260907 LIKE flash_delivery_pricing;
+INSERT INTO flash_delivery_pricing_backup_20260907 SELECT * FROM flash_delivery_pricing;
+
+DELETE pricing
+FROM flash_delivery_pricing pricing
+JOIN (
+  SELECT retained_type.service_type
+  FROM (
+    SELECT DISTINCT service_type
+    FROM flash_delivery_pricing
+  ) retained_type
+  ORDER BY FIELD(retained_type.service_type, 'HELP_SEND', 'HELP_PICKUP', 'URGENT'), retained_type.service_type
+  LIMIT 1
+) retained ON retained.service_type <> pricing.service_type;
+
+ALTER TABLE flash_delivery_pricing
+  DROP INDEX idx_flash_pricing_period,
+  DROP COLUMN service_type,
+  ADD COLUMN urgent_rate DECIMAL(8,2) NOT NULL DEFAULT 0.00 COMMENT '加急比例,百分比' AFTER freight,
+  ADD COLUMN minimum_urgent_fee BIGINT NOT NULL DEFAULT 0 COMMENT '最低加急费,整数TWD' AFTER urgent_rate,
+  ADD KEY idx_flash_pricing_period (start_time, end_time);
+
+DROP TABLE IF EXISTS flash_delivery_pricing_lock;
+CREATE TABLE flash_delivery_pricing_lock (
+  lock_key VARCHAR(20) NOT NULL,
+  PRIMARY KEY (lock_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='闪送统一运价并发锁';
+INSERT INTO flash_delivery_pricing_lock (lock_key) VALUES ('GLOBAL');
+
+ALTER TABLE flash_delivery_order
+  ADD COLUMN delivery_type VARCHAR(16) NOT NULL DEFAULT 'NORMAL' COMMENT '配送等级:NORMAL普通、URGENT加急' AFTER service_type,
+  ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT '物品数量' AFTER package_type,
+  ADD COLUMN total_weight_kg DECIMAL(6,2) NOT NULL DEFAULT 1.00 COMMENT '物品总重量kg' AFTER quantity,
+  ADD COLUMN specification VARCHAR(255) NULL COMMENT '体积或规格说明' AFTER total_weight_kg,
+  ADD COLUMN base_delivery_fee BIGINT NOT NULL DEFAULT 0 COMMENT '基础配送费快照' AFTER distance_fee,
+  ADD COLUMN urgent_rate DECIMAL(8,2) NOT NULL DEFAULT 0.00 COMMENT '加急比例快照' AFTER base_delivery_fee,
+  ADD COLUMN minimum_urgent_fee BIGINT NOT NULL DEFAULT 0 COMMENT '最低加急费快照' AFTER urgent_rate,
+  ADD COLUMN urgent_fee BIGINT NOT NULL DEFAULT 0 COMMENT '加急费快照' AFTER minimum_urgent_fee,
+  ADD COLUMN tip_amount BIGINT NOT NULL DEFAULT 0 COMMENT '骑手小费快照' AFTER urgent_fee;
+
+UPDATE flash_delivery_order
+SET delivery_type = 'URGENT', service_type = 'HELP_SEND'
+WHERE service_type = 'URGENT';
+
+UPDATE flash_delivery_order
+SET base_delivery_fee = starting_fare + distance_fee,
+    amount = starting_fare + distance_fee + urgent_fee + tip_amount;
+
+ALTER TABLE flash_delivery_order
+  DROP INDEX idx_flash_available,
+  ADD KEY idx_flash_available (status, delivery_mode, scheduled_pickup_start_at, delivery_type, create_time);
+```