Jelajahi Sumber

调整闪送订单重量范围接口

报价和创建订单统一改用四档重量范围,并同步订单回显、校验、多语言提示、迁移脚本及接口文档。
qmj 3 hari lalu
induk
melakukan
7bbc75786f
26 mengubah file dengan 232 tambahan dan 120 penghapusan
  1. 56 22
      docs/flash-delivery-app-api.md
  2. 1 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderView.java
  3. 3 4
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryQuoteRequest.java
  4. 1 3
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListView.java
  5. 1 2
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryUserOrderListView.java
  6. 7 21
      ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java
  7. 1 1
      ruoyi-admin/src/main/resources/i18n/messages.properties
  8. 1 1
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  9. 1 1
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  10. 1 1
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  11. 1 1
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  12. 1 1
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  13. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryOrderViewTest.java
  14. 2 2
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/dto/FlashDeliveryRiderOrderListViewTest.java
  15. 66 9
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java
  16. 2 3
      ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java
  17. 1 2
      ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml
  18. 9 1
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/FlashDeliveryMapperXmlTest.java
  19. 4 4
      specs/024-flash-delivery/contracts/api.md
  20. 1 1
      specs/024-flash-delivery/data-model.md
  21. 5 5
      specs/024-flash-delivery/design.md
  22. 7 7
      specs/024-flash-delivery/plan.md
  23. 4 4
      specs/024-flash-delivery/spec.md
  24. 8 0
      specs/024-flash-delivery/tasks.md
  25. 29 20
      specs/024-flash-delivery/weight-range-implementation-plan.md
  26. 18 0
      updatesql/sql.md

+ 56 - 22
docs/flash-delivery-app-api.md

@@ -8,7 +8,7 @@
 
 - `serviceType` 只区分 `HELP_SEND`(帮送)和 `HELP_PICKUP`(帮取),不再使用 `URGENT` 作为服务类型。
 - 是否加急由独立字段 `deliveryType=NORMAL/URGENT` 表达;帮送和帮取使用同一套分时段基础运价。
-- 报价和创建订单都必须提交取件方式、物品类别、数量、总重量、规格说明和骑手小费;立即取件使用 `deliveryMode=NOW`,预约取件使用 `deliveryMode=SCHEDULED` 并提交取件时间段。
+- 报价和创建订单都必须提交取件方式、物品类别、数量、四档重量范围、规格说明和骑手小费;立即取件使用 `deliveryMode=NOW`,预约取件使用 `deliveryMode=SCHEDULED` 并提交取件时间段。
 - App 必须先报价,再把报价 ID、版本及四个费用字段原样回传创建订单接口。后端重新计算不一致时不会创建订单,而会返回最新报价。
 
 ## 1. 功能范围
@@ -16,7 +16,7 @@
 当前闪送支持:
 
 - 帮送、帮取两种业务场景,以及普通配送、1 对 1 加急两种配送等级。
-- 物品类别、数量、总重量、体积/规格,以及服务端生成的重量档
+- 物品类别、数量、设计稿四档重量范围和体积/规格
 - 基础配送费、距离费、加急费、骑手小费和订单总金额明细。
 - 共享地址簿、路线报价、立即配送和预约配送。
 - 用户发布订单,并按“我发的/我收的”查询订单;发件人可取消,发件人或创建时绑定的收件人可确认收货。
@@ -153,15 +153,18 @@ App 必须以响应体 `code` 判断业务是否成功,不能只依赖 HTTP 
 | `SMALL_APPLIANCE` | 小家电 |
 | `OTHER` | 其他 |
 
-### 3.4 包裹重量档 `packageSize`
+### 3.4 物品重量范围 `weightRange`
 
-| 值 | 含义 |
-|---|---|
-| `SMALL` | 不超过 5kg |
-| `MEDIUM` | 不超过 12kg |
-| `LARGE` | 不超过 20kg |
+| 接口值 | 连续重量区间 | UI 文案 |
+|---|---|---|
+| `UP_TO_5_KG` | 大于 0 且不超过 5 公斤 | 5 公斤以内 |
+| `OVER_5_TO_10_KG` | 超过 5 且不超过 10 公斤 | 6–10 公斤 |
+| `OVER_10_TO_15_KG` | 超过 10 且不超过 15 公斤 | 11–15 公斤 |
+| `OVER_15_TO_20_KG` | 超过 15 且不超过 20 公斤 | 16–20 公斤 |
 
-App 必须提交 `totalWeightKg`,不得提交 `packageSize`。后端按总重量生成重量档:不超过 5kg 为 `SMALL`,超过 5kg 且不超过 12kg 为 `MEDIUM`,超过 12kg 且不超过 20kg 为 `LARGE`。
+App 必须直接提交上述四个枚举之一,不再提交精确重量 `totalWeightKg`,也不再提交旧三档字段 `packageSize`。API 使用连续区间定义,UI 按设计稿显示整数范围;例如实际估重 5.5 公斤应选择 `OVER_5_TO_10_KG`。
+
+`weightRange` 仅用于物品申报、订单展示和骑手判断是否适合承运,当前不参与基础配送费、距离费、加急费或总金额计算;选择不同重量范围不会改变报价。
 
 ### 3.5 配送方式 `deliveryMode`
 
@@ -297,9 +300,8 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 | `status` | string | 当前状态 |
 | `packageType` | string | 包裹类型 |
 | `quantity` | integer | 物品数量,正整数 |
-| `totalWeightKg` | number | 物品总重量,大于 0 且不超过 20kg,最多两位小数 |
+| `weightRange` | string | 用户选择的四档重量范围,取值见 3.4 |
 | `specification` | string/null | 体积或规格说明,最长 255 字符 |
-| `packageSize` | string | 后端按总重量生成的重量档 |
 | `deliveryMode` | string | `NOW` 或 `SCHEDULED` |
 | `scheduledPickupStartAt` | datetime/null | 预约开始时间 |
 | `scheduledPickupEndAt` | datetime/null | 预约结束时间 |
@@ -325,9 +327,9 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
 | `createTime` | datetime | 创建时间 |
 | `updateTime` | datetime | 更新时间 |
 
-用户列表摘要固定字段为:`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,deliveryType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。用户列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。
 
-骑手列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,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`,不返回内部加急比例、最低加急费或骑手收入字段。
+骑手列表摘要固定字段为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,distanceFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 分页响应额外返回 `nearbyTaskCount` 和 `highestOrderAmount`,不返回内部加急比例、最低加急费或骑手收入字段。
 
 ### 6.3 App 完整详情对象
 
@@ -342,9 +344,8 @@ App 完整详情使用以下订单字段,响应的 `data` 直接返回这些
   "status": "ACCEPTED",
   "packageType": "DOCUMENT",
   "quantity": 1,
-  "totalWeightKg": 3.50,
+  "weightRange": "UP_TO_5_KG",
   "specification": "30 x 20 x 10 cm",
-  "packageSize": "SMALL",
   "deliveryMode": "NOW",
   "scheduledPickupStartAt": null,
   "scheduledPickupEndAt": null,
@@ -462,7 +463,7 @@ POST /system/flashDelivery/quote
   "scheduledPickupEndAt": "2026-09-08T17:00:00+08:00",
   "packageType": "DOCUMENT",
   "quantity": 1,
