Ver Fonte

036 多店组详情补齐逐店配送距离

qmj há 2 dias atrás
pai
commit
2f7b93b48b

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutBatchService.java

@@ -182,7 +182,8 @@ public class MultiStoreCheckoutBatchService {
                 throw invalid("multi.store.checkout.store.invalid");
             }
             PromotionCalcResponse food = quotes.priceFoodForBatch(choice, userId, price.discountedFreight());
-            if (food == null || food.getOriginalAmount() == null || food.getFinalAmount() == null
+            if (food == null || price.routeDistanceMeters() == null || price.routeDistanceMeters().signum() <= 0
+                    || food.getOriginalAmount() == null || food.getFinalAmount() == null
                     || food.getOriginalAmount().compareTo(price.originalFoodAmount()) != 0
                     || food.getFinalAmount().compareTo(price.foodAmount()) != 0
                     || food.getDeliveryFee() == null || food.getDeliveryFee().compareTo(price.discountedFreight()) != 0
@@ -229,6 +230,8 @@ public class MultiStoreCheckoutBatchService {
             order.setShAddress(frozenAddress);
             order.setLongitude(store.getLongitude());
             order.setLatitude(store.getLatitude());
+            // 沿用本次服务端报价的门店→收货地址道路里程,供旧订单详情的 jvli 字段展示。
+            order.setJvli(price.routeDistanceMeters().movePointLeft(3).doubleValue());
             order.setDelryTime(choice.deliveryWindow());
             // 008 完整配送报价固化骑手毛运费、客户运费与商家补贴;普通订单入口不受此处影响。
             promotions.applyVersion2Pricing(order, food);

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

@@ -153,6 +153,8 @@ public class MultiStoreCheckoutQueryService {
                     for (MultiStoreMemberView member : view.getMembers()) {
                         attachOrderContext(member.getOrderId(), originals, storeMap, userMap, longitude, latitude,
                                 member::setLegacyOrder, member::setStore, member::setUser, member::setJuli);
+                        // 组没有单一配送里程;逐子单暴露旧详情的 jvli,缺失快照不臆造距离。
+                        member.setJvli(member.getLegacyOrder() == null ? null : member.getLegacyOrder().getJvli());
                     }
                 }
             }

+ 2 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteService.java

@@ -168,7 +168,8 @@ public class MultiStoreCheckoutQuoteService {
             // 先按参与店数确定多店折后运费,再把它作为免运活动的骑手毛配送费基数。
             BigDecimal reduced = MultiStoreCheckoutRules.discountedFreight(original, ratio);
             PromotionCalcResponse price = priceFoodForBatch(candidate.selected(), userId, reduced);
-            prices.add(new MultiStoreQuoteView.StorePrice(candidate.selected().storeId(), price.getOriginalAmount(), price.getFinalAmount(),
+            prices.add(new MultiStoreQuoteView.StorePrice(candidate.selected().storeId(), distances.get(index),
+                    price.getOriginalAmount(), price.getFinalAmount(),
                     original, reduced, price.getCustomerDeliveryFee(),
                     price.getDeliveryDiscountAmount(), price.getPayableAmount(), candidate.payTypes()));
         }

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

@@ -29,5 +29,7 @@ public class MultiStoreMemberView {
     private PosStore store;
     private InfoUser user;
     private Double juli;
+    /** 门店到收货地址的配送距离(公里),与 legacyOrder.jvli 相同。 */
+    private Double jvli;
     private List<String> allowedActions = List.of();
 }

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

@@ -8,7 +8,8 @@ public record MultiStoreQuoteView(String quoteToken, long expiresAtEpochMillis,
                                   Long anchorStoreId, int fenceMeters, List<Long> participatingStoreIds,
                                   List<StorePrice> stores, List<ExcludedStore> excludedStores,
                                   BigDecimal freightRatio, String fingerprint) {
-    public record StorePrice(Long storeId, BigDecimal originalFoodAmount, BigDecimal foodAmount, BigDecimal originalFreight,
+    public record StorePrice(Long storeId, BigDecimal routeDistanceMeters,
+                             BigDecimal originalFoodAmount, BigDecimal foodAmount, BigDecimal originalFreight,
                              BigDecimal discountedFreight, BigDecimal customerDeliveryFee,
                              BigDecimal merchantDeliverySubsidy, BigDecimal amount,
                              List<String> availablePayTypes) {

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

@@ -189,6 +189,7 @@ class MultiStoreCheckoutBatchServiceTest {
         verify(orders, org.mockito.Mockito.times(2)).insert(captured.capture());
         int sequence = 1;
         for (PosOrder order : captured.getAllValues()) {
+            assertEquals(Double.valueOf(sequence == 1 ? 5.0 : 11.2), order.getJvli());
             assertTrue(order.getDdId().length() <= 20, "子单号不能超过现有订单关联字段上限");
             assertEquals(result.parentOrderNo(), order.getParentDdId());
             assertEquals(result.parentOrderNo() + String.format("%03d", sequence++), order.getDdId());
@@ -256,10 +257,12 @@ class MultiStoreCheckoutBatchServiceTest {
                         List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), 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("50"), new BigDecimal("50"),
+        var price1 = new MultiStoreQuoteView.StorePrice(1L, new BigDecimal("5000"),
+                new BigDecimal("50"), new BigDecimal("50"),
                 new BigDecimal("40"), new BigDecimal("36"), new BigDecimal("36"), BigDecimal.ZERO,
                 new BigDecimal("86"), List.of("1", "3"));
-        var price2 = new MultiStoreQuoteView.StorePrice(2L, new BigDecimal("50"), new BigDecimal("50"),
+        var price2 = new MultiStoreQuoteView.StorePrice(2L, new BigDecimal("11200"),
+                new BigDecimal("50"), new BigDecimal("50"),
                 new BigDecimal("40"), new BigDecimal("36"), new BigDecimal("36"), BigDecimal.ZERO,
                 new BigDecimal("86"), List.of("1", "2"));
         when(quotes.quote(input, 42L)).thenReturn(new MultiStoreQuoteView("fresh", System.currentTimeMillis() + 60_000,

+ 23 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQueryServiceTest.java

@@ -22,6 +22,7 @@ import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -202,6 +203,28 @@ class MultiStoreCheckoutQueryServiceTest {
         assertEquals(0.0, orderCard.getJuli());
     }
 
+    @Test
+    void groupDetailExposesEachMembersLegacyDistanceFields() {
+        CheckoutCardRow group = card("GROUP", 4L, "GROUP:4");
+        group.setBatchId(7L);
+        group.setGroupStatus("ACCEPTED");
+        when(mapper.selectGroupForRider(4L, 33L, 1000L)).thenReturn(group);
+        when(mapper.selectMembersForBatches(List.of(7L)))
+                .thenReturn(List.of(member(7L, 71L, 1L, "COD_CONFIRMED", 80, 0)));
+        PosOrder order = new PosOrder();
+        order.setId(71L);
+        order.setJvli(5.0);
+        when(ordinaryOrders.listByIds(List.of(71L))).thenReturn(List.of(order));
+
+        var detail = new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(
+                service.groupDetail(4L, 33L, 1000L));
+        var member = detail.path("members").get(0);
+        assertTrue(member.has("juli"));
+        assertTrue(member.path("juli").isNull()); // 与旧普通订单详情一样,未传定位时为空。
+        assertEquals(5.0, member.path("jvli").asDouble());
+        assertEquals(5.0, member.path("legacyOrder").path("jvli").asDouble());
+    }
+
     @Test
     void invalidPaginationIsRejectedBeforeQuery() {
         try (org.mockito.MockedStatic<com.ruoyi.common.utils.MessageUtils> messages =

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

@@ -150,6 +150,8 @@ class MultiStoreCheckoutQuoteServiceTest {
         assertEquals(List.of(1L, 2L), quote.participatingStoreIds());
         assertEquals("OUTSIDE_FENCE", quote.excludedStores().get(0).reason());
         assertEquals(new BigDecimal("80"), quote.stores().get(0).originalFreight());
+        assertEquals(5000, new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(quote.stores().get(0))
+                .path("routeDistanceMeters").asInt());
         assertEquals(new BigDecimal("72"), quote.stores().get(0).discountedFreight());
         assertEquals(new BigDecimal("72"), quote.stores().get(0).customerDeliveryFee());
         assertEquals(new BigDecimal("122"), quote.stores().get(0).amount());

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

@@ -23,6 +23,7 @@ LINE 创建使用 `{ddId:orderNo}`;OMG 创建使用 `{orderId:orderNo,paymentM
 ## 骑手/平台
 
 - 骑手待抢列表对同骑手配送组只返回一个任务,显示各店及总计;已生效、到预约开放时点才可见。`POST /system/multiStoreCheckout/groups/accept`,JSON `{groupId,version,clientRequestId}`,原子承接全部有效成员;旧单笔接单 API 对组成员拒绝拆抢。骑手仍逐子单取餐/送达。接组须以创建时首店为位置基准,在同一骑手锁内重验资格、位置时效、接单范围、容量与成员状态。首店到其他门店的合并围栏另行校验,不用最近店替代首店。组详情路径为 `GET /groups/{groupId}`,待抢/已接列表和角标使用下节的统一口径。
+- 组详情 `members[]` 逐子单返回 `legacyOrder`、`juli`、`jvli`:`juli` 沿旧普通详情的无定位口径返回空,`jvli` 为建单时服务端道路里程的公里快照,并与 `legacyOrder.jvli` 一致;历史无快照时为空。组卡顶层没有单一门店配送距离。
 - 商家已接未取但实际无法供餐:`POST /merchant/members/cancelUnavailable`,body `{batchId,orderId,reason:"MERCHANT_UNAVAILABLE",clientRequestId}`。仅授权门店、已接组中尚未被骑手取餐的本子单可取消;本店实收登记原路退款意图,其他店不补差;最后一个有效子单取消时结清组和父单。
 - 平台配置 `GET/PUT /system/multiStoreCheckout/config`:开关、围栏米、最多店数、缓冲分钟、可增删改折扣阶梯;保存会校验并更新规则版本,仅影响新报价。平台退款跟进列表显示各商家渠道、实收、退款意图/结果/待人工处理,不提供“手工标记渠道已退款”。
 - 平台退款跟进只读接口 `GET /system/multiStoreCheckout/admin/refunds?page=1&size=20&status=ALL`,可按 `PENDING/PROCESSING/UNKNOWN/FAILED/MANUAL_REVIEW/REFUNDED` 过滤;需要现有 `system:order:list` 权限,按退款意图逐笔分页,返回 `batchId/memberId/orderId/storeId/channel/amount/merchantOrderNo/intentStatus/gatewayStatus/effectiveStatus`。仅 `REFUNDED` 表示原渠道已证实退款;测试 OMG 无法实际退款时不得手工置为成功。

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

@@ -231,6 +231,7 @@ POST /system/multiStoreCheckout/quote
 | `fenceMeters` | int | 合并围栏半径(米),首店到其他店的距离上限 |
 | `participatingStoreIds` | long[] | 可参与的门店 ID 列表 |
 | `stores[]` | array | 逐店报价,字段见 3.8,另有 `availablePayTypes` |
+| `stores[].routeDistanceMeters` | number | 服务端矩阵计算的该店到收货地址道路米数;建批时重新报价,并固化为子单 `jvli`(公里) |
 | `excludedStores[]` | array | 圈外/不兼容门店:`{storeId, reason}`。**不自动创建订单**,须剔除或另行结算 |
 | `freightRatio` | number | 当前折扣系数 |
 | `fingerprint` | string | 报价指纹(服务端内部使用,App 原样忽略即可) |
@@ -443,7 +444,9 @@ GET /system/multiStoreCheckout/groups/{groupId}
 - `allowedActions`:待抢时 `ACCEPT_GROUP`;本人已接组后 `members[]` 内出现 `PICKUP_MEMBER` / `DELIVER_MEMBER`。
 - `codToCollectAmount`:有效到付子单的待收总额。
 
-成员摘要(含待抢阶段)附带 `legacyOrder`/`store`/`user`/`juli`,与旧骑手列表逐单口径一致,
+成员摘要(含待抢阶段)附带 `legacyOrder`/`store`/`user`/`juli`/`jvli`,
+其中 `juli` 与旧普通订单详情一致:此详情接口未传骑手定位时为 `null`;`jvli` 是该店到收货地址的道路距离(公里),与 `legacyOrder.jvli` 一致。组内每店距离不同,组卡顶层不提供单一 `jvli`。历史子单若建单时未保存距离则保持 `null`,不把直线距离伪装成原道路距离。
+其他订单字段与旧骑手列表逐单口径一致,
 可直接复用旧列表项渲染;已接单后逐店履约仍按子单详情读取(见 6.5)。如产品后续要求待抢阶段
 脱敏联系人与地址,须另行变更口径并同步两端。
 

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

@@ -435,3 +435,7 @@ T033仅修正MultiStoreCheckoutQuoteService的商品可售判断,与单店完
 ## 2026-10-21 规格快照修复
 
 版本 2 订单共享的 `OrderPromotionHelper.buildServerFoodJson` 在完成规格归属/加价校验后,按规格 ID 读取数据库名称并写入兼容旧详情的 `ask` 与 `specValueIds`;不改报价、建批请求、旧订单保存逻辑或历史数据。用定向测试覆盖有加价和零加价规格,再运行受影响模块回归。
+
+## 2026-10-21 组详情逐店距离
+
+复用多店报价已取得的逐店道路米数,在建批重新报价时固化 `pos_order.jvli` 公里快照;组详情 `members[]` 从各子单快照返回 `jvli`,`juli` 与旧普通详情一致为空,不新增地图调用或计算组级单一距离。历史缺失保持空,报价金额及普通订单不变。

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

@@ -211,6 +211,7 @@
 - **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-039 距离展示补充(2026-10-21)** 多店建单逐店固化本次服务端道路里程到 `pos_order.jvli`(公里);骑手组详情按成员返回 `jvli` 及原 `legacyOrder`,`juli` 沿旧普通详情无定位时为空。历史无里程快照不使用直线距离伪造,不回写旧单;普通订单路径不变。
 - **FR-040** 新批次整批或合法子单取消释放/返还对应取消成员占用/核销的券,不返仍履约成员的券。保留原有效期,仍有效恢复可用、过期不续期;原券/原子单绑定及状态参加并发幂等,重复取消/退款/扫描不重复返券,旧取消不得释放已由新单使用的券。创建同一券不能并发重复使用,返券不是渠道退款成功;普通非多店券规则不改。
 - **FR-041** 新批次详情、付款进度、关联订单、支付/取消按token确定用户并校验父/子/组归属及关联,不能仅凭传入ID返回信息或操作;现有getParentOrderDetail涉及新批次不得原样复用无用户过滤查询。商家仅访问授权子单,骑手沿现有待抢/本人承接权限,不放宽其他商家或用户敏感信息。未授权无款项/状态/退款/返券副作用。
 

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

@@ -164,3 +164,7 @@ T040验证:2026-10-21 定向 23 项(核实器/两服务/选择入口)及 M
 ## 2026-10-21 规格快照修复
 
 - [x] T041 修复新建版本 2 子单规格快照:服务端按已校验的规格 ID 固化数据库规格名称 `ask` 和 `specValueIds`,补有加价及零加价规格测试;历史订单不回写。
+
+## 2026-10-21 组详情逐店距离
+
+- [x] T042 报价记录各店道路米数,建批固化为 `pos_order.jvli` 公里,组详情 `members[]` 返回 `juli`/`jvli`/`legacyOrder`;补报价、建批及详情红绿测试,更新 App 接入契约。