Przeglądaj źródła

feat(订单): 036 多店结算配置与询价后端(批次服务未完成)

- 平台配置 order_checkout_config:读取/保存(CAS版本)、规则类 MultiStoreCheckoutRules
- 询价接口:栅栏校验、配送费阶梯、报价视图与过期控制
- 领域与 Mapper:OrderCheckoutBatch/Member、OrderDeliveryGroup、config 表
- specs 036 补 contracts/data-model/tasks;SQL 迁移登记(待手动执行,enabled 默认关)
- multi.store.* i18n key ×5 语言文件;新增 5 个测试套件 18 例全绿
- MultiStoreCheckoutBatchServiceTest 暂不提交:实现类 MultiStoreCheckoutBatchService 尚未编写
qmj 1 tydzień temu
rodzic
commit
4f7382d4bf
33 zmienionych plików z 1717 dodań i 5 usunięć
  1. 60 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutConfig.java
  2. 70 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutConfigController.java
  3. 102 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutConfigService.java
  4. 32 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteController.java
  5. 353 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteService.java
  6. 80 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRules.java
  7. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreCheckoutConfigSaveDto.java
  8. 13 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreQuoteRequest.java
  9. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreQuoteView.java
  10. 9 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  11. 9 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  12. 9 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  13. 9 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  14. 9 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  15. 49 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutConfigControllerTest.java
  16. 93 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutConfigServiceTest.java
  17. 54 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutConfigTest.java
  18. 153 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteServiceTest.java
  19. 48 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutRulesTest.java
  20. 19 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/MultiStoreCheckoutConfigRow.java
  21. 33 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/OrderCheckoutBatch.java
  22. 27 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/OrderCheckoutMember.java
  23. 18 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/OrderDeliveryGroup.java
  24. 12 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/MultiStoreCheckoutConfigMapper.java
  25. 30 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/OrderCheckoutMapper.java
  26. 30 0
      ruoyi-system/src/main/resources/mapper/system/MultiStoreCheckoutConfigMapper.xml
  27. 99 0
      ruoyi-system/src/main/resources/mapper/system/OrderCheckoutMapper.xml
  28. 29 0
      specs/036-multi-store-checkout/contracts/api.md
  29. 40 0
      specs/036-multi-store-checkout/data-model.md
  30. 3 3
      specs/036-multi-store-checkout/plan.md
  31. 2 2
      specs/036-multi-store-checkout/spec.md
  32. 41 0
      specs/036-multi-store-checkout/tasks.md
  33. 152 0
      updatesql/sql.md

+ 60 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutConfig.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.system.domain.MultiStoreCheckoutConfigRow;
+
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 新多店结算的专表配置快照。固定参数与 JSON 档位同一行读取,避免报价混用版本。
+ * 配置缺失或主体非法时保守关闭合并,不影响旧单店流程;缓冲时间非法只回退到五分钟。
+ */
+public record MultiStoreCheckoutConfig(boolean enabled, int fenceMeters, int maxStores,
+                                       int confirmationBufferMinutes, Map<Integer, BigDecimal> tiers,
+                                       long version) {
+    private static final int DEFAULT_FENCE_METERS = 1_000;
+    private static final int DEFAULT_MAX_STORES = 5;
+    private static final int DEFAULT_BUFFER_MINUTES = 5;
+    private static final Map<Integer, BigDecimal> DEFAULT_TIERS = Map.of(
+            2, new BigDecimal("0.9"), 3, new BigDecimal("0.8"));
+
+    /** 只解析专用配置表,缺行/非法配置始终关闭新多店合并。 */
+    public static MultiStoreCheckoutConfig fromRow(MultiStoreCheckoutConfigRow row) {
+        if (row == null) return disabled();
+        try {
+            Integer fenceMeters = row.getFenceMeters();
+            Integer maxStores = row.getMaxStores();
+            Integer bufferMinutes = row.getConfirmationBufferMinutes();
+            JSONArray tierRows = JSON.parseArray(row.getTiersJson());
+            if (fenceMeters == null || fenceMeters <= 0 || maxStores == null || maxStores < 2
+                    || row.getVersion() == null || row.getVersion() < 0
+                    || tierRows == null || tierRows.isEmpty()) return disabled();
+            Map<Integer, BigDecimal> tiers = new HashMap<>();
+            for (int index = 0; index < tierRows.size(); index++) {
+                JSONObject tier = tierRows.getJSONObject(index);
+                if (tier == null) return disabled();
+                Integer minimum = tier.getInteger("minimumStores");
+                BigDecimal ratio = tier.getBigDecimal("ratio");
+                if (minimum == null || minimum < 2 || minimum > maxStores || ratio == null
+                        || ratio.signum() <= 0 || ratio.compareTo(BigDecimal.ONE) > 0
+                        || ratio.stripTrailingZeros().scale() > 4
+                        || tiers.putIfAbsent(minimum, ratio) != null) return disabled();
+            }
+            int buffer = bufferMinutes == null || bufferMinutes <= 0
+                    ? DEFAULT_BUFFER_MINUTES : bufferMinutes;
+            return new MultiStoreCheckoutConfig(Integer.valueOf(1).equals(row.getEnabled()),
+                    fenceMeters, maxStores, buffer, Map.copyOf(tiers), row.getVersion());
+        } catch (RuntimeException exception) {
+            return disabled();
+        }
+    }
+
+    private static MultiStoreCheckoutConfig disabled() {
+        return new MultiStoreCheckoutConfig(false, DEFAULT_FENCE_METERS, DEFAULT_MAX_STORES,
+                DEFAULT_BUFFER_MINUTES, DEFAULT_TIERS, 0);
+    }
+}

+ 70 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutConfigController.java

