Explorar o código

036 报价两项修复:to_examine 口口径与参与店不足点名提示

- 修正 priceFoodForBatch 商品审核校验反转:to_examine=1 才是审核通过
  可售(与菜单/单店报价一致),含联调复现用例(203/168 店规格选配)
- 参与店被围栏/支付方式筛到不足 2 家时,错误消息点名门店与原因;新增
  i18n key stores.excluded / excluded.outside.fence / excluded.no.payment
  六语言
- MultiStore* 109 个测试全绿(clean 重建验证)
qmj hai 3 días
pai
achega
c6a6d6abad

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

@@ -49,6 +49,7 @@ import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 /** 036 新批次专用的服务端报价;旧 `/createOrder` 的价格与支付路径不受影响。 */
 @Service
@@ -119,6 +120,7 @@ public class MultiStoreCheckoutQuoteService {
         Set<Long> seen = new HashSet<>();
         List<Candidate> candidates = new ArrayList<>();
         List<MultiStoreQuoteView.ExcludedStore> excluded = new ArrayList<>();
+        Map<Long, String> excludedNames = new HashMap<>();
         PosFreight fare = currentFreight();
         for (MultiStoreQuoteRequest.Store selected : input.stores()) {
             if (selected == null || selected.storeId() == null || !seen.add(selected.storeId())
@@ -130,6 +132,7 @@ public class MultiStoreCheckoutQuoteService {
             if (store == null || !hasCoordinates(store)) throw invalid("multi.store.checkout.store.invalid");
             if (!MultiStoreCheckoutRules.withinFence(anchor.getLatitude().doubleValue(), anchor.getLongitude().doubleValue(),
                     store.getLatitude().doubleValue(), store.getLongitude().doubleValue(), policy.fenceMeters())) {
+                excludedNames.put(selected.storeId(), store.getPosName());
                 excluded.add(new MultiStoreQuoteView.ExcludedStore(selected.storeId(), "OUTSIDE_FENCE"));
                 continue;
             }
@@ -143,12 +146,15 @@ public class MultiStoreCheckoutQuoteService {
                     .map(PaymentMethodGateService.PayMethodOption::getPayType)
                     .filter(ALLOWED_PAY_TYPES::contains).distinct().toList();
             if (payTypes.isEmpty()) {
+                excludedNames.put(selected.storeId(), store.getPosName());
                 excluded.add(new MultiStoreQuoteView.ExcludedStore(selected.storeId(), "NO_SUPPORTED_PAYMENT"));
                 continue;
             }
             candidates.add(new Candidate(selected, store, payTypes));
         }
         if (candidates.size() < 2 || !candidates.get(0).selected().storeId().equals(Long.valueOf(anchor.getId()))) {
+            // 参与店被筛到不足时点名门店与原因,避免笼统报错掩盖围栏或支付方式问题。
+            if (candidates.size() < 2 && !excluded.isEmpty()) throw excludedFailure(excluded, excludedNames);
             throw invalid("multi.store.checkout.invalid");
         }
         // 资格/围栏/支付方式筛选完成后,只对真正参与的门店发一次N×1请求。
@@ -206,8 +212,9 @@ public class MultiStoreCheckoutQuoteService {
             if (item == null || item.productId() == null || item.quantity() == null
                     || item.quantity() <= 0 || item.quantity() > 99) throw invalid("multi.store.checkout.food.invalid");
             PosFood food = foods.selectById(item.productId());
+            // 与菜单和单店报价一致:to_examine=1才是审核通过;缺失或其他状态均不可售。
             if (food == null || !selected.storeId().equals(food.getMdid())
-                    || !"0".equals(food.getStackingUp()) || "1".equals(food.getToExamine())
+                    || !"0".equals(food.getStackingUp()) || !"1".equals(food.getToExamine())
                     || food.getPrice() == null || food.getPrice().signum() < 0) {
                 throw invalid("multi.store.checkout.food.invalid");
             }
@@ -377,8 +384,22 @@ public class MultiStoreCheckoutQuoteService {
         }
     }
 
-    private ServiceException invalid(String key) {
-        return new ServiceException(MessageUtils.message(key));
+    private ServiceException excludedFailure(List<MultiStoreQuoteView.ExcludedStore> excluded,
+                                            Map<Long, String> names) {
+        String joined = excluded.stream()
+                .map(item -> names.getOrDefault(item.storeId(), String.valueOf(item.storeId()))
+                        + "(" + MessageUtils.message(excludedReasonKey(item.reason())) + ")")
+                .collect(Collectors.joining(", "));
+        return invalid("multi.store.checkout.stores.excluded", joined);
+    }
+
+    private String excludedReasonKey(String reason) {
+        return "NO_SUPPORTED_PAYMENT".equals(reason) ? "multi.store.checkout.excluded.no.payment"
+                : "multi.store.checkout.excluded.outside.fence";
+    }
+
+    private ServiceException invalid(String key, Object... args) {
+        return new ServiceException(MessageUtils.message(key, args));
     }
 
     private record Candidate(MultiStoreQuoteRequest.Store selected, PosStore store,

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

@@ -477,6 +477,9 @@ multi.store.checkout.store.invalid=Cửa hàng không đủ điều kiện giao
 multi.store.checkout.food.invalid=Sản phẩm, tùy chọn hoặc ưu đãi đã thay đổi. Vui lòng tính lại
 multi.store.checkout.freight.invalid=Không thể tính phí giao hàng. Vui lòng thử lại sau
 multi.store.checkout.quote.expired=Báo giá đã hết hạn. Vui lòng lấy báo giá mới
+multi.store.checkout.stores.excluded=以下门店无法参与多店配送:{0}。参与门店不足2家,请调整门店后重试
+multi.store.checkout.excluded.outside.fence=超出与首店的配送范围
+multi.store.checkout.excluded.no.payment=暂无可用的支付方式
 # 008 T074 满额免运活动管理
 promotion.freeDelivery.invalidThreshold=满额免运门槛必须为正整数元
 promotion.freeDelivery.invalidRules=满额免运需且仅需一条门槛规则,且活动时间必须完整

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

@@ -463,6 +463,9 @@ multi.store.checkout.store.invalid=Store is not eligible for this multi-store de
 multi.store.checkout.food.invalid=Products, options, or promotions have changed. Please refresh checkout
 multi.store.checkout.freight.invalid=Delivery pricing is unavailable. Please retry later
 multi.store.checkout.quote.expired=Quote expired. Please request a new one
+multi.store.checkout.stores.excluded=These stores cannot join multi-store delivery: {0}. Fewer than 2 stores can participate, please adjust the selection and retry.
+multi.store.checkout.excluded.outside.fence=outside the delivery range of the first store
+multi.store.checkout.excluded.no.payment=no available payment method
 # 008 T074 满额免运活动管理
 promotion.freeDelivery.invalidThreshold=Free delivery threshold must be a positive whole number of dollars
 promotion.freeDelivery.invalidRules=Free delivery requires exactly one threshold rule and complete activity dates

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

@@ -464,6 +464,9 @@ multi.store.checkout.store.invalid=ร้านค้านี้ไม่สา
 multi.store.checkout.food.invalid=ข้อมูลสินค้า ตัวเลือก หรือโปรโมชันมีการเปลี่ยนแปลง โปรดคำนวณใหม่
 multi.store.checkout.freight.invalid=ไม่สามารถคำนวณค่าจัดส่งได้ โปรดลองอีกครั้ง
 multi.store.checkout.quote.expired=ใบเสนอราคาหมดอายุ โปรดขอใหม่
+multi.store.checkout.stores.excluded=ร้านค้าต่อไปนี้ไม่สามารถร่วมการจัดส่งแบบหลายร้านได้: {0} เหลือร้านค้าที่เข้าร่วมไม่ถึง 2 ร้าน โปรดปรับรายการแล้วลองอีกครั้ง
+multi.store.checkout.excluded.outside.fence=อยู่นอกระยะจัดส่งจากร้านค้าแรก
+multi.store.checkout.excluded.no.payment=ไม่มีช่องทางชำระเงินที่ใช้ได้
 # 008 T074 满额免运活动管理
 promotion.freeDelivery.invalidThreshold=เกณฑ์การส่งฟรีต้องเป็นจำนวนเต็มบวกหน่วยหยวน
 promotion.freeDelivery.invalidRules=การส่งฟรีต้องมีกฎเกณฑ์เพียงหนึ่งข้อและระบุช่วงเวลากิจกรรมให้ครบถ้วน

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

@@ -21,3 +21,6 @@ promotion.freeDelivery.duplicateOrder=Đơn hàng đã được gửi, vui lòng
 multi.store.checkout.batch.cancel.required=Vui lòng hủy qua quy trình đơn hàng nhiều cửa hàng
 multi.store.checkout.admin.read.only=Vui lòng xử lý trạng thái đơn nhiều cửa hàng qua quy trình riêng
 multi.store.checkout.upgrade.required=Vui lòng cập nhật ứng dụng để sử dụng thanh toán nhiều cửa hàng và nhóm giao hàng
+multi.store.checkout.stores.excluded=Các cửa hàng sau không thể tham gia giao hàng đa cửa hàng: {0}. Số cửa hàng tham gia ít hơn 2, vui lòng điều chỉnh và thử lại.
+multi.store.checkout.excluded.outside.fence=ngoài phạm vi giao hàng tính từ cửa hàng đầu tiên
+multi.store.checkout.excluded.no.payment=không có phương thức thanh toán khả dụng

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

@@ -465,6 +465,9 @@ multi.store.checkout.store.invalid=门店不可参与当前多店配送
 multi.store.checkout.food.invalid=商品、规格或优惠信息已变化,请重新结算
 multi.store.checkout.freight.invalid=当前配送费配置不可用,请稍后重试
 multi.store.checkout.quote.expired=报价已过期,请重新获取
+multi.store.checkout.stores.excluded=以下门店无法参与多店配送:{0}。参与门店不足2家,请调整门店后重试
+multi.store.checkout.excluded.outside.fence=超出与首店的配送范围
+multi.store.checkout.excluded.no.payment=暂无可用的支付方式
 # 008 T074 满额免运活动管理
 promotion.freeDelivery.invalidThreshold=满额免运门槛必须为正整数元
 promotion.freeDelivery.invalidRules=满额免运需且仅需一条门槛规则,且活动时间必须完整

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

@@ -464,6 +464,9 @@ multi.store.checkout.store.invalid=門店無法參與目前多店配送
 multi.store.checkout.food.invalid=商品、規格或優惠資訊已變更,請重新結算
 multi.store.checkout.freight.invalid=目前運費設定無法使用,請稍後重試
 multi.store.checkout.quote.expired=報價已過期,請重新取得
+multi.store.checkout.stores.excluded=以下門市無法參與多店配送:{0}。參與門市不足2家,請調整門市後重試
+multi.store.checkout.excluded.outside.fence=超出與首店的配送範圍
+multi.store.checkout.excluded.no.payment=暫無可用的付款方式
 # 008 T074 满额免运活动管理
 promotion.freeDelivery.invalidThreshold=滿額免運門檻必須為正整數元
 promotion.freeDelivery.invalidRules=滿額免運需且僅需一條門檻規則,且活動時間必須完整

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

@@ -7,6 +7,9 @@ import com.ruoyi.app.order.dto.MultiStoreQuoteView;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.FoodSpecRelation;
+import com.ruoyi.system.domain.FoodSpecs;
+import com.ruoyi.system.domain.FoodSpecsValue;
 import com.ruoyi.system.domain.InfoAddress;
 import com.ruoyi.system.domain.OperatingHours;
 import com.ruoyi.system.domain.PosFood;
@@ -26,6 +29,9 @@ import com.ruoyi.system.service.IPromotionCalcService;
 import com.ruoyi.system.service.PaymentMethodGateService;
 import com.ruoyi.system.service.StallQualificationGateService;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.AfterEach;
 import org.mockito.MockedStatic;
@@ -37,6 +43,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -59,6 +66,8 @@ class MultiStoreCheckoutQuoteServiceTest {
         messages = mockStatic(MessageUtils.class);
         messages.when(() -> MessageUtils.message(any(String.class)))
                 .thenAnswer(invocation -> invocation.getArgument(0));
+        messages.when(() -> MessageUtils.message(any(String.class), any(Object[].class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
     }
 
     @AfterEach
@@ -112,7 +121,7 @@ class MultiStoreCheckoutQuoteServiceTest {
         for (long id : List.of(11L, 22L)) {
             PosFood food = new PosFood();
             food.setId(id); food.setMdid(id == 11L ? 1L : 2L);
-            food.setPrice(new BigDecimal("50")); food.setStackingUp("0");
+            food.setPrice(new BigDecimal("50")); food.setStackingUp("0"); food.setToExamine("1");
             when(foods.selectById(id)).thenReturn(food);
         }
         when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of());
@@ -148,6 +157,35 @@ class MultiStoreCheckoutQuoteServiceTest {
         verify(redis).setCacheObject(any(String.class), any(String.class), eq(5), eq(TimeUnit.MINUTES));
     }
 
+    @Test
+    void insufficientCandidatesNamesExcludedStoresAndReasons() {
+        when(config.current()).thenReturn(new MultiStoreCheckoutConfig(true, 1000, 5, 5,
+                Map.of(2, new BigDecimal("0.9")), 1));
+        InfoAddress address = new InfoAddress();
+        address.setId(10L); address.setUserId(42L);
+        address.setLatitude(new BigDecimal("22.6000")); address.setLongitude(new BigDecimal("120.3000"));
+        when(addresses.selectById(10L)).thenReturn(address);
+        when(stores.selectById(1L)).thenReturn(store(1, "22.6010", "120.3000"));
+        when(stores.selectById(2L)).thenReturn(store(2, "22.6300", "120.3000"));
+        OperatingHours open = new OperatingHours();
+        open.setMdId(1L); open.setDayOfWeek(LocalDate.now().getDayOfWeek().getValue());
+        open.setIsOpen(true); open.setStartTime("00:00"); open.setEndTime("23:59");
+        when(hours.selectOperatingHoursList(any(OperatingHours.class))).thenReturn(List.of(open));
+        PosFreight fare = new PosFreight();
+        fare.setStartingFare(40L); fare.setStartingDistance(1L); fare.setFreight(10L); fare.setDistance(1L);
+        when(freight.getyunjia()).thenReturn(List.of(fare));
+        when(payments.listAvailable(eq(PaymentMethodGateService.GateScope.MERCHANT), anyLong(), anyLong(), eq(0L)))
+                .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),
+                new MultiStoreQuoteRequest.Store(2L, null,
+                        List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), null, null)));
+        ServiceException failure = assertThrows(ServiceException.class,
+                () -> subject().quote(twoStores, 42L));
+        assertEquals("multi.store.checkout.stores.excluded", failure.getMessage());
+    }
+
     @Test
     void merchantFreeDeliveryUsesDiscountedFreightAsSubsidyBase() {
         when(config.current()).thenReturn(new MultiStoreCheckoutConfig(true, 1000, 5, 5,
@@ -166,7 +204,7 @@ class MultiStoreCheckoutQuoteServiceTest {
         for (long id : List.of(11L, 22L)) {
             PosFood food = new PosFood();
             food.setId(id); food.setMdid(id == 11L ? 1L : 2L);
-            food.setPrice(new BigDecimal("50")); food.setStackingUp("0");
+            food.setPrice(new BigDecimal("50")); food.setStackingUp("0"); food.setToExamine("1");
             when(foods.selectById(id)).thenReturn(food);
         }
         when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of());
@@ -263,7 +301,7 @@ class MultiStoreCheckoutQuoteServiceTest {
         for (long id : List.of(11L, 22L)) {
             PosFood food = new PosFood();
             food.setId(id); food.setMdid(id == 11L ? 1L : 2L);
-            food.setPrice(new BigDecimal("50")); food.setStackingUp("0");
+            food.setPrice(new BigDecimal("50")); food.setStackingUp("0"); food.setToExamine("1");
             when(foods.selectById(id)).thenReturn(food);
         }
         when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of());
@@ -283,6 +321,75 @@ class MultiStoreCheckoutQuoteServiceTest {
         });
     }
 
+    @Test
+    void approvedProductsWithReportedSpecSelectionsCanBePriced() {
+        // 复现联调请求:203店9135(规格43)+9136,168店9115(规格15/7)+9078(规格15)。
+        Map<Long, Long> storeByFood = Map.of(9135L, 203L, 9136L, 203L, 9115L, 168L, 9078L, 168L);
+        Map<Long, String> basePrice = Map.of(9135L, "60.00", 9136L, "66.00", 9115L, "60.00", 9078L, "15.00");
+        for (Long id : storeByFood.keySet()) {
+            PosFood food = new PosFood();
+            food.setId(id); food.setMdid(storeByFood.get(id));
+            food.setPrice(new BigDecimal(basePrice.get(id))); food.setStackingUp("0"); food.setToExamine("1");
+            when(foods.selectById(id)).thenReturn(food);
+        }
+        Map<Long, List<Long>> groupsByFood = Map.of(9135L, List.of(20L), 9136L, List.of(),
+                9115L, List.of(10L, 5L), 9078L, List.of(10L));
+        when(relations.selectFoodSpecRelationList(any())).thenAnswer(call -> {
+            FoodSpecRelation query = call.getArgument(0);
+            return groupsByFood.get(query.getFoodId()).stream().map(id -> {
+                FoodSpecRelation link = new FoodSpecRelation(); link.setSpecsId(id); return link;
+            }).toList();
+        });
+        for (long id : List.of(20L, 10L, 5L)) {
+            FoodSpecs group = new FoodSpecs();
+            group.setId(id); group.setMdId(id == 20L ? 203L : 168L);
+            group.setIsOpen(true); group.setIsDelete(false); group.setState("1"); group.setType("1");
+            when(specs.selectById(id)).thenReturn(group);
+        }
+        for (long id : List.of(43L, 15L, 7L)) {
+            FoodSpecsValue value = new FoodSpecsValue();
+            value.setId(id); value.setParentId(id == 43L ? 20L : id == 15L ? 10L : 5L);
+            value.setIsOpen(true); value.setPrice(new BigDecimal(id == 43L ? "20.00" : "0.00"));
+            when(values.selectById(id)).thenReturn(value);
+        }
+        when(promotions.calculate(any(), eq(42L), any(PromotionDeliveryContext.class))).thenAnswer(call -> {
+            com.ruoyi.system.dto.PromotionCalcRequest request = call.getArgument(0);
+            PromotionDeliveryContext delivery = call.getArgument(2);
+            PromotionCalcResponse result = new PromotionCalcResponse();
+            BigDecimal goods = new BigDecimal(request.getStoreId() == 203L ? "146" : "75");
+            result.setOriginalAmount(goods); result.setFinalAmount(goods);
+            result.setDeliveryFee(delivery.getDeliveryFee()); result.setDeliveryDiscountAmount(BigDecimal.ZERO);
+            result.setCustomerDeliveryFee(delivery.getDeliveryFee());
+            result.setPayableAmount(goods.add(delivery.getDeliveryFee()));
+            return result;
+        });
+        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.Store(168L, null, List.of(
+                        new MultiStoreQuoteRequest.Item(9115L, 1, List.of(15L, 7L)),
+                        new MultiStoreQuoteRequest.Item(9078L, 1, List.of(15L))), null, null));
+        assertEquals(new BigDecimal("146"), assertDoesNotThrow(() ->
+                subject().priceFoodForBatch(selected.get(0), 42L, new BigDecimal("36"))).getOriginalAmount());
+        assertEquals(new BigDecimal("75"), assertDoesNotThrow(() ->
+                subject().priceFoodForBatch(selected.get(1), 42L, new BigDecimal("36"))).getOriginalAmount());
+    }
+
+    @ParameterizedTest
+    @NullSource
+    @ValueSource(strings = {"0", "2"})
+    void unapprovedProductsCannotBePricedEvenWhenOnShelf(String approval) {
+        PosFood food = new PosFood();
+        food.setId(11L); food.setMdid(1L); food.setPrice(new BigDecimal("50"));
+        food.setStackingUp("0"); food.setToExamine(approval);
+        when(foods.selectById(11L)).thenReturn(food);
+        when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of());
+        assertThrows(ServiceException.class, () -> subject().priceFoodForBatch(request().stores().get(0),
+                42L, new BigDecimal("36")));
+        verifyNoInteractions(promotions);
+    }
+
     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),