FoodDeliveryPromotionQuoteService.java 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. package com.ruoyi.app.order;
  2. import java.math.BigDecimal;
  3. import java.math.RoundingMode;
  4. import java.util.ArrayList;
  5. import java.util.HashMap;
  6. import java.util.HashSet;
  7. import java.util.List;
  8. import java.util.Map;
  9. import org.springframework.stereotype.Service;
  10. import com.ruoyi.app.order.dto.FoodDeliveryPromotionQuoteRequest;
  11. import com.ruoyi.common.exception.ServiceException;
  12. import com.ruoyi.common.utils.MessageUtils;
  13. import com.ruoyi.system.domain.FoodSpecRelation;
  14. import com.ruoyi.system.domain.FoodSpecs;
  15. import com.ruoyi.system.domain.FoodSpecsValue;
  16. import com.ruoyi.system.domain.InfoAddress;
  17. import com.ruoyi.system.domain.PosFood;
  18. import com.ruoyi.system.domain.PosFreight;
  19. import com.ruoyi.system.domain.PosStore;
  20. import com.ruoyi.system.dto.PromotionCalcRequest;
  21. import com.ruoyi.system.dto.PromotionCalcResponse;
  22. import com.ruoyi.system.dto.PromotionDeliveryContext;
  23. import com.ruoyi.system.mapper.FoodSpecRelationMapper;
  24. import com.ruoyi.system.mapper.FoodSpecsMapper;
  25. import com.ruoyi.system.mapper.FoodSpecsValueMapper;
  26. import com.ruoyi.system.mapper.InfoAddressMapper;
  27. import com.ruoyi.system.mapper.PosFoodMapper;
  28. import com.ruoyi.system.mapper.PosFreightMapper;
  29. import com.ruoyi.system.mapper.PosStoreMapper;
  30. import com.ruoyi.system.service.IPromotionCalcService;
  31. import com.ruoyi.app.flashdelivery.route.GeoPoint;
  32. import com.ruoyi.app.order.route.RouteDistanceMatrixProvider;
  33. /**
  34. * 满额免运完整配送报价服务(008 T075,spec FR-033/FR-034)。
  35. *
  36. * 服务端核实门店、在售商品、规格归属与数量,并按用餐类型构造可信
  37. * PromotionDeliveryContext 调用算价三参重载(2026-10-19 追加 FR-037 报价入口统一):
  38. * 平台骑手外送按 Google 矩阵路线与运价跳表计算 R 并评估满额免运;商家自配送 R 照算但
  39. * 免运不评估(F=0);自取/堂食免地址、R/F/D=0、A=G。客户端传入的运费、免运金额、
  40. * 商品单价一律不作为计费依据;外送路线/运价不可用时返回业务错误,不默认运费0。
  41. */
  42. @Service
  43. public class FoodDeliveryPromotionQuoteService
  44. {
  45. private final PosStoreMapper stores;
  46. private final InfoAddressMapper addresses;
  47. private final PosFoodMapper foods;
  48. private final FoodSpecRelationMapper relations;
  49. private final FoodSpecsMapper specs;
  50. private final FoodSpecsValueMapper values;
  51. private final PosFreightMapper freight;
  52. private final SelfDeliveryService selfDeliveryService;
  53. private final IPromotionCalcService promotions;
  54. private final RouteDistanceMatrixProvider routeMatrix;
  55. public FoodDeliveryPromotionQuoteService(PosStoreMapper stores,
  56. InfoAddressMapper addresses,
  57. PosFoodMapper foods,
  58. FoodSpecRelationMapper relations,
  59. FoodSpecsMapper specs,
  60. FoodSpecsValueMapper values,
  61. PosFreightMapper freight,
  62. SelfDeliveryService selfDelivery,
  63. IPromotionCalcService promotions,
  64. RouteDistanceMatrixProvider routeMatrix)
  65. {
  66. this.stores = stores;
  67. this.addresses = addresses;
  68. this.foods = foods;
  69. this.relations = relations;
  70. this.specs = specs;
  71. this.values = values;
  72. this.freight = freight;
  73. this.selfDeliveryService = selfDelivery;
  74. this.promotions = promotions;
  75. this.routeMatrix = routeMatrix;
  76. }
  77. /**
  78. * 完整配送报价:预览与订单创建复用同一能力(spec FR-033)。
  79. *
  80. * @return PromotionCalcResponse(pricingVersion=2,含 R/F/D/A 与预算扩展字段)
  81. */
  82. public PromotionCalcResponse quote(FoodDeliveryPromotionQuoteRequest request, Long userId)
  83. {
  84. boolean delivery = request != null && Integer.valueOf(0).equals(request.getOrderType());
  85. boolean pickupOrDineIn = request != null && (Integer.valueOf(1).equals(request.getOrderType())
  86. || Integer.valueOf(2).equals(request.getOrderType()));
  87. if (request == null || request.getStoreId() == null || (!delivery && !pickupOrDineIn)
  88. || request.getItems() == null || request.getItems().isEmpty())
  89. {
  90. throw invalid("promotion.freeDelivery.unsupportedOrder");
  91. }
  92. for (FoodDeliveryPromotionQuoteRequest.Item item : request.getItems())
  93. {
  94. if (item == null || item.getProductId() == null || item.getQuantity() == null
  95. || item.getQuantity() < 1 || item.getQuantity() > 99)
  96. {
  97. throw invalid("promotion.freeDelivery.invalidProduct",
  98. item == null ? "-" : item.getProductId());
  99. }
  100. }
  101. PosStore store = stores.selectById(request.getStoreId());
  102. if (store == null)
  103. {
  104. throw invalid("promotion.freeDelivery.unsupportedOrder");
  105. }
  106. boolean selfDeliveryMode = false;
  107. InfoAddress address = null;
  108. if (delivery)
  109. {
  110. if (!hasCoordinates(store))
  111. {
  112. throw invalid("promotion.freeDelivery.freightUnavailable");
  113. }
  114. // 实际配送模式以门店自配送配置与(预约单)判定时段为准,不信客户端 selfDelivery;
  115. // 自配送单不再拒绝:运费照常按同一跳表口径报价,仅满额免运不评估(spec FR-037)
  116. selfDeliveryMode = selfDeliveryService.isSelfDelivery(store,
  117. selfDeliveryService.resolveJudgeTime(request.getDelryTime()));
  118. address = addresses.selectById(request.getAddressId());
  119. if (address == null || !userId.equals(address.getUserId()) || !hasCoordinates(address))
  120. {
  121. throw invalid("promotion.freeDelivery.invalidAddress");
  122. }
  123. }
  124. // 自取/堂食:无配送环节,addressId 不校验不使用,固定 R/F/D=0、A=G
  125. // 逐商品核实归属/在售/数量,规格加价由服务端按数据库计算
  126. List<PromotionCalcRequest.CartItem> cart = new ArrayList<>();
  127. for (FoodDeliveryPromotionQuoteRequest.Item item : request.getItems())
  128. {
  129. PosFood food = foods.selectById(item.getProductId());
  130. // 口径与菜单一致(PosFoodController):to_examine='1' 审核通过、stacking_up='0' 上架
  131. if (food == null || !request.getStoreId().equals(food.getMdid())
  132. || !"0".equals(food.getStackingUp()) || !"1".equals(food.getToExamine())
  133. || food.getPrice() == null || food.getPrice().signum() < 0)
  134. {
  135. throw invalid("promotion.freeDelivery.invalidProduct", item.getProductId());
  136. }
  137. BigDecimal specPrice = resolveSpecSurcharge(food, item.getSpecValueIds());
  138. PromotionCalcRequest.CartItem cartItem = new PromotionCalcRequest.CartItem();
  139. cartItem.setProductId(item.getProductId());
  140. cartItem.setQuantity(item.getQuantity());
  141. cartItem.setSpecPrice(specPrice);
  142. cart.add(cartItem);
  143. }
  144. BigDecimal deliveryFee = BigDecimal.ZERO;
  145. if (delivery)
  146. {
  147. PosFreight fare = currentFreight();
  148. BigDecimal distanceMeters = routeDistanceMeters(store, address);
  149. deliveryFee = originalFreight(fare, distanceMeters);
  150. }
  151. PromotionCalcRequest calcRequest = new PromotionCalcRequest();
  152. calcRequest.setStoreId(request.getStoreId());
  153. calcRequest.setItems(cart);
  154. calcRequest.setCouponId(request.getCouponId());
  155. calcRequest.setForcePath(request.getForcePath());
  156. // 满额免运仅平台骑手外送评估;自配送与自取/堂食 eligible=false(F=0,D 语义见契约)
  157. return promotions.calculate(calcRequest, userId,
  158. new PromotionDeliveryContext(deliveryFee, delivery && !selfDeliveryMode));
  159. }
  160. /**
  161. * 规格加价核实:唯一数据源 food_spec_relation(2026-10-19 确认统一口径,
  162. * 不再回退读 pos_food.food_sku JSON;存量商品须先用
  163. * updatesql/backfill_food_spec_relation.py 回填关联表后再部署)。
  164. * 规格值必须确属该商品关联且启用的规格组(同店);
  165. * 必选组(state=1)至少选一,单选组(type=1)至多一;加价为所选规格值价格合计。
  166. * 供报价与版本2订单 food JSON 生成共用,保证两处加价同源。
  167. */
  168. public BigDecimal resolveSpecSurcharge(PosFood food, List<Long> selectedValues)
  169. {
  170. List<Long> chosen = selectedValues == null ? List.of() : selectedValues;
  171. if (chosen.size() != new HashSet<>(chosen).size())
  172. {
  173. throw invalid("promotion.freeDelivery.invalidSpec", food.getId());
  174. }
  175. FoodSpecRelation query = new FoodSpecRelation();
  176. query.setFoodId(food.getId());
  177. List<FoodSpecRelation> links = relations.selectFoodSpecRelationList(query);
  178. Map<Long, FoodSpecs> groups = new HashMap<>();
  179. if (links != null)
  180. {
  181. for (FoodSpecRelation link : links)
  182. {
  183. FoodSpecs group = specs.selectById(link.getSpecsId());
  184. if (group != null && food.getMdid().equals(group.getMdId())
  185. && Boolean.TRUE.equals(group.getIsOpen()) && !Boolean.TRUE.equals(group.getIsDelete()))
  186. {
  187. groups.put(group.getId(), group);
  188. }
  189. }
  190. }
  191. Map<Long, Integer> counts = new HashMap<>();
  192. BigDecimal price = BigDecimal.ZERO;
  193. for (Long valueId : chosen)
  194. {
  195. FoodSpecsValue value = valueId == null ? null : values.selectById(valueId);
  196. if (value == null || !Boolean.TRUE.equals(value.getIsOpen())
  197. || !groups.containsKey(value.getParentId()) || value.getPrice() == null
  198. || value.getPrice().signum() < 0)
  199. {
  200. throw invalid("promotion.freeDelivery.invalidSpec", food.getId());
  201. }
  202. counts.merge(value.getParentId(), 1, Integer::sum);
  203. price = price.add(value.getPrice());
  204. }
  205. for (FoodSpecs group : groups.values())
  206. {
  207. int count = counts.getOrDefault(group.getId(), 0);
  208. if (("1".equals(group.getState()) && count == 0)
  209. || ("1".equals(group.getType()) && count > 1))
  210. {
  211. throw invalid("promotion.freeDelivery.invalidSpec", food.getId());
  212. }
  213. }
  214. return price;
  215. }
  216. /** 运价行:优先 getyunjia,缺省 getmiuyunjia;不可用即业务错误,不回退免费运费 */
  217. private PosFreight currentFreight()
  218. {
  219. List<PosFreight> rows = freight.getyunjia();
  220. if (rows == null || rows.isEmpty())
  221. {
  222. rows = freight.getmiuyunjia();
  223. }
  224. if (rows == null || rows.isEmpty())
  225. {
  226. throw invalid("promotion.freeDelivery.freightUnavailable");
  227. }
  228. PosFreight row = rows.get(0);
  229. if (row.getStartingFare() == null || row.getStartingFare() < 0
  230. || row.getStartingDistance() == null || row.getStartingDistance() < 0
  231. || row.getFreight() == null || row.getFreight() < 0
  232. || row.getDistance() == null || row.getDistance() <= 0)
  233. {
  234. throw invalid("promotion.freeDelivery.freightUnavailable");
  235. }
  236. return row;
  237. }
  238. /**
  239. * 普通外送以1×1矩阵取得门店→用户的道路米数,与多店报价使用同一提供器。
  240. * 客户端和运价公式不变;外部失败仍返回原有业务错误,不补0运费或回退直线。
  241. */
  242. protected BigDecimal routeDistanceMeters(PosStore store, InfoAddress address)
  243. {
  244. try
  245. {
  246. List<BigDecimal> distances = routeMatrix.distancesToDestination(
  247. List.of(new GeoPoint(store.getLatitude(), store.getLongitude())),
  248. new GeoPoint(address.getLatitude(), address.getLongitude()));
  249. if (distances == null || distances.size() != 1 || distances.get(0) == null
  250. || distances.get(0).signum() <= 0 || distances.get(0).stripTrailingZeros().scale() > 0)
  251. {
  252. throw invalid("promotion.freeDelivery.freightUnavailable");
  253. }
  254. return distances.get(0);
  255. }
  256. catch (ServiceException known)
  257. {
  258. throw known;
  259. }
  260. catch (Exception unavailable)
  261. {
  262. throw invalid("promotion.freeDelivery.freightUnavailable");
  263. }
  264. }
  265. /**
  266. * 运费跳表(对齐旧 getyunfei/getZuiyyfei 口径,含半公里门槛与千元舍入):
  267. * 公里数取三位;超出起步距离部分 0.5km 内不计、不足1km按1km;超出单价为整数
  268. * freight/distance;整数元 HALF_EVEN;满千元按 HALF_UP 舍到千位。
  269. */
  270. BigDecimal originalFreight(PosFreight row, BigDecimal meters)
  271. {
  272. BigDecimal km = meters.movePointLeft(3);
  273. BigDecimal extra = km.subtract(BigDecimal.valueOf(row.getStartingDistance())).max(BigDecimal.ZERO);
  274. if (extra.signum() > 0 && extra.compareTo(new BigDecimal("0.5")) < 0)
  275. {
  276. extra = BigDecimal.ZERO;
  277. }
  278. else if (extra.compareTo(new BigDecimal("0.5")) >= 0 && extra.compareTo(BigDecimal.ONE) < 0)
  279. {
  280. extra = BigDecimal.ONE;
  281. }
  282. long perKm = row.getFreight() / row.getDistance();
  283. BigDecimal amount = extra.multiply(BigDecimal.valueOf(perKm)).setScale(0, RoundingMode.HALF_EVEN)
  284. .add(BigDecimal.valueOf(row.getStartingFare()));
  285. return amount.compareTo(new BigDecimal("1000")) < 0
  286. ? amount : amount.setScale(-3, RoundingMode.HALF_UP).setScale(0);
  287. }
  288. private boolean hasCoordinates(InfoAddress address)
  289. {
  290. return address.getLatitude() != null && address.getLongitude() != null;
  291. }
  292. private boolean hasCoordinates(PosStore store)
  293. {
  294. return store.getLatitude() != null && store.getLongitude() != null;
  295. }
  296. private ServiceException invalid(String messageKey, Object... args)
  297. {
  298. return new ServiceException(MessageUtils.message(messageKey, args));
  299. }
  300. }