| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319 |
- package com.ruoyi.app.order;
- import java.math.BigDecimal;
- import java.math.RoundingMode;
- import java.util.ArrayList;
- import java.util.HashMap;
- import java.util.HashSet;
- import java.util.List;
- import java.util.Map;
- import org.springframework.stereotype.Service;
- 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;
- import com.ruoyi.app.flashdelivery.route.GeoPoint;
- import com.ruoyi.app.order.route.RouteDistanceMatrixProvider;
- /**
- * 满额免运完整配送报价服务(008 T075,spec FR-033/FR-034)。
- *
- * 服务端核实门店、在售商品、规格归属与数量,并按用餐类型构造可信
- * PromotionDeliveryContext 调用算价三参重载(2026-10-19 追加 FR-037 报价入口统一):
- * 平台骑手外送按 Google 矩阵路线与运价跳表计算 R 并评估满额免运;商家自配送 R 照算但
- * 免运不评估(F=0);自取/堂食免地址、R/F/D=0、A=G。客户端传入的运费、免运金额、
- * 商品单价一律不作为计费依据;外送路线/运价不可用时返回业务错误,不默认运费0。
- */
- @Service
- public class FoodDeliveryPromotionQuoteService
- {
- private final PosStoreMapper stores;
- private final InfoAddressMapper addresses;
- private final PosFoodMapper foods;
- private final FoodSpecRelationMapper relations;
- private final FoodSpecsMapper specs;
- private final FoodSpecsValueMapper values;
- private final PosFreightMapper freight;
- private final SelfDeliveryService selfDeliveryService;
- private final IPromotionCalcService promotions;
- private final RouteDistanceMatrixProvider routeMatrix;
- public FoodDeliveryPromotionQuoteService(PosStoreMapper stores,
- InfoAddressMapper addresses,
- PosFoodMapper foods,
- FoodSpecRelationMapper relations,
- FoodSpecsMapper specs,
- FoodSpecsValueMapper values,
- PosFreightMapper freight,
- SelfDeliveryService selfDelivery,
- IPromotionCalcService promotions,
- RouteDistanceMatrixProvider routeMatrix)
- {
- this.stores = stores;
- this.addresses = addresses;
- this.foods = foods;
- this.relations = relations;
- this.specs = specs;
- this.values = values;
- this.freight = freight;
- this.selfDeliveryService = selfDelivery;
- this.promotions = promotions;
- this.routeMatrix = routeMatrix;
- }
- /**
- * 完整配送报价:预览与订单创建复用同一能力(spec FR-033)。
- *
- * @return PromotionCalcResponse(pricingVersion=2,含 R/F/D/A 与预算扩展字段)
- */
- public PromotionCalcResponse quote(FoodDeliveryPromotionQuoteRequest request, Long userId)
- {
- boolean delivery = request != null && Integer.valueOf(0).equals(request.getOrderType());
- boolean pickupOrDineIn = request != null && (Integer.valueOf(1).equals(request.getOrderType())
- || Integer.valueOf(2).equals(request.getOrderType()));
- if (request == null || request.getStoreId() == null || (!delivery && !pickupOrDineIn)
- || request.getItems() == null || request.getItems().isEmpty())
- {
- throw invalid("promotion.freeDelivery.unsupportedOrder");
- }
- for (FoodDeliveryPromotionQuoteRequest.Item item : request.getItems())
- {
- if (item == null || item.getProductId() == null || item.getQuantity() == null
- || item.getQuantity() < 1 || item.getQuantity() > 99)
- {
- throw invalid("promotion.freeDelivery.invalidProduct",
- item == null ? "-" : item.getProductId());
- }
- }
- PosStore store = stores.selectById(request.getStoreId());
- if (store == null)
- {
- throw invalid("promotion.freeDelivery.unsupportedOrder");
- }
- boolean selfDeliveryMode = false;
- InfoAddress address = null;
- if (delivery)
- {
- if (!hasCoordinates(store))
- {
- throw invalid("promotion.freeDelivery.freightUnavailable");
- }
- // 实际配送模式以门店自配送配置与(预约单)判定时段为准,不信客户端 selfDelivery;
- // 自配送单不再拒绝:运费照常按同一跳表口径报价,仅满额免运不评估(spec FR-037)
- selfDeliveryMode = selfDeliveryService.isSelfDelivery(store,
- selfDeliveryService.resolveJudgeTime(request.getDelryTime()));
- address = addresses.selectById(request.getAddressId());
- if (address == null || !userId.equals(address.getUserId()) || !hasCoordinates(address))
- {
- throw invalid("promotion.freeDelivery.invalidAddress");
- }
- }
- // 自取/堂食:无配送环节,addressId 不校验不使用,固定 R/F/D=0、A=G
- // 逐商品核实归属/在售/数量,规格加价由服务端按数据库计算
- List<PromotionCalcRequest.CartItem> cart = new ArrayList<>();
- for (FoodDeliveryPromotionQuoteRequest.Item item : request.getItems())
- {
- PosFood food = foods.selectById(item.getProductId());
- // 口径与菜单一致(PosFoodController):to_examine='1' 审核通过、stacking_up='0' 上架
- if (food == null || !request.getStoreId().equals(food.getMdid())
- || !"0".equals(food.getStackingUp()) || !"1".equals(food.getToExamine())
- || food.getPrice() == null || food.getPrice().signum() < 0)
- {
- throw invalid("promotion.freeDelivery.invalidProduct", item.getProductId());
- }
- BigDecimal specPrice = resolveSpecSurcharge(food, item.getSpecValueIds());
- PromotionCalcRequest.CartItem cartItem = new PromotionCalcRequest.CartItem();
- cartItem.setProductId(item.getProductId());
- cartItem.setQuantity(item.getQuantity());
- cartItem.setSpecPrice(specPrice);
- cart.add(cartItem);
- }
- BigDecimal deliveryFee = BigDecimal.ZERO;
- if (delivery)
- {
- PosFreight fare = currentFreight();
- BigDecimal distanceMeters = routeDistanceMeters(store, address);
- deliveryFee = originalFreight(fare, distanceMeters);
- }
- PromotionCalcRequest calcRequest = new PromotionCalcRequest();
- calcRequest.setStoreId(request.getStoreId());
- calcRequest.setItems(cart);
- calcRequest.setCouponId(request.getCouponId());
- calcRequest.setForcePath(request.getForcePath());
- // 满额免运仅平台骑手外送评估;自配送与自取/堂食 eligible=false(F=0,D 语义见契约)
- return promotions.calculate(calcRequest, userId,
- new PromotionDeliveryContext(deliveryFee, delivery && !selfDeliveryMode));
- }
- /**
- * 规格加价核实:唯一数据源 food_spec_relation(2026-10-19 确认统一口径,
- * 不再回退读 pos_food.food_sku JSON;存量商品须先用
- * updatesql/backfill_food_spec_relation.py 回填关联表后再部署)。
- * 规格值必须确属该商品关联且启用的规格组(同店);
- * 必选组(state=1)至少选一,单选组(type=1)至多一;加价为所选规格值价格合计。
- * 供报价与版本2订单 food JSON 生成共用,保证两处加价同源。
- */
- public BigDecimal resolveSpecSurcharge(PosFood food, List<Long> selectedValues)
- {
- List<Long> chosen = selectedValues == null ? List.of() : selectedValues;
- if (chosen.size() != new HashSet<>(chosen).size())
- {
- throw invalid("promotion.freeDelivery.invalidSpec", food.getId());
- }
- FoodSpecRelation query = new FoodSpecRelation();
- query.setFoodId(food.getId());
- List<FoodSpecRelation> links = relations.selectFoodSpecRelationList(query);
- Map<Long, FoodSpecs> groups = new HashMap<>();
- if (links != null)
- {
- for (FoodSpecRelation link : links)
- {
- FoodSpecs group = specs.selectById(link.getSpecsId());
- if (group != null && food.getMdid().equals(group.getMdId())
- && Boolean.TRUE.equals(group.getIsOpen()) && !Boolean.TRUE.equals(group.getIsDelete()))
- {
- groups.put(group.getId(), group);
- }
- }
- }
- Map<Long, Integer> counts = new HashMap<>();
- BigDecimal price = BigDecimal.ZERO;
- for (Long valueId : chosen)
- {
- FoodSpecsValue value = valueId == null ? null : values.selectById(valueId);
- if (value == null || !Boolean.TRUE.equals(value.getIsOpen())
- || !groups.containsKey(value.getParentId()) || value.getPrice() == null
- || value.getPrice().signum() < 0)
- {
- throw invalid("promotion.freeDelivery.invalidSpec", food.getId());
- }
- counts.merge(value.getParentId(), 1, Integer::sum);
- price = price.add(value.getPrice());
- }
- for (FoodSpecs group : groups.values())
- {
- int count = counts.getOrDefault(group.getId(), 0);
- if (("1".equals(group.getState()) && count == 0)
- || ("1".equals(group.getType()) && count > 1))
- {
- throw invalid("promotion.freeDelivery.invalidSpec", food.getId());
- }
- }
- return price;
- }
- /** 运价行:优先 getyunjia,缺省 getmiuyunjia;不可用即业务错误,不回退免费运费 */
- private PosFreight currentFreight()
- {
- List<PosFreight> rows = freight.getyunjia();
- if (rows == null || rows.isEmpty())
- {
- rows = freight.getmiuyunjia();
- }
- if (rows == null || rows.isEmpty())
- {
- throw invalid("promotion.freeDelivery.freightUnavailable");
- }
- PosFreight row = rows.get(0);
- if (row.getStartingFare() == null || row.getStartingFare() < 0
- || row.getStartingDistance() == null || row.getStartingDistance() < 0
- || row.getFreight() == null || row.getFreight() < 0
- || row.getDistance() == null || row.getDistance() <= 0)
- {
- throw invalid("promotion.freeDelivery.freightUnavailable");
- }
- return row;
- }
- /**
- * 普通外送以1×1矩阵取得门店→用户的道路米数,与多店报价使用同一提供器。
- * 客户端和运价公式不变;外部失败仍返回原有业务错误,不补0运费或回退直线。
- */
- protected BigDecimal routeDistanceMeters(PosStore store, InfoAddress address)
- {
- try
- {
- List<BigDecimal> distances = routeMatrix.distancesToDestination(
- List.of(new GeoPoint(store.getLatitude(), store.getLongitude())),
- new GeoPoint(address.getLatitude(), address.getLongitude()));
- if (distances == null || distances.size() != 1 || distances.get(0) == null
- || distances.get(0).signum() <= 0 || distances.get(0).stripTrailingZeros().scale() > 0)
- {
- throw invalid("promotion.freeDelivery.freightUnavailable");
- }
- return distances.get(0);
- }
- catch (ServiceException known)
- {
- throw known;
- }
- catch (Exception unavailable)
- {
- throw invalid("promotion.freeDelivery.freightUnavailable");
- }
- }
- /**
- * 运费跳表(对齐旧 getyunfei/getZuiyyfei 口径,含半公里门槛与千元舍入):
- * 公里数取三位;超出起步距离部分 0.5km 内不计、不足1km按1km;超出单价为整数
- * freight/distance;整数元 HALF_EVEN;满千元按 HALF_UP 舍到千位。
- */
- BigDecimal originalFreight(PosFreight row, BigDecimal meters)
- {
- BigDecimal km = meters.movePointLeft(3);
- BigDecimal extra = km.subtract(BigDecimal.valueOf(row.getStartingDistance())).max(BigDecimal.ZERO);
- if (extra.signum() > 0 && extra.compareTo(new BigDecimal("0.5")) < 0)
- {
- extra = BigDecimal.ZERO;
- }
- else if (extra.compareTo(new BigDecimal("0.5")) >= 0 && extra.compareTo(BigDecimal.ONE) < 0)
- {
- extra = BigDecimal.ONE;
- }
- long perKm = row.getFreight() / row.getDistance();
- BigDecimal amount = extra.multiply(BigDecimal.valueOf(perKm)).setScale(0, RoundingMode.HALF_EVEN)
- .add(BigDecimal.valueOf(row.getStartingFare()));
- return amount.compareTo(new BigDecimal("1000")) < 0
- ? amount : amount.setScale(-3, RoundingMode.HALF_UP).setScale(0);
- }
- private boolean hasCoordinates(InfoAddress address)
- {
- return address.getLatitude() != null && address.getLongitude() != null;
- }
- private boolean hasCoordinates(PosStore store)
- {
- return store.getLatitude() != null && store.getLongitude() != null;
- }
- private ServiceException invalid(String messageKey, Object... args)
- {
- return new ServiceException(MessageUtils.message(messageKey, args));
- }
- }
|