|
|
@@ -0,0 +1,391 @@
|
|
|
+package com.ruoyi.app.order;
|
|
|
+
|
|
|
+import com.alibaba.fastjson2.JSON;
|
|
|
+import com.ruoyi.app.order.dto.MultiStoreQuoteRequest;
|
|
|
+import com.ruoyi.app.order.dto.MultiStoreQuoteView;
|
|
|
+import com.ruoyi.common.core.redis.RedisCache;
|
|
|
+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.OperatingHours;
|
|
|
+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.OperatingHoursMapper;
|
|
|
+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.system.service.PaymentMethodGateService;
|
|
|
+import com.ruoyi.system.service.StallQualificationGateService;
|
|
|
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
|
|
|
+import com.ruoyi.app.order.route.RouteDistanceMatrixProvider;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.security.MessageDigest;
|
|
|
+import java.security.NoSuchAlgorithmException;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.time.LocalTime;
|
|
|
+import java.time.format.DateTimeParseException;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.UUID;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+
|
|
|
+/** 036 新批次专用的服务端报价;旧 `/createOrder` 的价格与支付路径不受影响。 */
|
|
|
+@Service
|
|
|
+public class MultiStoreCheckoutQuoteService {
|
|
|
+ static final String QUOTE_KEY_PREFIX = "multi:store:checkout:quote:";
|
|
|
+ private static final int QUOTE_TTL_MINUTES = 5;
|
|
|
+ private static final Set<String> ALLOWED_PAY_TYPES = Set.of("1", "2", "3", "5");
|
|
|
+
|
|
|
+ private final MultiStoreCheckoutConfigService config;
|
|
|
+ private final InfoAddressMapper addresses;
|
|
|
+ private final PosStoreMapper stores;
|
|
|
+ private final OperatingHoursMapper hours;
|
|
|
+ private final PosFoodMapper foods;
|
|
|
+ private final FoodSpecRelationMapper relations;
|
|
|
+ private final FoodSpecsMapper specs;
|
|
|
+ private final FoodSpecsValueMapper values;
|
|
|
+ private final PosFreightMapper freight;
|
|
|
+ private final PaymentMethodGateService payments;
|
|
|
+ private final StallQualificationGateService qualifications;
|
|
|
+ private final SelfDeliveryService selfDelivery;
|
|
|
+ private final IPromotionCalcService promotions;
|
|
|
+ private final RedisCache redis;
|
|
|
+ private final RouteDistanceMatrixProvider routeMatrix;
|
|
|
+
|
|
|
+ public MultiStoreCheckoutQuoteService(MultiStoreCheckoutConfigService config, InfoAddressMapper addresses,
|
|
|
+ PosStoreMapper stores, OperatingHoursMapper hours, PosFoodMapper foods,
|
|
|
+ FoodSpecRelationMapper relations, FoodSpecsMapper specs,
|
|
|
+ FoodSpecsValueMapper values, PosFreightMapper freight,
|
|
|
+ PaymentMethodGateService payments, StallQualificationGateService qualifications,
|
|
|
+ SelfDeliveryService selfDelivery, IPromotionCalcService promotions,
|
|
|
+ RedisCache redis, RouteDistanceMatrixProvider routeMatrix) {
|
|
|
+ this.config = config;
|
|
|
+ this.addresses = addresses;
|
|
|
+ this.stores = stores;
|
|
|
+ this.hours = hours;
|
|
|
+ this.foods = foods;
|
|
|
+ this.relations = relations;
|
|
|
+ this.specs = specs;
|
|
|
+ this.values = values;
|
|
|
+ this.freight = freight;
|
|
|
+ this.payments = payments;
|
|
|
+ this.qualifications = qualifications;
|
|
|
+ this.selfDelivery = selfDelivery;
|
|
|
+ this.promotions = promotions;
|
|
|
+ this.redis = redis;
|
|
|
+ this.routeMatrix = routeMatrix;
|
|
|
+ }
|
|
|
+
|
|
|
+ public MultiStoreQuoteView quote(MultiStoreQuoteRequest input, Long userId) {
|
|
|
+ MultiStoreCheckoutConfig policy = config.current();
|
|
|
+ if (!policy.enabled()) throw invalid("multi.store.checkout.disabled");
|
|
|
+ if (userId == null || input == null || input.addressId() == null
|
|
|
+ || !Boolean.TRUE.equals(input.sameRider()) || input.stores() == null
|
|
|
+ || input.stores().size() < 2 || input.stores().size() > policy.maxStores()) {
|
|
|
+ throw invalid("multi.store.checkout.invalid");
|
|
|
+ }
|
|
|
+ InfoAddress address = addresses.selectById(input.addressId());
|
|
|
+ if (address == null || !userId.equals(address.getUserId()) || !hasCoordinates(address)) {
|
|
|
+ throw invalid("multi.store.checkout.address.invalid");
|
|
|
+ }
|
|
|
+ if (input.stores().get(0) == null || input.stores().get(0).storeId() == null) {
|
|
|
+ throw invalid("multi.store.checkout.invalid");
|
|
|
+ }
|
|
|
+ PosStore anchor = stores.selectById(input.stores().get(0).storeId());
|
|
|
+ if (anchor == null || !hasCoordinates(anchor)) throw invalid("multi.store.checkout.store.invalid");
|
|
|
+ String deliveryWindow = input.stores().get(0).deliveryWindow();
|
|
|
+ LocalDateTime deliveryAt = parseWindow(deliveryWindow);
|
|
|
+ Set<Long> seen = new HashSet<>();
|
|
|
+ List<Candidate> candidates = new ArrayList<>();
|
|
|
+ List<MultiStoreQuoteView.ExcludedStore> excluded = new ArrayList<>();
|
|
|
+ PosFreight fare = currentFreight();
|
|
|
+ for (MultiStoreQuoteRequest.Store selected : input.stores()) {
|
|
|
+ if (selected == null || selected.storeId() == null || !seen.add(selected.storeId())
|
|
|
+ || !sameWindow(deliveryWindow, selected.deliveryWindow())) {
|
|
|
+ throw invalid("multi.store.checkout.invalid");
|
|
|
+ }
|
|
|
+ PosStore store = selected.storeId().equals(Long.valueOf(anchor.getId()))
|
|
|
+ ? anchor : stores.selectById(selected.storeId());
|
|
|
+ if (store == null || !hasCoordinates(store)) throw invalid("multi.store.checkout.store.invalid");
|
|
|
+ if (!MultiStoreCheckoutRules.withinFence(anchor.getLatitude().doubleValue(), anchor.getLongitude().doubleValue(),
|
|
|
+ store.getLatitude().doubleValue(), store.getLongitude().doubleValue(), policy.fenceMeters())) {
|
|
|
+ excluded.add(new MultiStoreQuoteView.ExcludedStore(selected.storeId(), "OUTSIDE_FENCE"));
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ qualifications.requireApproved(store);
|
|
|
+ if (!storeOpen(store, deliveryAt) || selfDelivery.isSelfDelivery(store, deliveryAt)) {
|
|
|
+ throw invalid("multi.store.checkout.store.invalid");
|
|
|
+ }
|
|
|
+ List<String> payTypes = payments.listAvailable(PaymentMethodGateService.GateScope.MERCHANT,
|
|
|
+ selected.storeId(), store.getUserId(), 0L).stream()
|
|
|
+ .filter(PaymentMethodGateService.PayMethodOption::isReady)
|
|
|
+ .map(PaymentMethodGateService.PayMethodOption::getPayType)
|
|
|
+ .filter(ALLOWED_PAY_TYPES::contains).distinct().toList();
|
|
|
+ if (payTypes.isEmpty()) {
|
|
|
+ excluded.add(new MultiStoreQuoteView.ExcludedStore(selected.storeId(), "NO_SUPPORTED_PAYMENT"));
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ candidates.add(new Candidate(selected, store, payTypes));
|
|
|
+ }
|
|
|
+ if (candidates.size() < 2 || !candidates.get(0).selected().storeId().equals(Long.valueOf(anchor.getId()))) {
|
|
|
+ throw invalid("multi.store.checkout.invalid");
|
|
|
+ }
|
|
|
+ // 资格/围栏/支付方式筛选完成后,只对真正参与的门店发一次N×1请求。
|
|
|
+ // 每店到用户的道路里程独立计价;这不是按门店顺序串联的一条导航路线。
|
|
|
+ List<BigDecimal> distances = routeDistancesMeters(candidates.stream().map(Candidate::store).toList(), address);
|
|
|
+ BigDecimal ratio = MultiStoreCheckoutRules.resolveRatio(policy.tiers(), candidates.size());
|
|
|
+ List<MultiStoreQuoteView.StorePrice> prices = new ArrayList<>();
|
|
|
+ for (int index = 0; index < candidates.size(); index++) {
|
|
|
+ Candidate candidate = candidates.get(index);
|
|
|
+ BigDecimal original = originalFreight(fare, distances.get(index));
|
|
|
+ // 先按参与店数确定多店折后运费,再把它作为免运活动的骑手毛配送费基数。
|
|
|
+ BigDecimal reduced = MultiStoreCheckoutRules.discountedFreight(original, ratio);
|
|
|
+ PromotionCalcResponse price = priceFoodForBatch(candidate.selected(), userId, reduced);
|
|
|
+ prices.add(new MultiStoreQuoteView.StorePrice(candidate.selected().storeId(), price.getOriginalAmount(), price.getFinalAmount(),
|
|
|
+ original, reduced, price.getCustomerDeliveryFee(),
|
|
|
+ price.getDeliveryDiscountAmount(), price.getPayableAmount(), candidate.payTypes()));
|
|
|
+ }
|
|
|
+ prices = List.copyOf(prices);
|
|
|
+ List<Long> participants = candidates.stream().map(candidate -> candidate.selected().storeId()).toList();
|
|
|
+ long expiresAt = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(QUOTE_TTL_MINUTES);
|
|
|
+ // Token 是随机缓存索引而非客户端可自行构造的价格凭据;建批时还须重新报价并比对指纹。
|
|
|
+ String quoteToken = UUID.randomUUID().toString();
|
|
|
+ String fingerprint = fingerprint(JSON.toJSONString(List.of(policy.version(), input, prices, participants)));
|
|
|
+ MultiStoreQuoteView result = new MultiStoreQuoteView(quoteToken, expiresAt, policy.version(),
|
|
|
+ Long.valueOf(anchor.getId()), policy.fenceMeters(), participants, prices, excluded, ratio, fingerprint);
|
|
|
+ redis.setCacheObject(QUOTE_KEY_PREFIX + quoteToken,
|
|
|
+ JSON.toJSONString(new QuoteSnapshot(userId, input, fingerprint, expiresAt)),
|
|
|
+ QUOTE_TTL_MINUTES, TimeUnit.MINUTES);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** T005 建批使用;缺失、越权或到期的报价不可用,客户端只传 token 不传价格。 */
|
|
|
+ public QuoteSnapshot loadSnapshot(String token, Long userId) {
|
|
|
+ if (token == null || token.isBlank() || userId == null) throw invalid("multi.store.checkout.invalid");
|
|
|
+ String json = redis.getCacheObject(QUOTE_KEY_PREFIX + token);
|
|
|
+ QuoteSnapshot snapshot = json == null ? null : JSON.parseObject(json, QuoteSnapshot.class);
|
|
|
+ if (snapshot == null || !userId.equals(snapshot.userId())
|
|
|
+ || snapshot.expiresAtEpochMillis() <= System.currentTimeMillis()) {
|
|
|
+ throw invalid("multi.store.checkout.quote.expired");
|
|
|
+ }
|
|
|
+ return snapshot;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 建批事务复算同一商品/券明细并用于固化促销快照;不接受客户端商品价格。 */
|
|
|
+ PromotionCalcResponse priceFoodForBatch(MultiStoreQuoteRequest.Store selected, Long userId,
|
|
|
+ BigDecimal discountedFreight) {
|
|
|
+ if (selected.items() == null || selected.items().isEmpty()) throw invalid("multi.store.checkout.food.invalid");
|
|
|
+ PromotionCalcRequest request = new PromotionCalcRequest();
|
|
|
+ request.setStoreId(selected.storeId());
|
|
|
+ request.setCouponId(selected.couponId());
|
|
|
+ request.setForcePath(selected.forcePath());
|
|
|
+ List<PromotionCalcRequest.CartItem> cart = new ArrayList<>();
|
|
|
+ BigDecimal independentlyPriced = BigDecimal.ZERO;
|
|
|
+ for (MultiStoreQuoteRequest.Item item : selected.items()) {
|
|
|
+ if (item == null || item.productId() == null || item.quantity() == null
|
|
|
+ || item.quantity() <= 0 || item.quantity() > 99) throw invalid("multi.store.checkout.food.invalid");
|
|
|
+ PosFood food = foods.selectById(item.productId());
|
|
|
+ if (food == null || !selected.storeId().equals(food.getMdid())
|
|
|
+ || !"0".equals(food.getStackingUp()) || "1".equals(food.getToExamine())
|
|
|
+ || food.getPrice() == null || food.getPrice().signum() < 0) {
|
|
|
+ throw invalid("multi.store.checkout.food.invalid");
|
|
|
+ }
|
|
|
+ BigDecimal specPrice = priceSpecs(food, item.specValueIds());
|
|
|
+ PromotionCalcRequest.CartItem cartItem = new PromotionCalcRequest.CartItem();
|
|
|
+ cartItem.setProductId(item.productId());
|
|
|
+ cartItem.setQuantity(item.quantity());
|
|
|
+ cartItem.setSpecPrice(specPrice);
|
|
|
+ cartItem.setSpecValueIds(item.specValueIds());
|
|
|
+ cart.add(cartItem);
|
|
|
+ independentlyPriced = independentlyPriced.add(food.getPrice().add(specPrice)
|
|
|
+ .multiply(BigDecimal.valueOf(item.quantity())));
|
|
|
+ }
|
|
|
+ request.setItems(cart);
|
|
|
+ PromotionCalcResponse promotion = promotions.calculate(request, userId,
|
|
|
+ new PromotionDeliveryContext(discountedFreight, true));
|
|
|
+ if (promotion == null || promotion.getOriginalAmount() == null || promotion.getFinalAmount() == null
|
|
|
+ || promotion.getOriginalAmount().compareTo(independentlyPriced.setScale(0, RoundingMode.HALF_UP)) != 0
|
|
|
+ || promotion.getFinalAmount().signum() < 0
|
|
|
+ || promotion.getDeliveryFee() == null || promotion.getDeliveryFee().compareTo(discountedFreight) != 0
|
|
|
+ || promotion.getDeliveryDiscountAmount() == null || promotion.getDeliveryDiscountAmount().signum() < 0
|
|
|
+ || promotion.getCustomerDeliveryFee() == null || promotion.getCustomerDeliveryFee().signum() < 0
|
|
|
+ || promotion.getDeliveryDiscountAmount().add(promotion.getCustomerDeliveryFee())
|
|
|
+ .compareTo(discountedFreight) != 0
|
|
|
+ || promotion.getPayableAmount() == null || promotion.getPayableAmount().compareTo(
|
|
|
+ promotion.getFinalAmount().add(promotion.getCustomerDeliveryFee())) != 0
|
|
|
+ || (selected.couponId() != null && !selected.couponId().equals(promotion.getCouponId()))) {
|
|
|
+ throw invalid("multi.store.checkout.food.invalid");
|
|
|
+ }
|
|
|
+ return promotion;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 规格必须确属商品所关联且启用的组,不能让客户端直接报加价。 */
|
|
|
+ private BigDecimal priceSpecs(PosFood food, List<Long> selectedValues) {
|
|
|
+ List<Long> chosen = selectedValues == null ? List.of() : selectedValues;
|
|
|
+ if (chosen.size() != new HashSet<>(chosen).size()) throw invalid("multi.store.checkout.food.invalid");
|
|
|
+ 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("multi.store.checkout.food.invalid");
|
|
|
+ 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("multi.store.checkout.food.invalid");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return price;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean storeOpen(PosStore store, LocalDateTime deliveryAt) {
|
|
|
+ if (Long.valueOf(1).equals(store.getState())) return false;
|
|
|
+ OperatingHours query = new OperatingHours();
|
|
|
+ query.setMdId(Long.valueOf(store.getId()));
|
|
|
+ List<OperatingHours> entries = hours.selectOperatingHoursList(query);
|
|
|
+ if (entries == null) return false;
|
|
|
+ return entries.stream().anyMatch(entry -> {
|
|
|
+ if (!Boolean.TRUE.equals(entry.getIsOpen()) || entry.getDayOfWeek() == null
|
|
|
+ || entry.getDayOfWeek() != deliveryAt.getDayOfWeek().getValue()) return false;
|
|
|
+ try {
|
|
|
+ LocalTime start = LocalTime.parse(entry.getStartTime());
|
|
|
+ LocalTime end = LocalTime.parse(entry.getEndTime());
|
|
|
+ return !deliveryAt.toLocalTime().isBefore(start) && deliveryAt.toLocalTime().isBefore(end);
|
|
|
+ } catch (RuntimeException exception) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private LocalDateTime parseWindow(String window) {
|
|
|
+ if (window == null || window.isBlank()) return LocalDateTime.now();
|
|
|
+ try {
|
|
|
+ LocalDate date = LocalDate.parse(window.substring(0, 10));
|
|
|
+ LocalTime start = LocalTime.parse(window.substring(11, 16));
|
|
|
+ LocalTime end = LocalTime.parse(window.substring(17, 22));
|
|
|
+ if (window.length() != 22 || window.charAt(10) != ' ' || window.charAt(16) != '-' || !end.isAfter(start)
|
|
|
+ || !date.atTime(start).isAfter(LocalDateTime.now())) {
|
|
|
+ throw invalid("multi.store.checkout.invalid");
|
|
|
+ }
|
|
|
+ return date.atTime(start);
|
|
|
+ } catch (IndexOutOfBoundsException | DateTimeParseException exception) {
|
|
|
+ throw invalid("multi.store.checkout.invalid");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean sameWindow(String first, String other) {
|
|
|
+ return first == null || first.isBlank() ? other == null || other.isBlank() : first.equals(other);
|
|
|
+ }
|
|
|
+
|
|
|
+ private PosFreight currentFreight() {
|
|
|
+ List<PosFreight> rows = freight.getyunjia();
|
|
|
+ if (rows == null || rows.isEmpty()) rows = freight.getmiuyunjia();
|
|
|
+ if (rows == null || rows.isEmpty()) throw invalid("multi.store.checkout.freight.invalid");
|
|
|
+ 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("multi.store.checkout.freight.invalid");
|
|
|
+ }
|
|
|
+ return row;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 对齐普通外卖的道路里程运价跳表;首店合并围栏仍独立按直线距离判断。 */
|
|
|
+ private 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 矩阵返回顺序按参与店归位;部分失败不能生成报价,建批复核仍会重新调用本服务。 */
|
|
|
+ protected List<BigDecimal> routeDistancesMeters(List<PosStore> participants, InfoAddress address) {
|
|
|
+ try {
|
|
|
+ List<GeoPoint> origins = participants.stream()
|
|
|
+ .map(store -> new GeoPoint(store.getLatitude(), store.getLongitude())).toList();
|
|
|
+ List<BigDecimal> distances = routeMatrix.distancesToDestination(origins,
|
|
|
+ new GeoPoint(address.getLatitude(), address.getLongitude()));
|
|
|
+ if (distances == null || distances.size() != participants.size()
|
|
|
+ || distances.stream().anyMatch(value -> value == null || value.signum() <= 0
|
|
|
+ || value.stripTrailingZeros().scale() > 0)) {
|
|
|
+ throw invalid("multi.store.checkout.freight.invalid");
|
|
|
+ }
|
|
|
+ return distances;
|
|
|
+ } catch (ServiceException known) {
|
|
|
+ throw known;
|
|
|
+ } catch (Exception unavailable) {
|
|
|
+ throw invalid("multi.store.checkout.freight.invalid");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String fingerprint(String canonical) {
|
|
|
+ try {
|
|
|
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8));
|
|
|
+ return java.util.HexFormat.of().formatHex(digest);
|
|
|
+ } catch (NoSuchAlgorithmException exception) {
|
|
|
+ throw new IllegalStateException("SHA-256 unavailable", exception);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private ServiceException invalid(String key) {
|
|
|
+ return new ServiceException(MessageUtils.message(key));
|
|
|
+ }
|
|
|
+
|
|
|
+ private record Candidate(MultiStoreQuoteRequest.Store selected, PosStore store,
|
|
|
+ List<String> payTypes) {
|
|
|
+ }
|
|
|
+
|
|
|
+ public record QuoteSnapshot(Long userId, MultiStoreQuoteRequest request, String fingerprint,
|
|
|
+ long expiresAtEpochMillis) {
|
|
|
+ }
|
|
|
+}
|