@@ -0,0 +1,70 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreCheckoutConfigSaveDto;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.SecurityUtils;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** 平台管理多店结算专表配置;尚未完整上线的批次流程默认保持关闭。 */
+@RestController
+@RequestMapping("/system/multiStoreCheckout/config")
+public class MultiStoreCheckoutConfigController extends BaseController {
+    private final MultiStoreCheckoutConfigService service;
+
+    public MultiStoreCheckoutConfigController(MultiStoreCheckoutConfigService service) {
+        this.service = service;
+    }
+
+    @PreAuthorize("@ss.hasPermi('order:multiStoreConfig:list')")
+    @GetMapping
+    public AjaxResult getConfig() {
+        return success(service.current());
+    }
+
+    @PreAuthorize("@ss.hasPermi('order:multiStoreConfig:edit')")
+    @Log(title = "多店结算配置", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult saveConfig(@RequestBody MultiStoreCheckoutConfigSaveDto dto) {
+        MultiStoreCheckoutConfig requested = toConfig(dto);
+        try {
+            return success(service.save(requested, SecurityUtils.getUserId()));
+        } catch (IllegalArgumentException exception) {
+            throw new ServiceException(MessageUtils.message("multi.store.config.invalid"));
+        } catch (IllegalStateException exception) {
+            throw new ServiceException(MessageUtils.message("multi.store.config.conflict"));
+        }
+    }
+
+    private MultiStoreCheckoutConfig toConfig(MultiStoreCheckoutConfigSaveDto dto) {
+        if (dto == null || dto.enabled() == null || dto.fenceMeters() == null
+                || dto.maxStores() == null || dto.confirmationBufferMinutes() == null
+                || dto.version() == null || dto.tiers() == null || dto.tiers().isEmpty()) {
+            throw new ServiceException(MessageUtils.message("multi.store.config.invalid"));
+        }
+        Map<Integer, BigDecimal> tiers = new HashMap<>();
+        List<MultiStoreCheckoutConfigSaveDto.Tier> items = dto.tiers();
+        for (MultiStoreCheckoutConfigSaveDto.Tier item : items) {
+            if (item == null || item.minimumStores() == null || item.ratio() == null
+                    || tiers.putIfAbsent(item.minimumStores(), item.ratio()) != null) {
+                throw new ServiceException(MessageUtils.message("multi.store.config.invalid"));
+            }
+        }
+        return new MultiStoreCheckoutConfig(dto.enabled(), dto.fenceMeters(), dto.maxStores(),
+                dto.confirmationBufferMinutes(), Map.copyOf(tiers), dto.version());
+    }
+}

+ 102 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutConfigService.java

@@ -0,0 +1,102 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.system.domain.MultiStoreCheckoutConfigRow;
+import com.ruoyi.system.mapper.MultiStoreCheckoutConfigMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** 036 多店结算配置专表服务;普通订单不读取也不依赖此配置。 */
+@Service
+public class MultiStoreCheckoutConfigService {
+    private static final Logger log = LoggerFactory.getLogger(MultiStoreCheckoutConfigService.class);
+    private final MultiStoreCheckoutConfigMapper mapper;
+
+    public MultiStoreCheckoutConfigService(MultiStoreCheckoutConfigMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    /** 配置表不可读、缺行或值非法时关闭新合并报价,不能放宽成无限制。 */
+    public MultiStoreCheckoutConfig current() {
+        try {
+            return MultiStoreCheckoutConfig.fromRow(mapper.selectCurrent());
+        } catch (RuntimeException exception) {
+            log.warn("Failed to load dedicated multi-store checkout policy, close new merging", exception);
+            return MultiStoreCheckoutConfig.fromRow(null);
+        }
+    }
+
+    /** 同行保存固定参数与动态档位,并以客户端读取到的版本做 CAS,避免管理员互相覆盖。 */
+    @Transactional(rollbackFor = Exception.class)
+    public MultiStoreCheckoutConfig save(MultiStoreCheckoutConfig requested, Long adminId) {
+        validate(requested, adminId);
+        MultiStoreCheckoutConfigRow current;
+        try {
+            current = mapper.selectCurrent();
+        } catch (RuntimeException exception) {
+            // 尚未执行 036 手工迁移时给平台返回可解释的配置冲突,不允许静默创建启用行。
+            throw new IllegalStateException("Multi-store checkout config table is unavailable", exception);
+        }
+        if (current == null || current.getVersion() == null
+                || current.getVersion() != requested.version()) {
+            throw new IllegalStateException("Multi-store checkout config is missing or changed");
+        }
+        MultiStoreCheckoutConfigRow update = new MultiStoreCheckoutConfigRow();
+        update.setId(1);
+        update.setEnabled(requested.enabled() ? 1 : 0);
+        update.setFenceMeters(requested.fenceMeters());
+        update.setMaxStores(requested.maxStores());
+        update.setConfirmationBufferMinutes(requested.confirmationBufferMinutes());
+        update.setTiersJson(encodeTiers(requested.tiers()));
+        update.setVersion(requested.version());
+        update.setUpdatedBy(adminId);
+        int changed;
+        try {
+            changed = mapper.updateIfVersion(update);
+        } catch (RuntimeException exception) {
+            throw new IllegalStateException("Multi-store checkout config table is unavailable", exception);
+        }
+        if (changed != 1) {
+            throw new IllegalStateException("Multi-store checkout config changed during save");
+        }
+        // Mapper 参数仍保留旧版本供 CAS 使用;响应单独返回递增后的版本。
+        return new MultiStoreCheckoutConfig(requested.enabled(), requested.fenceMeters(),
+                requested.maxStores(), requested.confirmationBufferMinutes(),
+                requested.tiers(), requested.version() + 1);
+    }
+
+    private void validate(MultiStoreCheckoutConfig requested, Long adminId) {
+        if (requested == null || adminId == null || requested.version() < 0
+                || requested.fenceMeters() <= 0 || requested.maxStores() < 2
+                || requested.confirmationBufferMinutes() <= 0 || requested.tiers() == null
+                || requested.tiers().isEmpty()) {
+            throw new IllegalArgumentException("Invalid multi-store checkout config");
+        }
+        for (Map.Entry<Integer, BigDecimal> tier : requested.tiers().entrySet()) {
+            Integer minimum = tier.getKey();
+            BigDecimal ratio = tier.getValue();
+            if (minimum == null || minimum < 2 || minimum > requested.maxStores()
+                    || ratio == null || ratio.signum() <= 0 || ratio.compareTo(BigDecimal.ONE) > 0) {
+                throw new IllegalArgumentException("Invalid multi-store discount tier");
+            }
+            // 批次快照的 discount_ratio 为四位小数,配置精度不能高于持久化精度。
+            if (ratio.stripTrailingZeros().scale() > 4) {
+                throw new IllegalArgumentException("Invalid multi-store discount tier");
+            }
+        }
+    }
+
+    private String encodeTiers(Map<Integer, BigDecimal> configured) {
+        List<Map<String, Object>> rows = new ArrayList<>();
+        configured.entrySet().stream().sorted(Map.Entry.comparingByKey())
+                .forEach(entry -> rows.add(Map.of("minimumStores", entry.getKey(), "ratio", entry.getValue())));
+        return JSON.toJSONString(rows);
+    }
+}

+ 32 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteController.java

@@ -0,0 +1,32 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreQuoteRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 用户侧 036 独立报价入口;旧单店创建接口不调用该服务。 */
+@RestController
+@RequestMapping("/system/multiStoreCheckout")
+public class MultiStoreCheckoutQuoteController extends BaseController {
+    private final MultiStoreCheckoutQuoteService quoteService;
+
+    public MultiStoreCheckoutQuoteController(MultiStoreCheckoutQuoteService quoteService) {
+        this.quoteService = quoteService;
+    }
+
+    @PostMapping("/quote")
+    @Auth
+    @Anonymous
+    public AjaxResult quote(@RequestHeader String token, @RequestBody MultiStoreQuoteRequest request) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        return success(quoteService.quote(request, userId));
+    }
+}

+ 353 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteService.java

@@ -0,0 +1,353 @@
+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.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 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;
+
+    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) {
+        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;
+    }
+
+    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;
+            }
+            PromotionCalcResponse foodPrice = priceFood(selected, userId);
+            BigDecimal distanceMeters = BigDecimal.valueOf(MultiStoreCheckoutRules.distanceMeters(
+                    store.getLatitude().doubleValue(), store.getLongitude().doubleValue(),
+                    address.getLatitude().doubleValue(), address.getLongitude().doubleValue()));
+            candidates.add(new Candidate(selected.storeId(), foodPrice.getOriginalAmount(), foodPrice.getFinalAmount(),
+                    originalFreight(fare, distanceMeters), payTypes));
+        }
+        if (candidates.size() < 2 || !candidates.get(0).storeId().equals(Long.valueOf(anchor.getId()))) {
+            throw invalid("multi.store.checkout.invalid");
+        }
+        BigDecimal ratio = MultiStoreCheckoutRules.resolveRatio(policy.tiers(), candidates.size());
+        List<MultiStoreQuoteView.StorePrice> prices = candidates.stream().map(candidate -> {
+            BigDecimal reduced = MultiStoreCheckoutRules.discountedFreight(candidate.originalFreight(), ratio);
+            return new MultiStoreQuoteView.StorePrice(candidate.storeId(), candidate.originalFoodAmount(), candidate.foodAmount(),
+                    candidate.originalFreight(), reduced, candidate.foodAmount().add(reduced), candidate.payTypes());
+        }).toList();
+        List<Long> participants = candidates.stream().map(Candidate::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;
+    }
+
+    private PromotionCalcResponse priceFood(MultiStoreQuoteRequest.Store selected, Long userId) {
+        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);
+            cart.add(cartItem);
+            independentlyPriced = independentlyPriced.add(food.getPrice().add(specPrice)
+                    .multiply(BigDecimal.valueOf(item.quantity())));
+        }
+        request.setItems(cart);
+        PromotionCalcResponse promotion = promotions.calculate(request, userId);
+        if (promotion == null || promotion.getOriginalAmount() == null || promotion.getFinalAmount() == null
+                || promotion.getOriginalAmount().compareTo(independentlyPriced.setScale(0, RoundingMode.HALF_UP)) != 0
+                || promotion.getFinalAmount().signum() < 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(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;
+    }
+
+    /** 对齐旧 getyunfei 的公里取三位、跳表整数单价、半公里门槛及千元舍入规则。 */
+    private BigDecimal originalFreight(PosFreight row, BigDecimal meters) {
+        // 旧直线运费按 6378.137km 地球半径先取整到米;围栏则保留标准球面距离。
+        BigDecimal km = BigDecimal.valueOf(Math.round(meters.doubleValue() * 6378.137D / 6371D))
+                .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 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(Long storeId, BigDecimal originalFoodAmount, BigDecimal foodAmount,
+                             BigDecimal originalFreight,
+                             List<String> payTypes) {
+    }
+
+    public record QuoteSnapshot(Long userId, MultiStoreQuoteRequest request, String fingerprint,
+                                long expiresAtEpochMillis) {
+    }
+}

+ 80 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRules.java

@@ -0,0 +1,80 @@
+package com.ruoyi.app.order;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Map;
+
+/**
+ * 仅供服务端标识的新多店批次使用的纯计价规则;不改变普通订单的运费入口。
+ */
+public final class MultiStoreCheckoutRules {
+    private static final double EARTH_RADIUS_METERS = 6_371_000D;
+
+    private MultiStoreCheckoutRules() {
+    }
+
+    /** 按已参与且去重的门店数,选择不超过该数量的最高门槛,而非最低折扣。 */
+    public static BigDecimal resolveRatio(Map<Integer, BigDecimal> tiers, int storeCount) {
+        if (tiers == null || tiers.isEmpty() || storeCount < 2) {
+            throw new IllegalArgumentException("Invalid multi-store discount tiers or store count");
+        }
+        int matched = -1;
+        BigDecimal ratio = null;
+        for (Map.Entry<Integer, BigDecimal> entry : tiers.entrySet()) {
+            Integer minimum = entry.getKey();
+            BigDecimal value = entry.getValue();
+            if (minimum == null || minimum < 2 || value == null
+                    || value.signum() <= 0 || value.compareTo(BigDecimal.ONE) > 0) {
+                throw new IllegalArgumentException("Invalid multi-store discount tier");
+            }
+            if (minimum <= storeCount && minimum > matched) {
+                matched = minimum;
+                ratio = value;
+            }
+        }
+        if (ratio == null) {
+            throw new IllegalArgumentException("No matching multi-store discount tier");
+        }
+        return ratio;
+    }
+
+    /** 各店分别四舍五入至整数新台币,不能先汇总原运费再统一舍入。 */
+    public static BigDecimal discountedFreight(BigDecimal originalFreight, BigDecimal ratio) {
+        if (originalFreight == null || originalFreight.signum() < 0 || ratio == null
+                || ratio.signum() <= 0 || ratio.compareTo(BigDecimal.ONE) > 0) {
+            throw new IllegalArgumentException("Invalid multi-store freight or discount ratio");
+        }
+        return originalFreight.multiply(ratio).setScale(0, RoundingMode.HALF_UP);
+    }
+
+    /** 围栏固定以用户选择的第一家店为圆心;使用 OSM 坐标的球面直线距离(米)。 */
+    public static boolean withinFence(double anchorLatitude, double anchorLongitude,
+                                      double candidateLatitude, double candidateLongitude,
+                                      int fenceMeters) {
+        if (fenceMeters <= 0 || !validCoordinate(anchorLatitude, anchorLongitude)
+                || !validCoordinate(candidateLatitude, candidateLongitude)) {
+            throw new IllegalArgumentException("Invalid multi-store fence or coordinates");
+        }
+        return distanceMeters(anchorLatitude, anchorLongitude, candidateLatitude, candidateLongitude) <= fenceMeters;
+    }
+
+    /** 与围栏共用直线距离;运费仍按既有按公里计价规则单独计算。 */
+    public static double distanceMeters(double anchorLatitude, double anchorLongitude,
+                                        double candidateLatitude, double candidateLongitude) {
+        if (!validCoordinate(anchorLatitude, anchorLongitude)
+                || !validCoordinate(candidateLatitude, candidateLongitude)) {
+            throw new IllegalArgumentException("Invalid multi-store coordinates");
+        }
+        double latitudeDelta = Math.toRadians(candidateLatitude - anchorLatitude);
+        double longitudeDelta = Math.toRadians(candidateLongitude - anchorLongitude);
+        double arc = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2)
+                + Math.cos(Math.toRadians(anchorLatitude)) * Math.cos(Math.toRadians(candidateLatitude))
+                * Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2);
+        return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(arc), Math.sqrt(1 - arc));
+    }
+
+    private static boolean validCoordinate(double latitude, double longitude) {
+        return Double.isFinite(latitude) && Double.isFinite(longitude)
+                && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
+    }
+}

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreCheckoutConfigSaveDto.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.order.dto;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 平台多店结算配置写入参数;业务合法性由控制器和服务校验。 */
+public record MultiStoreCheckoutConfigSaveDto(Boolean enabled, Integer fenceMeters,
+                                               Integer maxStores, Integer confirmationBufferMinutes,
+                                               Long version, List<Tier> tiers) {
+    public record Tier(Integer minimumStores, BigDecimal ratio) {
+    }
+}

+ 13 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreQuoteRequest.java

@@ -0,0 +1,13 @@
+package com.ruoyi.app.order.dto;
+
+import java.util.List;
+
+/** 新多店报价只接受商品/规格 ID;客户端价格、运费与商家归属均不作为计价依据。 */
+public record MultiStoreQuoteRequest(Long addressId, Boolean sameRider, List<Store> stores) {
+    public record Store(Long storeId, String deliveryWindow, List<Item> items,
+                        Long couponId, String forcePath) {
+    }
+
+    public record Item(Long productId, Integer quantity, List<Long> specValueIds) {
+    }
+}

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreQuoteView.java