-  "totalWeightKg": 3.50,
+  "weightRange": "UP_TO_5_KG",
   "specification": "30 x 20 x 10 cm",
   "tipAmount": 5,
   "pickup": {
@@ -501,7 +502,7 @@ POST /system/flashDelivery/quote
 | `scheduledPickupEndAt` | datetime | 预约时是 | 必须比开始时间晚 30 分钟;`NOW` 时不得传 |
 | `packageType` | string | 是 | 取值见 3.3 |
 | `quantity` | integer | 是 | 大于 0 的整数 |
-| `totalWeightKg` | number | 是 | 大于 0 且不超过 20,最多两位小数 |
+| `weightRange` | string | 是 | 取值见 3.4,只能提交四个重量范围枚举之一 |
 | `specification` | string | 否 | 体积或规格说明,去除首尾空格后最长 255 字符 |
 | `tipAmount` | integer | 是 | 骑手小费,非负整数 TWD;没有小费时传 `0` |
 | `pickup` | object | 是 | 完整取件信息,结构和规则见 6.1 |
@@ -553,10 +554,42 @@ POST /system/flashDelivery/quote
 POST /system/flashDelivery/orders
 ```
 
-创建请求必须完整包含 7.2 的所有报价请求字段,并在同一个 JSON 对象中增加以下字段。下例只展示新增部分,实际请求不能省略 7.2 中的业务场景、配送等级、取件方式、适用的预约时间段、物品、小费和完整取送地址:
+创建请求必须在同一个 JSON 对象中完整提交 7.2 的所有报价入参,并增加报价回传、幂等号、PIN、图片和备注字段。`weightRange` 与报价接口的字段名及枚举规则完全一致,不能改传 `totalWeightKg` 或 `packageSize`。
 
 ```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",
+  "quantity": 1,
+  "weightRange": "UP_TO_5_KG",
+  "specification": "30 x 20 x 10 cm",
+  "tipAmount": 5,
+  "pickup": {
+    "name": "王小明",
+    "phone": "0912345678",
+    "address": "台北市信义区市府路1号",
+    "addressDetail": "3楼A室",
+    "city": "台北市",
+    "area": "信义区",
+    "handoffMethod": "请电话联系",
+    "longitude": 121.5645,
+    "latitude": 25.033
+  },
+  "delivery": {
+    "name": "李小华",
+    "phone": "0987654321",
+    "address": "台北市大安区信义路三段56号",
+    "addressDetail": "1楼",
+    "city": "台北市",
+    "area": "大安区",
+    "handoffMethod": "交给本人",
+    "longitude": 121.538,
+    "latitude": 25.0335
+  },
   "clientRequestId": "FD-20260908-USER1001-0001",
   "pricingId": 1,
   "pricingVersion": 1,
@@ -572,6 +605,8 @@ POST /system/flashDelivery/orders
 }
 ```
 
+其中 `serviceType` 至 `delivery` 的字段定义与 7.2 报价请求表一致;以下是创建订单额外增加的字段:
+
 | 新增字段 | 类型 | 必填 | 说明 |
 |---|---|---:|---|
 | `clientRequestId` | string | 是 | 客户端幂等请求号,去除首尾空格后最长 64 字符 |
@@ -736,9 +771,8 @@ GET /system/flashDelivery/rider/orders?page=1&size=10&tab=newTask&longitude=121.
   "status": "WAITING_ACCEPTANCE",
   "packageType": "DOCUMENT",
   "quantity": 1,
-  "totalWeightKg": 3.50,
+  "weightRange": "UP_TO_5_KG",
   "specification": "30 x 20 x 10 cm",
-  "packageSize": "SMALL",
   "deliveryMode": "NOW",
   "scheduledPickupStartAt": null,
   "scheduledPickupEndAt": null,
@@ -908,7 +942,7 @@ POST /system/address/{id}/top
 | token 已过期 | 登录态失效 | 清理登录态并重新登录 |
 | 对应取件时段暂无可用运价 | 立即时刻或预约开始时刻没有匹配统一运价 | 重新选择取件时间或稍后重试 |
 | 报价已变化,请重新确认最新费用 | 运价版本、路线或费用与创建请求回传值不一致 | 用响应 `data` 刷新费用并让用户再次确认 |
-| 物品数量、总重量或规格信息不正确 | 数量非正整数、重量不大于 0、超过 20kg、小数超过两位或规格过长 | 返回物品信息页修正 |
+| 物品数量、重量范围或规格信息不正确 | 数量非正整数、`weightRange` 缺失或不属于四个合法枚举、规格过长 | 返回物品信息页修正 |
 | 取件地址和收件地址不能相同 | 地址文本相同或经纬度完全相同 | 要求用户修改地址 |
 | 配送距离不能超过 40 公里 | 服务端路线距离超限 | 提示当前路线不可下单 |
 | 预约取件时段无效 | 时间已过、超过三天或不是 30 分钟 | 重新选择预约时段 |
@@ -923,7 +957,7 @@ POST /system/address/{id}/top
 ## 11. 接入验收清单
 
 - 首页只展示 `/home` 返回的当前时段可用服务,不在 App 中维护启停状态或默认价格。
-- 报价和创建订单都提交 `serviceType`、`deliveryType`、配送方式、完整物品信息、骑手小费及取送坐标。
+- 报价和创建订单都提交 `serviceType`、`deliveryType`、配送方式、`packageType`、`quantity`、`weightRange`、规格、骑手小费及取送坐标。
 - 创建订单原样回传报价的配置 ID、版本、基础配送费、距离费、加急费和总金额;报价变化时展示最新明细并重新确认。
 - 金额按整数 TWD 展示;`baseDeliveryFee` 已包含 `distanceFee`,距离费只作为基础配送费的明细展示,总金额只按 `baseDeliveryFee + urgentFee + tipAmount` 计算;不显示支付成功、骑手收入、时长费或尖峰费用。
 - 创建订单具备稳定的 `clientRequestId` 重试策略。

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

@@ -2,7 +2,6 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
-import java.math.BigDecimal;
 import java.util.Date;
 
 /** 用户和中单骑手共用的业务订单视图,不包含持久化及身份内部字段。 */
@@ -15,9 +14,8 @@ public class FlashDeliveryOrderView {
     private String status;
     private String packageType;
     private Integer quantity;
-    private BigDecimal totalWeightKg;
+    private String weightRange;
     private String specification;
-    private String packageSize;
     private String deliveryMode;
     private Date scheduledPickupStartAt;
     private Date scheduledPickupEndAt;

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

@@ -2,7 +2,6 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
-import java.math.BigDecimal;
 import java.util.Date;
 
 /** 闪送报价请求;帮送/帮取与普通/加急为两个独立维度。 */
@@ -31,10 +30,10 @@ public class FlashDeliveryQuoteRequest {
     private Integer quantity;
 
     /**
-     * 物品重量,必填,单位为千克
-     * 必须大于 0、不超过 20,且最多保留两位小数;服务端据此生成重量档
+     * 物品重量范围,必填。
+     * 可选值:UP_TO_5_KG、OVER_5_TO_10_KG、OVER_10_TO_15_KG、OVER_15_TO_20_KG
      */
-    private BigDecimal totalWeightKg;
+    private String weightRange;
 
     /** 物品体积、尺寸或其他规格说明,选填,去除首尾空格后最长 255 个字符。 */
     private String specification;

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

@@ -2,7 +2,6 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
-import java.math.BigDecimal;
 import java.util.Date;
 
 /** 骑手端闪送任务卡片,不包含联系人、电话、精确坐标或内部字段。 */
@@ -15,9 +14,8 @@ public class FlashDeliveryRiderOrderListView {
     private String status;
     private String packageType;
     private Integer quantity;
-    private BigDecimal totalWeightKg;
+    private String weightRange;
     private String specification;
-    private String packageSize;
     private String deliveryMode;
     private Date scheduledPickupStartAt;
     private Date scheduledPickupEndAt;

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

@@ -2,7 +2,6 @@ package com.ruoyi.app.flashdelivery.dto;
 
 import lombok.Data;
 
-import java.math.BigDecimal;
 import java.util.Date;
 
 /** 用户端闪送订单卡片,仅包含列表页面所需字段。 */
@@ -15,7 +14,7 @@ public class FlashDeliveryUserOrderListView {
     private String status;
     private String packageType;
     private Integer quantity;
-    private BigDecimal totalWeightKg;
+    private String weightRange;
     private String deliveryMode;
     private Date scheduledPickupStartAt;
     private Date scheduledPickupEndAt;

+ 7 - 21
ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java

@@ -48,8 +48,9 @@ public class FlashDeliveryApplicationService {
     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> WEIGHT_RANGES = Set.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
+            "OVER_10_TO_15_KG", "OVER_15_TO_20_KG");
     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;
@@ -161,9 +162,8 @@ public class FlashDeliveryApplicationService {
         order.setStatus(WAITING_ACCEPTANCE);
         order.setPackageType(request.getPackageType());
         order.setQuantity(request.getQuantity());
-        order.setTotalWeightKg(scaleWeight(request.getTotalWeightKg()));
+        order.setWeightRange(request.getWeightRange());
         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);
@@ -634,7 +634,7 @@ public class FlashDeliveryApplicationService {
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
-        view.setTotalWeightKg(order.getTotalWeightKg());
+        view.setWeightRange(order.getWeightRange());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -663,9 +663,8 @@ public class FlashDeliveryApplicationService {
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
-        view.setTotalWeightKg(order.getTotalWeightKg());
+        view.setWeightRange(order.getWeightRange());
         view.setSpecification(order.getSpecification());
-        view.setPackageSize(order.getPackageSize());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -734,9 +733,8 @@ public class FlashDeliveryApplicationService {
         view.setStatus(order.getStatus());
         view.setPackageType(order.getPackageType());
         view.setQuantity(order.getQuantity());
-        view.setTotalWeightKg(order.getTotalWeightKg());
+        view.setWeightRange(order.getWeightRange());
         view.setSpecification(order.getSpecification());
-        view.setPackageSize(order.getPackageSize());
         view.setDeliveryMode(order.getDeliveryMode());
         view.setScheduledPickupStartAt(order.getScheduledPickupStartAt());
         view.setScheduledPickupEndAt(order.getScheduledPickupEndAt());
@@ -903,10 +901,8 @@ public class FlashDeliveryApplicationService {
     }
 
     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
+                || !hasText(request.getWeightRange()) || !WEIGHT_RANGES.contains(request.getWeightRange())
                 || tooLong(request.getSpecification(), 255)) {
             throw fail("flash.delivery.item.invalid");
         }
@@ -915,16 +911,6 @@ public class FlashDeliveryApplicationService {
         }
     }
 
-    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());

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

@@ -323,7 +323,7 @@ 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.item.invalid=Item quantity, weight range 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

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

@@ -326,7 +326,7 @@ 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.item.invalid=Item quantity, weight range 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

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

@@ -327,7 +327,7 @@ flash.delivery.scene.invalid=พารามิเตอร์กลุ่มส
 flash.delivery.pricing.not.found=ไม่พบการตั้งค่าราคา
 flash.delivery.pricing.changed=การตั้งค่าราคาเปลี่ยนแปลงแล้ว โปรดรีเฟรชและลองอีกครั้ง
 flash.delivery.delivery.type.invalid=ไม่รองรับระดับการจัดส่งนี้
-flash.delivery.item.invalid=ข้อมูลจำนวน น้ำหนักรวม หรือรายละเอียดสิ่งของไม่ถูกต้อง
+flash.delivery.item.invalid=ข้อมูลจำนวน ช่วงน้ำหนัก หรือรายละเอียดสิ่งของไม่ถูกต้อง
 flash.delivery.tip.invalid=ทิปสำหรับไรเดอร์ต้องเป็นจำนวนเต็ม TWD ที่ไม่ติดลบ
 flash.delivery.quote.changed=ราคาเสนอมีการเปลี่ยนแปลง โปรดยืนยันค่าบริการล่าสุดอีกครั้ง
 flash.delivery.tab.invalid=แท็บรายการส่งด่วนไม่ถูกต้อง

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

@@ -326,7 +326,7 @@ 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.item.invalid=Số lượng, khoả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ệ

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

@@ -327,7 +327,7 @@ flash.delivery.scene.invalid=订单状态分组参数无效
 flash.delivery.pricing.not.found=计价配置不存在
 flash.delivery.pricing.changed=计价配置已发生变化,请刷新后重试
 flash.delivery.delivery.type.invalid=不支持的配送等级
-flash.delivery.item.invalid=物品数量、重量或规格信息不正确
+flash.delivery.item.invalid=物品数量、重量范围或规格信息不正确
 flash.delivery.tip.invalid=骑手小费必须是非负整数新台币金额
 flash.delivery.quote.changed=报价已变化,请重新确认最新费用
 flash.delivery.tab.invalid=闪送列表页签无效

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

@@ -327,7 +327,7 @@ flash.delivery.scene.invalid=訂單狀態分組參數無效
 flash.delivery.pricing.not.found=計價設定不存在
 flash.delivery.pricing.changed=計價設定已發生變更,請重新整理後重試
 flash.delivery.delivery.type.invalid=不支援的配送等級
-flash.delivery.item.invalid=物品數量、重量或規格資訊不正確
+flash.delivery.item.invalid=物品數量、重量範圍或規格資訊不正確
 flash.delivery.tip.invalid=騎手小費必須是非負整數新台幣金額
 flash.delivery.quote.changed=報價已變更,請重新確認最新費用
 flash.delivery.tab.invalid=閃送列表頁籤無效

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

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

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

@@ -12,7 +12,7 @@ class FlashDeliveryRiderOrderListViewTest {
         Set<String> fields = Stream.of(FlashDeliveryUserOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
         Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
-                "quantity", "totalWeightKg", "baseDeliveryFee", "urgentFee", "tipAmount",
+                "quantity", "weightRange", "baseDeliveryFee", "urgentFee", "tipAmount",
                 "deliveryMode", "scheduledPickupStartAt", "scheduledPickupEndAt", "pickupAddress",
                 "pickupDetailAddress", "deliveryAddress", "deliveryDetailAddress",
                 "estimatedDurationSeconds", "amount", "currency", "deliveredAt", "createTime");
@@ -24,7 +24,7 @@ class FlashDeliveryRiderOrderListViewTest {
         Set<String> fields = Stream.of(FlashDeliveryRiderOrderListView.class.getDeclaredFields())
                 .map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
         Set<String> expected = Set.of("id", "orderNo", "serviceType", "deliveryType", "status", "packageType",
-                "quantity", "totalWeightKg", "specification", "packageSize", "deliveryMode",
+                "quantity", "weightRange", "specification", "deliveryMode",
                 "scheduledPickupStartAt", "scheduledPickupEndAt",
                 "pinRequired", "pickupAddress", "pickupDetailAddress", "deliveryAddress",
                 "deliveryDetailAddress", "pickupDistanceMeters", "distanceMeters",

+ 66 - 9
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationServiceTest.java

@@ -189,9 +189,8 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals("NORMAL", saved.getValue().getDeliveryType());
         assertEquals("DOCUMENT", saved.getValue().getPackageType());
         assertEquals(1, saved.getValue().getQuantity());
-        assertEquals(new BigDecimal("3.00"), saved.getValue().getTotalWeightKg());
+        assertEquals("UP_TO_5_KG", saved.getValue().getWeightRange());
         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());
@@ -213,6 +212,7 @@ class FlashDeliveryApplicationServiceTest {
     void userListOnlyFiltersByOwnerAndReturnsCardFields() {
         Fixture fixture = new Fixture();
         FlashDeliveryOrder order = order(10L, "COMPLETED");
+        order.setWeightRange("OVER_5_TO_10_KG");
         Page<FlashDeliveryOrder> source = new Page<>(1, 10, 1);
         source.setRecords(List.of(order));
         when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
@@ -230,6 +230,7 @@ class FlashDeliveryApplicationServiceTest {
         assertFalse(sql.contains("service_type"));
         assertTrue(parameters.contains(7L));
         assertEquals("pickup road", result.getRecords().get(0).getPickupAddress());
+        assertEquals("OVER_5_TO_10_KG", result.getRecords().get(0).getWeightRange());
         assertEquals(112L, result.getRecords().get(0).getAmount());
     }
 
@@ -297,6 +298,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         rider(fixture, 8L);
         FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
+        order.setWeightRange("OVER_10_TO_15_KG");
         Page<FlashDeliveryOrder> source = new Page<>(1, 10, 1);
         source.setRecords(List.of(order));
         when(fixture.orderMapper.selectPage(any(com.baomidou.mybatisplus.core.metadata.IPage.class),
@@ -317,6 +319,7 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(168L, result.getHighestOrderAmount());
         assertEquals("pickup road", result.getRecords().get(0).getPickupAddress());
         assertEquals("delivery road", result.getRecords().get(0).getDeliveryAddress());
+        assertEquals("OVER_10_TO_15_KG", result.getRecords().get(0).getWeightRange());
         assertTrue(result.getRecords().get(0).getPickupDistanceMeters() > 0);
         assertTrue(result.getRecords().get(0).getPinRequired());
         ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.QueryWrapper> query =
@@ -470,6 +473,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         rider(fixture, 8L);
         FlashDeliveryOrder order = order(10L, "WAITING_ACCEPTANCE");
+        order.setWeightRange("OVER_15_TO_20_KG");
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
 
         var result = fixture.service.riderDetail(8L, 10L);
@@ -479,6 +483,7 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals("pickup door", result.getPickup().getAddressDetail());
         assertEquals("delivery road", result.getDelivery().getAddress());
         assertEquals("delivery door", result.getDelivery().getAddressDetail());
+        assertEquals("OVER_15_TO_20_KG", result.getWeightRange());
         assertNull(result.getPickup().getName());
         assertNull(result.getPickup().getPhone());
         assertNull(result.getPickup().getLongitude());
@@ -501,6 +506,7 @@ class FlashDeliveryApplicationServiceTest {
         Fixture fixture = new Fixture();
         FlashDeliveryOrder order = order(10L, ACCEPTED);
         order.setRiderId(null);
+        order.setWeightRange("OVER_5_TO_10_KG");
         when(fixture.orderMapper.selectById(10L)).thenReturn(order);
         when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of(
                 image("SENDER", "https://example.com/sender.jpg"),
@@ -514,6 +520,7 @@ class FlashDeliveryApplicationServiceTest {
         assertEquals(List.of("https://example.com/pickup.jpg"), result.getPickupImageUrls());
         assertEquals(List.of("https://example.com/delivery.jpg"), result.getDeliveryImageUrls());
         assertEquals("4821", result.getDeliveryPinCode());
+        assertEquals("OVER_5_TO_10_KG", result.getWeightRange());
         verify(fixture.logMapper, never()).selectList(any());
     }
 
@@ -823,9 +830,60 @@ class FlashDeliveryApplicationServiceTest {
         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));
+        request.setWeightRange("SMALL");
+        FlashDeliveryQuoteRequest oldWeightRange = request;
+        assertThrows(ServiceException.class, () -> fixture.service.quote(oldWeightRange));
+    }
+
+    @Test
+    void quoteAcceptsAllFourWeightRanges() {
+        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"));
+
+        for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
+                "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
+            FlashDeliveryQuoteRequest request = request();
+            request.setWeightRange(weightRange);
+            var quote = assertDoesNotThrow(() -> fixture.service.quote(request), weightRange);
+            assertEquals(90L, quote.getAmount(), weightRange);
+        }
+    }
+
+    @Test
+    void createPersistsAndReturnsEveryWeightRange() {
+        for (String weightRange : List.of("UP_TO_5_KG", "OVER_5_TO_10_KG",
+                "OVER_10_TO_15_KG", "OVER_15_TO_20_KG")) {
+            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"));
+            doAnswer(invocation -> {
+                FlashDeliveryOrder order = invocation.getArgument(0);
+                order.setId(99L);
+                return 1;
+            }).when(fixture.orderMapper).insert(any(FlashDeliveryOrder.class));
+            when(fixture.imageMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+            when(fixture.logMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
+            FlashDeliveryCreateRequest request = createRequest();
+            request.setWeightRange(weightRange);
+
+            var result = fixture.service.create(7L, request);
+
+            ArgumentCaptor<FlashDeliveryOrder> saved = ArgumentCaptor.forClass(FlashDeliveryOrder.class);
+            verify(fixture.orderMapper).insert(saved.capture());
+            assertEquals(weightRange, saved.getValue().getWeightRange());
+            assertEquals(weightRange, result.getWeightRange());
+        }
+    }
+
+    @Test
+    void quoteRejectsMissingAndUnknownWeightRanges() {
+        Fixture fixture = new Fixture();
+        for (String weightRange : new String[]{null, "", "SMALL", "OVER_20_KG"}) {
+            FlashDeliveryQuoteRequest request = request();
+            request.setWeightRange(weightRange);
+            assertThrows(ServiceException.class, () -> fixture.service.quote(request), String.valueOf(weightRange));
+        }
     }
 
     private FlashDeliveryQuoteRequest request() {
@@ -834,7 +892,7 @@ class FlashDeliveryApplicationServiceTest {
         request.setDeliveryType("NORMAL");
         request.setPackageType("DOCUMENT");
         request.setQuantity(1);
-        request.setTotalWeightKg(new BigDecimal("3.00"));
+        request.setWeightRange("UP_TO_5_KG");
         request.setSpecification("30 x 20 x 10 cm");
         request.setTipAmount(0L);
         request.setDeliveryMode("NOW");
@@ -851,7 +909,7 @@ class FlashDeliveryApplicationServiceTest {
         request.setDeliveryType(quote.getDeliveryType());
         request.setPackageType(quote.getPackageType());
         request.setQuantity(quote.getQuantity());
-        request.setTotalWeightKg(quote.getTotalWeightKg());
+        request.setWeightRange(quote.getWeightRange());
         request.setSpecification(quote.getSpecification());
         request.setTipAmount(quote.getTipAmount());
         request.setPickup(quote.getPickup());
@@ -949,8 +1007,7 @@ class FlashDeliveryApplicationServiceTest {
         order.setStatus(status);
         order.setPackageType("DOCUMENT");
         order.setQuantity(1);
-        order.setTotalWeightKg(new BigDecimal("3.00"));
-        order.setPackageSize("SMALL");
+        order.setWeightRange("UP_TO_5_KG");
         order.setDeliveryMode("NOW");
         order.setPinRequired(true);
         order.setDeliveryPinCode("4821");

+ 2 - 3
ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java

@@ -25,12 +25,11 @@ public class FlashDeliveryOrder {
     private String serviceType;
     private String deliveryType;
     private String status;
-    /** 用户申报的包裹类别和机车可载重量档。 */
+    /** 用户申报的包裹类别和重量范围。 */
     private String packageType;
     private Integer quantity;
-    private BigDecimal totalWeightKg;
+    private String weightRange;
     private String specification;
-    private String packageSize;
     /** NOW 为立即配送,SCHEDULED 为预约取件时段。 */
     private String deliveryMode;
     private Date scheduledPickupStartAt;

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

@@ -13,9 +13,8 @@
         <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="weightRange" column="weight_range"/>
         <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"/>
         <result property="scheduledPickupEndAt" column="scheduled_pickup_end_at"/>

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

@@ -12,6 +12,7 @@ import java.util.Set;
 import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -53,6 +54,11 @@ class FlashDeliveryMapperXmlTest {
                 .getResultMappings().stream().filter(item -> "amount".equals(item.getProperty()))
                 .findFirst().orElseThrow();
         assertEquals("amount", amount.getColumn());
+        ResultMapping weightRange = configuration
+                .getResultMap("com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.FlashDeliveryOrderResult")
+                .getResultMappings().stream().filter(item -> "weightRange".equals(item.getProperty()))
+                .findFirst().orElseThrow();
+        assertEquals("weight_range", weightRange.getColumn());
         Set<String> mappedProperties = configuration
                 .getResultMap("com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper.FlashDeliveryOrderResult")
                 .getResultMappings().stream()
@@ -60,10 +66,12 @@ class FlashDeliveryMapperXmlTest {
                 .collect(Collectors.toSet());
         assertTrue(mappedProperties.containsAll(Set.of(
                 "receiverUserId",
-                "deliveryType", "quantity", "totalWeightKg", "specification",
+                "deliveryType", "quantity", "weightRange", "specification",
                 "pricingId", "pricingVersion", "pricingStartTime", "pricingEndTime",
                 "startingDistance", "startingFare", "distance", "freight", "distanceFee",
                 "baseDeliveryFee", "urgentRate", "minimumUrgentFee", "urgentFee", "tipAmount")));
+        assertFalse(mappedProperties.contains("totalWeightKg"));
+        assertFalse(mappedProperties.contains("packageSize"));
         assertTrue(xml.contains("status = 'WAITING_ACCEPTANCE'"));
         assertTrue(xml.contains("rider_id is null"));
         assertTrue(xml.contains("scheduled_pickup_start_at &lt;= #{now}"));

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

@@ -9,7 +9,7 @@
 - `serviceType`:`HELP_SEND`、`HELP_PICKUP`。
 - `deliveryType`:`NORMAL`、`URGENT`。
 - `packageType`:`DOCUMENT`、`GIFT`、`CLOTHING`、`BEAUTY`、`DAILY_NECESSITIES`、`FOOD_INGREDIENTS`、`ELECTRONICS`、`SMALL_APPLIANCE`、`OTHER`。
-- 物品信息提交 `packageType`、正整数 `quantity`、大于 0 且不超过 20 的两位小数 `totalWeightKg` 及最长 255 字符的可选 `specification`;`packageSize` 由服务端根据总重量生成
+- 物品信息提交 `packageType`、正整数 `quantity`、必填 `weightRange` 及最长 255 字符的可选 `specification`;`weightRange` 只接受 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG`
 - `deliveryMode`:`NOW`、`SCHEDULED`。预约起止时间使用 ISO 日期时间,时段固定 30 分钟且开始时间不晚于三天后。
 - `status`:`WAITING_ACCEPTANCE`、`ACCEPTED`、`PICKED_UP`、`DELIVERED`、`COMPLETED`、`CANCELLED`。
 
