ソースを参照

036 多店逐店备注:报价请求stores[].remarks固化到子单

报价请求 Store 增加可选 remarks,随快照原样固化到对应子单
pos_order.remarks(口径同普通单 item.remarks),父单不另存,
不参与计价与资格校验;旧快照缺字段反序列化为 null,兼容免改。

建批测试补备注落库断言(填写/留空两口径),spec US1 验收第13条、
契约、App 接入文档及 tasks T043 同步;无 SQL 迁移。
qmj 1 日 前
親
コミット
b4100ce4a4

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutBatchService.java

@@ -233,6 +233,8 @@ public class MultiStoreCheckoutBatchService {
             // 沿用本次服务端报价的门店→收货地址道路里程,供旧订单详情的 jvli 字段展示。
             order.setJvli(price.routeDistanceMeters().movePointLeft(3).doubleValue());
             order.setDelryTime(choice.deliveryWindow());
+            // 逐店备注随报价快照固化到子单,口径同普通单 item.remarks;父单不另存。
+            order.setRemarks(choice.remarks());
             // 008 完整配送报价固化骑手毛运费、客户运费与商家补贴;普通订单入口不受此处影响。
             promotions.applyVersion2Pricing(order, food);
             order.setFood(promotions.buildServerFoodJson(cart));

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreQuoteRequest.java

@@ -5,7 +5,7 @@ import java.util.List;
 /** 新多店报价只接受商品/规格 ID;客户端价格、运费与商家归属均不作为计价依据。 */
 public record MultiStoreQuoteRequest(Long addressId, Boolean sameRider, List<Store> stores) {
     public record Store(Long storeId, String deliveryWindow, List<Item> items,
-                        Long couponId, String forcePath) {
+                        Long couponId, String forcePath, String remarks) {
     }
 
     public record Item(Long productId, Integer quantity, List<Long> specValueIds) {

+ 4 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java

@@ -190,6 +190,8 @@ class MultiStoreCheckoutBatchServiceTest {
         int sequence = 1;
         for (PosOrder order : captured.getAllValues()) {
             assertEquals(Double.valueOf(sequence == 1 ? 5.0 : 11.2), order.getJvli());
+            // 逐店备注按报价快照原样落库;未填写的店保持 null
+            assertEquals(sequence == 1 ? "少辣,不要香菜" : null, order.getRemarks());
             assertTrue(order.getDdId().length() <= 20, "子单号不能超过现有订单关联字段上限");
             assertEquals(result.parentOrderNo(), order.getParentDdId());
             assertEquals(result.parentOrderNo() + String.format("%03d", sequence++), order.getDdId());
@@ -252,9 +254,9 @@ class MultiStoreCheckoutBatchServiceTest {
     private void arrangeQuote() {
         MultiStoreQuoteRequest input = new MultiStoreQuoteRequest(10L, true, List.of(
                 new MultiStoreQuoteRequest.Store(1L, null,
-                        List.of(new MultiStoreQuoteRequest.Item(11L, 1, List.of())), null, null),
+                        List.of(new MultiStoreQuoteRequest.Item(11L, 1, List.of())), null, null, "少辣,不要香菜"),
                 new MultiStoreQuoteRequest.Store(2L, null,
-                        List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), null, null)));
+                        List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), null, null, null)));
         when(quotes.loadSnapshot("quote-1", 42L)).thenReturn(new MultiStoreCheckoutQuoteService.QuoteSnapshot(
                 42L, input, "fingerprint", System.currentTimeMillis() + 60_000));
         var price1 = new MultiStoreQuoteView.StorePrice(1L, new BigDecimal("5000"),

+ 7 - 7
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteServiceTest.java

@@ -180,9 +180,9 @@ class MultiStoreCheckoutQuoteServiceTest {
                 .thenReturn(List.of(new PaymentMethodGateService.PayMethodOption("COD", "1", true)));
         MultiStoreQuoteRequest twoStores = new MultiStoreQuoteRequest(10L, true, List.of(
                 new MultiStoreQuoteRequest.Store(1L, null,
-                        List.of(new MultiStoreQuoteRequest.Item(11L, 1, List.of())), null, null),
+                        List.of(new MultiStoreQuoteRequest.Item(11L, 1, List.of())), null, null, null),
                 new MultiStoreQuoteRequest.Store(2L, null,
-                        List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), null, null)));
+                        List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), null, null, null)));
         ServiceException failure = assertThrows(ServiceException.class,
                 () -> subject().quote(twoStores, 42L));
         assertEquals("multi.store.checkout.stores.excluded", failure.getMessage());