@@ -0,0 +1,18 @@
+package com.ruoyi.app.order.dto;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 报价是短期快照,创建批次时仍须服务端重新校验、重算并比对指纹。 */
+public record MultiStoreQuoteView(String quoteToken, long expiresAtEpochMillis, long ruleVersion,
+                                  Long anchorStoreId, int fenceMeters, List<Long> participatingStoreIds,
+                                  List<StorePrice> stores, List<ExcludedStore> excludedStores,
+                                  BigDecimal freightRatio, String fingerprint) {
+    public record StorePrice(Long storeId, BigDecimal originalFoodAmount, BigDecimal foodAmount, BigDecimal originalFreight,
+                             BigDecimal discountedFreight, BigDecimal amount,
+                             List<String> availablePayTypes) {
+    }
+
+    public record ExcludedStore(Long storeId, String reason) {
+    }
+}

+ 9 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -452,3 +452,12 @@ stall.qualification.file.invalid=Ảnh hồ sơ không hợp lệ, vượt giớ
 stall.owner.not.found=Không tìm thấy chủ quầy
 stall.owner.name.blank=Vui lòng nhập tên đăng nhập
 stall.owner.name.exist=Tên đăng nhập đã tồn tại
+multi.store.config.invalid=Cấu hình thanh toán nhiều cửa hàng không hợp lệ
+multi.store.config.conflict=Cấu hình đã thay đổi hoặc chưa được khởi tạo. Vui lòng làm mới hoặc thực hiện bản cập nhật cơ sở dữ liệu 036
+multi.store.checkout.disabled=Thanh toán nhiều cửa hàng chưa được mở
+multi.store.checkout.invalid=Yêu cầu thanh toán nhiều cửa hàng không hợp lệ hoặc quá ít cửa hàng tham gia
+multi.store.checkout.address.invalid=Địa chỉ giao hàng không tồn tại hoặc không thuộc tài khoản của bạn
+multi.store.checkout.store.invalid=Cửa hàng không đủ điều kiện giao hàng nhiều cửa hàng
+multi.store.checkout.food.invalid=Sản phẩm, tùy chọn hoặc ưu đãi đã thay đổi. Vui lòng tính lại
+multi.store.checkout.freight.invalid=Không thể tính phí giao hàng. Vui lòng thử lại sau
+multi.store.checkout.quote.expired=Báo giá đã hết hạn. Vui lòng lấy báo giá mới

+ 9 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -438,3 +438,12 @@ stall.qualification.file.invalid=Invalid qualification image, limit exceeded or
 stall.owner.not.found=Stall owner not found
 stall.owner.name.blank=Username is required
 stall.owner.name.exist=Username already exists
+multi.store.config.invalid=Invalid multi-store checkout configuration
+multi.store.config.conflict=Configuration changed or is not initialized. Refresh or apply database migration 036
+multi.store.checkout.disabled=Multi-store checkout is not available yet
+multi.store.checkout.invalid=Invalid multi-store checkout request or too few participating stores
+multi.store.checkout.address.invalid=Delivery address does not exist or does not belong to you
+multi.store.checkout.store.invalid=Store is not eligible for this multi-store delivery
+multi.store.checkout.food.invalid=Products, options, or promotions have changed. Please refresh checkout
+multi.store.checkout.freight.invalid=Delivery pricing is unavailable. Please retry later
+multi.store.checkout.quote.expired=Quote expired. Please request a new one

+ 9 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -439,3 +439,12 @@ stall.qualification.file.invalid=รูปภาพเอกสารไม่
 stall.owner.not.found=ไม่พบเจ้าของแผง
 stall.owner.name.blank=กรุณาระบุชื่อผู้ใช้
 stall.owner.name.exist=ชื่อผู้ใช้นี้มีอยู่แล้ว
+multi.store.config.invalid=การตั้งค่าการชำระเงินหลายร้านค้าไม่ถูกต้อง
+multi.store.config.conflict=การตั้งค่าเปลี่ยนแปลงหรือยังไม่ได้เริ่มต้น โปรดรีเฟรชหรือใช้การย้ายฐานข้อมูล 036
+multi.store.checkout.disabled=ยังไม่เปิดใช้การชำระเงินหลายร้าน
+multi.store.checkout.invalid=คำขอชำระเงินหลายร้านไม่ถูกต้องหรือมีร้านที่เข้าร่วมน้อยเกินไป
+multi.store.checkout.address.invalid=ไม่พบที่อยู่จัดส่งหรือไม่ได้เป็นของคุณ
+multi.store.checkout.store.invalid=ร้านค้านี้ไม่สามารถเข้าร่วมการจัดส่งหลายร้านได้
+multi.store.checkout.food.invalid=ข้อมูลสินค้า ตัวเลือก หรือโปรโมชันมีการเปลี่ยนแปลง โปรดคำนวณใหม่
+multi.store.checkout.freight.invalid=ไม่สามารถคำนวณค่าจัดส่งได้ โปรดลองอีกครั้ง
+multi.store.checkout.quote.expired=ใบเสนอราคาหมดอายุ โปรดขอใหม่

+ 9 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -440,3 +440,12 @@ stall.qualification.file.invalid=审核图片无效、超出限制或不属于
 stall.owner.not.found=摊位主不存在
 stall.owner.name.blank=用户名不能为空
 stall.owner.name.exist=用户名已存在
+multi.store.config.invalid=多店结算配置参数不合法
+multi.store.config.conflict=配置已变化或尚未初始化,请刷新或先执行 036 数据库迁移
+multi.store.checkout.disabled=多店结算暂未开放
+multi.store.checkout.invalid=多店结算请求无效或参与店铺不足
+multi.store.checkout.address.invalid=收货地址不存在或不属于当前用户
+multi.store.checkout.store.invalid=门店不可参与当前多店配送
+multi.store.checkout.food.invalid=商品、规格或优惠信息已变化,请重新结算
+multi.store.checkout.freight.invalid=当前配送费配置不可用,请稍后重试
+multi.store.checkout.quote.expired=报价已过期,请重新获取

+ 9 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -439,3 +439,12 @@ stall.qualification.file.invalid=審核圖片無效、超過限制或不屬於
 stall.owner.not.found=攤位主不存在
 stall.owner.name.blank=用戶名不能為空
 stall.owner.name.exist=用戶名已存在
+multi.store.config.invalid=多店結算設定參數不合法
+multi.store.config.conflict=設定已變更或尚未初始化,請重新整理或先執行 036 資料庫遷移
+multi.store.checkout.disabled=多店結算暫未開放
+multi.store.checkout.invalid=多店結算請求無效或參與店鋪不足
+multi.store.checkout.address.invalid=收件地址不存在或不屬於目前使用者
+multi.store.checkout.store.invalid=門店無法參與目前多店配送
+multi.store.checkout.food.invalid=商品、規格或優惠資訊已變更,請重新結算
+multi.store.checkout.freight.invalid=目前運費設定無法使用,請稍後重試
+multi.store.checkout.quote.expired=報價已過期,請重新取得

+ 49 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutConfigControllerTest.java

@@ -0,0 +1,49 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreCheckoutConfigSaveDto;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutConfigControllerTest {
+    @Test
+    void missingDedicatedConfigIsReturnedDisabled() {
+        MultiStoreCheckoutConfigService service = mock(MultiStoreCheckoutConfigService.class);
+        when(service.current()).thenReturn(new MultiStoreCheckoutConfig(false, 1000, 5, 5,
+                Map.of(2, new BigDecimal("0.9")), 0));
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            AjaxResult result = new MultiStoreCheckoutConfigController(service).getConfig();
+            assertFalse(((MultiStoreCheckoutConfig) result.get("data")).enabled());
+        }
+    }
+
+    @Test
+    void duplicateOrMissingTierNeverReachesSaveService() {
+        MultiStoreCheckoutConfigService service = mock(MultiStoreCheckoutConfigService.class);
+        MultiStoreCheckoutConfigController controller = new MultiStoreCheckoutConfigController(service);
+        MultiStoreCheckoutConfigSaveDto invalid = new MultiStoreCheckoutConfigSaveDto(
+                true, 1000, 5, 5, 0L,
+                List.of(new MultiStoreCheckoutConfigSaveDto.Tier(2, new BigDecimal("0.9")),
+                        new MultiStoreCheckoutConfigSaveDto.Tier(2, new BigDecimal("0.8"))));
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.config.invalid"))
+                    .thenReturn("invalid config");
+            assertThrows(ServiceException.class, () -> controller.saveConfig(invalid));
+            assertThrows(ServiceException.class, () -> controller.saveConfig(null));
+        }
+    }
+}

+ 93 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutConfigServiceTest.java

@@ -0,0 +1,93 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.MultiStoreCheckoutConfigRow;
+import com.ruoyi.system.mapper.MultiStoreCheckoutConfigMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.math.BigDecimal;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutConfigServiceTest {
+    @Test
+    void missingDedicatedRowClosesMergingAndCannotBeSilentlyEnabled() {
+        MultiStoreCheckoutConfigMapper mapper = mock(MultiStoreCheckoutConfigMapper.class);
+        MultiStoreCheckoutConfigService subject = new MultiStoreCheckoutConfigService(mapper);
+
+        assertFalse(subject.current().enabled());
+        assertThrows(IllegalStateException.class, () -> subject.save(validPolicy(0), 123L));
+        verify(mapper, never()).updateIfVersion(any(MultiStoreCheckoutConfigRow.class));
+    }
+
+    @Test
+    void unreadableDedicatedTableFailsClosedAndReportsSaveConflict() {
+        MultiStoreCheckoutConfigMapper mapper = mock(MultiStoreCheckoutConfigMapper.class);
+        when(mapper.selectCurrent()).thenThrow(new RuntimeException("table missing"));
+        MultiStoreCheckoutConfigService subject = new MultiStoreCheckoutConfigService(mapper);
+
+        assertFalse(subject.current().enabled());
+        assertThrows(IllegalStateException.class, () -> subject.save(validPolicy(0), 123L));
+        verify(mapper, never()).updateIfVersion(any(MultiStoreCheckoutConfigRow.class));
+    }
+
+    @Test
+    void validSaveUsesVersionCasAndPersistsDynamicTiersInDedicatedRow() {
+        MultiStoreCheckoutConfigMapper mapper = mock(MultiStoreCheckoutConfigMapper.class);
+        when(mapper.selectCurrent()).thenReturn(row(7L));
+        when(mapper.updateIfVersion(any(MultiStoreCheckoutConfigRow.class))).thenReturn(1);
+
+        MultiStoreCheckoutConfig saved = new MultiStoreCheckoutConfigService(mapper).save(validPolicy(7), 123L);
+
+        ArgumentCaptor<MultiStoreCheckoutConfigRow> update = ArgumentCaptor.forClass(MultiStoreCheckoutConfigRow.class);
+        verify(mapper).updateIfVersion(update.capture());
+        assertEquals(1, update.getValue().getId());
+        assertEquals(7L, update.getValue().getVersion());
+        assertEquals(123L, update.getValue().getUpdatedBy());
+        assertTrue(update.getValue().getTiersJson().contains("\"minimumStores\":4"));
+        assertEquals(8L, saved.version());
+    }
+
+    @Test
+    void staleVersionAndInvalidPolicyNeverOverwriteExistingConfig() {
+        MultiStoreCheckoutConfigMapper mapper = mock(MultiStoreCheckoutConfigMapper.class);
+        when(mapper.selectCurrent()).thenReturn(row(8L));
+        MultiStoreCheckoutConfigService subject = new MultiStoreCheckoutConfigService(mapper);
+
+        assertThrows(IllegalStateException.class, () -> subject.save(validPolicy(7), 123L));
+        MultiStoreCheckoutConfig invalid = new MultiStoreCheckoutConfig(true, 0, 5, 5,
+                Map.of(2, new BigDecimal("0.9")), 8);
+        assertThrows(IllegalArgumentException.class, () -> subject.save(invalid, 123L));
+        MultiStoreCheckoutConfig tooPrecise = new MultiStoreCheckoutConfig(true, 1000, 5, 5,
+                Map.of(2, new BigDecimal("0.12345")), 8);
+        assertThrows(IllegalArgumentException.class, () -> subject.save(tooPrecise, 123L));
+        verify(mapper, never()).updateIfVersion(any(MultiStoreCheckoutConfigRow.class));
+    }
+
+    private MultiStoreCheckoutConfig validPolicy(long version) {
+        return new MultiStoreCheckoutConfig(true, 1500, 6, 8,
+                Map.of(2, new BigDecimal("0.9"), 3, new BigDecimal("0.8"),
+                        4, new BigDecimal("0.7")), version);
+    }
+
+    private MultiStoreCheckoutConfigRow row(long version) {
+        MultiStoreCheckoutConfigRow row = new MultiStoreCheckoutConfigRow();
+        row.setId(1);
+        row.setEnabled(0);
+        row.setFenceMeters(1000);
+        row.setMaxStores(5);
+        row.setConfirmationBufferMinutes(5);
+        row.setTiersJson("[{\"minimumStores\":2,\"ratio\":0.9},{\"minimumStores\":3,\"ratio\":0.8}]");
+        row.setVersion(version);
+        return row;
+    }
+}

