Explorar el Código

036 版本2子单food快照固化规格名称:ask与specValueIds

buildServerFoodJson 在规格归属/加价校验后按规格 ID 读数据库名称写入 ask 与
specValueIds,零加价规格同样保留,不信任客户端规格文本;spec 登记 FR-039 补充,
plan/tasks 记录规格快照修复任务,定向测试 4 项通过。
qmj hace 2 días
padre
commit
26716db775

+ 22 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderPromotionHelper.java

@@ -12,12 +12,14 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.system.domain.FoodSpecsValue;
 import com.ruoyi.system.domain.PosFood;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrderPromotion;
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
 import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
 import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosOrderPromotionMapper;
 import com.ruoyi.system.service.IPromotionCalcService;
@@ -48,6 +50,9 @@ public class OrderPromotionHelper {
     @Autowired
     private PosFoodMapper posFoodMapper;
 
+    @Autowired
+    private FoodSpecsValueMapper foodSpecsValueMapper;
+
     @Autowired
     private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
 
@@ -197,7 +202,7 @@ public class OrderPromotionHelper {
     }
 
     /**
-     * 版本2订单的 food JSON 由服务端按数据库商品与规格生成(兼容 price/otherPrice/number 结构),
+     * 版本2订单的 food JSON 由服务端按数据库商品与规格生成(兼容 price/otherPrice/number/ask 结构),
      * 不接受客户端 food 中的价格作为来源;规格加价按已通过报价校验的规格值价格合计。
      */
     public String buildServerFoodJson(List<PromotionCalcRequest.CartItem> cartItems)
@@ -223,6 +228,22 @@ public class OrderPromotionHelper {
             line.put("price", food.getPrice());
             line.put("otherPrice", specPrice);
             line.put("number", item.getQuantity());
+            if (item.getSpecValueIds() != null && !item.getSpecValueIds().isEmpty())
+            {
+                List<String> selectedNames = new ArrayList<>();
+                for (Long valueId : item.getSpecValueIds())
+                {
+                    // 先由报价服务校验规格归属和加价,再以数据库名称固化展示快照,不信任客户端规格文本。
+                    FoodSpecsValue value = foodSpecsValueMapper.selectById(valueId);
+                    if (value == null || value.getName() == null || value.getName().isBlank())
+                    {
+                        throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidSpec", food.getId()));
+                    }
+                    selectedNames.add(value.getName());
+                }
+                line.put("ask", selectedNames);
+                line.put("specValueIds", item.getSpecValueIds());
+            }
             foods.add(line);
         }
         return foods.toJSONString();

+ 24 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderPromotionHelperFreeDeliveryTest.java

@@ -25,12 +25,14 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.FoodSpecsValue;
 import com.ruoyi.system.domain.PosFood;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrderPromotion;
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
 import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
 import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosOrderPromotionMapper;
 import com.ruoyi.system.service.IPromotionCalcService;
@@ -58,6 +60,9 @@ class OrderPromotionHelperFreeDeliveryTest
     @Mock
     private PosFoodMapper posFoodMapper;
 
+    @Mock
+    private FoodSpecsValueMapper foodSpecsValueMapper;
+
     @Mock
     private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
 
@@ -142,7 +147,15 @@ class OrderPromotionHelperFreeDeliveryTest
         PromotionCalcRequest.CartItem item = new PromotionCalcRequest.CartItem();
         item.setProductId(FOOD_ID);
         item.setQuantity(2);
-        item.setSpecValueIds(List.of(301L));
+        item.setSpecValueIds(List.of(301L, 302L));
+        FoodSpecsValue size = new FoodSpecsValue();
+        size.setId(301L);
+        size.setName("小");
+        FoodSpecsValue spice = new FoodSpecsValue();
+        spice.setId(302L);
+        spice.setName("中辣");
+        when(foodSpecsValueMapper.selectById(301L)).thenReturn(size);
+        when(foodSpecsValueMapper.selectById(302L)).thenReturn(spice);
 
         String json = helper.buildServerFoodJson(List.of(item));
         JSONArray foods = JSON.parseArray(json);
@@ -151,6 +164,16 @@ class OrderPromotionHelperFreeDeliveryTest
         assertEquals(10, foods.getJSONObject(0).getIntValue("otherPrice"));
         assertEquals(2, foods.getJSONObject(0).getIntValue("number"));
         assertEquals("测试商品", foods.getJSONObject(0).getString("name"));
+        // 有加价的规格也要保留名称和 ID,供订单详情展示。
+        assertEquals(List.of("小", "中辣"), foods.getJSONObject(0).getJSONArray("ask").toJavaList(String.class));
+        assertEquals(List.of(301L, 302L), foods.getJSONObject(0).getJSONArray("specValueIds").toJavaList(Long.class));
+
+        when(foodDeliveryPromotionQuoteService.resolveSpecSurcharge(any(), any()))
+                .thenReturn(BigDecimal.ZERO);
+        JSONArray freeSpecFoods = JSON.parseArray(helper.buildServerFoodJson(List.of(item)));
+        assertEquals(0, freeSpecFoods.getJSONObject(0).getIntValue("otherPrice"));
+        // 零加价不代表未选择规格。
+        assertEquals(List.of("小", "中辣"), freeSpecFoods.getJSONObject(0).getJSONArray("ask").toJavaList(String.class));
     }
 
     @Test

