FoodDeliveryPromotionQuoteServiceTest.java 16 KB

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