+ 54 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutConfigTest.java

@@ -0,0 +1,54 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.MultiStoreCheckoutConfigRow;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MultiStoreCheckoutConfigTest {
+    private MultiStoreCheckoutConfig stored(Integer enabled, Integer fence, Integer maxStores,
+                                           Integer buffer, String tiers, Long version) {
+        MultiStoreCheckoutConfigRow row = new MultiStoreCheckoutConfigRow();
+        row.setEnabled(enabled);
+        row.setFenceMeters(fence);
+        row.setMaxStores(maxStores);
+        row.setConfirmationBufferMinutes(buffer);
+        row.setTiersJson(tiers);
+        row.setVersion(version);
+        return MultiStoreCheckoutConfig.fromRow(row);
+    }
+
+    @Test
+    void missingRowFailsClosedWithSafeDisplayDefaults() {
+        MultiStoreCheckoutConfig absent = MultiStoreCheckoutConfig.fromRow(null);
+        assertFalse(absent.enabled());
+        assertEquals(1000, absent.fenceMeters());
+        assertEquals(5, absent.maxStores());
+        assertEquals(5, absent.confirmationBufferMinutes());
+    }
+
+    @Test
+    void validDedicatedRowEnablesPolicyAndKeepsVersionForQuoteRevalidation() {
+        MultiStoreCheckoutConfig policy = stored(1, 1200, 5, 8,
+                "[{\"minimumStores\":2,\"ratio\":0.9},{\"minimumStores\":3,\"ratio\":0.8}]", 7L);
+        assertTrue(policy.enabled());
+        assertEquals(1200, policy.fenceMeters());
+        assertEquals(8, policy.confirmationBufferMinutes());
+        assertEquals(7L, policy.version());
+    }
+
+    @Test
+    void invalidFenceOrDuplicateTierClosesMergingButInvalidBufferFallsBackToFive() {
+        String tiers = "[{\"minimumStores\":2,\"ratio\":0.9}]";
+        assertFalse(stored(1, 0, 5, 5, tiers, 1L).enabled());
+        assertFalse(stored(1, 1000, 5, 5,
+                "[{\"minimumStores\":2,\"ratio\":0.9},{\"minimumStores\":2,\"ratio\":0.8}]", 1L).enabled());
+        assertFalse(stored(1, 1000, 5, 5,
+                "[{\"minimumStores\":2,\"ratio\":0.12345}]", 1L).enabled());
+        MultiStoreCheckoutConfig bufferFallback = stored(1, 1000, 5, 0, tiers, 1L);
+        assertTrue(bufferFallback.enabled());
+        assertEquals(5, bufferFallback.confirmationBufferMinutes());
+    }
+}

+ 153 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteServiceTest.java

@@ -0,0 +1,153 @@
+package com.ruoyi.app.order;
+
+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.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.PromotionCalcResponse;
+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 org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.AfterEach;
+import org.mockito.MockedStatic;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutQuoteServiceTest {
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUpMessages() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+    }
+
+    @AfterEach
+    void closeMessages() {
+        messages.close();
+    }
+
+    private final MultiStoreCheckoutConfigService config = mock(MultiStoreCheckoutConfigService.class);
+    private final InfoAddressMapper addresses = mock(InfoAddressMapper.class);
+    private final PosStoreMapper stores = mock(PosStoreMapper.class);
+    private final OperatingHoursMapper hours = mock(OperatingHoursMapper.class);
+    private final PosFoodMapper foods = mock(PosFoodMapper.class);
+    private final FoodSpecRelationMapper relations = mock(FoodSpecRelationMapper.class);
+    private final FoodSpecsMapper specs = mock(FoodSpecsMapper.class);
+    private final FoodSpecsValueMapper values = mock(FoodSpecsValueMapper.class);
+    private final PosFreightMapper freight = mock(PosFreightMapper.class);
+    private final PaymentMethodGateService payments = mock(PaymentMethodGateService.class);
+    private final StallQualificationGateService qualifications = mock(StallQualificationGateService.class);
+    private final SelfDeliveryService selfDelivery = mock(SelfDeliveryService.class);
+    private final IPromotionCalcService promotions = mock(IPromotionCalcService.class);
+    private final RedisCache redis = mock(RedisCache.class);
+
+    private MultiStoreCheckoutQuoteService subject() {
+        return new MultiStoreCheckoutQuoteService(config, addresses, stores, hours, foods,
+                relations, specs, values, freight, payments, qualifications, selfDelivery, promotions, redis);
+    }
+
+    @Test
+    void disabledPolicyCannotIssueQuote() {
+        when(config.current()).thenReturn(new MultiStoreCheckoutConfig(false, 1000, 5, 5,
+                Map.of(2, new BigDecimal("0.9")), 1));
+        assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
+    }
+
+    @Test
+    void quotesOnlyInFenceStoresAndComputesEachDiscountedFreight() {
+        when(config.current()).thenReturn(new MultiStoreCheckoutConfig(true, 1000, 5, 5,
+                Map.of(2, new BigDecimal("0.9")), 1));
+        InfoAddress address = new InfoAddress();
+        address.setId(10L); address.setUserId(42L);
+        address.setLatitude(new BigDecimal("22.6000")); address.setLongitude(new BigDecimal("120.3000"));
+        when(addresses.selectById(10L)).thenReturn(address);
+        when(stores.selectById(1L)).thenReturn(store(1, "22.6010", "120.3000"));
+        when(stores.selectById(2L)).thenReturn(store(2, "22.6030", "120.3000"));
+        when(stores.selectById(3L)).thenReturn(store(3, "22.6300", "120.3000"));
+        OperatingHours open = new OperatingHours();
+        open.setMdId(1L); open.setDayOfWeek(LocalDate.now().getDayOfWeek().getValue());
+        open.setIsOpen(true); open.setStartTime("00:00"); open.setEndTime("23:59");
+        when(hours.selectOperatingHoursList(any(OperatingHours.class))).thenReturn(List.of(open));
+        for (long id : List.of(11L, 22L)) {
+            PosFood food = new PosFood();
+            food.setId(id); food.setMdid(id == 11L ? 1L : 2L);
+            food.setPrice(new BigDecimal("50")); food.setStackingUp("0");
+            when(foods.selectById(id)).thenReturn(food);
+        }
+        when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of());
+        PosFreight fare = new PosFreight();
+        fare.setStartingFare(40L); fare.setStartingDistance(1L); fare.setFreight(10L); fare.setDistance(1L);
+        when(freight.getyunjia()).thenReturn(List.of(fare));
+        when(payments.listAvailable(eq(PaymentMethodGateService.GateScope.MERCHANT), anyLong(), anyLong(), eq(0L)))
+                .thenReturn(List.of(new PaymentMethodGateService.PayMethodOption("COD", "1", true)));
+        PromotionCalcResponse promo = new PromotionCalcResponse();
+        promo.setOriginalAmount(new BigDecimal("50")); promo.setFinalAmount(new BigDecimal("50"));
+        when(promotions.calculate(any(), eq(42L))).thenReturn(promo);
+
+        MultiStoreQuoteView quote = subject().quote(request(), 42L);
+
+        assertEquals(List.of(1L, 2L), quote.participatingStoreIds());
+        assertEquals("OUTSIDE_FENCE", quote.excludedStores().get(0).reason());
+        assertEquals(new BigDecimal("40"), quote.stores().get(0).originalFreight());
+        assertEquals(new BigDecimal("36"), quote.stores().get(0).discountedFreight());
+        assertEquals(new BigDecimal("86"), quote.stores().get(0).amount());
+        assertTrue(quote.quoteToken() != null && !quote.quoteToken().isBlank());
+        verify(redis).setCacheObject(any(String.class), any(String.class), eq(5), eq(TimeUnit.MINUTES));
+    }
+
+    @Test
+    void rejectsForeignAddressBeforeAnyQuote() {
+        when(config.current()).thenReturn(new MultiStoreCheckoutConfig(true, 1000, 5, 5,
+                Map.of(2, new BigDecimal("0.9")), 1));
+        InfoAddress foreign = new InfoAddress(); foreign.setId(10L); foreign.setUserId(99L);
+        when(addresses.selectById(10L)).thenReturn(foreign);
+        assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
+    }
+
+    private MultiStoreQuoteRequest request() {
+        return new MultiStoreQuoteRequest(10L, true, List.of(
+                new MultiStoreQuoteRequest.Store(1L, null, List.of(new MultiStoreQuoteRequest.Item(11L, 1, List.of())), null, null),
+                new MultiStoreQuoteRequest.Store(2L, null, List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), null, null),
+                new MultiStoreQuoteRequest.Store(3L, null, List.of(new MultiStoreQuoteRequest.Item(33L, 1, List.of())), null, null)));
+    }
+
+    private PosStore store(int id, String latitude, String longitude) {
+        PosStore store = new PosStore();
+        store.setId(id); store.setUserId((long) id); store.setState(0L);
+        store.setLatitude(new BigDecimal(latitude)); store.setLongitude(new BigDecimal(longitude));
+        return store;
+    }
+}

+ 48 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutRulesTest.java

