| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- package com.ruoyi.app.order;
- 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.ArgumentMatchers.any;
- import static org.mockito.ArgumentMatchers.eq;
- import static org.mockito.Mockito.doReturn;
- import static org.mockito.Mockito.doThrow;
- import static org.mockito.Mockito.mock;
- import static org.mockito.Mockito.mockStatic;
- import static org.mockito.Mockito.verify;
- import static org.mockito.Mockito.when;
- import java.math.BigDecimal;
- import java.util.Collections;
- import java.util.List;
- import org.junit.jupiter.api.AfterEach;
- import org.junit.jupiter.api.BeforeEach;
- import org.junit.jupiter.api.Test;
- import org.mockito.ArgumentCaptor;
- import org.mockito.MockedStatic;
- import com.ruoyi.app.order.dto.FoodDeliveryPromotionQuoteRequest;
- 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.PosFood;
- import com.ruoyi.system.domain.PosFreight;
- import com.ruoyi.system.domain.PosStore;
- import com.ruoyi.system.dto.PromotionCalcRequest;
- 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;
- import com.ruoyi.system.mapper.InfoAddressMapper;
- import com.ruoyi.system.mapper.PosFoodMapper;
- import com.ruoyi.system.mapper.PosFreightMapper;
- import com.ruoyi.system.mapper.PosStoreMapper;
- import com.ruoyi.system.service.IPromotionCalcService;
- /**
- * T075 满额免运完整配送报价:地址归属/跨店规格/服务端规格加价、路线与运价不可用、
- * 运费跳表边界(3km/3.49km/3.5km/4.25km/千元舍入)与仅平台骑手外送的范围限制。
- */
- class FoodDeliveryPromotionQuoteServiceTest
- {
- private static final Long USER_ID = 9L;
- private static final Long STORE_ID = 1L;
- private static final Long ADDRESS_ID = 8L;
- private static final Long FOOD_ID = 10L;
- private static final Long SPEC_VALUE_ID = 301L;
- private MockedStatic<MessageUtils> messages;
- private final PosStoreMapper stores = mock(PosStoreMapper.class);
- private final InfoAddressMapper addresses = mock(InfoAddressMapper.class);
- private final PosFoodMapper foods = mock(PosFoodMapper.class);
- private final FoodSpecRelationMapper relations = mock(FoodSpecRelationMapper.class);
- private final FoodSpecsMapper specs = mock(FoodSpecsMapper.class);
- private final FoodSpecsValueMapper values = mock(FoodSpecsValueMapper.class);
- private final PosFreightMapper freight = mock(PosFreightMapper.class);
- private final SelfDeliveryService selfDelivery = mock(SelfDeliveryService.class);
- private final IPromotionCalcService promotions = mock(IPromotionCalcService.class);
- private FoodDeliveryPromotionQuoteService service;
- @BeforeEach
- void setUp()
- {
- messages = mockStatic(MessageUtils.class);
- messages.when(() -> MessageUtils.message(any(String.class)))
- .thenAnswer(invocation -> invocation.getArgument(0));
- when(stores.selectById(STORE_ID)).thenReturn(store());
- when(addresses.selectById(ADDRESS_ID)).thenReturn(address(USER_ID));
- when(selfDelivery.isSelfDelivery(any(), any())).thenReturn(false);
- when(foods.selectById(FOOD_ID)).thenReturn(food(STORE_ID, "50"));
- stubSpecGroup(STORE_ID);
- when(freight.getyunjia()).thenReturn(List.of(fare(20L, 3L, 10L, 1L)));
- when(promotions.calculate(any(), eq(USER_ID), any())).thenReturn(new PromotionCalcResponse());
- service = subject();
- }
- @AfterEach
- void closeMessages()
- {
- messages.close();
- }
- private FoodDeliveryPromotionQuoteService subject()
- {
- return new FoodDeliveryPromotionQuoteService(stores, addresses, foods, relations, specs,
- values, freight, selfDelivery, promotions);
- }
- // ==================== 归属与输入校验 ====================
- @Test
- void quote_shouldRejectForeignAddressAndCrossStoreSpec()
- {
- FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
- when(addresses.selectById(ADDRESS_ID)).thenReturn(address(77L));
- ServiceException foreignAddress = assertThrows(ServiceException.class,
- () -> spy.quote(request(), USER_ID));
- assertEquals("promotion.freeDelivery.invalidAddress", foreignAddress.getMessage());
- when(addresses.selectById(ADDRESS_ID)).thenReturn(address(USER_ID));
- stubSpecGroup(2L);
- ServiceException crossStoreSpec = assertThrows(ServiceException.class,
- () -> spy.quote(request(), USER_ID));
- assertEquals("promotion.freeDelivery.invalidSpec", crossStoreSpec.getMessage());
- }
- @Test
- void quote_shouldRejectInvalidProductOrQuantity()
- {
- FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
- when(foods.selectById(FOOD_ID)).thenReturn(food(2L, "50"));
- ServiceException otherStore = assertThrows(ServiceException.class,
- () -> spy.quote(request(), USER_ID));
- assertEquals("promotion.freeDelivery.invalidProduct", otherStore.getMessage());
- when(foods.selectById(FOOD_ID)).thenReturn(food(STORE_ID, "50"));
- FoodDeliveryPromotionQuoteRequest zero = request();
- zero.getItems().get(0).setQuantity(0);
- ServiceException badQuantity = assertThrows(ServiceException.class,
- () -> spy.quote(zero, USER_ID));
- assertEquals("promotion.freeDelivery.invalidProduct", badQuantity.getMessage());
- }
- @Test
- void quote_shouldRejectUnapprovedFood()
- {
- // to_examine='0' 为待审核(菜单口径 '1' 才可售),必须拒绝
- FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
- PosFood pending = food(STORE_ID, "50");
- pending.setToExamine("0");
- when(foods.selectById(FOOD_ID)).thenReturn(pending);
- ServiceException rejected = assertThrows(ServiceException.class,
- () -> spy.quote(request(), USER_ID));
- assertEquals("promotion.freeDelivery.invalidProduct", rejected.getMessage());
- }
- @Test
- void quote_shouldRejectUnknownOrderTypeOrMissingStore()
- {
- FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
- FoodDeliveryPromotionQuoteRequest unknown = request();
- unknown.setOrderType(3);
- ServiceException badType = assertThrows(ServiceException.class,
- () -> spy.quote(unknown, USER_ID));
- assertEquals("promotion.freeDelivery.unsupportedOrder", badType.getMessage());
- FoodDeliveryPromotionQuoteRequest nullType = request();
- nullType.setOrderType(null);
- ServiceException missingType = assertThrows(ServiceException.class,
- () -> spy.quote(nullType, USER_ID));
- assertEquals("promotion.freeDelivery.unsupportedOrder", missingType.getMessage());
- when(stores.selectById(STORE_ID)).thenReturn(null);
- ServiceException missingStore = assertThrows(ServiceException.class,
- () -> spy.quote(request(), USER_ID));
- assertEquals("promotion.freeDelivery.unsupportedOrder", missingStore.getMessage());
- }
- @Test
- void quote_shouldSupportPickupAndDineInWithoutAddress()
- {
- // T079 报价入口统一:自取/堂食免地址、无运费无免运,A=G
- int call = 0;
- for (Integer orderType : new Integer[] { 1, 2 })
- {
- call++;
- FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
- FoodDeliveryPromotionQuoteRequest pickup = request();
- pickup.setOrderType(orderType);
- pickup.setAddressId(null);
- spy.quote(pickup, USER_ID);
- ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
- verify(promotions, org.mockito.Mockito.times(call)).calculate(any(), eq(USER_ID), contextCaptor.capture());
- PromotionDeliveryContext context = contextCaptor.getAllValues().get(call - 1);
- assertEquals(0, BigDecimal.ZERO.compareTo(context.getDeliveryFee()));
- assertEquals(false, context.isEligible());
- }
- verify(addresses, org.mockito.Mockito.never()).selectById(any());
- }
- @Test
- void quote_shouldQuoteSelfDeliveryWithoutFreeDelivery()
- {
- // T079:自配送不再拒绝——运费照算(同跳表),免运不评估(eligible=false)
- when(selfDelivery.isSelfDelivery(any(), any())).thenReturn(true);
- FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3500));
- spy.quote(request(), USER_ID);
- ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
- verify(promotions).calculate(any(), eq(USER_ID), contextCaptor.capture());
- assertEquals(0, new BigDecimal("30").compareTo(contextCaptor.getValue().getDeliveryFee()));
- assertEquals(false, contextCaptor.getValue().isEligible());
- }
- @Test
- void quote_shouldFailOnMissingRouteOrFare()
- {
- FoodDeliveryPromotionQuoteService spy = org.mockito.Mockito.spy(subject());
- doThrow(new ServiceException("promotion.freeDelivery.freightUnavailable")).when(spy).routeDistanceMeters(any(), any());
- ServiceException noRoute = assertThrows(ServiceException.class,
- () -> spy.quote(request(), USER_ID));
- assertEquals("promotion.freeDelivery.freightUnavailable", noRoute.getMessage());
- when(freight.getyunjia()).thenReturn(Collections.emptyList());
- when(freight.getmiuyunjia()).thenReturn(Collections.emptyList());
- FoodDeliveryPromotionQuoteService withRoute = spyWithRoute(BigDecimal.valueOf(3000));
- ServiceException noFare = assertThrows(ServiceException.class,
- () -> withRoute.quote(request(), USER_ID));
- assertEquals("promotion.freeDelivery.freightUnavailable", noFare.getMessage());
- }
- // ==================== 规格加价与运费边界 ====================
- @Test
- void quote_shouldIgnoreClientSpecPrice()
- {
- FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3500));
- PromotionCalcResponse response = spy.quote(request(), USER_ID);
- // 规格加价由服务端按数据库规格值计算(10元),商品原价取数据库价;客户端无报价字段可传
- ArgumentCaptor<PromotionCalcRequest> captor = ArgumentCaptor.forClass(PromotionCalcRequest.class);
- ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
- verify(promotions).calculate(captor.capture(), eq(USER_ID), contextCaptor.capture());
- assertEquals(1, captor.getValue().getItems().size());
- assertEquals(FOOD_ID, captor.getValue().getItems().get(0).getProductId());
- assertEquals(0, new BigDecimal("10").compareTo(captor.getValue().getItems().get(0).getSpecPrice()));
- assertTrue(contextCaptor.getValue().isEligible());
- // 3.5km:超起步3km半公里门槛按1km计,单价10 → 20+10=30
- assertEquals(0, new BigDecimal("30").compareTo(contextCaptor.getValue().getDeliveryFee()));
- assertTrue(response != null);
- }
- @Test
- void fare_shouldFollowLegacyJumpTableBoundaries()
- {
- assertFreight(1, "3000", "20");
- assertFreight(2, "3490", "20");
- assertFreight(3, "3500", "30");
- assertFreight(4, "4250", "32");
- }
- @Test
- void fare_shouldRoundThousandAndUp()
- {
- // 起步1500:≥1000 按 HALF_UP 舍到千位 → 2000
- FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
- when(freight.getyunjia()).thenReturn(List.of(fare(1500L, 3L, 10L, 1L)));
- PromotionCalcResponse response = spy.quote(request(), USER_ID);
- ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
- verify(promotions).calculate(any(), eq(USER_ID), contextCaptor.capture());
- assertEquals(0, new BigDecimal("2000").compareTo(contextCaptor.getValue().getDeliveryFee()));
- }
- // ==================== 辅助构造 ====================
- private void assertFreight(int callIndex, String meters, String expectedFee)
- {
- FoodDeliveryPromotionQuoteService spy = spyWithRoute(new BigDecimal(meters));
- spy.quote(request(), USER_ID);
- ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
- verify(promotions, org.mockito.Mockito.times(callIndex)).calculate(any(), eq(USER_ID), contextCaptor.capture());
- PromotionDeliveryContext context = contextCaptor.getAllValues().get(callIndex - 1);
- assertEquals(0, new BigDecimal(expectedFee).compareTo(context.getDeliveryFee()),
- meters + "m should be " + expectedFee);
- }
- private FoodDeliveryPromotionQuoteService spyWithRoute(BigDecimal meters)
- {
- FoodDeliveryPromotionQuoteService spy = org.mockito.Mockito.spy(subject());
- doReturn(meters).when(spy).routeDistanceMeters(any(), any());
- return spy;
- }
- private FoodDeliveryPromotionQuoteRequest request()
- {
- FoodDeliveryPromotionQuoteRequest request = new FoodDeliveryPromotionQuoteRequest();
- request.setStoreId(STORE_ID);
- request.setOrderType(0);
- request.setAddressId(ADDRESS_ID);
- FoodDeliveryPromotionQuoteRequest.Item item = new FoodDeliveryPromotionQuoteRequest.Item();
- item.setProductId(FOOD_ID);
- item.setQuantity(2);
- item.setSpecValueIds(List.of(SPEC_VALUE_ID));
- request.setItems(new java.util.ArrayList<>(List.of(item)));
- return request;
- }
- private PosStore store()
- {
- PosStore store = new PosStore();
- store.setId(STORE_ID.intValue());
- store.setUserId(5L);
- store.setLatitude(new BigDecimal("25.0330"));
- store.setLongitude(new BigDecimal("121.5654"));
- return store;
- }
- private InfoAddress address(Long userId)
- {
- InfoAddress address = new InfoAddress();
- address.setId(ADDRESS_ID);
- address.setUserId(userId);
- address.setLatitude(new BigDecimal("25.0400"));
- address.setLongitude(new BigDecimal("121.5700"));
- return address;
- }
- private PosFood food(Long mdId, String price)
- {
- PosFood food = new PosFood();
- food.setId(FOOD_ID);
- food.setMdid(mdId);
- food.setPrice(new BigDecimal(price));
- food.setName("测试商品");
- food.setStackingUp("0");
- food.setToExamine("1");
- return food;
- }
- private PosFreight fare(Long startingFare, Long startingDistance, Long freightPerUnit, Long distanceUnit)
- {
- PosFreight row = new PosFreight();
- row.setStartingFare(startingFare);
- row.setStartingDistance(startingDistance);
- row.setFreight(freightPerUnit);
- row.setDistance(distanceUnit);
- return row;
- }
- /** 规格组300(mdId 指定门店,必选/多选)+ 规格值301(加价10),经 foodId=10 关联 */
- private void stubSpecGroup(Long mdId)
- {
- FoodSpecRelation relation = new FoodSpecRelation();
- relation.setFoodId(FOOD_ID);
- relation.setSpecsId(300L);
- when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of(relation));
- FoodSpecs group = new FoodSpecs();
- group.setId(300L);
- group.setMdId(mdId);
- group.setIsOpen(true);
- group.setIsDelete(false);
- group.setState("1");
- group.setType("0");
- when(specs.selectById(300L)).thenReturn(group);
- FoodSpecsValue value = new FoodSpecsValue();
- value.setId(SPEC_VALUE_ID);
- value.setParentId(300L);
- value.setPrice(new BigDecimal("10"));
- value.setIsOpen(true);
- when(values.selectById(SPEC_VALUE_ID)).thenReturn(value);
- }
- }
|