@@ -32,14 +32,14 @@
 | 方法 | 路径 | 请求/说明 |
 |---|---|---|
 | GET | `/system/flashDelivery/home` | 返回 `serviceTypes=[HELP_SEND,HELP_PICKUP]`、`deliveryTypes=[NORMAL,URGENT]` 及当前时刻命中的统一运价摘要;不返回内部更新人、时间或地图密钥 |
-| POST | `/system/flashDelivery/quote` | `serviceType,deliveryType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,packageType,quantity,totalWeightKg,specification?,tipAmount,pickup,delivery`;立即订单按当前时刻、预约订单按预约开始时刻匹配运价 |
+| POST | `/system/flashDelivery/quote` | `serviceType,deliveryType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,packageType,quantity,weightRange,specification?,tipAmount,pickup,delivery`;立即订单按当前时刻、预约订单按预约开始时刻匹配运价 |
 | POST | `/system/flashDelivery/orders` | 报价请求字段 + `clientRequestId,pricingId,pricingVersion,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount,pinRequired?,senderImageUrls?,userNote?`;服务端复算并逐项校验后幂等创建 |
 | GET | `/system/flashDelivery/orders` | `page,size,role?`;`role=sender/receiver`,省略为 `sender`;按创建时间倒序返回当前用户作为寄件人或已绑定收件人的订单摘要 |
 | GET | `/system/flashDelivery/orders/{id}` | 订单创建人或创建时已绑定收件人的 App 订单详情;`data` 直接为订单字段,不返回原始状态日志 |
 | POST | `/system/flashDelivery/orders/{id}/cancel` | `{"reason":"行程变化"}`;仅订单创建人且状态为待接/已接 |
 | POST | `/system/flashDelivery/orders/{id}/confirmReceipt` | 无 body;订单创建人或已绑定收件人可对 DELIVERED 订单确认签收 |
 
