FoodDeliveryPromotionQuoteServiceTest.java 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. package com.ruoyi.app.order;
  2. import static org.junit.jupiter.api.Assertions.assertEquals;
  3. import static org.junit.jupiter.api.Assertions.assertThrows;
  4. import static org.junit.jupiter.api.Assertions.assertTrue;
  5. import static org.mockito.ArgumentMatchers.any;
  6. import static org.mockito.ArgumentMatchers.eq;
  7. import static org.mockito.Mockito.doReturn;
  8. import static org.mockito.Mockito.doThrow;
  9. import static org.mockito.Mockito.mock;
  10. import static org.mockito.Mockito.mockStatic;
  11. import static org.mockito.Mockito.verify;
  12. import static org.mockito.Mockito.when;
  13. import java.math.BigDecimal;
  14. import java.util.Collections;
  15. import java.util.List;
  16. import org.junit.jupiter.api.AfterEach;
  17. import org.junit.jupiter.api.BeforeEach;
  18. import org.junit.jupiter.api.Test;
  19. import org.mockito.ArgumentCaptor;
  20. import org.mockito.MockedStatic;
  21. import com.ruoyi.app.order.dto.FoodDeliveryPromotionQuoteRequest;
  22. import com.ruoyi.common.exception.ServiceException;
  23. import com.ruoyi.common.utils.MessageUtils;
  24. import com.ruoyi.system.domain.FoodSpecRelation;
  25. import com.ruoyi.system.domain.FoodSpecs;
  26. import com.ruoyi.system.domain.FoodSpecsValue;
  27. import com.ruoyi.system.domain.InfoAddress;
  28. import com.ruoyi.system.domain.PosFood;
  29. import com.ruoyi.system.domain.PosFreight;
  30. import com.ruoyi.system.domain.PosStore;
  31. import com.ruoyi.system.dto.PromotionCalcRequest;
  32. import com.ruoyi.system.dto.PromotionCalcResponse;
  33. import com.ruoyi.system.dto.PromotionDeliveryContext;
  34. import com.ruoyi.system.mapper.FoodSpecRelationMapper;
  35. import com.ruoyi.system.mapper.FoodSpecsMapper;
  36. import com.ruoyi.system.mapper.FoodSpecsValueMapper;
  37. import com.ruoyi.system.mapper.InfoAddressMapper;
  38. import com.ruoyi.system.mapper.PosFoodMapper;
  39. import com.ruoyi.system.mapper.PosFreightMapper;
  40. import com.ruoyi.system.mapper.PosStoreMapper;
  41. import com.ruoyi.system.service.IPromotionCalcService;
  42. /**
  43. * T075 满额免运完整配送报价:地址归属/跨店规格/服务端规格加价、路线与运价不可用、
  44. * 运费跳表边界(3km/3.49km/3.5km/4.25km/千元舍入)与仅平台骑手外送的范围限制。
  45. */
  46. class FoodDeliveryPromotionQuoteServiceTest
  47. {
  48. private static final Long USER_ID = 9L;
  49. private static final Long STORE_ID = 1L;
  50. private static final Long ADDRESS_ID = 8L;
  51. private static final Long FOOD_ID = 10L;
  52. private static final Long SPEC_VALUE_ID = 301L;
  53. private MockedStatic<MessageUtils> messages;
  54. private final PosStoreMapper stores = mock(PosStoreMapper.class);
  55. private final InfoAddressMapper addresses = mock(InfoAddressMapper.class);
  56. private final PosFoodMapper foods = mock(PosFoodMapper.class);
  57. private final FoodSpecRelationMapper relations = mock(FoodSpecRelationMapper.class);
  58. private final FoodSpecsMapper specs = mock(FoodSpecsMapper.class);
  59. private final FoodSpecsValueMapper values = mock(FoodSpecsValueMapper.class);
  60. private final PosFreightMapper freight = mock(PosFreightMapper.class);
  61. private final SelfDeliveryService selfDelivery = mock(SelfDeliveryService.class);
  62. private final IPromotionCalcService promotions = mock(IPromotionCalcService.class);
  63. private FoodDeliveryPromotionQuoteService service;
  64. @BeforeEach
  65. void setUp()
  66. {
  67. messages = mockStatic(MessageUtils.class);
  68. messages.when(() -> MessageUtils.message(any(String.class)))
  69. .thenAnswer(invocation -> invocation.getArgument(0));
  70. when(stores.selectById(STORE_ID)).thenReturn(store());
  71. when(addresses.selectById(ADDRESS_ID)).thenReturn(address(USER_ID));
  72. when(selfDelivery.isSelfDelivery(any(), any())).thenReturn(false);
  73. when(foods.selectById(FOOD_ID)).thenReturn(food(STORE_ID, "50"));
  74. stubSpecGroup(STORE_ID);
  75. when(freight.getyunjia()).thenReturn(List.of(fare(20L, 3L, 10L, 1L)));
  76. when(promotions.calculate(any(), eq(USER_ID), any())).thenReturn(new PromotionCalcResponse());
  77. service = subject();
  78. }
  79. @AfterEach
  80. void closeMessages()
  81. {
  82. messages.close();
  83. }
  84. private FoodDeliveryPromotionQuoteService subject()
  85. {
  86. return new FoodDeliveryPromotionQuoteService(stores, addresses, foods, relations, specs,
  87. values, freight, selfDelivery, promotions);
  88. }
  89. // ==================== 归属与输入校验 ====================
  90. @Test
  91. void quote_shouldRejectForeignAddressAndCrossStoreSpec()
  92. {
  93. FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
  94. when(addresses.selectById(ADDRESS_ID)).thenReturn(address(77L));
  95. ServiceException foreignAddress = assertThrows(ServiceException.class,
  96. () -> spy.quote(request(), USER_ID));
  97. assertEquals("promotion.freeDelivery.invalidAddress", foreignAddress.getMessage());
  98. when(addresses.selectById(ADDRESS_ID)).thenReturn(address(USER_ID));
  99. stubSpecGroup(2L);
  100. ServiceException crossStoreSpec = assertThrows(ServiceException.class,
  101. () -> spy.quote(request(), USER_ID));
  102. assertEquals("promotion.freeDelivery.invalidSpec", crossStoreSpec.getMessage());
  103. }
  104. @Test
  105. void quote_shouldRejectInvalidProductOrQuantity()
  106. {
  107. FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
  108. when(foods.selectById(FOOD_ID)).thenReturn(food(2L, "50"));
  109. ServiceException otherStore = assertThrows(ServiceException.class,
  110. () -> spy.quote(request(), USER_ID));
  111. assertEquals("promotion.freeDelivery.invalidProduct", otherStore.getMessage());
  112. when(foods.selectById(FOOD_ID)).thenReturn(food(STORE_ID, "50"));
  113. FoodDeliveryPromotionQuoteRequest zero = request();
  114. zero.getItems().get(0).setQuantity(0);
  115. ServiceException badQuantity = assertThrows(ServiceException.class,
  116. () -> spy.quote(zero, USER_ID));
  117. assertEquals("promotion.freeDelivery.invalidProduct", badQuantity.getMessage());
  118. }
  119. @Test
  120. void quote_shouldRejectUnapprovedFood()
  121. {
  122. // to_examine='0' 为待审核(菜单口径 '1' 才可售),必须拒绝
  123. FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
  124. PosFood pending = food(STORE_ID, "50");
  125. pending.setToExamine("0");
  126. when(foods.selectById(FOOD_ID)).thenReturn(pending);
  127. ServiceException rejected = assertThrows(ServiceException.class,
  128. () -> spy.quote(request(), USER_ID));
  129. assertEquals("promotion.freeDelivery.invalidProduct", rejected.getMessage());
  130. }
  131. @Test
  132. void quote_shouldRejectUnknownOrderTypeOrMissingStore()
  133. {
  134. FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
  135. FoodDeliveryPromotionQuoteRequest unknown = request();
  136. unknown.setOrderType(3);
  137. ServiceException badType = assertThrows(ServiceException.class,
  138. () -> spy.quote(unknown, USER_ID));
  139. assertEquals("promotion.freeDelivery.unsupportedOrder", badType.getMessage());
  140. FoodDeliveryPromotionQuoteRequest nullType = request();
  141. nullType.setOrderType(null);
  142. ServiceException missingType = assertThrows(ServiceException.class,
  143. () -> spy.quote(nullType, USER_ID));
  144. assertEquals("promotion.freeDelivery.unsupportedOrder", missingType.getMessage());
  145. when(stores.selectById(STORE_ID)).thenReturn(null);
  146. ServiceException missingStore = assertThrows(ServiceException.class,
  147. () -> spy.quote(request(), USER_ID));
  148. assertEquals("promotion.freeDelivery.unsupportedOrder", missingStore.getMessage());
  149. }
  150. @Test
  151. void quote_shouldSupportPickupAndDineInWithoutAddress()
  152. {
  153. // T079 报价入口统一:自取/堂食免地址、无运费无免运,A=G
  154. int call = 0;
  155. for (Integer orderType : new Integer[] { 1, 2 })
  156. {
  157. call++;
  158. FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
  159. FoodDeliveryPromotionQuoteRequest pickup = request();
  160. pickup.setOrderType(orderType);
  161. pickup.setAddressId(null);
  162. spy.quote(pickup, USER_ID);
  163. ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
  164. verify(promotions, org.mockito.Mockito.times(call)).calculate(any(), eq(USER_ID), contextCaptor.capture());
  165. PromotionDeliveryContext context = contextCaptor.getAllValues().get(call - 1);
  166. assertEquals(0, BigDecimal.ZERO.compareTo(context.getDeliveryFee()));
  167. assertEquals(false, context.isEligible());
  168. }
  169. verify(addresses, org.mockito.Mockito.never()).selectById(any());
  170. }
  171. @Test
  172. void quote_shouldQuoteSelfDeliveryWithoutFreeDelivery()
  173. {
  174. // T079:自配送不再拒绝——运费照算(同跳表),免运不评估(eligible=false)
  175. when(selfDelivery.isSelfDelivery(any(), any())).thenReturn(true);
  176. FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3500));
  177. spy.quote(request(), USER_ID);
  178. ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
  179. verify(promotions).calculate(any(), eq(USER_ID), contextCaptor.capture());
  180. assertEquals(0, new BigDecimal("30").compareTo(contextCaptor.getValue().getDeliveryFee()));
  181. assertEquals(false, contextCaptor.getValue().isEligible());
  182. }
  183. @Test
  184. void quote_shouldFailOnMissingRouteOrFare()
  185. {
  186. FoodDeliveryPromotionQuoteService spy = org.mockito.Mockito.spy(subject());
  187. doThrow(new ServiceException("promotion.freeDelivery.freightUnavailable")).when(spy).routeDistanceMeters(any(), any());
  188. ServiceException noRoute = assertThrows(ServiceException.class,
  189. () -> spy.quote(request(), USER_ID));
  190. assertEquals("promotion.freeDelivery.freightUnavailable", noRoute.getMessage());
  191. when(freight.getyunjia()).thenReturn(Collections.emptyList());
  192. when(freight.getmiuyunjia()).thenReturn(Collections.emptyList());
  193. FoodDeliveryPromotionQuoteService withRoute = spyWithRoute(BigDecimal.valueOf(3000));
  194. ServiceException noFare = assertThrows(ServiceException.class,
  195. () -> withRoute.quote(request(), USER_ID));
  196. assertEquals("promotion.freeDelivery.freightUnavailable", noFare.getMessage());
  197. }
  198. // ==================== 规格加价与运费边界 ====================
  199. @Test
  200. void quote_shouldIgnoreClientSpecPrice()
  201. {
  202. FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3500));
  203. PromotionCalcResponse response = spy.quote(request(), USER_ID);
  204. // 规格加价由服务端按数据库规格值计算(10元),商品原价取数据库价;客户端无报价字段可传
  205. ArgumentCaptor<PromotionCalcRequest> captor = ArgumentCaptor.forClass(PromotionCalcRequest.class);
  206. ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
  207. verify(promotions).calculate(captor.capture(), eq(USER_ID), contextCaptor.capture());
  208. assertEquals(1, captor.getValue().getItems().size());
  209. assertEquals(FOOD_ID, captor.getValue().getItems().get(0).getProductId());
  210. assertEquals(0, new BigDecimal("10").compareTo(captor.getValue().getItems().get(0).getSpecPrice()));
  211. assertTrue(contextCaptor.getValue().isEligible());
  212. // 3.5km:超起步3km半公里门槛按1km计,单价10 → 20+10=30
  213. assertEquals(0, new BigDecimal("30").compareTo(contextCaptor.getValue().getDeliveryFee()));
  214. assertTrue(response != null);
  215. }
  216. @Test
  217. void fare_shouldFollowLegacyJumpTableBoundaries()
  218. {
  219. assertFreight(1, "3000", "20");
  220. assertFreight(2, "3490", "20");
  221. assertFreight(3, "3500", "30");
  222. assertFreight(4, "4250", "32");
  223. }
  224. @Test
  225. void fare_shouldRoundThousandAndUp()
  226. {
  227. // 起步1500:≥1000 按 HALF_UP 舍到千位 → 2000
  228. FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
  229. when(freight.getyunjia()).thenReturn(List.of(fare(1500L, 3L, 10L, 1L)));
  230. PromotionCalcResponse response = spy.quote(request(), USER_ID);
  231. ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
  232. verify(promotions).calculate(any(), eq(USER_ID), contextCaptor.capture());
  233. assertEquals(0, new BigDecimal("2000").compareTo(contextCaptor.getValue().getDeliveryFee()));
  234. }
  235. // ==================== 辅助构造 ====================
  236. private void assertFreight(int callIndex, String meters, String expectedFee)
  237. {
  238. FoodDeliveryPromotionQuoteService spy = spyWithRoute(new BigDecimal(meters));
  239. spy.quote(request(), USER_ID);
  240. ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
  241. verify(promotions, org.mockito.Mockito.times(callIndex)).calculate(any(), eq(USER_ID), contextCaptor.capture());
  242. PromotionDeliveryContext context = contextCaptor.getAllValues().get(callIndex - 1);
  243. assertEquals(0, new BigDecimal(expectedFee).compareTo(context.getDeliveryFee()),
  244. meters + "m should be " + expectedFee);
  245. }
  246. private FoodDeliveryPromotionQuoteService spyWithRoute(BigDecimal meters)
  247. {
  248. FoodDeliveryPromotionQuoteService spy = org.mockito.Mockito.spy(subject());
  249. doReturn(meters).when(spy).routeDistanceMeters(any(), any());
  250. return spy;
  251. }
  252. private FoodDeliveryPromotionQuoteRequest request()
  253. {
  254. FoodDeliveryPromotionQuoteRequest request = new FoodDeliveryPromotionQuoteRequest();
  255. request.setStoreId(STORE_ID);
  256. request.setOrderType(0);
  257. request.setAddressId(ADDRESS_ID);
  258. FoodDeliveryPromotionQuoteRequest.Item item = new FoodDeliveryPromotionQuoteRequest.Item();
  259. item.setProductId(FOOD_ID);
  260. item.setQuantity(2);
  261. item.setSpecValueIds(List.of(SPEC_VALUE_ID));
  262. request.setItems(new java.util.ArrayList<>(List.of(item)));
  263. return request;
  264. }
  265. private PosStore store()
  266. {
  267. PosStore store = new PosStore();
  268. store.setId(STORE_ID.intValue());
  269. store.setUserId(5L);
  270. store.setLatitude(new BigDecimal("25.0330"));
  271. store.setLongitude(new BigDecimal("121.5654"));
  272. return store;
  273. }
  274. private InfoAddress address(Long userId)
  275. {
  276. InfoAddress address = new InfoAddress();
  277. address.setId(ADDRESS_ID);
  278. address.setUserId(userId);
  279. address.setLatitude(new BigDecimal("25.0400"));
  280. address.setLongitude(new BigDecimal("121.5700"));
  281. return address;
  282. }
  283. private PosFood food(Long mdId, String price)
  284. {
  285. PosFood food = new PosFood();
  286. food.setId(FOOD_ID);
  287. food.setMdid(mdId);
  288. food.setPrice(new BigDecimal(price));
  289. food.setName("测试商品");
  290. food.setStackingUp("0");
  291. food.setToExamine("1");
  292. return food;
  293. }
  294. private PosFreight fare(Long startingFare, Long startingDistance, Long freightPerUnit, Long distanceUnit)
  295. {
  296. PosFreight row = new PosFreight();
  297. row.setStartingFare(startingFare);
  298. row.setStartingDistance(startingDistance);
  299. row.setFreight(freightPerUnit);
  300. row.setDistance(distanceUnit);
  301. return row;
  302. }
  303. /** 规格组300(mdId 指定门店,必选/多选)+ 规格值301(加价10),经 foodId=10 关联 */
  304. private void stubSpecGroup(Long mdId)
  305. {
  306. FoodSpecRelation relation = new FoodSpecRelation();
  307. relation.setFoodId(FOOD_ID);
  308. relation.setSpecsId(300L);
  309. when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of(relation));
  310. FoodSpecs group = new FoodSpecs();
  311. group.setId(300L);
  312. group.setMdId(mdId);
  313. group.setIsOpen(true);
  314. group.setIsDelete(false);
  315. group.setState("1");
  316. group.setType("0");
  317. when(specs.selectById(300L)).thenReturn(group);
  318. FoodSpecsValue value = new FoodSpecsValue();
  319. value.setId(SPEC_VALUE_ID);
  320. value.setParentId(300L);
  321. value.setPrice(new BigDecimal("10"));
  322. value.setIsOpen(true);
  323. when(values.selectById(SPEC_VALUE_ID)).thenReturn(value);
  324. }
  325. }