@@ -368,10 +368,10 @@ class MultiStoreCheckoutQuoteServiceTest {
         List<MultiStoreQuoteRequest.Store> selected = List.of(
                 new MultiStoreQuoteRequest.Store(203L, null, List.of(
                         new MultiStoreQuoteRequest.Item(9135L, 1, List.of(43L)),
-                        new MultiStoreQuoteRequest.Item(9136L, 1, List.of())), null, null),
+                        new MultiStoreQuoteRequest.Item(9136L, 1, List.of())), null, null, null),
                 new MultiStoreQuoteRequest.Store(168L, null, List.of(
                         new MultiStoreQuoteRequest.Item(9115L, 1, List.of(15L, 7L)),
-                        new MultiStoreQuoteRequest.Item(9078L, 1, List.of(15L))), null, null));
+                        new MultiStoreQuoteRequest.Item(9078L, 1, List.of(15L))), null, null, null));
         assertEquals(new BigDecimal("146"), assertDoesNotThrow(() ->
                 subject().priceFoodForBatch(selected.get(0), 42L, new BigDecimal("36"))).getOriginalAmount());
         assertEquals(new BigDecimal("75"), assertDoesNotThrow(() ->
@@ -394,9 +394,9 @@ class MultiStoreCheckoutQuoteServiceTest {
 
     private MultiStoreQuoteRequest request() {
         return new MultiStoreQuoteRequest(10L, true, List.of(
-                new MultiStoreQuoteRequest.Store(1L, null, List.of(new MultiStoreQuoteRequest.Item(11L, 1, List.of())), null, null),
-                new MultiStoreQuoteRequest.Store(2L, null, List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), null, null),
-                new MultiStoreQuoteRequest.Store(3L, null, List.of(new MultiStoreQuoteRequest.Item(33L, 1, List.of())), null, null)));
+                new MultiStoreQuoteRequest.Store(1L, null, List.of(new MultiStoreQuoteRequest.Item(11L, 1, List.of())), null, null, null),
+                new MultiStoreQuoteRequest.Store(2L, null, List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), null, null, null),
+                new MultiStoreQuoteRequest.Store(3L, null, List.of(new MultiStoreQuoteRequest.Item(33L, 1, List.of())), null, null, null)));
     }
 
     private PosStore store(int id, String latitude, String longitude) {

+ 2 - 2
specs/036-multi-store-checkout/contracts/api.md

@@ -6,8 +6,8 @@
 
 | 方法与路径 | 请求关键字段 | 成功响应 | 拒绝条件 |
 |---|---|---|---|
-| `POST /quote` | 地址 ID、顺序候选店与商品/规格/数量、同骑手、各店即时/预约时段 | `quoteToken`、有效至、锚点/围栏、参与店及逐店原运费/折后运费/商品价/实付/可选支付方式、圈外店和不兼容原因、规则版本 | 店/商品/配送资格不合格、支付方式仅转账、报价配置非法;圈外店仅报告不创建 |
-| `POST /batches` | `requestKey`、`quoteToken`、参与店 ID 列表、逐店 `paymentMethods:[{storeId,payType}]` | 父批次号、子单号/金额/已选方式及状态/可选方式、两个截止时间;含未付线上子单为 `PENDING`,全到付为 `ACTIVE`,但 `ACTIVE` 不等于已开放 | 报价过期、成员/地址/价格/规则变化、逐店方式缺漏/重复/夹带非参与店/不可用、权限不符;同用户同 requestKey 且载荷相同返回原批次,否则拒绝冲突 |
+| `POST /quote` | 地址 ID、顺序候选店与商品/规格/数量、同骑手、各店即时/预约时段、逐店备注 | `quoteToken`、有效至、锚点/围栏、参与店及逐店原运费/折后运费/商品价/实付/可选支付方式、圈外店和不兼容原因、规则版本 | 店/商品/配送资格不合格、支付方式仅转账、报价配置非法;圈外店仅报告不创建 |
+| `POST /batches` | `requestKey`、`quoteToken`、参与店 ID 列表、逐店 `paymentMethods:[{storeId,payType}]` | 父批次号、子单号/金额/已选方式及状态/可选方式、两个截止时间;逐店备注已按报价快照固化到子单 `remarks`,父单不存;含未付线上子单为 `PENDING`,全到付为 `ACTIVE`,但 `ACTIVE` 不等于已开放 | 报价过期、成员/地址/价格/规则变化、逐店方式缺漏/重复/夹带非参与店/不可用、权限不符;同用户同 requestKey 且载荷相同返回原批次,否则拒绝冲突 |
 | `GET /batches/{batchId}` | token + 路径批次 ID | 批次/逐店支付选择、真实实收、到付确认、退款与履约状态、截止时间、关联订单 | 非本人批次拒绝,不暴露其他用户父/子/组 |
 | `POST /batches/members/payment` | body `{batchId,orderId,payType,clientRequestId}`,`payType` = 1/2/3/5 | 新批次的安全改选:到付返回批次状态,线上返回 `ONLINE_PENDING`;网关创建仍走原接口 | 批次已生效/过操作截止、该店在途支付已实收或渠道核实未通过、商家不可用、归属不符;6 转账始终不支持。存在在途尝试时服务端先逐笔向渠道核实,确定未付(OMG UNPAID/FAILED、LINE 未捕获)即放行改选 |
 | `POST /batches/cancel` | body `{batchId,reason:"USER_CANCEL",clientRequestId}` | 当前入口只返回是否成功关闭未生效批次的布尔值;退款真实进度待详情接口返回 | 已 `ACTIVE` 须走子单取消规则;退款处理中不伪称退款成功 |

+ 3 - 2
specs/036-multi-store-checkout/frontend-app-integration.md

@@ -198,10 +198,10 @@ POST /system/multiStoreCheckout/quote
   "stores": [
     { "storeId": 101, "deliveryWindow": null,
       "items": [{ "productId": 1001, "quantity": 1, "specValueIds": [] }],
-      "couponId": null, "forcePath": null },
+      "couponId": null, "forcePath": null, "remarks": null },
     { "storeId": 102, "deliveryWindow": null,
       "items": [{ "productId": 2001, "quantity": 2, "specValueIds": [] }],
-      "couponId": null, "forcePath": null }
+      "couponId": null, "forcePath": null, "remarks": null }
   ]
 }
 ```
@@ -217,6 +217,7 @@ POST /system/multiStoreCheckout/quote
 | `stores[].items[].specValueIds` | long[] | 是 | 规格 ID 列表,无规格传 `[]` |
 | `stores[].couponId` | long | 否 | 门店优惠券 ID,无传 `null` |
 | `stores[].forcePath` | string | 否 | 预留字段,正常流程传 `null` |
+| `stores[].remarks` | string | 否 | 逐店订单备注,随报价快照固化到对应子单 `remarks` 字段(口径同普通单逐店备注),不参与计价;无备注传 `null` |
 
 建批后的子单 `food` 商品快照由服务端按 `specValueIds` 查数据库生成:有规格时同时返回 `ask`(规格名称数组,兼容原订单详情展示)和 `specValueIds`(规格值 ID 数组)。免费规格的 `otherPrice` 可以为 0,但 `ask` 仍应展示;不要只传客户端的 `ask` 文本代替规格 ID。此修复只影响新创建的版本 2 快照,不回写历史订单。
 

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

@@ -79,6 +79,7 @@
 10. 预结算区分购物车候选集合、本次合并参与集合和不符合店铺清单。首店A/B圈内、C圈外时,仅A/B参与本批;创建接口若夹带C或成员与确认报价不一致,明确拒绝,不在提交事务里静默删C或自动另建C单。
 11. 删除首店或改变参与店、地址等会重算围栏资格和报价,重新确认成员;圈内可参与店不足2家时不形成多店同骑手组。
 12. A/B新批次创建后,A已付款而B普通改价/下架/停止接新单,B在期限内仍按已创建快照结算,不重新取当前菜单价格或自动取消A/B。已取消、过期、非本人或支付方式/凭证不可用的请求仍拒绝;商家真实不能供餐按既定子单操作处理。
+13. 逐店备注随报价请求 `stores[].remarks` 提交并随快照固化到对应子单 `remarks` 字段(口径同普通单逐店备注),父单不另存整单备注;备注不参与计价、资格与围栏校验,商家/骑手沿现有子单字段展示。
 
 ### US2 各店独立支付与批次统一生效(P1)
 

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

@@ -168,3 +168,7 @@ T040验证:2026-10-21 定向 23 项(核实器/两服务/选择入口)及 M
 ## 2026-10-21 组详情逐店距离
 
 - [x] T042 报价记录各店道路米数,建批固化为 `pos_order.jvli` 公里,组详情 `members[]` 返回 `juli`/`jvli`/`legacyOrder`;补报价、建批及详情红绿测试,更新 App 接入契约。
+
+## 2026-10-22 多店逐店备注
+
+- [x] T043 报价请求 `stores[].remarks` 随快照固化到对应子单 `pos_order.remarks`(父单不另存),商家/骑手沿现有子单字段展示;补建批备注落库测试,更新 spec/契约/接入文档。