+ 0 - 1
specs/036-multi-store-checkout/frontend-app-integration.md

@@ -215,7 +215,6 @@ POST /system/multiStoreCheckout/quote
 | `stores[].items[].productId` | long | 是 | 商品 ID;只提交商品/规格 ID,客户端价格不作为计价依据 |
 | `stores[].items[].quantity` | int | 是 | 数量 |
 | `stores[].items[].specValueIds` | long[] | 是 | 规格 ID 列表,无规格传 `[]` |
-
 | `stores[].couponId` | long | 否 | 门店优惠券 ID,无传 `null` |
 | `stores[].forcePath` | string | 否 | 预留字段,正常流程传 `null` |
 

+ 4 - 0
specs/036-multi-store-checkout/plan.md

@@ -431,3 +431,7 @@ T033仅修正MultiStoreCheckoutQuoteService的商品可售判断,与单店完
 2. 用户普通下单、商家代客下单与多店建批共用OrderNumberReservationService,在任何父子单落库前对同一号码SET NX占用,再查父表和子表的实际记录。占用保留1天,事务回滚后按owner比较删除;缓存过期或重启后仍由持久记录防止复用。多店号码已占用时取下一毫秒(最多8次),普通下单重复号沿原国际化重复单错误拒绝,不改前端参数。
 3. 修正所有MS快速筛选以兼容数字号,资格始终由order_checkout_member/order_checkout_batch关联决定;新增控制器真实关联门禁,普通99号不能被前缀误判。保留旧MS关联读取;不改变订单、支付、退款、权限与取送状态规则。
 4. 回归共享号码双向冲突与回滚释放、用户和商家入口写前占用、建批长度、父子一致/幂等、数字成员支付与回调、旧取消/接单/平台状态防绕过、组合列表以及普通同号段兼容;全部实现后统一运行JDK21回归和构建。App单号字段仍是string,不能转JS Number。
+
+## 2026-10-21 规格快照修复
+
+版本 2 订单共享的 `OrderPromotionHelper.buildServerFoodJson` 在完成规格归属/加价校验后,按规格 ID 读取数据库名称并写入兼容旧详情的 `ask` 与 `specValueIds`;不改报价、建批请求、旧订单保存逻辑或历史数据。用定向测试覆盖有加价和零加价规格,再运行受影响模块回归。

+ 1 - 0
specs/036-multi-store-checkout/spec.md