-用户列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,totalWeightKg,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。`receiver` 不是按当前手机号动态查询:订单只在创建时唯一匹配已注册普通用户并固化 `receiverUserId`,未匹配订单以后不追溯认领。
+用户列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,deliveredAt,createTime`。列表不返回联系人、电话、精确坐标、照片、日志或内部计价审计字段。`receiver` 不是按当前手机号动态查询:订单只在创建时唯一匹配已注册普通用户并固化 `receiverUserId`,未匹配订单以后不追溯认领。
 
 报价响应 data:`serviceType,deliveryType,deliveryMode,scheduledPickupStartAt?,scheduledPickupEndAt?,pricingId,startTime,endTime,distanceMeters,distanceSource,estimatedDurationSeconds?,startingDistance,startingFare,distance,freight,billableDistance,distanceFee,baseDeliveryFee,urgentRate,minimumUrgentFee,urgentFee,tipAmount,amount,currency,pricingVersion`。`billableDistance` 为按外卖规则处理 0.5 公里边界后的计费公里数;全部费用为整数 TWD,`urgentRate` 为百分比。`baseDeliveryFee=startingFare+distanceFee`;普通配送 `urgentFee=0`;加急配送 `urgentFee=max(minimumUrgentFee,roundHalfUp(baseDeliveryFee*urgentRate/100))`;`amount=baseDeliveryFee+urgentFee+tipAmount`。
 
@@ -59,7 +59,7 @@
 
 骑手页签状态映射:`newTask=WAITING_ACCEPTANCE`、`toPickup=ACCEPTED`、`delivering=PICKED_UP`、`completed=DELIVERED+COMPLETED`、`cancelled=CANCELLED`。除 `newTask` 外只返回当前骑手本人任务;闪送没有 `refund` 页签。
 
-骑手列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,totalWeightKg,specification,packageSize,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 分页 data 在 `records,total,current,size` 外增加 `nearbyTaskCount,highestOrderAmount`;不返回加急比例、最低加急费或骑手收入字段。
+骑手列表摘要字段固定为:`id,orderNo,serviceType,deliveryType,status,packageType,quantity,weightRange,specification,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,pinRequired,pickupAddress,pickupDetailAddress,deliveryAddress,deliveryDetailAddress,pickupDistanceMeters,distanceMeters,estimatedDurationSeconds,baseDeliveryFee,urgentFee,tipAmount,amount,currency,createTime`。`newTask` 分页 data 在 `records,total,current,size` 外增加 `nearbyTaskCount,highestOrderAmount`;不返回加急比例、最低加急费或骑手收入字段。
 
 骑手接单前可按原型查看完整取送文字地址,但不返回用户 ID、联系人、电话、实际 PIN、精确经纬度、备注、幂等请求号、履约凭证或日志。抢单成功后才向中单骑手返回完整联系方式、坐标和有权限查看的照片数组。骑手详情在接单前后使用同一个直接 DTO,不返回 `{order,images,logs}` 包装,也不要求 App 判断 `data.order`。
 

+ 1 - 1
specs/024-flash-delivery/data-model.md

@@ -2,7 +2,7 @@
 
 ## `flash_delivery_order`
 
-核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、创建人 `user_id`、创建时按收件手机号唯一匹配并固化的可空 `receiver_user_id`、`rider_id`、帮送/帮取 `service_type`、普通/加急 `delivery_type`、`status`;`package_type`、正整数 `quantity`、两位小数 `total_weight_kg`、可空 `specification`、服务端生成的 `package_size`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee`、`base_delivery_fee`、`urgent_rate`、`minimum_urgent_fee`、`urgent_fee`、`tip_amount` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
+核心字段:`id`、唯一 `order_no`、与用户联合唯一的 `client_request_id`、创建人 `user_id`、创建时按收件手机号唯一匹配并固化的可空 `receiver_user_id`、`rider_id`、帮送/帮取 `service_type`、普通/加急 `delivery_type`、`status`;`package_type`、正整数 `quantity`、四档 `weight_range`、可空 `specification`;`delivery_mode`、预约开始/结束时间;`pin_required`、四位交付 PIN;取件/收件联系人、电话、市/区、交付方式、地址、详细地址和经纬度快照;`distance_meters`、`distance_source`、`estimated_duration_seconds`;整数 `amount`、`currency`、`pricing_id`、`pricing_version`、`pricing_start_time`、`pricing_end_time`、`starting_distance`、`starting_fare`、`distance`、`freight`、`distance_fee`、`base_delivery_fee`、`urgent_rate`、`minimum_urgent_fee`、`urgent_fee`、`tip_amount` 计价快照;`user_note`、`version`、关键状态时间、取消审计及创建/更新时间。
 
 索引:订单号唯一、用户幂等号唯一、待抢排序、创建人列表、收件人列表、骑手列表、自动完成扫描。`receiver_user_id` 只保存创建当时唯一匹配的普通用户;空值和历史订单不补写,用户手机号变更也不回写。
 