@@ -0,0 +1,48 @@
+package com.ruoyi.app.order;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MultiStoreCheckoutRulesTest {
+    @Test
+    void choosesHighestEligibleTierInsteadOfLowestRatioOrListOrder() {
+        Map<Integer, BigDecimal> tiers = Map.of(4, new BigDecimal("0.7"),
+                2, new BigDecimal("0.9"), 3, new BigDecimal("0.8"));
+        assertEquals(new BigDecimal("0.9"), MultiStoreCheckoutRules.resolveRatio(tiers, 2));
+        assertEquals(new BigDecimal("0.8"), MultiStoreCheckoutRules.resolveRatio(tiers, 3));
+        assertEquals(new BigDecimal("0.7"), MultiStoreCheckoutRules.resolveRatio(tiers, 5));
+    }
+
+    @Test
+    void discountsEachStoreFreightAndRoundsHalfUpToWholeTwd() {
+        assertEquals(new BigDecimal("32"), MultiStoreCheckoutRules.discountedFreight(
+                new BigDecimal("40"), new BigDecimal("0.8")));
+        assertEquals(new BigDecimal("33"), MultiStoreCheckoutRules.discountedFreight(
+                new BigDecimal("41"), new BigDecimal("0.8")));
+        assertEquals(new BigDecimal("2"), MultiStoreCheckoutRules.discountedFreight(
+                new BigDecimal("2.5"), new BigDecimal("0.8")));
+    }
+
+    @Test
+    void comparesEveryStoreWithFirstStoreUsingStraightLineMeters() {
+        assertTrue(MultiStoreCheckoutRules.withinFence(22.857387, 108.364897,
+                22.860000, 108.367000, 1000));
+        assertFalse(MultiStoreCheckoutRules.withinFence(22.857387, 108.364897,
+                22.759320, 108.321780, 1000));
+    }
+
+    @Test
+    void rejectsMalformedTierOrFenceInsteadOfOfferingUnlimitedMerging() {
+        assertThrows(IllegalArgumentException.class, () -> MultiStoreCheckoutRules.resolveRatio(
+                Map.of(2, BigDecimal.ZERO), 3));
+        assertThrows(IllegalArgumentException.class, () -> MultiStoreCheckoutRules.withinFence(
+                22.0, 108.0, 22.0, 108.0, 0));
+    }
+}

+ 19 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/MultiStoreCheckoutConfigRow.java

@@ -0,0 +1,19 @@
+package com.ruoyi.system.domain;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 036 独立平台配置表的唯一一行;固定 id=1,版本用于 CAS 保存。 */
+@Data
+public class MultiStoreCheckoutConfigRow {
+    private Integer id;
+    private Integer enabled;
+    private Integer fenceMeters;
+    private Integer maxStores;
+    private Integer confirmationBufferMinutes;
+    private String tiersJson;
+    private Long version;
+    private Long updatedBy;
+    private Date updateTime;
+}

+ 33 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/OrderCheckoutBatch.java