@@ -210,6 +210,7 @@
 - **FR-037** 补充普通平台配送外卖及新多店预约的持久开放扫描,沿原时间判断/sys_appointorder_handletime,不新增倒推最迟接单算法;现有无人接单规则的多店接入另按 FR-042。创建先设预约信息再判显示;扫描校验原开放时点、未取消未分配和原付款/履约资格,新批次额外已生效,普通单不检查批次期限。重启数据库补扫,开放状态与通知意图原子幂等并支持重试;直接接单不可绕过。自取/夜市自配送不纳入,不批量改写历史已开放单。
 - **FR-038** 用户原订单列表与统一列表识别新批次:线上未付、部分付款或缓冲核实中保留可见进度/继续结算信息;新建成员均已选方式,历史未选记录也不能漏单,不因payStatus=1误标可履约。真实已付与到付确认分开,生效后逐子单普通进度不被覆盖;覆盖本批LINE、OMG信用卡/Apple Pay及到付,旧非多店分类不全局改写,App只交付接入文档。
 - **FR-039** 新多店预结算/创建服务端读取真实门店商品/规格价格、数量与优惠规则,计算餐费、原/折后运费及实付并固化。商品可售条件须与单店报价及菜单一致:`to_examine=1`为审核通过、`stacking_up=0`为上架;未通过或缺失审核状态必须拒绝。拒绝跨店商品/规格、非法数量和报价/金额不一致,不能直接保存客户端amount/freight/foodAmount或仅警告放行。明确金额单位与渠道转换,逐店HALF_UP及支付/退款/到付/账单同快照不变,原普通算价不改。
+- **FR-039 补充(2026-10-21)** 新建版本 2 子单的 `food` 快照须保存已校验规格 ID 及数据库规格名称 `ask`,即使规格加价为 0 也要保留;客户端规格名称不作为可信来源。普通旧单和历史快照不回写。
 - **FR-040** 新批次整批或合法子单取消释放/返还对应取消成员占用/核销的券,不返仍履约成员的券。保留原有效期,仍有效恢复可用、过期不续期;原券/原子单绑定及状态参加并发幂等,重复取消/退款/扫描不重复返券,旧取消不得释放已由新单使用的券。创建同一券不能并发重复使用,返券不是渠道退款成功;普通非多店券规则不改。
 - **FR-041** 新批次详情、付款进度、关联订单、支付/取消按token确定用户并校验父/子/组归属及关联,不能仅凭传入ID返回信息或操作;现有getParentOrderDetail涉及新批次不得原样复用无用户过滤查询。商家仅访问授权子单,骑手沿现有待抢/本人承接权限,不放宽其他商家或用户敏感信息。未授权无款项/状态/退款/返券副作用。
 

+ 4 - 0
specs/036-multi-store-checkout/tasks.md

@@ -160,3 +160,7 @@ T038–T039验证:2026-10-21 使用 JDK21 执行 `mvn -q -pl ruoyi-admin -am v
 - [x] T040 逐店换渠道/改到付前逐笔核实渠道在途支付(OMG reconcile、LINE 本地状态+捕获查询):确定未付放行改选、已收款提示已支付、结果未知维持专属拒绝;核实发生在批次行锁之前,核实后又有新发起保守拒绝。新增 MultiStoreCheckoutCaptureVerifier、专属文案 multi.store.checkout.payment.dispatched(六语言)并复用 no.pay.switch.already.paid;同步 spec/data-model/api/接入文档。
 
 T040验证:2026-10-21 定向 23 项(核实器/两服务/选择入口)及 MultiStoreCheckout*、PayMethodSwitch* 全量回归 0 失败;未连接真实数据库与真实支付渠道。
+
+## 2026-10-21 规格快照修复
+
+- [x] T041 修复新建版本 2 子单规格快照:服务端按已校验的规格 ID 固化数据库规格名称 `ask` 和 `specValueIds`,补有加价及零加价规格测试;历史订单不回写。