+ 5 - 5
specs/024-flash-delivery/design.md

@@ -14,7 +14,7 @@
 6. 闪送
 7. 闪送 送
 
-最初的蓝湖 7 页明确展示三种入口、取件与收件地址、联系人和电话、地址簿、起步价格及下单入口;后续用户端/骑手端原型明确包裹类别、数量、总重量、体积/规格、预约配送、交付 PIN、寄件图片以及基础配送、距离加价、加急和骑手小费明细。此前把精确重量和件数排除在范围外是错误判断,2026-09-07 起按设计稿补齐
+最初的蓝湖 7 页明确展示三种入口、取件与收件地址、联系人和电话、地址簿、起步价格及下单入口;后续用户端/骑手端原型明确包裹类别、数量、四档重量范围、体积/规格、预约配送、交付 PIN、寄件图片以及基础配送、距离加价、加急和骑手小费明细。2026-09-07 起按设计稿使用重量范围,不要求用户填写无法准确获知的精确重量
 
 ## 2. 架构决策
 
@@ -93,7 +93,7 @@ ruoyi-system
 
 ### 4.1 `flash_delivery_order`
 
-保存订单号、客户端请求号、用户、骑手、帮送/帮取业务场景、普通/加急配送等级、包裹类别、数量、重量、规格、服务端生成的重量档、配送方式、预约时段、交付 PIN、取件与收件信息快照、路线距离、距离来源、预计时长、基础配送费、距离费、加急费、小费、总金额、匹配时段完整计价快照、币种、计价配置版本、备注、状态、并发版本号、关键操作时间与取消信息。
+保存订单号、客户端请求号、用户、骑手、帮送/帮取业务场景、普通/加急配送等级、包裹类别、数量、重量范围、规格、配送方式、预约时段、交付 PIN、取件与收件信息快照、路线距离、距离来源、预计时长、基础配送费、距离费、加急费、小费、总金额、匹配时段完整计价快照、币种、计价配置版本、备注、状态、并发版本号、关键操作时间与取消信息。
 
 ### 4.2 `flash_delivery_pricing`
 
@@ -178,7 +178,7 @@ COMPLETED
 
 ## 9. 明确延期内容
 
-支付、退款、分账、骑手收入、代购、垫付、商品金额、自动派单、动态附近骑手数、预计接单时间以及用户端、骑手端和商家端页面均不在本期范围。物品数量、重量、体积/规格和骑手小费属于本期接口范围。
+支付、退款、分账、骑手收入、代购、垫付、商品金额、自动派单、动态附近骑手数、预计接单时间以及用户端、骑手端和商家端页面均不在本期范围。物品数量、重量范围、体积/规格和骑手小费属于本期接口范围。
 
 ## 10. App 订单列表精简增量(2026-09-01)
 
