|
|
@@ -1,6 +1,8 @@
|
|
|
package com.ruoyi.app.order;
|
|
|
|
|
|
import com.ruoyi.app.order.dto.MultiStoreQuoteRequest;
|
|
|
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
|
|
|
+import com.ruoyi.app.order.route.RouteDistanceMatrixProvider;
|
|
|
import com.ruoyi.app.order.dto.MultiStoreQuoteView;
|
|
|
import com.ruoyi.common.core.redis.RedisCache;
|
|
|
import com.ruoyi.common.exception.ServiceException;
|
|
|
@@ -11,6 +13,7 @@ import com.ruoyi.system.domain.PosFood;
|
|
|
import com.ruoyi.system.domain.PosFreight;
|
|
|
import com.ruoyi.system.domain.PosStore;
|
|
|
import com.ruoyi.system.dto.PromotionCalcResponse;
|
|
|
+import com.ruoyi.system.dto.PromotionDeliveryContext;
|
|
|
import com.ruoyi.system.mapper.FoodSpecRelationMapper;
|
|
|
import com.ruoyi.system.mapper.FoodSpecsMapper;
|
|
|
import com.ruoyi.system.mapper.FoodSpecsValueMapper;
|
|
|
@@ -26,6 +29,7 @@ import org.junit.jupiter.api.Test;
|
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
|
import org.junit.jupiter.api.AfterEach;
|
|
|
import org.mockito.MockedStatic;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
|
|
|
import java.math.BigDecimal;
|
|
|
import java.time.LocalDate;
|
|
|
@@ -42,6 +46,9 @@ import static org.mockito.ArgumentMatchers.eq;
|
|
|
import static org.mockito.Mockito.mock;
|
|
|
import static org.mockito.Mockito.mockStatic;
|
|
|
import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.doReturn;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
import static org.mockito.Mockito.when;
|
|
|
|
|
|
class MultiStoreCheckoutQuoteServiceTest {
|
|
|
@@ -73,10 +80,11 @@ class MultiStoreCheckoutQuoteServiceTest {
|
|
|
private final SelfDeliveryService selfDelivery = mock(SelfDeliveryService.class);
|
|
|
private final IPromotionCalcService promotions = mock(IPromotionCalcService.class);
|
|
|
private final RedisCache redis = mock(RedisCache.class);
|
|
|
+ private final RouteDistanceMatrixProvider routeMatrix = mock(RouteDistanceMatrixProvider.class);
|
|
|
|
|
|
private MultiStoreCheckoutQuoteService subject() {
|
|
|
return new MultiStoreCheckoutQuoteService(config, addresses, stores, hours, foods,
|
|
|
- relations, specs, values, freight, payments, qualifications, selfDelivery, promotions, redis);
|
|
|
+ relations, specs, values, freight, payments, qualifications, selfDelivery, promotions, redis, routeMatrix);
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
@@ -113,21 +121,81 @@ class MultiStoreCheckoutQuoteServiceTest {
|
|
|
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)));
|
|
|
- PromotionCalcResponse promo = new PromotionCalcResponse();
|
|
|
- promo.setOriginalAmount(new BigDecimal("50")); promo.setFinalAmount(new BigDecimal("50"));
|
|
|
- when(promotions.calculate(any(), eq(42L))).thenReturn(promo);
|
|
|
+ when(promotions.calculate(any(), eq(42L), any(PromotionDeliveryContext.class))).thenAnswer(call -> {
|
|
|
+ PromotionDeliveryContext delivery = call.getArgument(2);
|
|
|
+ PromotionCalcResponse promo = new PromotionCalcResponse();
|
|
|
+ promo.setOriginalAmount(new BigDecimal("50")); promo.setFinalAmount(new BigDecimal("50"));
|
|
|
+ promo.setDeliveryFee(delivery.getDeliveryFee());
|
|
|
+ promo.setDeliveryDiscountAmount(BigDecimal.ZERO);
|
|
|
+ promo.setCustomerDeliveryFee(delivery.getDeliveryFee());
|
|
|
+ promo.setPayableAmount(new BigDecimal("50").add(delivery.getDeliveryFee()));
|
|
|
+ return promo;
|
|
|
+ });
|
|
|
|
|
|
- MultiStoreQuoteView quote = subject().quote(request(), 42L);
|
|
|
+ MultiStoreCheckoutQuoteService service = subject();
|
|
|
+ // 围栏仍按直线,运费须按道路路线;5km 路线不能被约 0.1km 直线代替。
|
|
|
+ when(routeMatrix.distancesToDestination(any(), any()))
|
|
|
+ .thenReturn(List.of(new BigDecimal("5000"), new BigDecimal("5000")));
|
|
|
+ MultiStoreQuoteView quote = service.quote(request(), 42L);
|
|
|
|
|
|
assertEquals(List.of(1L, 2L), quote.participatingStoreIds());
|
|
|
assertEquals("OUTSIDE_FENCE", quote.excludedStores().get(0).reason());
|
|
|
- assertEquals(new BigDecimal("40"), quote.stores().get(0).originalFreight());
|
|
|
- assertEquals(new BigDecimal("36"), quote.stores().get(0).discountedFreight());
|
|
|
- assertEquals(new BigDecimal("86"), quote.stores().get(0).amount());
|
|
|
+ assertEquals(new BigDecimal("80"), quote.stores().get(0).originalFreight());
|
|
|
+ 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());
|
|
|
assertTrue(quote.quoteToken() != null && !quote.quoteToken().isBlank());
|
|
|
verify(redis).setCacheObject(any(String.class), any(String.class), eq(5), eq(TimeUnit.MINUTES));
|
|
|
}
|
|
|
|
|
|
+ @Test
|
|
|
+ void merchantFreeDeliveryUsesDiscountedFreightAsSubsidyBase() {
|
|
|
+ when(config.current()).thenReturn(new MultiStoreCheckoutConfig(true, 1000, 5, 5,
|
|
|
+ Map.of(2, new BigDecimal("0.8")), 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.6030", "120.3000"));
|
|
|
+ when(stores.selectById(3L)).thenReturn(store(3, "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));
|
|
|
+ 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");
|
|
|
+ when(foods.selectById(id)).thenReturn(food);
|
|
|
+ }
|
|
|
+ when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of());
|
|
|
+ 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)));
|
|
|
+ when(promotions.calculate(any(), eq(42L), any(PromotionDeliveryContext.class))).thenAnswer(call -> {
|
|
|
+ PromotionDeliveryContext delivery = call.getArgument(2);
|
|
|
+ assertEquals(new BigDecimal("32"), delivery.getDeliveryFee());
|
|
|
+ PromotionCalcResponse promo = new PromotionCalcResponse();
|
|
|
+ promo.setOriginalAmount(new BigDecimal("50")); promo.setFinalAmount(new BigDecimal("50"));
|
|
|
+ promo.setDeliveryFee(delivery.getDeliveryFee());
|
|
|
+ promo.setDeliveryDiscountAmount(delivery.getDeliveryFee());
|
|
|
+ promo.setCustomerDeliveryFee(BigDecimal.ZERO);
|
|
|
+ promo.setPayableAmount(new BigDecimal("50"));
|
|
|
+ return promo;
|
|
|
+ });
|
|
|
+ MultiStoreCheckoutQuoteService service = subject();
|
|
|
+ when(routeMatrix.distancesToDestination(any(), any()))
|
|
|
+ .thenReturn(List.of(new BigDecimal("1000"), new BigDecimal("1000")));
|
|
|
+ MultiStoreQuoteView quote = service.quote(request(), 42L);
|
|
|
+ assertEquals(new BigDecimal("32"), quote.stores().get(0).discountedFreight());
|
|
|
+ assertEquals(new BigDecimal("32"), quote.stores().get(0).merchantDeliverySubsidy());
|
|
|
+ assertEquals(BigDecimal.ZERO, quote.stores().get(0).customerDeliveryFee());
|
|
|
+ assertEquals(new BigDecimal("50"), quote.stores().get(0).amount());
|
|
|
+ }
|
|
|
+
|
|
|
@Test
|
|
|
void rejectsForeignAddressBeforeAnyQuote() {
|
|
|
when(config.current()).thenReturn(new MultiStoreCheckoutConfig(true, 1000, 5, 5,
|
|
|
@@ -137,6 +205,84 @@ class MultiStoreCheckoutQuoteServiceTest {
|
|
|
assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
|
|
|
}
|
|
|
|
|
|
+ @Test
|
|
|
+ void batchesOnlyEligibleStoresAndKeepsEachStoresOwnDistanceAndFreight() {
|
|
|
+ stubEligibleStores();
|
|
|
+ when(routeMatrix.distancesToDestination(any(), any()))
|
|
|
+ .thenReturn(List.of(new BigDecimal("1000"), new BigDecimal("5000")));
|
|
|
+
|
|
|
+ MultiStoreQuoteView quote = subject().quote(request(), 42L);
|
|
|
+
|
|
|
+ assertEquals(List.of(1L, 2L), quote.participatingStoreIds());
|
|
|
+ assertEquals(new BigDecimal("40"), quote.stores().get(0).originalFreight());
|
|
|
+ assertEquals(new BigDecimal("80"), quote.stores().get(1).originalFreight());
|
|
|
+ assertEquals(new BigDecimal("86"), quote.stores().get(0).amount());
|
|
|
+ assertEquals(new BigDecimal("122"), quote.stores().get(1).amount());
|
|
|
+ ArgumentCaptor<List<GeoPoint>> origins = ArgumentCaptor.forClass(List.class);
|
|
|
+ ArgumentCaptor<GeoPoint> destination = ArgumentCaptor.forClass(GeoPoint.class);
|
|
|
+ verify(routeMatrix).distancesToDestination(origins.capture(), destination.capture());
|
|
|
+ assertEquals(List.of(new GeoPoint(new BigDecimal("22.6010"), new BigDecimal("120.3000")),
|
|
|
+ new GeoPoint(new BigDecimal("22.6030"), new BigDecimal("120.3000"))), origins.getValue());
|
|
|
+ assertEquals(new BigDecimal("22.6000"), destination.getValue().latitude());
|
|
|
+ assertEquals("OUTSIDE_FENCE", quote.excludedStores().get(0).reason());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void matrixFailureOrIncompleteDistancesCannotIssueQuoteToken() {
|
|
|
+ stubEligibleStores();
|
|
|
+ when(routeMatrix.distancesToDestination(any(), any())).thenThrow(new IllegalStateException("element failed"));
|
|
|
+ ServiceException failure = assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
|
|
|
+ assertEquals("multi.store.checkout.freight.invalid", failure.getMessage());
|
|
|
+ doReturn(List.of(new BigDecimal("1000"))).when(routeMatrix).distancesToDestination(any(), any());
|
|
|
+ assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
|
|
|
+ verify(redis, never()).setCacheObject(any(String.class), any(String.class), any(Integer.class), any(TimeUnit.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void fewerThanTwoEligibleStoresFailBeforeAnyMatrixRequest() {
|
|
|
+ stubEligibleStores();
|
|
|
+ when(stores.selectById(2L)).thenReturn(store(2, "22.6300", "120.3000"));
|
|
|
+ assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
|
|
|
+ verifyNoInteractions(routeMatrix);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubEligibleStores() {
|
|
|
+ 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.6030", "120.3000"));
|
|
|
+ when(stores.selectById(3L)).thenReturn(store(3, "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));
|
|
|
+ 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");
|
|
|
+ when(foods.selectById(id)).thenReturn(food);
|
|
|
+ }
|
|
|
+ when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of());
|
|
|
+ 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)));
|
|
|
+ when(promotions.calculate(any(), eq(42L), any(PromotionDeliveryContext.class))).thenAnswer(call -> {
|
|
|
+ PromotionDeliveryContext delivery = call.getArgument(2);
|
|
|
+ PromotionCalcResponse promo = new PromotionCalcResponse();
|
|
|
+ promo.setOriginalAmount(new BigDecimal("50")); promo.setFinalAmount(new BigDecimal("50"));
|
|
|
+ promo.setDeliveryFee(delivery.getDeliveryFee()); promo.setDeliveryDiscountAmount(BigDecimal.ZERO);
|
|
|
+ promo.setCustomerDeliveryFee(delivery.getDeliveryFee());
|
|
|
+ promo.setPayableAmount(new BigDecimal("50").add(delivery.getDeliveryFee()));
|
|
|
+ return promo;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
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),
|