@@ -0,0 +1,33 @@
+package com.ruoyi.system.domain;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/** 036 专用批次状态,不复用旧单 payStatus / isDisplay 推导。 */
+@Data
+public class OrderCheckoutBatch {
+    private Long id;
+    private String parentDdId;
+    private Long userId;
+    private String requestKey;
+    private String requestHash;
+    private String checkoutStatus;
+    private Long anchorStoreId;
+    private Long addressId;
+    private Integer sameRider;
+    private Integer fenceMeters;
+    private Integer maxStoreCount;
+    private String ruleVersion;
+    private Integer matchedMinStores;
+    private BigDecimal discountRatio;
+    private Integer totalAmount;
+    private Integer confirmationBufferMinutes;
+    private LocalDateTime operationDeadlineAt;
+    private LocalDateTime finalDeadlineAt;
+    private String cancelledReason;
+    private Long version;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 27 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/OrderCheckoutMember.java

@@ -0,0 +1,27 @@
+package com.ruoyi.system.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/** 每个商家的子单快照与支付事实独立保存。 */
+@Data
+public class OrderCheckoutMember {
+    private Long id;
+    private Long batchId;
+    private Long posOrderId;
+    private Long storeId;
+    private Integer sortNo;
+    private String paymentState;
+    private String payType;
+    private LocalDateTime readyAt;
+    private Integer paidAmount;
+    private Integer originalFreight;
+    private Integer discountedFreight;
+    private Integer foodAmount;
+    private Integer amount;
+    private Long couponId;
+    private Long version;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 18 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/OrderDeliveryGroup.java

@@ -0,0 +1,18 @@
+package com.ruoyi.system.domain;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/** 同骑手配送组;抢单按组,实际取餐/送达仍逐子单。 */
+@Data
+public class OrderDeliveryGroup {
+    private Long id;
+    private Long batchId;
+    private Long riderId;
+    private String status;
+    private Long version;
+    private LocalDateTime acceptedAt;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 12 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/MultiStoreCheckoutConfigMapper.java

@@ -0,0 +1,12 @@
+package com.ruoyi.system.mapper;
+
+import com.ruoyi.system.domain.MultiStoreCheckoutConfigRow;
+import org.apache.ibatis.annotations.Mapper;
+
+/** 036 独立平台配置;CAS 更新防止两个管理员互相覆盖。 */
+@Mapper
+public interface MultiStoreCheckoutConfigMapper {
+    MultiStoreCheckoutConfigRow selectCurrent();
+
+    int updateIfVersion(MultiStoreCheckoutConfigRow row);
+}

+ 30 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/OrderCheckoutMapper.java

@@ -0,0 +1,30 @@
+package com.ruoyi.system.mapper;
+
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/** 036 批次专表 Mapper;普通订单查询无需知道这些表。 */
+public interface OrderCheckoutMapper {
+    OrderCheckoutBatch selectByUserAndRequestKey(@Param("userId") Long userId,
+                                                 @Param("requestKey") String requestKey);
+
+    OrderCheckoutBatch selectBatchById(@Param("id") Long id);
+
+    int insertOrGetBatch(OrderCheckoutBatch batch);
+
+    int insertMember(OrderCheckoutMember member);
+
+    int insertGroup(OrderDeliveryGroup group);
+
+    List<OrderCheckoutMember> selectMembersByBatchId(@Param("batchId") Long batchId);
+
+    /** NULL 为普通订单;旧单始终按旧入口处理。 */
+    String selectStatusByOrderId(@Param("orderId") Long orderId);
+
+    /** NULL 为非配送组子单;旧单单笔抢单不可拆新组。 */
+    Long selectGroupIdByOrderId(@Param("orderId") Long orderId);
+}

+ 30 - 0
ruoyi-system/src/main/resources/mapper/system/MultiStoreCheckoutConfigMapper.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.MultiStoreCheckoutConfigMapper">
+    <resultMap id="ConfigRow" type="com.ruoyi.system.domain.MultiStoreCheckoutConfigRow">
+        <id property="id" column="id"/>
+        <result property="enabled" column="enabled"/>
+        <result property="fenceMeters" column="fence_meters"/>
+        <result property="maxStores" column="max_stores"/>
+        <result property="confirmationBufferMinutes" column="confirmation_buffer_minutes"/>
+        <result property="tiersJson" column="tiers_json"/>
+        <result property="version" column="version"/>
+        <result property="updatedBy" column="updated_by"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <select id="selectCurrent" resultMap="ConfigRow">
+        SELECT id, enabled, fence_meters, max_stores, confirmation_buffer_minutes,
+               tiers_json, version, updated_by, update_time
+        FROM order_checkout_config WHERE id = 1
+    </select>
+
+    <update id="updateIfVersion" parameterType="com.ruoyi.system.domain.MultiStoreCheckoutConfigRow">
+        UPDATE order_checkout_config
+        SET enabled = #{enabled}, fence_meters = #{fenceMeters}, max_stores = #{maxStores},
+            confirmation_buffer_minutes = #{confirmationBufferMinutes}, tiers_json = #{tiersJson},
+            version = version + 1, updated_by = #{updatedBy}, update_time = NOW(3)
+        WHERE id = 1 AND version = #{version}
+    </update>
+</mapper>

+ 99 - 0
ruoyi-system/src/main/resources/mapper/system/OrderCheckoutMapper.xml

@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.OrderCheckoutMapper">
+    <resultMap id="batch" type="com.ruoyi.system.domain.OrderCheckoutBatch">
+        <id property="id" column="id"/>
+        <result property="parentDdId" column="parent_dd_id"/>
+        <result property="userId" column="user_id"/>
+        <result property="requestKey" column="request_key"/>
+        <result property="requestHash" column="request_hash"/>
+        <result property="checkoutStatus" column="checkout_status"/>
+        <result property="anchorStoreId" column="anchor_store_id"/>
+        <result property="addressId" column="address_id"/>
+        <result property="sameRider" column="same_rider"/>
+        <result property="fenceMeters" column="fence_meters"/>
+        <result property="maxStoreCount" column="max_store_count"/>
+        <result property="ruleVersion" column="rule_version"/>
+        <result property="matchedMinStores" column="matched_min_stores"/>
+        <result property="discountRatio" column="discount_ratio"/>
+        <result property="totalAmount" column="total_amount"/>
+        <result property="confirmationBufferMinutes" column="confirmation_buffer_minutes"/>
+        <result property="operationDeadlineAt" column="operation_deadline_at"/>
+        <result property="finalDeadlineAt" column="final_deadline_at"/>
+        <result property="cancelledReason" column="cancelled_reason"/>
+        <result property="version" column="version"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <resultMap id="member" type="com.ruoyi.system.domain.OrderCheckoutMember">
+        <id property="id" column="id"/>
+        <result property="batchId" column="batch_id"/>
+        <result property="posOrderId" column="pos_order_id"/>
+        <result property="storeId" column="store_id"/>
+        <result property="sortNo" column="sort_no"/>
+        <result property="paymentState" column="payment_state"/>
+        <result property="payType" column="pay_type"/>
+        <result property="readyAt" column="ready_at"/>
+        <result property="paidAmount" column="paid_amount"/>
+        <result property="originalFreight" column="original_freight"/>
+        <result property="discountedFreight" column="discounted_freight"/>
+        <result property="foodAmount" column="food_amount"/>
+        <result property="amount" column="amount"/>
+        <result property="couponId" column="coupon_id"/>
+        <result property="version" column="version"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <select id="selectByUserAndRequestKey" resultMap="batch">
+        SELECT * FROM order_checkout_batch WHERE user_id=#{userId} AND request_key=#{requestKey}
+    </select>
+
+    <select id="selectBatchById" resultMap="batch">
+        SELECT * FROM order_checkout_batch WHERE id=#{id}
+    </select>
+
+    <insert id="insertOrGetBatch" parameterType="com.ruoyi.system.domain.OrderCheckoutBatch"
+            useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO order_checkout_batch
+        (parent_dd_id,user_id,request_key,request_hash,checkout_status,anchor_store_id,address_id,
+         same_rider,fence_meters,max_store_count,rule_version,matched_min_stores,discount_ratio,
+         total_amount,confirmation_buffer_minutes,operation_deadline_at,final_deadline_at,version,create_time,update_time)
+        VALUES
+        (#{parentDdId},#{userId},#{requestKey},#{requestHash},#{checkoutStatus},#{anchorStoreId},#{addressId},
+         #{sameRider},#{fenceMeters},#{maxStoreCount},#{ruleVersion},#{matchedMinStores},#{discountRatio},
+         #{totalAmount},#{confirmationBufferMinutes},#{operationDeadlineAt},#{finalDeadlineAt},0,#{createTime},#{updateTime})
+        ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
+    </insert>
+
+    <insert id="insertMember" parameterType="com.ruoyi.system.domain.OrderCheckoutMember"
+            useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO order_checkout_member
+        (batch_id,pos_order_id,store_id,sort_no,payment_state,pay_type,paid_amount,original_freight,
+         discounted_freight,food_amount,amount,coupon_id,version,create_time,update_time)
+        VALUES
+        (#{batchId},#{posOrderId},#{storeId},#{sortNo},#{paymentState},#{payType},#{paidAmount},#{originalFreight},
+         #{discountedFreight},#{foodAmount},#{amount},#{couponId},0,#{createTime},#{updateTime})
+    </insert>
+
+    <insert id="insertGroup" parameterType="com.ruoyi.system.domain.OrderDeliveryGroup"
+            useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO order_delivery_group (batch_id,status,version,create_time,update_time)
+        VALUES (#{batchId},#{status},0,#{createTime},#{updateTime})
+    </insert>
+
+    <select id="selectMembersByBatchId" resultMap="member">
+        SELECT * FROM order_checkout_member WHERE batch_id=#{batchId} ORDER BY sort_no
+    </select>
+
+    <select id="selectStatusByOrderId" resultType="string">
+        SELECT b.checkout_status FROM order_checkout_member m
+        JOIN order_checkout_batch b ON b.id=m.batch_id WHERE m.pos_order_id=#{orderId}
+    </select>
+
+    <select id="selectGroupIdByOrderId" resultType="long">
+        SELECT g.id FROM order_checkout_member m
+        JOIN order_delivery_group g ON g.batch_id=m.batch_id WHERE m.pos_order_id=#{orderId}
+    </select>
+</mapper>

+ 29 - 0
specs/036-multi-store-checkout/contracts/api.md

@@ -0,0 +1,29 @@
+# 多店结算接口契约(实施版)
+
+路径均为 `/system/multiStoreCheckout` 前缀;用户接口使用 `@RequestHeader String token`,POST 使用明确 DTO 与 `@RequestBody`,GET 参数显式声明。App 仅据此接入,不修改 App 源码。错误响应使用现有 `AjaxResult` 及项目 i18n 消息,不返回硬编码单语言字符串。
+
+## 用户流程
+
+| 方法与路径 | 请求关键字段 | 成功响应 | 拒绝条件 |
+|---|---|---|---|
+| `POST /quote` | 地址 ID、顺序候选店与商品/规格/数量、同骑手、各店即时/预约时段 | `quoteToken`、有效至、锚点/围栏、参与店及逐店原运费/折后运费/商品价/实付/可选支付方式、圈外店和不兼容原因、规则版本 | 店/商品/配送资格不合格、支付方式仅转账、报价配置非法;圈外店仅报告不创建 |
+| `POST /batches` | `requestKey`、`quoteToken`、用户明确确认的参与店 ID 列表 | 父批次号、子单号/金额/可选方式、`operationDeadlineAt`、`finalDeadlineAt`、`PENDING` | 报价过期、成员/地址/价格/规则变化、夹带圈外店、少于 2 店、权限不符;同用户同 requestKey 返回同批或拒绝载荷冲突 |
+| `GET /batches/{batchId}` | token + 路径批次 ID | 批次/逐店支付选择、真实实收、到付确认、退款与履约状态、截止时间、关联订单 | 非本人批次拒绝,不暴露其他用户父/子/组 |
+| `POST /batches/{batchId}/members/{orderId}/payment` | `payType` = 1/2/3/5 | 到付确认结果或线上支付凭证/跳转信息与原尝试号 | 截止后新发起、未决尝试换方式、商家不可用、金额/归属不符;6 转账始终不支持 |
+| `POST /batches/{batchId}/cancel` | 可选原因 | 批次 `CANCELLED` 及每店退款真实进度 | 已 `ACTIVE` 须走子单取消规则;退款处理中不伪称退款成功 |
+| `POST /batches/{batchId}/members/{orderId}/cancel` | 原因 | 该子单取消和真实退款进度、其他成员保持快照 | 未生效不允许单店取消;骑手已接/取餐拒绝用户取消;并发接组只能一方成功 |
+
+回调/补单仍使用现有 LINE/OMG 入口,服务端以原订单/尝试号识别新批次成员:先验签与核实实收、落支付事实,再在事务内聚合批次;不得直接发履约新单通知。到付只表示用户确认按现有流程交餐付款,不伪称线上实收。用户列表应展示未选方式、部分付款、缓冲核实、待批次生效、退款中并提供继续结算入口;普通旧单字段语义不变。
+
+## 骑手/平台
+
+- 骑手待抢列表对同骑手配送组只返回一个任务,显示各店及总计;已生效、到预约开放时点才可见。`POST /system/multiStoreCheckout/groups/{groupId}/accept` 原子承接全部有效成员;旧单笔接单 API 对组成员拒绝拆抢。骑手仍逐子单取餐/送达。
+- 平台配置 `GET/PUT /system/multiStoreCheckout/config`:开关、围栏米、最多店数、缓冲分钟、可增删改折扣阶梯;保存会校验并更新规则版本,仅影响新报价。平台退款跟进列表显示各商家渠道、实收、退款意图/结果/待人工处理,不提供“手工标记渠道已退款”。
+- 平台配置只读写独立 `order_checkout_config`,不占用 `sys_config`。GET 返回 `enabled/fenceMeters/maxStores/confirmationBufferMinutes/version/tiers`,其中 `tiers` 为门槛数字键到系数的对象;PUT 将 `tiers` 作为 `{minimumStores,ratio}` 数组传入,并回传新版本。缺表或缺行时读取保守关闭,保存拒绝并提示先执行 036 迁移;客户端旧 `version` 不能覆盖新配置。
+- 新任务通知出站与预约旧时点扫描按持久记录幂等重试;组只推一个待抢任务,独立成员各推一个。用户付款通知与订单取餐/送达通知继续独立,不在未全额就绪时发骑手/商家履约新单。
+
+## 时间和兼容示例
+
+14:00 创建:14:15 前可发起线上/确认到付;14:14 已实际发起的线上尝试可在 14:20 前完成付款及可信确认;14:16 不可新发起或改到付;14:20 仍未全部就绪则关闭,迟到实收只退款不激活。截止在创建时快照,不因配置变更或重试延长。
+
+普通单仍调用现有 `/createOrder`、LINE/OMG/到付入口并保持原校验与状态;新接口不得仅凭客户端传入的批次号放宽原入口。预约订单不改旧时间语义或添加无人接单自动取消。

+ 40 - 0
specs/036-multi-store-checkout/data-model.md

@@ -0,0 +1,40 @@
+# 多店结算数据模型(实施版)
+
+日期:2026-10-13。仅适用于服务端新建的多店批次;未关联批次的普通及历史订单保持原语义。金额统一以整数 TWD 存业务快照,渠道需要最小货币单位时只在网关适配层转换,不回写订单金额。
+
+## 1. 持久化实体
+
+| 实体 | 唯一约束/核心字段 | 职责 |
+|---|---|---|
+| `order_checkout_config` | 固定 `id=1`;`enabled`、`fence_meters`、`max_stores`、`confirmation_buffer_minutes`、`tiers_json`、`version` | 平台全局配置专表,固定项用独立列,动态折扣阶梯用 JSON;版本用于报价指纹和后台乐观锁,不接 `sys_config`。 |
+| `order_checkout_batch` | `id`;`parent_dd_id` 唯一;`(user_id,request_key)` 唯一;专用 `checkout_status`、`version`、`create_time`、`operation_deadline_at`、`final_deadline_at`、`confirmation_buffer_minutes` | 服务端批次标识、幂等创建、15 分钟发起截止和创建时缓冲快照;专用状态只表示统一履约资格,不代表退款到账。 |
+| `order_checkout_batch` 规则列 | `anchor_store_id`、`address_id`、`same_rider`、`fence_meters`、`max_store_count`、`rule_version`、`matched_min_stores`、`discount_ratio`、`total_amount` | 创建时冻结本次真实参与集合与计价规则;配置变化不追改。 |
+| `order_checkout_member` | `id`;`pos_order_id` 唯一;`(batch_id,store_id)` 唯一;`sort_no`、`payment_state`、`pay_type`、`ready_at`、`original_freight`、`discounted_freight`、`food_amount`、`amount`、`coupon_id` | 每店子单金额和付款/到付确认事实。`pay_type` 允许未选择;真实实收只来自渠道记录,不能把到付确认当作到账。 |
+| `order_checkout_payment_attempt` | `id`;`(channel,merchant_order_no)` 唯一;`member_id`、`pay_type`、`state`、`requested_at`、`dispatched_at`、`gateway_transaction_id`、`gateway_paid_at`、`verified_at` | 保存第 15 分钟前服务端发起的证据及其后缓冲内可信结果。请求预留不是实际发起;回调、查询及重试按原交易号对账。 |
+| `order_delivery_group` | `id`;`batch_id` 唯一;`rider_id`、`status`、`version` | 仅同骑手批次创建;组是一个抢单任务,不是合并收款或强制同步取送状态。 |
+| `order_checkout_outbox` | `id`;`(event_type,aggregate_id)` 唯一;`status`、`next_retry_at`、`attempts` | 生效/预约开放后的新任务推送,组按组、非组按子单去重;提交后发送、失败可重试、取消后不可再开放。 |
+| `order_checkout_refund_intent` | `id`;`(member_id,reason)` 唯一;`channel`、`amount`、`merchant_order_no`、`gateway_transaction_id`、`status`、`next_retry_at` | 跟踪 LINE/OMG 原路全额退款、未知结果和人工处理;意图创建不等于到账。到付未收不建网关退款意图。 |
+
+不在 `pos_order` 增加多店标志:以 `order_checkout_member.pos_order_id` 的唯一关联作为不可伪造服务端标识;老订单无关联即沿旧流程。容量统计与组列表通过成员表关联 `order_delivery_group`,避免为纯读取再加一列。父单 `order_parent` 沿用现有结构和子单号关联,其 `orderStatus` 不复用为批次生效或实收状态。
+
+## 2. 状态和不变量
+
+- 批次:`PENDING → ACTIVE` 或 `PENDING → CANCELLED`;`CANCELLED` 永不重开。到最终确认截止仍未全部合法就绪时关批次,截止后收到真实付款也只进入退款/对账,不转 `ACTIVE`。
+- 成员:`UNSELECTED → ONLINE_PENDING → ONLINE_PAID`,或 `UNSELECTED → COD_CONFIRMED`;渠道失败/未知不得标记就绪。未决在线尝试不能改选到付。`ONLINE_PAID` 与 `COD_CONFIRMED` 均计就绪,但仅前者计已实收。
+- 发起线上尝试、重试、换方式、到付确认均要求 `now < operation_deadline_at`;已有实际发起且可核实的在线尝试允许在 `now <= final_deadline_at` 完成。缓冲内不新建尝试。
+- 同一父批次读写必须以批次行锁或状态版本 CAS 保护;激活/取消必须在同一事务内重新读取全部成员事实。外部网关 HTTP 不持有数据库事务锁。
+- 组接单:骑手锁 → 组行锁 → 成员子单按 ID 加锁;资格、现有任务容量、组门店上限和全部成员可接状态在锁内校验;全部分配或全部失败。单笔接单入口发现组成员必须拒绝拆抢。
+- 子单创建时写当前业务字段快照,但 `isDisplay` 不等于批次准入;骑手列表、直接接单、商家操作及新单通知均校验批次已 `ACTIVE` 和预约旧时点已开放。普通订单不套批次期限。
+- 组任务只有全部有效成员都履约结束或合法取消后释放。独立取餐、独立送达、独立结算/通知按子单进行,部分取消不重算余单折扣。
+
+## 3. 平台配置和金额
+
+独立 `order_checkout_config` 单行保存全局开关、围栏距离米(默认 1000)、最多店数(默认 5)、缓冲分钟(默认 5)及 JSON 动态折扣阶梯(初始 `{2:0.9,3:0.8}`),配置保存时校验:围栏/最多店数/缓冲为正整数,门槛为不小于 2 的唯一整数,系数 `0 < ratio <= 1` 且最多四位小数,与批次快照 `DECIMAL(7,4)` 一致。开关/围栏/门店上限/阶梯缺失或非法时保守关闭新合并报价;缓冲异常回退 5 分钟,不变为无限。配置版本在同一行 CAS 增加并纳入报价指纹;创建时重算与报价版本不一致则要求重报。配置表缺行时读取显示关闭态、保存时提示执行 036 手动迁移,不能偷偷初始化为启用。
+
+首店门店坐标为圆心、对每个后续门店作球面直线距离,米精度比较 `distance <= fence_meters`;不以道路/用户地址距离代替。圈外店不进入本批次父/子单或金额;创建成员集合必须与确认报价一致,否则拒绝。
+
+每店原运费按现有有效规则权威计算;同骑手批次取最高满足门槛档位,对每店分别执行 `original_freight × ratio` 后 `HALF_UP` 到整数 TWD。子单 `amount = food_amount - valid_promotion_and_coupon_reduction + discounted_freight`,父单总额为子单实付之和。用户到付代收该子单 `amount`,给商家 `amount - discounted_freight`,商家给骑手折后运费;不存在跨商家补差。取消余单金额不重算。
+
+## 4. 迁移与兼容
+
+DDL 只登记到 `updatesql/sql.md`,不自动执行。迁移前不得开启新入口。已有父/子单不回填批次关联;老单店转账、自取、夜市自配送、已有 LINE/OMG/到付均沿旧流程。普通平台配送预约只补旧规则时点扫描,不套批次付款截止。

+ 3 - 3
specs/036-multi-store-checkout/plan.md

@@ -1,10 +1,10 @@
 # 同一用户多店结算与同骑手配送方案草案
 
-**Branch**: 当前 `test-202609v2`,不创建/切换分支。
+**Branch**: 当前 `codex/036-multi-store-checkout`,从 `test-202609v2` 创建;平台管理 PC 端仓库使用同名分支。
 
 **Date**: 2026-10-12 | **Spec**: [spec.md](spec.md)
 
-**阶段**: specify 与方案讨论;业务实现未开始。这是可持续修改的第一版方案,不是已批准的执行计划。
+**阶段**: 用户已要求按 spec-kit 继续实施;已补 data-model、contracts 和 tasks。业务实现中,未完成验收。
 
 ## 1 核心建议
 
@@ -146,7 +146,7 @@
 
 ### 动态配送费折扣阶梯(用户已确认)
 
-平台维护可增删改的“最少店数+折扣”列表,无需为后续4店七折等档位改代码或重新部署,不使用固定两档字段。具体配置存储形式在data-model阶段沿现有配置能力确定,本轮不预先创建新表或DDL。
+平台维护可增删改的“最少店数+折扣”列表,无需为后续4店七折等档位改代码或重新部署,不使用固定两档字段。2026-10-13用户明确要求不接 `sys_config`,改建 `order_checkout_config` 专表;固定参数列化,动态阶梯保存在 `tiers_json`,版本号用于报价重验和平台 CAS 保存,详见data-model与036 DDL。
 
 - 报价按实际参与同骑手配送组的去重门店数N匹配,圈外店不计;选择所有门槛不超过N的档位中门槛最大的档位,不按配置行顺序或直接选最低折扣。
 - 首版初始折扣已确认,只配置两个档位:最少2家九折(系数0.9)、最少3家八折(系数0.8);没有更高档时3/4/5家均八折。4家七折不默认启用。后续平台若新增4家七折,则新报价中4/5家七折;再增5家六折,则5家命中六折。新增示例不代表上线默认值,不取消最大合并店数/骑手容量约束。

+ 2 - 2
specs/036-multi-store-checkout/spec.md

@@ -2,7 +2,7 @@
 
 **Created**: 2026-10-12
 
-**Feature Branch**: 不创建新分支,沿用当前 `test-202609v2`;本轮不切换分支。
+**Feature Branch**: `codex/036-multi-store-checkout`,从 `test-202609v2` 创建;平台管理 PC 端仓库使用同名分支。
 
 **Status**: Draft,供用户逐轮讨论;业务代码未开始。
 
@@ -38,7 +38,7 @@
 - 本业务不设计“无法供餐、尚未取餐但骑手已垫款”的常规场景,不新增取餐前垫款状态/返款入口。到付款项当面在交餐环节交付,已取餐不走未取餐无法供餐取消路径;用户已接禁取消规则不变。
 - 可使用现有或新增字段控制开放;具体建议见 plan.md,尚未把字段选择当作用户已确认的决定。
 - OMG 正式退款纳入本功能。2026-10-13用户再次确认按现有测试环境无法执行真实退款的边界处理:测试环境验证取消、退款意图及模拟流程,不要求完成真实渠道退款联调;不能据此省略正式退款实现,也不能在测试环境调用正式端点或把模拟结果标为真实退款成功。正式退款未联调的验证限制单独记录,不阻断方案确认和本地/测试环境流程验证。
-- 本轮只编写规格和方案,不修改业务代码、不执行 SQL、不创建分支、不提交/推送。
+- 截至方案讨论阶段仅编写规格和方案,未修改业务代码或执行 SQL;现已按用户最新决定创建实现分支,尚未提交或推送实现内容。
 - 不修改用户/骑手/商家 App 源码,提供前端接入文档;平台配置与必要 PC 页面属于后续拟议实现范围。
 
 原需求文档提出、需要落实并讨论边界:

+ 41 - 0
specs/036-multi-store-checkout/tasks.md

@@ -0,0 +1,41 @@
+# Tasks:同一用户多店结算与同骑手配送
+
+**依据**:[spec.md](spec.md)、[plan.md](plan.md)、[data-model.md](data-model.md)、[contracts/api.md](contracts/api.md)。分支:`codex/036-multi-store-checkout`(后端与平台 PC)。每个生产行为先写可失败的定向测试;业务代码注释说明批次和旧单兼容边界;SQL 只登记不执行。
+
+## Phase 1:基础与计价(US1)
+
+- [x] T001 在 `ruoyi-admin/src/test/java/com/ruoyi/app/order/` 增加配置、首店围栏、阶梯命中、逐店折后运费舍入及非法配置测试,并确认先失败。
+- [x] T002 实现新多店全局配置读取/校验/保存及 PC 配置页、四语言文案;默认围栏 1000 米、最多 5 店、缓冲 5 分钟、2 店九折/3 店八折;普通骑手字典不增加第二套限制。配置改为专表 `order_checkout_config`,不接 `sys_config`。
+- [ ] T003 对真实店铺、商品/规格/价格、促销/优惠券、地址及现有运费进行服务端预结算;输出圈外店原因、本批参与集合、可选支付方式和报价指纹,不信任客户端金额。
+- [x] T004 在 `updatesql/sql.md` 登记 036 配置、批次、成员、支付尝试、配送组、通知与退款意图表及索引;不执行迁移。
+- [ ] T005 新批次幂等创建:重算报价/成员、原子写父子单与快照,写 15 分钟发起截止和缓冲快照,不创建圈外店订单、不默认到付就绪、不通知履约;普通 `/createOrder` 不走新规则。
+
+## Phase 2:逐店收银与统一生效(US2)
+
+- [ ] T006 测试逐店支付选择、方式过滤、未决在线尝试、15 分钟与缓冲边界、真实收款和到付确认的差别、取消与回调竞争。
+- [ ] T007 接入 LINE/OMG 既有支付入口,只对合法 036 成员放宽旧多店拒绝;记录实际发起证据、原交易、商家凭证与金额,非批次订单保留原校验。
+- [ ] T008 到付确认与 LINE/OMG 回调/查询/补单统一记成员事实,全部合法就绪才 CAS 激活;失败/未知和客户端回跳不能激活。
+- [ ] T009 新批次超时/迟到支付处理及重启可恢复扫描;最终截止未就绪整批关闭,迟到实收建退款意图不重开。
+- [ ] T010 持久幂等履约通知/失败重试,预约按旧规则时点开放;补普通平台配送预约的旧时点扫描,夜市自配送/自取及旧单期限不受影响。
+
+## Phase 3:取消、退款与券(US3)
+
+- [ ] T011 测试未生效整批取消、已生效未接单店取消、已接用户拒绝、商家已接未取不能出餐取消;线上真实实收全退、到付未收不退款,余单不补差。
+- [ ] T012 LINE 原渠道全额退款与新批次意图关联;OMG 正式退款执行/查询/重试/结果未知/人工处理,测试环境硬拒绝正式 URL,测试仅验证意图不宣称到账。
+- [ ] T013 新批次取消成员的优惠券条件返还:仅原子单绑定、原有效期、不复活已过期券、重复取消幂等,不影响旧单券流程。
+
+## Phase 4:同骑手配送(US4)
+
+- [ ] T014 测试按组计 1 个外卖任务、跨闪送总任务、加急独占、字典非法回退 1;接组/取消/另一个骑手竞争及组内混合进度释放。
+- [ ] T015 待抢任务组去重、旧单笔接单拒绝拆组、新接组 API 在同一骑手锁与事务中统一校验并分配全部成员;组门店上限实时重验。
+- [ ] T016 骑手逐子单取餐/送达、商家逐单出餐和到付现金/账单继续沿原有效入口;不增加全组取齐、整组送达/结算。
+
+## Phase 5:查询、PC 与交付(US5)
+
+- [ ] T017 测试批次/子单/组归属及越权、用户列表未选/部分付款/Apple Pay/缓冲/退款状态,旧单分类不变。
+- [ ] T018 批次详情/进度/继续结算接口、平台退款跟进 PC 页四语言;配置页已在 T002 完成;App 只写 `frontend-app-integration.md`。
+- [ ] T019 定向测试、受影响模块构建及回归普通 LINE/OMG/到付/转账、自取、夜市自配送、预约、促销券/发票;检查 SQL、编码、换行和差异范围,记录正式 OMG 退款未联调限制。
+
+## 依赖与交付闸门
+
+T001→T002→T003→T005;T004 在 T005 前。T005→T006–T010→T011–T013→T014–T016→T017–T019。未完成持久状态、支付事实、取消/退款和接组防护前不得把配置开关视为可上线;测试数据库迁移由开发者手动执行。最终状态仅按真实验证报告,不把文档或局部代码称作整功能完成。

+ 152 - 0
updatesql/sql.md

@@ -2034,3 +2034,155 @@ WHERE NOT EXISTS(SELECT 1 FROM sys_menu WHERE perms='infouser:riderLinePay:toggl
 -- 旧营销系统(vip_quanyi/sales_promotion 系)已被 promotion_* 新系统替代(spec 008),
 -- 平台管理端三个旧页面不再使用,隐藏菜单入口;代码保留备查,路由仍可直链访问。
 UPDATE sys_menu SET visible = '1' WHERE menu_id IN (2003, 2020, 2118, 2179);
+
+## 2026-10-13 036 同一用户多店结算与同骑手配送(待开发者手动执行)
+
+以下仅登记迁移,不在本轮自动执行。必须等新批次的创建、支付事实、取消退款、接组门禁、预约扫描和平台配置全部部署且通过验收后,才将配置 `enabled` 改为 `true`;未开启时普通/历史订单仍走原流程。`checkout_status` 是专门的批次履约资格状态,不复用 `order_parent.order_status`、`pos_order.pay_status` 或 `is_display`。
+
+```sql
+CREATE TABLE IF NOT EXISTS order_checkout_config (
+  id TINYINT NOT NULL PRIMARY KEY COMMENT '固定为1的全局配置',
+  enabled TINYINT NOT NULL DEFAULT 0,
+  fence_meters INT NOT NULL DEFAULT 1000,
+  max_stores INT NOT NULL DEFAULT 5,
+  confirmation_buffer_minutes INT NOT NULL DEFAULT 5,
+  tiers_json TEXT NOT NULL COMMENT '最少门店数和配送费系数动态阶梯JSON数组',
+  version BIGINT NOT NULL DEFAULT 0 COMMENT '配置保存CAS与报价版本',
+  updated_by BIGINT DEFAULT NULL,
+  update_time DATETIME(3) NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='036多店结算独立平台配置';
+
+INSERT INTO order_checkout_config
+  (id, enabled, fence_meters, max_stores, confirmation_buffer_minutes, tiers_json, version, update_time)
+SELECT 1, 0, 1000, 5, 5,
+       '[{"minimumStores":2,"ratio":0.9},{"minimumStores":3,"ratio":0.8}]', 0, NOW(3)
+WHERE NOT EXISTS (SELECT 1 FROM order_checkout_config WHERE id=1);
+
+-- 平台配置菜单及写入权限;菜单可先登记,但完整批次流程验收前配置保持关闭。
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache,
+  menu_type, visible, status, perms, icon, create_by, create_time, remark)
+SELECT 'multiStoreCheckout', 0, 19, 'multiStoreCheckout', 'multiStoreCheckout/index', 1, 1,
+  'C', '0', '0', 'order:multiStoreConfig:list', 'form', 'admin', NOW(), '036 多店结算配置'
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='order:multiStoreConfig:list');
+SET @multi_store_config_menu_id=(SELECT menu_id FROM sys_menu WHERE perms='order:multiStoreConfig:list' ORDER BY menu_id LIMIT 1);
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, is_frame, is_cache,
+  menu_type, visible, status, perms, icon, create_by, create_time)
+SELECT 'multiStoreCheckoutEdit', @multi_store_config_menu_id, 1, '', 1, 1,
+  'F', '0', '0', 'order:multiStoreConfig:edit', '#', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='order:multiStoreConfig:edit');
+
+CREATE TABLE IF NOT EXISTS order_checkout_batch (
+  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  parent_dd_id VARCHAR(64) NOT NULL,
+  user_id BIGINT NOT NULL,
+  request_key VARCHAR(64) NOT NULL,
+  request_hash CHAR(64) NOT NULL,
+  checkout_status VARCHAR(16) NOT NULL COMMENT 'PENDING/ACTIVE/CANCELLED',
+  anchor_store_id BIGINT NOT NULL,
+  address_id BIGINT NOT NULL,
+  same_rider TINYINT NOT NULL,
+  fence_meters INT NOT NULL,
+  max_store_count INT NOT NULL,
+  rule_version CHAR(64) NOT NULL,
+  matched_min_stores INT DEFAULT NULL,
+  discount_ratio DECIMAL(7,4) DEFAULT NULL,
+  total_amount INT NOT NULL COMMENT '本批真实成员应付整数TWD之和',
+  confirmation_buffer_minutes INT NOT NULL,
+  operation_deadline_at DATETIME(3) NOT NULL,
+  final_deadline_at DATETIME(3) NOT NULL,
+  cancelled_reason VARCHAR(255) DEFAULT NULL,
+  version BIGINT NOT NULL DEFAULT 0,
+  create_time DATETIME(3) NOT NULL,
+  update_time DATETIME(3) NOT NULL,
+  UNIQUE KEY uk_checkout_batch_parent (parent_dd_id),
+  UNIQUE KEY uk_checkout_batch_request (user_id, request_key),
+  KEY idx_checkout_batch_deadline (checkout_status, final_deadline_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='036多店结算批次与专用生效状态';
+
+CREATE TABLE IF NOT EXISTS order_checkout_member (
+  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  batch_id BIGINT NOT NULL,
+  pos_order_id BIGINT NOT NULL,
+  store_id BIGINT NOT NULL,
+  sort_no INT NOT NULL,
+  payment_state VARCHAR(24) NOT NULL COMMENT 'UNSELECTED/ONLINE_PENDING/ONLINE_PAID/COD_CONFIRMED',
+  pay_type VARCHAR(4) DEFAULT NULL,
+  ready_at DATETIME(3) DEFAULT NULL,
+  paid_amount INT NOT NULL DEFAULT 0 COMMENT '真实线上实收;到付确认仍为0',
+  original_freight INT NOT NULL,
+  discounted_freight INT NOT NULL,
+  food_amount INT NOT NULL,
+  amount INT NOT NULL COMMENT '本店应付整数TWD快照',
+  coupon_id BIGINT DEFAULT NULL,
+  version BIGINT NOT NULL DEFAULT 0,
+  create_time DATETIME(3) NOT NULL,
+  update_time DATETIME(3) NOT NULL,
+  UNIQUE KEY uk_checkout_member_order (pos_order_id),
+  UNIQUE KEY uk_checkout_member_store (batch_id, store_id),
+  KEY idx_checkout_member_batch_state (batch_id, payment_state)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='036多店子单快照与逐店付款事实';
+
+CREATE TABLE IF NOT EXISTS order_checkout_payment_attempt (
+  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  member_id BIGINT NOT NULL,
+  channel VARCHAR(16) NOT NULL,
+  pay_type VARCHAR(4) NOT NULL,
+  merchant_order_no VARCHAR(100) NOT NULL,
+  gateway_transaction_id VARCHAR(100) DEFAULT NULL,
+  state VARCHAR(24) NOT NULL COMMENT 'PREPARED/DISPATCHED/UNKNOWN/PAID/FAILED',
+  requested_at DATETIME(3) NOT NULL,
+  dispatched_at DATETIME(3) DEFAULT NULL,
+  gateway_paid_at DATETIME(3) DEFAULT NULL,
+  verified_at DATETIME(3) DEFAULT NULL,
+  create_time DATETIME(3) NOT NULL,
+  update_time DATETIME(3) NOT NULL,
+  UNIQUE KEY uk_checkout_attempt_order (channel, merchant_order_no),
+  KEY idx_checkout_attempt_member (member_id, id),
+  KEY idx_checkout_attempt_unknown (state, update_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='036线上真实发起证据和渠道核实';
+
+CREATE TABLE IF NOT EXISTS order_delivery_group (
+  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  batch_id BIGINT NOT NULL,
+  rider_id BIGINT DEFAULT NULL,
+  status VARCHAR(20) NOT NULL COMMENT 'WAITING/ACCEPTED/FINISHED/CANCELLED',
+  version BIGINT NOT NULL DEFAULT 0,
+  accepted_at DATETIME(3) DEFAULT NULL,
+  create_time DATETIME(3) NOT NULL,
+  update_time DATETIME(3) NOT NULL,
+  UNIQUE KEY uk_delivery_group_batch (batch_id),
+  KEY idx_delivery_group_rider (rider_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='036同骑手配送组;整组占1个任务';
+
+CREATE TABLE IF NOT EXISTS order_checkout_outbox (
+  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  event_type VARCHAR(32) NOT NULL,
+  aggregate_id BIGINT NOT NULL,
+  status VARCHAR(20) NOT NULL COMMENT 'PENDING/SENDING/SENT/CANCELLED',
+  attempts INT NOT NULL DEFAULT 0,
+  next_retry_at DATETIME(3) DEFAULT NULL,
+  create_time DATETIME(3) NOT NULL,
+  update_time DATETIME(3) NOT NULL,
+  UNIQUE KEY uk_checkout_event (event_type, aggregate_id),
+  KEY idx_checkout_outbox_retry (status, next_retry_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='036履约开放通知持久幂等事件';
+
+CREATE TABLE IF NOT EXISTS order_checkout_refund_intent (
+  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  member_id BIGINT NOT NULL,
+  reason VARCHAR(32) NOT NULL,
+  channel VARCHAR(16) NOT NULL,
+  amount INT NOT NULL COMMENT '原路退回真实实收整数TWD',
+  merchant_order_no VARCHAR(100) NOT NULL,
+  gateway_transaction_id VARCHAR(100) DEFAULT NULL,
+  status VARCHAR(24) NOT NULL COMMENT 'PENDING/UNKNOWN/RETRY/SUCCEEDED/FAILED/MANUAL',
+  attempts INT NOT NULL DEFAULT 0,
+  next_retry_at DATETIME(3) DEFAULT NULL,
+  version BIGINT NOT NULL DEFAULT 0,
+  create_time DATETIME(3) NOT NULL,
+  update_time DATETIME(3) NOT NULL,
+  UNIQUE KEY uk_checkout_refund_member_reason (member_id, reason),
+  KEY idx_checkout_refund_retry (status, next_retry_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='036多店子单原路退款意图及恢复';
+
+```