@@ -201,7 +201,7 @@ COMPLETED
 - 参数名、默认分页和坐标用途与骑手外卖订单列表保持一致;经纬度用于 `newTask` 的附近范围、取件点距离和排序,其他页签忽略坐标。
 - `newTask -> WAITING_ACCEPTANCE`;`toPickup -> ACCEPTED`;`delivering -> PICKED_UP`;`completed -> DELIVERED + COMPLETED`;`cancelled -> CANCELLED`。除 `newTask` 外均只查询当前骑手本人。
 - 闪送没有退款流程,因此不接受外卖列表的 `refund` 页签。
-- 列表使用统一卡片摘要 DTO,包含订单标识、业务场景、配送等级、状态、包裹类别/数量/总重量/重量档、配送方式/预约时段、是否需要 PIN、完整取送文字地址、取件点距离、路线距离、预计时长、费用明细、订单金额和创建时间。
+- 列表使用统一卡片摘要 DTO,包含订单标识、业务场景、配送等级、状态、包裹类别/数量/重量范围、配送方式/预约时段、是否需要 PIN、完整取送文字地址、取件点距离、路线距离、预计时长、费用明细、订单金额和创建时间。
 - `newTask` 分页响应额外返回附近任务总数与最高订单金额,用于原型顶部摘要;不返回已移除的尖峰倍率,也不将订单金额表述为尚未实现的骑手收入。
 - 接单前不返回联系人、电话、实际 PIN、精确坐标、用户备注、图片凭证、状态日志、用户 ID、幂等号或计价审计字段。接单成功后,详情接口仅向中单骑手返回完整履约信息。
 
@@ -259,7 +259,7 @@ COMPLETED
 
 ### 12.4 物品信息
 
-请求保存 `packageType`、`quantity`、`totalWeightKg` 和可选 `specification`。数量必须是正整数;总重量精确到两位小数且大于 0、不超过 20kg;规格说明去除首尾空格后最长 255 字符。`packageSize` 不再由客户端提交,而由服务端按总重量生成并作为订单快照保留给骑手与平台使用
+请求保存 `packageType`、`quantity`、`weightRange` 和可选 `specification`。数量必须是正整数;`weightRange` 必须为 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG` 之一;规格说明去除首尾空格后最长 255 字符。重量范围由客户端按设计稿选择并作为订单快照保留给用户、骑手和平台,不再维护精确重量或另一套包裹尺寸字段
 
 ### 12.5 非支付边界
 

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

@@ -4,7 +4,7 @@
 
 ## 摘要
 
-在既有独立闪送模块上补充包裹类别/重量档、立即或预约配送、可选 PIN 交付、寄件图片、费用明细、页面状态分组、骑手公开摘要和角色安全响应。服务端继续负责路线报价、骑手原子抢单、取件/送达图片、用户签收、24 小时自动完成和平台介入;支付、退款、结算、骑手收入与自动派单仍不在本期范围。
+在既有独立闪送模块上补充包裹类别/四档重量范围、立即或预约配送、可选 PIN 交付、寄件图片、费用明细、页面状态分组、骑手公开摘要和角色安全响应。服务端继续负责路线报价、骑手原子抢单、取件/送达图片、用户签收、24 小时自动完成和平台介入;支付、退款、结算、骑手收入与自动派单仍不在本期范围。
 
 ## 技术上下文
 
@@ -60,7 +60,7 @@ ruoyi-system/src/test/java/com/ruoyi/system/mapper/flash/
 7. 寄件图片在创建事务中写入;取件/送达图片在状态更新事务中写入。三类凭证均校验 1 至 9 个 HTTP(S) URL,取件和送达不得为空。
 8. 预约订单沿用 `WAITING_ACCEPTANCE`,可抢查询和原子抢单 SQL 同时限制 `scheduled_pickup_start_at <= now`,不增加仅为调度使用的新状态。
 9. PIN 默认启用并由服务端生成四位数字;用户详情可见,骑手响应始终剔除,送达事务先校验 PIN 再写凭证和状态。
-10. 用户/骑手使用业务视图,平台详情保留完整审计实体;待抢视图只增加类别、重量、配送时段、预计时长和市/区级路线信息。
+10. 用户/骑手使用业务视图,平台详情保留完整审计实体;待抢视图只增加类别、重量范围、配送时段、预计时长和市/区级路线信息。
 11. 自动任务扫描送达满 24 小时订单并逐条条件完成,和用户/平台并发时只允许一次成功。
 
 ## 补充原型增量实施批次
@@ -353,7 +353,7 @@ public class FlashDeliveryOrderDetailView extends FlashDeliveryOrderView {
 }
 ```
 
-`FlashDeliveryUserOrderListView` 固定卡片字段为订单标识、服务/包裹、状态、配送方式/预约时段、取送文字地址、预计时长、金额、送达时间和创建时间。`FlashDeliveryRiderOrderListView` 固定增加重量、`pinRequired`、`pickupDistanceMeters` 和路线距离。分页 DTO 只使用上述六个分页/摘要属性。
+`FlashDeliveryUserOrderListView` 固定卡片字段为订单标识、服务/包裹、状态、配送方式/预约时段、取送文字地址、预计时长、金额、送达时间和创建时间。`FlashDeliveryRiderOrderListView` 固定增加重量范围、`pinRequired`、`pickupDistanceMeters` 和路线距离。分页 DTO 只使用上述六个分页/摘要属性。
 
 - [ ] **Step 4: Run DTO tests**
 
@@ -586,7 +586,7 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 
 - `FlashDeliveryPricingCalculator.calculateBreakdown(FlashDeliveryPricing pricing, int distanceMeters, String deliveryType, Long tipAmount)` 返回 `distanceFee/baseDeliveryFee/urgentFee/tipAmount/amount`。
 - `FlashDeliveryPricingMapper.selectAtTime(String targetTime)` 返回目标 `HH:mm` 命中的全部配置;`countOverlapping(String startTime,String endTime,Long excludedId)` 全局查重;`lockPricing()` 获取单行全局锁。
-- `FlashDeliveryOrder` 新增 `deliveryType,quantity,totalWeightKg,specification,baseDeliveryFee,urgentRate,minimumUrgentFee,urgentFee,tipAmount`。
+- `FlashDeliveryOrder` 使用 `deliveryType,quantity,weightRange,specification,baseDeliveryFee,urgentRate,minimumUrgentFee,urgentFee,tipAmount`。
 
 - [ ] 先扩展计价器和 Mapper 契约测试,覆盖普通加急费为 0、比例费高于最低值、最低值兜底、HALF_UP、小费相加、全局时段查询/查重和订单字段映射,并运行测试确认旧实现失败。
 - [ ] 修改实体、Mapper 和纯计价器;删除运价配置的 `serviceType` 依赖,使上述测试通过。
@@ -614,13 +614,13 @@ The staged list must not contain `.claude/homunculus/observations.jsonl`、`.tmp
 
 **接口:**
 
-- 报价请求新增 `deliveryType,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,quantity,totalWeightKg,specification,tipAmount`,移除客户端 `packageSize`
+- 报价请求使用 `deliveryType,deliveryMode,scheduledPickupStartAt,scheduledPickupEndAt,quantity,weightRange,specification,tipAmount`,不接收精确重量或另一套包裹尺寸字段
 - 创建请求新增 `pricingId,pricingVersion,quotedBaseDeliveryFee,quotedDistanceFee,quotedUrgentFee,quotedAmount`。
-- 报价和订单视图新增业务、物品与费用明细;`packageSize` 由服务端按 `<=5/<=12/<=20kg` 生成
+- 报价和订单视图提供业务、物品与费用明细;订单视图原样返回四档 `weightRange` 快照
 - 报价变化异常携带 `FlashDeliveryQuoteView latestQuote`;Controller 返回国际化错误和 `data=latestQuote`。
 
 - [ ] 先扩展应用服务、Controller 和 DTO 测试,覆盖业务/配送维度校验、完整物品校验、立即与预约时段匹配、报价费用字段、创建逐项一致、配置或金额变化拒绝并返回最新报价、订单费用快照和 `deliveryType=URGENT` 独占,并确认旧实现失败。
-- [ ] 实现请求/响应 DTO、目标计价时刻选择、统一运价查询、报价复算比较、重量档生成、快照保存和报价变化错误响应。
+- [ ] 实现请求/响应 DTO、目标计价时刻选择、统一运价查询、报价复算比较、重量范围校验、快照保存和报价变化错误响应。
 - [ ] 将所有急送独占查询从 `service_type='URGENT'` 改为 `delivery_type='URGENT'`,保持既有骑手锁和状态边界不变。
 - [ ] 运行:`mvn -pl ruoyi-admin -am -Dtest='FlashDeliveryApplicationServiceTest,FlashDeliveryControllerContractTest,FlashDeliveryOrderViewTest,FlashDeliveryRiderOrderListViewTest' -Dsurefire.failIfNoSpecifiedTests=false test`。
 

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

@@ -29,7 +29,7 @@
 7. **假如** 用户选择预约配送,**当** 创建订单,**那么** 系统保存 30 分钟取件时段,预约开始前订单不进入骑手可抢列表。
 8. **假如** 路线距离超过 40 公里,**当** 用户报价或创建订单,**那么** 系统拒绝请求。
 9. **假如** 用户选择普通配送,**当** 系统报价,**那么** 加急费必须为 0;用户选择加急配送时,加急费必须为普通配送费乘当前时段加急比例并与最低加急费取较大值。
-10. **假如** 用户填写物品类别、数量、总重量和规格,**当** 报价或创建订单,**那么** 系统校验数量为正整数、总重量大于 0 且不超过 20kg,并由总重量生成内部重量档
+10. **假如** 用户填写物品类别、数量、四档重量范围和规格,**当** 报价或创建订单,**那么** 系统校验数量为正整数,并且重量范围属于设计稿规定的四个枚举之一
 11. **假如** 用户填写骑手小费,**当** 系统报价或创建订单,**那么** 小费作为独立非负整数 TWD 明细参与总金额,但不改变基础配送费或加急费。
 
 ---
@@ -182,7 +182,7 @@
 - **FR-009**:创建订单时前端必须回传报价配置 ID、版本、基础配送费、距离费、加急费和总金额;服务端必须重新计算距离和价格并逐项精确校验,一致后才能保存地址、路线、金额和计价配置快照,不一致时不得创建订单并必须返回最新报价。
 - **FR-010**:系统必须使用客户端请求号保证同一用户的订单创建幂等。
 - **FR-011**:闪送订单不得复用餐饮订单或打车订单数据模型。
-- **FR-012**:订单必须保存包裹类别、正整数数量、大于 0 且不超过 20kg 的总重量及可选体积/规格说明;内部重量档由总重量生成:不超过 5kg 为 `SMALL`,超过 5kg 且不超过 12kg 为 `MEDIUM`,超过 12kg 且不超过 20kg 为 `LARGE`,不得由客户端直接指定
+- **FR-012**:订单必须保存包裹类别、正整数数量、用户选择的重量范围及可选体积/规格说明;重量范围必须为 `UP_TO_5_KG`、`OVER_5_TO_10_KG`、`OVER_10_TO_15_KG`、`OVER_15_TO_20_KG` 之一,并作为订单快照原样保存
 - **FR-013**:订单必须支持用户备注,但备注不参与计价。
 - **FR-014**:骑手必须通过统一闪送订单列表的 `newTask` 页签主动抢单,第一阶段不实现自动派单。
 - **FR-015**:骑手闪送列表必须复用骑手外卖订单列表的查询参数名称和分页习惯:`page`、`size`、`tab`、`longitude`、`latitude`;不得再拆分为 `available`、`mine` 或叠加 `scene`、`status`、`serviceType` 筛选。
@@ -229,7 +229,7 @@
 - **FR-056**:平台前端新增的全部用户可见文本必须通过 Vue i18n 提供简体中文、繁体中文、英文和越南文,不得硬编码单一语言文本。
 - **FR-057**:平台前端必须沿用现有 Vue 2、Element UI、若依请求封装、动态菜单和 `v-hasPermi` 体系,不新增重复的状态管理或 UI 框架。
 - **FR-058**:骑手列表 `tab` 只接受 `newTask`、`toPickup`、`delivering`、`completed` 和 `cancelled`,分别映射待接单、已接单、已取件、已送达/已完成和已取消;闪送不提供外卖退款页签。
-- **FR-059**:骑手 `newTask` 列表必须返回取件点距离、路线距离、预计时长、基础配送费、加急费、小费、订单总金额、业务场景、配送等级、包裹类别、数量、重量、规格、重量档、配送方式、预约时段、完整取送文字地址和是否需要 PIN,并返回附近任务总数与最高订单金额;不得返回内部加急比例、最低加急费或尚未实现的骑手收入。
+- **FR-059**:骑手 `newTask` 列表必须返回取件点距离、路线距离、预计时长、基础配送费、加急费、小费、订单总金额、业务场景、配送等级、包裹类别、数量、重量范围、规格、配送方式、预约时段、完整取送文字地址和是否需要 PIN,并返回附近任务总数与最高订单金额;不得返回内部加急比例、最低加急费或尚未实现的骑手收入。
 - **FR-060**:用户创建订单、用户详情、骑手详情和骑手接单成功响应必须使用直接且稳定的 App 订单详情结构;权限差异只影响敏感字段是否返回,不得改变 `data` 的顶层形状,也不得要求 App 通过 `data.order` 是否存在判断响应类型。
 - **FR-061**:订单新增可空 `receiver_user_id`;创建订单时将收件手机号去除首尾/内部空白、`+`、`-` 和圆括号但不改写国家码,再以剩余数字唯一匹配当时已注册且 `userType=0` 的普通用户并固化匹配结果,不执行历史订单追溯认领。
 - **FR-062**:手机号无匹配或存在多个普通用户匹配时不得绑定收件账号;订单仍可创建,且不得选择任意一名用户作为收件人。
@@ -280,7 +280,7 @@
 ## 非目标
 
 - 不接入支付网关、付款、退款、取消费、退回费、结算或骑手收入分账。
-- 不实现代购、垫付款、商品金额或禁寄品电子确认;禁运品和货值限制由前端展示规则说明,后端校验本期明确的物品数量、重量、规格长度和最大路线距离。
+- 不实现代购、垫付款、商品金额或禁寄品电子确认;禁运品和货值限制由前端展示规则说明,后端校验本期明确的物品数量、重量范围、规格长度和最大路线距离。
 - 不实现自动派单、抢单推送筛选、骑手在线状态计算或接单时间预测。
 - 不修改餐饮订单 `pos_order` 或打车订单 `taxi_order` 的业务语义。
 - 不在本功能中实现用户端、骑手端或商家端页面;本轮前端增量仅实现平台管理端。

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

@@ -123,3 +123,11 @@
 - [x] T072 先移除应用服务测试夹具对固定锁行的模拟,运行新增运价测试并确认旧实现因依赖锁行红灯
 - [x] T073 删除 Mapper 锁方法、运价增删改锁调用及内部锁断言,同步规格和删表 SQL
 - [x] T074 使用 JDK 21 运行闪送运价定向测试、模块构建和差异检查,核对暂存范围后中文提交并推送
+
+## Phase 15:物品重量改为设计稿四档范围
+
+- [x] T075 记录四档 `weightRange` 的接口、数据迁移和验收设计,并制定实施计划
+- [x] T076 修改服务、DTO 和 Mapper 契约测试,确认旧实现缺少 `weightRange` 映射的预期红灯
+- [x] T077 将请求、订单实体、MyBatis 映射和用户/骑手/详情响应统一改为 `weightRange`,删除精确重量和三档尺寸派生逻辑
+- [x] T078 更新人工迁移 SQL、现有规格和 App 接入文档,完整说明报价与创建订单入参
+- [x] T079 使用 JDK 21 运行定向测试(50 项)、模块编译、完整回归(401 项)和差异检查,全部通过;迁移 SQL 未执行

+ 29 - 20
specs/024-flash-delivery/weight-range-implementation-plan.md

@@ -31,11 +31,11 @@
 - Consumes: 请求字段 `String weightRange` 和订单字段 `String weightRange`。
 - Produces: 合法四档、非法旧值、空值、保存快照以及响应字段集合的可执行契约。
 
-- [ ] 将测试 Fixture 中的 `request.setTotalWeightKg(new BigDecimal("3.00"))` 改为 `request.setWeightRange("UP_TO_5_KG")`。
-- [ ] 将创建订单断言改为 `assertEquals("UP_TO_5_KG", saved.getValue().getWeightRange())`,并删除 `totalWeightKg/packageSize` 断言。
-- [ ] 用参数化或循环断言覆盖四个合法枚举;为空、`SMALL`、未知值时断言 `flash.delivery.item.invalid` 对应的业务异常。
-- [ ] 更新用户、骑手和详情 DTO 的精确字段集合,只保留 `weightRange`。
-- [ ] 更新 Mapper XML 契约断言,要求 `weightRange -> weight_range`,不再要求旧属性。
+- [x] 将测试 Fixture 中的 `request.setTotalWeightKg(new BigDecimal("3.00"))` 改为 `request.setWeightRange("UP_TO_5_KG")`。
+- [x] 将创建订单断言改为 `assertEquals("UP_TO_5_KG", saved.getValue().getWeightRange())`,并删除 `totalWeightKg/packageSize` 断言。
+- [x] 用参数化或循环断言覆盖四个合法枚举;为空、`SMALL`、未知值时断言 `flash.delivery.item.invalid` 对应的业务异常。
+- [x] 更新用户、骑手和详情 DTO 的精确字段集合,只保留 `weightRange`。
+- [x] 更新 Mapper XML 契约断言,要求 `weightRange -> weight_range`,不再要求旧属性。
 
 ### Task 2: 实现统一重量范围模型
 
@@ -47,17 +47,24 @@
 - Modify: `ruoyi-admin/src/main/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryApplicationService.java`
 - Modify: `ruoyi-system/src/main/java/com/ruoyi/system/domain/flash/FlashDeliveryOrder.java`
 - Modify: `ruoyi-system/src/main/resources/mapper/flash/FlashDeliveryOrderMapper.xml`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_en_US.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_vi.properties`
+- Modify: `ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties`
 
 **Interfaces:**
 - Consumes: `FlashDeliveryQuoteRequest#getWeightRange(): String`。
 - Produces: `FlashDeliveryOrder#getWeightRange(): String` 以及各响应 DTO 的 `weightRange` 属性。
 
-- [ ] 在请求 DTO 中以 `String weightRange` 替换 `BigDecimal totalWeightKg`,Javadoc 写明四个枚举和必填规则。
-- [ ] 在订单 Entity、详情、用户列表和骑手列表 DTO 中以 `String weightRange` 替换 `totalWeightKg/packageSize`。
-- [ ] 在应用服务定义四值不可变集合 `WEIGHT_RANGES`,`validateItem(...)` 校验 `weightRange` 必填且属于该集合,同时保留数量和规格长度校验。
-- [ ] 创建订单时直接保存 `request.getWeightRange()`,删除 `scaleWeight(...)` 和 `packageSize(...)`。
-- [ ] 更新详情、用户列表和骑手列表映射,原样返回订单的 `weightRange`。
-- [ ] 更新 MyBatis `resultMap` 为 `<result property="weightRange" column="weight_range"/>`,删除旧重量映射。
+- [x] 在请求 DTO 中以 `String weightRange` 替换 `BigDecimal totalWeightKg`,Javadoc 写明四个枚举和必填规则。
+- [x] 在订单 Entity、详情、用户列表和骑手列表 DTO 中以 `String weightRange` 替换 `totalWeightKg/packageSize`。
+- [x] 在应用服务定义四值不可变集合 `WEIGHT_RANGES`,`validateItem(...)` 校验 `weightRange` 必填且属于该集合,同时保留数量和规格长度校验。
+- [x] 创建订单时直接保存 `request.getWeightRange()`,删除 `scaleWeight(...)` 和 `packageSize(...)`。
+- [x] 更新详情、用户列表和骑手列表映射,原样返回订单的 `weightRange`。
+- [x] 更新 MyBatis `resultMap` 为 `<result property="weightRange" column="weight_range"/>`,删除旧重量映射。
+- [x] 更新默认、英文、简体、繁体、越南语和泰语的物品信息错误提示,将精确重量改为重量范围。
 
 ### Task 3: 更新迁移 SQL、规格和 App API 文档
 
@@ -75,10 +82,10 @@
 - Consumes: Task 2 的最终字段名和枚举。
 - Produces: 可由前端直接实现的请求说明以及开发者手工执行的数据库迁移。
 
-- [ ] 在 `updatesql/sql.md` 追加 2026-09-07 迁移:先按 `total_weight_kg` 更新 `package_size` 为四档值,再把 `package_size` 重命名为 `weight_range VARCHAR(24) NOT NULL`,最后删除 `total_weight_kg`;另附超过 20 公斤异常数据的迁移前查询。
-- [ ] 将现有规格内所有当前契约描述改为四档 `weightRange`,并在任务记录中追加本次变更及验证状态。
-- [ ] 在 `docs/flash-delivery-app-api.md` 的全局规则、枚举、报价请求、创建订单请求、列表/详情响应、示例和错误处理处删除 `totalWeightKg/packageSize`,写明 `weightRange` 必填、四个枚举值、区间和 UI 文案。
-- [ ] 明确报价与创建订单均使用相同的 `weightRange` 字段格式和校验规则;重量范围当前不参与费用计算。
+- [x] 在 `updatesql/sql.md` 追加 2026-09-07 迁移:先按 `total_weight_kg` 更新 `package_size` 为四档值,再把 `package_size` 重命名为 `weight_range VARCHAR(24) NOT NULL`,最后删除 `total_weight_kg`;另附超过 20 公斤异常数据的迁移前查询。
+- [x] 将现有规格内所有当前契约描述改为四档 `weightRange`,并在任务记录中追加本次变更及验证状态。
+- [x] 在 `docs/flash-delivery-app-api.md` 的全局规则、枚举、报价请求、创建订单请求、列表/详情响应、示例和错误处理处删除 `totalWeightKg/packageSize`,写明 `weightRange` 必填、四个枚举值、区间和 UI 文案。
+- [x] 明确报价与创建订单均使用相同的 `weightRange` 字段格式和校验规则;重量范围当前不参与费用计算。
 
 ### Task 4: 统一验证与交付检查
 
@@ -89,8 +96,10 @@
 - Consumes: 完整实现和文档。
 - Produces: 编译、测试、差异与旧字段扫描结果。
 
-- [ ] 运行旧字段扫描:`rg -n "totalWeightKg|packageSize|total_weight_kg|package_size" ruoyi-admin/src ruoyi-system/src docs/flash-delivery-app-api.md specs/024-flash-delivery`,仅允许迁移说明或历史背景命中。
-- [ ] 运行定向测试:`mvn -pl ruoyi-admin -am -Dtest=FlashDeliveryApplicationServiceTest,FlashDeliveryOrderViewTest,FlashDeliveryRiderOrderListViewTest,FlashDeliveryMapperXmlTest -Dsurefire.failIfNoSpecifiedTests=false test`。
-- [ ] 使用 JDK 21 运行模块编译:`mvn -pl ruoyi-admin -am -DskipTests compile`。
-- [ ] 运行完整回归:`mvn test`。
-- [ ] 运行 `git diff --check` 并检查最终文件清单,确保 `.claude/homunculus/observations.jsonl` 未进入交付范围。
+- [x] 运行旧字段扫描:`rg -n "totalWeightKg|packageSize|total_weight_kg|package_size" ruoyi-admin/src ruoyi-system/src docs/flash-delivery-app-api.md specs/024-flash-delivery`,仅允许迁移说明或历史背景命中。
+- [x] 运行定向测试:`mvn -pl ruoyi-admin -am -Dtest=FlashDeliveryApplicationServiceTest,FlashDeliveryOrderViewTest,FlashDeliveryRiderOrderListViewTest,FlashDeliveryMapperXmlTest -Dsurefire.failIfNoSpecifiedTests=false test`。
+- [x] 使用 JDK 21 运行模块编译:`mvn -pl ruoyi-admin -am -DskipTests compile`。
+- [x] 运行完整回归:`mvn test`。
+- [x] 运行 `git diff --check` 并检查最终文件清单,确保 `.claude/homunculus/observations.jsonl` 未进入交付范围。
+
+验证结果:定向测试 50 项通过,JDK 21 模块编译成功,完整回归 401 项通过;SQL 仅写入迁移记录,未执行数据库操作。

+ 18 - 0
updatesql/sql.md

@@ -1503,3 +1503,21 @@ ALTER TABLE flash_delivery_order
 -- 运价为低频管理员配置;保留事务、时段重叠检查和配置版本校验,不再维护专用锁行。
 DROP TABLE IF EXISTS flash_delivery_pricing_lock;
 ```
+
+## 2026-09-07 闪送物品重量改为四档范围(024-flash-delivery)
+
+> 用途:按用户端设计稿将精确重量和旧三档 `package_size` 合并为四档 `weight_range`。当前仍处于开发阶段,不处理历史数据兼容;脚本只记录,由开发者人工执行。
+
+```sql
+UPDATE flash_delivery_order
+SET package_size = CASE
+  WHEN total_weight_kg <= 5 THEN 'UP_TO_5_KG'
+  WHEN total_weight_kg <= 10 THEN 'OVER_5_TO_10_KG'
+  WHEN total_weight_kg <= 15 THEN 'OVER_10_TO_15_KG'
+  ELSE 'OVER_15_TO_20_KG'
+END;
+
+ALTER TABLE flash_delivery_order
+  CHANGE COLUMN package_size weight_range VARCHAR(24) NOT NULL COMMENT '物品重量范围',
+  DROP COLUMN total_weight_kg;
+```