Prechádzať zdrojové kódy

新增008满额免运:type=5活动管理、完整配送报价、版本2订单与结算闭环

- 商家促销活动新增满额免运(type=5):单条门槛校验、门店归属+行锁、重叠检查、按日期有效状态
- 算价新增三参加价重载:原价门槛判断、免运最高优先允许超预算、其余优惠用max(0,B-F)、半价豁免保留
- 新增POST /app/userPromotionCalc/deliveryQuote:服务端核实商品/规格/地址/自配送,Google路线+跳表运费
- 订单显式promotionPricingVersion=2:同事务权威重算、quoteChanged拒绝、免运快照F/D固化、appliedCouponId真实核销
- 结算:商家分成基数G=A-D、发票扣D、骑手毛配送费R与钱包幂等不变、三方派生交款字段随payType转向
- 后端6语言包16个key+新增vi父包;商家PC第五Tab及四语言;SQL见updatesql/sql.md待手动执行
qmj 4 dní pred
rodič
commit
e907f511c5
45 zmenil súbory, kde vykonal 4880 pridanie a 206 odobranie
  1. 26 26
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionActivityController.java
  2. 309 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteService.java
  3. 22 7
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java
  4. 93 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderPromotionHelper.java
  5. 48 30
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderService.java
  6. 91 10
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  7. 51 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/FoodDeliveryPromotionQuoteRequest.java
  8. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderCreatItem.java
  9. 36 1
      ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCalcController.java
  10. 17 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  11. 17 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  12. 17 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  13. 20 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  14. 17 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  15. 17 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  16. 126 0
      ruoyi-admin/src/test/java/com/ruoyi/app/mendian/ShPromotionActivityFreeDeliveryTest.java
  17. 320 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java
  18. 192 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderFreeDeliverySettlementTest.java
  19. 92 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderInvoiceFreeDeliveryTest.java
  20. 217 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderPromotionHelperFreeDeliveryTest.java
  21. 396 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/UserOrderFreeDeliveryCreateTest.java
  22. 34 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayServiceCreateTest.java
  23. 41 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java
  24. 66 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java
  25. 59 0
      ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionActivitySaveRequest.java
  26. 4 1
      ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcRequest.java
  27. 41 1
      ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcResponse.java
  28. 36 0
      ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionDeliveryContext.java
  29. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreMapper.java
  30. 25 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityMapper.java
  31. 21 19
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityService.java
  32. 17 1
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCalcService.java
  33. 235 18
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java
  34. 686 81
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCalcServiceImpl.java
  35. 13 1
      ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml
  36. 32 0
      ruoyi-system/src/main/resources/mapper/system/PromotionActivityMapper.xml
  37. 388 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionActivityServiceImplFreeDeliveryTest.java
  38. 515 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionCalcServiceImplFreeDeliveryTest.java
  39. 16 1
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionDiscountLimitCheckerTest.java
  40. 63 0
      specs/008-promotion-coupon/claude-code-handoff.md
  41. 141 0
      specs/008-promotion-coupon/contracts/free-delivery.md
  42. 145 3
      specs/008-promotion-coupon/plan.md
  43. 64 3
      specs/008-promotion-coupon/spec.md
  44. 95 3
      specs/008-promotion-coupon/tasks.md
  45. 8 0
      updatesql/sql.md

+ 26 - 26
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionActivityController.java

@@ -1,22 +1,24 @@
 package com.ruoyi.app.mendian;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.system.domain.PromotionActivity;
-import com.ruoyi.system.domain.PromotionActivityRule;
+import com.ruoyi.system.dto.PromotionActivitySaveRequest;
 import com.ruoyi.system.service.IPromotionActivityService;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
-import java.util.List;
-
 /**
  * 商家端促销活动管理
+ *
+ * 2026-10-19 满额免运(008 T074):保存请求改用 PromotionActivitySaveRequest DTO
+ * (与旧请求体字段兼容),操作者ID从 token 解析并传入服务层做门店归属校验;
+ * 列表对 type=5 返回按日期计算的有效状态。
  */
 @RestController
 @RequestMapping("/system/shPromotionActivity")
@@ -39,18 +41,7 @@ public class ShPromotionActivityController extends BaseController
                            @RequestParam(required = false) Integer status)
     {
         IPage<PromotionActivity> pageParam = new Page<>(page, size);
-        LambdaQueryWrapper<PromotionActivity> queryWrapper = new LambdaQueryWrapper<>();
-        queryWrapper.eq(PromotionActivity::getStoreId, storeId);
-        if (type != null)
-        {
-            queryWrapper.eq(PromotionActivity::getType, type);
-        }
-        if (status != null)
-        {
-            queryWrapper.eq(PromotionActivity::getStatus, status);
-        }
-        queryWrapper.orderByDesc(PromotionActivity::getCreateTime);
-        IPage<PromotionActivity> result = promotionActivityService.page(pageParam, queryWrapper);
+        IPage<PromotionActivity> result = promotionActivityService.selectActivityPage(pageParam, storeId, type, status);
         return success(result);
     }
 
@@ -62,7 +53,7 @@ public class ShPromotionActivityController extends BaseController
     @GetMapping("/{id}")
     public AjaxResult getInfo(@RequestHeader String token, @PathVariable("id") Long id)
     {
-        PromotionActivity activity = promotionActivityService.selectActivityWithRules(id);
+        PromotionActivity activity = promotionActivityService.selectActivityWithRules(id, resolveOperator(token));
         return success(activity);
     }
 
@@ -72,10 +63,10 @@ public class ShPromotionActivityController extends BaseController
     @Anonymous
     @Auth
     @PostMapping
-    public AjaxResult add(@RequestHeader String token, @RequestBody PromotionActivity activity)
+    public AjaxResult add(@RequestHeader String token, @RequestBody PromotionActivitySaveRequest request)
     {
-        List<PromotionActivityRule> rules = activity.getRules();
-        return toAjax(promotionActivityService.createActivity(activity, rules));
+        PromotionActivity activity = request.toActivity();
+        return toAjax(promotionActivityService.createActivity(activity, request.getRules(), resolveOperator(token)));
     }
 
     /**
@@ -86,19 +77,19 @@ public class ShPromotionActivityController extends BaseController
     @PutMapping("/{id}/end")
     public AjaxResult end(@RequestHeader String token, @PathVariable("id") Long id)
     {
-        return toAjax(promotionActivityService.endActivity(id));
+        return toAjax(promotionActivityService.endActivity(id, resolveOperator(token)));
     }
 
     /**
-     * 修改促销活动(仅未开始)
+     * 修改促销活动(仅未开始;type=5 按有效状态判定)
      */
     @Anonymous
     @Auth
     @PutMapping
-    public AjaxResult edit(@RequestHeader String token, @RequestBody PromotionActivity activity)
+    public AjaxResult edit(@RequestHeader String token, @RequestBody PromotionActivitySaveRequest request)
     {
-        List<PromotionActivityRule> rules = activity.getRules();
-        return toAjax(promotionActivityService.updateActivity(activity, rules));
+        PromotionActivity activity = request.toActivity();
+        return toAjax(promotionActivityService.updateActivity(activity, request.getRules(), resolveOperator(token)));
     }
 
     /**
@@ -109,6 +100,15 @@ public class ShPromotionActivityController extends BaseController
     @DeleteMapping("/{id}")
     public AjaxResult remove(@RequestHeader String token, @PathVariable("id") Long id)
     {
-        return toAjax(promotionActivityService.deleteActivity(id));
+        return toAjax(promotionActivityService.deleteActivity(id, resolveOperator(token)));
+    }
+
+    /**
+     * 从 token 解析操作者用户ID(@Auth 切面已完成验签,这里只取可信id)
+     */
+    private Long resolveOperator(String token)
+    {
+        String userId = new JwtUtil().getusid(token);
+        return userId == null ? null : Long.valueOf(userId);
     }
 }

+ 309 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteService.java

@@ -0,0 +1,309 @@
+package com.ruoyi.app.order;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Service;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.app.order.dto.FoodDeliveryPromotionQuoteRequest;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.FoodSpecRelation;
+import com.ruoyi.system.domain.FoodSpecs;
+import com.ruoyi.system.domain.FoodSpecsValue;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PosFreight;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.dto.PromotionCalcRequest;
+import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionDeliveryContext;
+import com.ruoyi.system.mapper.FoodSpecRelationMapper;
+import com.ruoyi.system.mapper.FoodSpecsMapper;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
+import com.ruoyi.system.mapper.InfoAddressMapper;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PosFreightMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.IPromotionCalcService;
+import com.ruoyi.system.utils.GetArea;
+
+/**
+ * 满额免运完整配送报价服务(008 T075,spec FR-033/FR-034)。
+ *
+ * 服务端核实门店、配送模式(仅平台骑手单店外送)、在售商品、规格归属与数量、
+ * 收货地址归属,并按现行 Google 路线距离与运价跳表计算原配送费 R,构造可信
+ * PromotionDeliveryContext 调用算价三参重载。客户端传入的运费、免运金额、
+ * 商品单价一律不作为计费依据;路线/运价不可用时返回业务错误,不默认运费0。
+ */
+@Service
+public class FoodDeliveryPromotionQuoteService
+{
+    private final PosStoreMapper stores;
+    private final InfoAddressMapper addresses;
+    private final PosFoodMapper foods;
+    private final FoodSpecRelationMapper relations;
+    private final FoodSpecsMapper specs;
+    private final FoodSpecsValueMapper values;
+    private final PosFreightMapper freight;
+    private final SelfDeliveryService selfDeliveryService;
+    private final IPromotionCalcService promotions;
+
+    public FoodDeliveryPromotionQuoteService(PosStoreMapper stores,
+                                             InfoAddressMapper addresses,
+                                             PosFoodMapper foods,
+                                             FoodSpecRelationMapper relations,
+                                             FoodSpecsMapper specs,
+                                             FoodSpecsValueMapper values,
+                                             PosFreightMapper freight,
+                                             SelfDeliveryService selfDelivery,
+                                             IPromotionCalcService promotions)
+    {
+        this.stores = stores;
+        this.addresses = addresses;
+        this.foods = foods;
+        this.relations = relations;
+        this.specs = specs;
+        this.values = values;
+        this.freight = freight;
+        this.selfDeliveryService = selfDelivery;
+        this.promotions = promotions;
+    }
+
+    /**
+     * 完整配送报价:预览与订单创建复用同一能力(spec FR-033)。
+     *
+     * @return PromotionCalcResponse(pricingVersion=2,含 R/F/D/A 与预算扩展字段)
+     */
+    public PromotionCalcResponse quote(FoodDeliveryPromotionQuoteRequest request, Long userId)
+    {
+        if (request == null || request.getStoreId() == null
+                || !Integer.valueOf(0).equals(request.getOrderType())
+                || request.getItems() == null || request.getItems().isEmpty())
+        {
+            throw invalid("promotion.freeDelivery.unsupportedOrder");
+        }
+        for (FoodDeliveryPromotionQuoteRequest.Item item : request.getItems())
+        {
+            if (item == null || item.getProductId() == null || item.getQuantity() == null
+                    || item.getQuantity() < 1 || item.getQuantity() > 99)
+            {
+                throw invalid("promotion.freeDelivery.invalidProduct");
+            }
+        }
+
+        PosStore store = stores.selectById(request.getStoreId());
+        if (store == null)
+        {
+            throw invalid("promotion.freeDelivery.unsupportedOrder");
+        }
+        if (!hasCoordinates(store))
+        {
+            throw invalid("promotion.freeDelivery.freightUnavailable");
+        }
+        // 实际配送模式以门店自配送配置与(预约单)判定时段为准,不信客户端 selfDelivery
+        if (selfDeliveryService.isSelfDelivery(store,
+                selfDeliveryService.resolveJudgeTime(request.getDelryTime())))
+        {
+            throw invalid("promotion.freeDelivery.unsupportedOrder");
+        }
+
+        InfoAddress address = addresses.selectById(request.getAddressId());
+        if (address == null || !userId.equals(address.getUserId()) || !hasCoordinates(address))
+        {
+            throw invalid("promotion.freeDelivery.invalidAddress");
+        }
+
+        // 逐商品核实归属/在售/数量,规格加价由服务端按数据库计算
+        List<PromotionCalcRequest.CartItem> cart = new ArrayList<>();
+        for (FoodDeliveryPromotionQuoteRequest.Item item : request.getItems())
+        {
+            PosFood food = foods.selectById(item.getProductId());
+            if (food == null || !request.getStoreId().equals(food.getMdid())
+                    || !"0".equals(food.getStackingUp()) || "1".equals(food.getToExamine())
+                    || food.getPrice() == null || food.getPrice().signum() < 0)
+            {
+                throw invalid("promotion.freeDelivery.invalidProduct");
+            }
+            BigDecimal specPrice = priceSpecs(food, item.getSpecValueIds());
+            PromotionCalcRequest.CartItem cartItem = new PromotionCalcRequest.CartItem();
+            cartItem.setProductId(item.getProductId());
+            cartItem.setQuantity(item.getQuantity());
+            cartItem.setSpecPrice(specPrice);
+            cart.add(cartItem);
+        }
+
+        PosFreight fare = currentFreight();
+        BigDecimal distanceMeters = routeDistanceMeters(store, address);
+        BigDecimal deliveryFee = originalFreight(fare, distanceMeters);
+
+        PromotionCalcRequest calcRequest = new PromotionCalcRequest();
+        calcRequest.setStoreId(request.getStoreId());
+        calcRequest.setItems(cart);
+        calcRequest.setCouponId(request.getCouponId());
+        calcRequest.setForcePath(request.getForcePath());
+        return promotions.calculate(calcRequest, userId, new PromotionDeliveryContext(deliveryFee, true));
+    }
+
+    /**
+     * 规格加价核实:规格值必须确属该商品关联且启用的规格组(同店);
+     * 必选组(state=1)至少选一,单选组(type=1)至多一;加价为所选规格值价格合计。
+     */
+    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("promotion.freeDelivery.invalidSpec");
+        }
+        FoodSpecRelation query = new FoodSpecRelation();
+        query.setFoodId(food.getId());
+        List<FoodSpecRelation> links = relations.selectFoodSpecRelationList(query);
+        Map<Long, FoodSpecs> groups = new HashMap<>();
+        if (links != null)
+        {
+            for (FoodSpecRelation link : links)
+            {
+                FoodSpecs group = specs.selectById(link.getSpecsId());
+                if (group != null && food.getMdid().equals(group.getMdId())
+                        && Boolean.TRUE.equals(group.getIsOpen()) && !Boolean.TRUE.equals(group.getIsDelete()))
+                {
+                    groups.put(group.getId(), group);
+                }
+            }
+        }
+        Map<Long, Integer> counts = new HashMap<>();
+        BigDecimal price = BigDecimal.ZERO;
+        for (Long valueId : chosen)
+        {
+            FoodSpecsValue value = valueId == null ? null : values.selectById(valueId);
+            if (value == null || !Boolean.TRUE.equals(value.getIsOpen())
+                    || !groups.containsKey(value.getParentId()) || value.getPrice() == null
+                    || value.getPrice().signum() < 0)
+            {
+                throw invalid("promotion.freeDelivery.invalidSpec");
+            }
+            counts.merge(value.getParentId(), 1, Integer::sum);
+            price = price.add(value.getPrice());
+        }
+        for (FoodSpecs group : groups.values())
+        {
+            int count = counts.getOrDefault(group.getId(), 0);
+            if (("1".equals(group.getState()) && count == 0)
+                    || ("1".equals(group.getType()) && count > 1))
+            {
+                throw invalid("promotion.freeDelivery.invalidSpec");
+            }
+        }
+        return price;
+    }
+
+    /** 运价行:优先 getyunjia,缺省 getmiuyunjia;不可用即业务错误,不回退免费运费 */
+    private PosFreight currentFreight()
+    {
+        List<PosFreight> rows = freight.getyunjia();
+        if (rows == null || rows.isEmpty())
+        {
+            rows = freight.getmiuyunjia();
+        }
+        if (rows == null || rows.isEmpty())
+        {
+            throw invalid("promotion.freeDelivery.freightUnavailable");
+        }
+        PosFreight row = rows.get(0);
+        if (row.getStartingFare() == null || row.getStartingFare() < 0
+                || row.getStartingDistance() == null || row.getStartingDistance() < 0
+                || row.getFreight() == null || row.getFreight() < 0
+                || row.getDistance() == null || row.getDistance() <= 0)
+        {
+            throw invalid("promotion.freeDelivery.freightUnavailable");
+        }
+        return row;
+    }
+
+    /**
+     * 现行路线口径:服务端按门店/地址坐标调用 Google Directions 读取 routes[0].legs[0]
+     * 距离(米)。不接收客户端 quli 作为权威距离;失败返回业务错误。
+     * protected 便于单元测试以 spy 注入受控距离,不调用真实地图。
+     */
+    protected BigDecimal routeDistanceMeters(PosStore store, InfoAddress address)
+    {
+        try
+        {
+            String origin = store.getLatitude().doubleValue() + "," + store.getLongitude().doubleValue();
+            String destination = address.getLatitude().doubleValue() + "," + address.getLongitude().doubleValue();
+            JSONObject response = new GetArea().getluxian(origin, destination, "zh-TW");
+            JSONArray routes = response == null ? null : response.getJSONArray("routes");
+            if (routes == null || routes.isEmpty())
+            {
+                throw invalid("promotion.freeDelivery.freightUnavailable");
+            }
+            JSONArray legs = routes.getJSONObject(0).getJSONArray("legs");
+            if (legs == null || legs.isEmpty())
+            {
+                throw invalid("promotion.freeDelivery.freightUnavailable");
+            }
+            Integer meters = legs.getJSONObject(0).getJSONObject("distance").getInteger("value");
+            if (meters == null || meters <= 0)
+            {
+                throw invalid("promotion.freeDelivery.freightUnavailable");
+            }
+            return BigDecimal.valueOf(meters);
+        }
+        catch (ServiceException known)
+        {
+            throw known;
+        }
+        catch (Exception unavailable)
+        {
+            throw invalid("promotion.freeDelivery.freightUnavailable");
+        }
+    }
+
+    /**
+     * 运费跳表(对齐旧 getyunfei/getZuiyyfei 口径,含半公里门槛与千元舍入):
+     * 公里数取三位;超出起步距离部分 0.5km 内不计、不足1km按1km;超出单价为整数
+     * freight/distance;整数元 HALF_EVEN;满千元按 HALF_UP 舍到千位。
+     */
+    BigDecimal originalFreight(PosFreight row, BigDecimal meters)
+    {
+        BigDecimal km = meters.movePointLeft(3);
+        BigDecimal extra = km.subtract(BigDecimal.valueOf(row.getStartingDistance())).max(BigDecimal.ZERO);
+        if (extra.signum() > 0 && extra.compareTo(new BigDecimal("0.5")) < 0)
+        {
+            extra = BigDecimal.ZERO;
+        }
+        else if (extra.compareTo(new BigDecimal("0.5")) >= 0 && extra.compareTo(BigDecimal.ONE) < 0)
+        {
+            extra = BigDecimal.ONE;
+        }
+        long perKm = row.getFreight() / row.getDistance();
+        BigDecimal amount = extra.multiply(BigDecimal.valueOf(perKm)).setScale(0, RoundingMode.HALF_EVEN)
+                .add(BigDecimal.valueOf(row.getStartingFare()));
+        return amount.compareTo(new BigDecimal("1000")) < 0
+                ? amount : amount.setScale(-3, RoundingMode.HALF_UP).setScale(0);
+    }
+
+    private boolean hasCoordinates(InfoAddress address)
+    {
+        return address.getLatitude() != null && address.getLongitude() != null;
+    }
+
+    private boolean hasCoordinates(PosStore store)
+    {
+        return store.getLatitude() != null && store.getLongitude() != null;
+    }
+
+    private ServiceException invalid(String messageKey)
+    {
+        return new ServiceException(MessageUtils.message(messageKey));
+    }
+}

+ 22 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java

@@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.ruoyi.app.utils.ezPay.EzPay;
 import com.ruoyi.app.utils.ezPay.EzPayConfig;
 import com.ruoyi.common.exception.ServiceException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.common.utils.SecurityUtils;
 import com.ruoyi.system.domain.PosLoveOrg;
@@ -118,10 +120,8 @@ public class OrderInvoiceService {
             throw new ServiceException("该订单已开票,不可重复开票");
         }
 
-        // 金额拆分(不含运费:invoiceTotal = amount - freight)
-        int amount = order.getAmount() == null ? 0 : order.getAmount();
-        int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight());
-        int invoiceTotal = amount - freight;
+        // 金额拆分(不含客户实付运费:旧流程 amount−freight;版本2按 D 折抵,spec FR-036)
+        int invoiceTotal = invoiceGoodsTotal(order);
         if (invoiceTotal <= 0) {
             throw new ServiceException("订单可开票金额异常,无法开票");
         }
@@ -467,11 +467,26 @@ public class OrderInvoiceService {
 
     // ==================== 内部辅助 ====================
 
-    /** 复用开票核心(运营重开,无归属校验)。 */
-    private int reIssueAndSave(PosOrder order, PosOrderInvoice row, ApplyInvoiceDto dto, PosStoreEzpay ez) {
+    /**
+     * 可开票金额(不含客户实付运费):旧流程 amount−freight;
+     * 满额免运版本2按快照 customerDeliveryFee=D 折抵(免运时 freight 仍为 R,
+     * 若继续减 freight 会把商家承担的运费也从发票扣除)。版本2关键金额缺失拒绝开票。
+     */
+    int invoiceGoodsTotal(PosOrder order) {
         int amount = order.getAmount() == null ? 0 : order.getAmount();
+        if (Integer.valueOf(2).equals(order.getPromotionPricingVersion())) {
+            if (order.getCustomerDeliveryFee() == null || order.getMerchantDeliverySubsidy() == null) {
+                throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidSnapshot"));
+            }
+            return amount - order.getCustomerDeliveryFee().setScale(0, RoundingMode.HALF_UP).intValue();
+        }
         int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight());
-        int invoiceTotal = amount - freight;
+        return amount - freight;
+    }
+
+    /** 复用开票核心(运营重开,无归属校验)。 */
+    private int reIssueAndSave(PosOrder order, PosOrderInvoice row, ApplyInvoiceDto dto, PosStoreEzpay ez) {
+        int invoiceTotal = invoiceGoodsTotal(order);
         int sales = (int) Math.round(invoiceTotal / 1.05);
         int tax = invoiceTotal - sales;
 

+ 93 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderPromotionHelper.java

@@ -9,15 +9,23 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.system.domain.FoodSpecsValue;
+import com.ruoyi.system.domain.PosFood;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrderPromotion;
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
 import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
+import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosOrderPromotionMapper;
 import com.ruoyi.system.service.IPromotionCalcService;
 import com.ruoyi.system.service.IPromotionUserCouponService;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 
 /**
  * 订单促销辅助类
@@ -39,6 +47,12 @@ public class OrderPromotionHelper {
     @Autowired
     private IPromotionUserCouponService promotionUserCouponService;
 
+    @Autowired
+    private PosFoodMapper posFoodMapper;
+
+    @Autowired
+    private FoodSpecsValueMapper foodSpecsValueMapper;
+
     /**
      * 调用促销算价服务
      *
@@ -98,6 +112,10 @@ public class OrderPromotionHelper {
 
         for (PromotionDetail detail : calc.getDetails()) {
             if ("promotion".equals(detail.getType())) {
+                // 满额免运(subType=5)是配送费级优惠,由商家承担,不进商品减免汇总(008 FR-031)
+                if (Integer.valueOf(5).equals(detail.getSubType())) {
+                    continue;
+                }
                 // 累加所有促销减免(满减+新客立减)
                 totalPromotionReduceCents += detail.getReduce().intValue();
                 // 取第一个非新客立减的促销名称作为主名称
@@ -161,6 +179,81 @@ public class OrderPromotionHelper {
         posOrderPromotionMapper.insertBatch(snapshots);
     }
 
+    /**
+     * 完整配送契约(pricingVersion=2,008 满额免运)应用权威报价:
+     * amount=A、freight=R(不清零)、foodAmount=O、food JSON 服务端生成,
+     * 并固化 version/F/D 快照;商品优惠字段仍由 fillPromotionFields 回填(排除免运)。
+     */
+    public void applyVersion2Pricing(PosOrder posOrder, PromotionCalcResponse calc)
+    {
+        if (posOrder == null || calc == null)
+        {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidSnapshot"));
+        }
+        posOrder.setAmount(calc.getPayableAmount().intValue());
+        posOrder.setFreight(calc.getDeliveryFee().doubleValue());
+        posOrder.setFoodAmount(calc.getOriginalAmount().intValue());
+        posOrder.setPromotionPricingVersion(2);
+        posOrder.setMerchantDeliverySubsidy(calc.getDeliveryDiscountAmount());
+        posOrder.setCustomerDeliveryFee(calc.getCustomerDeliveryFee());
+    }
+
+    /**
+     * 版本2订单的 food JSON 由服务端按数据库商品与规格生成(兼容 price/otherPrice/number 结构),
+     * 不接受客户端 food 中的价格作为来源;规格加价按已通过报价校验的规格值价格合计。
+     */
+    public String buildServerFoodJson(List<PromotionCalcRequest.CartItem> cartItems)
+    {
+        if (cartItems == null || cartItems.isEmpty())
+        {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidProduct"));
+        }
+        JSONArray foods = new JSONArray();
+        for (PromotionCalcRequest.CartItem item : cartItems)
+        {
+            PosFood food = item == null || item.getProductId() == null ? null : posFoodMapper.selectById(item.getProductId());
+            if (food == null || item.getQuantity() == null || item.getQuantity() < 1)
+            {
+                throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidProduct"));
+            }
+            BigDecimal specPrice = BigDecimal.ZERO;
+            if (item.getSpecValueIds() != null)
+            {
+                for (Long valueId : item.getSpecValueIds())
+                {
+                    FoodSpecsValue value = valueId == null ? null : foodSpecsValueMapper.selectById(valueId);
+                    if (value == null || value.getPrice() == null)
+                    {
+                        throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidSpec"));
+                    }
+                    specPrice = specPrice.add(value.getPrice());
+                }
+            }
+            JSONObject line = new JSONObject();
+            line.put("id", food.getId());
+            line.put("name", food.getName());
+            line.put("image", food.getImage());
+            line.put("price", food.getPrice());
+            line.put("otherPrice", specPrice);
+            line.put("number", item.getQuantity());
+            foods.add(line);
+        }
+        return foods.toJSONString();
+    }
+
+    /**
+     * 应核销的优惠券:版本2只认实际生效且实减&gt;0的 appliedCouponId(无实际优惠不核销);
+     * 旧流程沿用 couponId(无券时 null)。
+     */
+    public Long resolveCouponToConsume(PromotionCalcResponse calc, boolean versionTwo)
+    {
+        if (calc == null)
+        {
+            return null;
+        }
+        return versionTwo ? calc.getAppliedCouponId() : calc.getCouponId();
+    }
+
     /**
      * 标记用户优惠券为已使用
      *

+ 48 - 30
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderService.java

@@ -50,6 +50,12 @@ public class OrderService {
         if (count <= 0) {
             UserBilling billing = new UserBilling();
             DecimalFormat df = new DecimalFormat("#.00");
+            // 008 满额免运(版本2):关键金额缺失视为不完整快照,在任何计费查询前拒绝处理
+            if (Integer.valueOf(2).equals(posOrder.getPromotionPricingVersion())
+                    && (posOrder.getAmount() == null || posOrder.getCustomerDeliveryFee() == null
+                            || posOrder.getMerchantDeliverySubsidy() == null)) {
+                throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidSnapshot"));
+            }
 
             // 判断是否为摊位订单
             PosStore store = posStoreService.getById(posOrder.getMdId());
@@ -62,37 +68,49 @@ public class OrderService {
                 user = infoUserService.getById(posOrder.getShId());
             }
             Double fcbili = user.getCommission() == null ? 0.00 : user.getCommission();
-            JSONArray list = JSONArray.parseArray(posOrder.getFood());
-            int fenc = 0;
-            for (int i = 0; i < list.size(); i++) {
-                JSONObject foods = list.getJSONObject(i);
-                int price = foods.getInteger("price");
-                int otherPrice = foods.getInteger("otherPrice");
-                int num = foods.getInteger("number");
-                int age = (price + otherPrice) * num;
-                fenc += age;
-            }
-            String remark = "";
-            if (posOrder.getMdYhId() != null && posOrder.getMdDiscountAmount() != null) {
-                VipUserQuanyi userQuanyi = userQuanyiService.getById(posOrder.getMdYhId());
-                if (userQuanyi != null && "1".equals(userQuanyi.getType())) {
-                    fenc = fenc - posOrder.getMdDiscountAmount();
-                    String yhmcMessage = MessageUtils.message("no.posorder.md.yh.mc.messag");
-                    yhmcMessage = StrUtil.format(yhmcMessage, posOrder.getMdYhName());
-                    remark += yhmcMessage;
-                    String yhAmount = MessageUtils.message("no.posorder.md.yh.jiner.messag");
-                    yhAmount = StrUtil.format(yhAmount, posOrder.getMdDiscountAmount());
-                    remark += yhAmount;
+            int fenc;
+            String remark;
+            if (Integer.valueOf(2).equals(posOrder.getPromotionPricingVersion())) {
+                // 008 满额免运(版本2):商品分成基数直接取 G=A−D(商品优惠已含在A内),
+                // 不再走 food JSON/旧 VipUserQuanyi 券查询路径,免运F不由餐费重复扣减。
+                fenc = BigDecimal.valueOf(posOrder.getAmount())
+                        .subtract(posOrder.getCustomerDeliveryFee()).intValue();
+                remark = "";
+            } else {
+                JSONArray list = JSONArray.parseArray(posOrder.getFood());
+                int legacyFenc = 0;
+                for (int i = 0; i < list.size(); i++) {
+                    JSONObject foods = list.getJSONObject(i);
+                    int price = foods.getInteger("price");
+                    int otherPrice = foods.getInteger("otherPrice");
+                    int num = foods.getInteger("number");
+                    int age = (price + otherPrice) * num;
+                    legacyFenc += age;
                 }
-            }
-            if (posOrder.getMdSalesName() != null && posOrder.getMdSalesReduction() != null) {
-                fenc = fenc - posOrder.getMdSalesReduction();
-                String cxNameMessage = MessageUtils.message("no.posorder.md.cx.mc.messag");
-                cxNameMessage = StrUtil.format(cxNameMessage, posOrder.getMdSalesName());
-                remark += cxNameMessage;
-                String cxAmount = MessageUtils.message("no.posorder.md.cx.jiner.messag");
-                cxAmount = StrUtil.format(cxAmount, posOrder.getMdSalesReduction());
-                remark += cxAmount;
+                StringBuilder legacyRemark = new StringBuilder();
+                if (posOrder.getMdYhId() != null && posOrder.getMdDiscountAmount() != null) {
+                    VipUserQuanyi userQuanyi = userQuanyiService.getById(posOrder.getMdYhId());
+                    if (userQuanyi != null && "1".equals(userQuanyi.getType())) {
+                        legacyFenc = legacyFenc - posOrder.getMdDiscountAmount();
+                        String yhmcMessage = MessageUtils.message("no.posorder.md.yh.mc.messag");
+                        yhmcMessage = StrUtil.format(yhmcMessage, posOrder.getMdYhName());
+                        legacyRemark.append(yhmcMessage);
+                        String yhAmount = MessageUtils.message("no.posorder.md.yh.jiner.messag");
+                        yhAmount = StrUtil.format(yhAmount, posOrder.getMdDiscountAmount());
+                        legacyRemark.append(yhAmount);
+                    }
+                }
+                if (posOrder.getMdSalesName() != null && posOrder.getMdSalesReduction() != null) {
+                    legacyFenc = legacyFenc - posOrder.getMdSalesReduction();
+                    String cxNameMessage = MessageUtils.message("no.posorder.md.cx.mc.messag");
+                    cxNameMessage = StrUtil.format(cxNameMessage, posOrder.getMdSalesName());
+                    legacyRemark.append(cxNameMessage);
+                    String cxAmount = MessageUtils.message("no.posorder.md.cx.jiner.messag");
+                    cxAmount = StrUtil.format(cxAmount, posOrder.getMdSalesReduction());
+                    legacyRemark.append(cxAmount);
+                }
+                fenc = legacyFenc;
+                remark = legacyRemark.toString();
             }
             billing.setIllustrate(remark);
             Double chouc = fenc * fcbili;

+ 91 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -6,6 +6,7 @@ import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.ruoyi.app.order.dto.FoodDeliveryPromotionQuoteRequest;
 import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
@@ -37,6 +38,7 @@ import org.springframework.web.bind.annotation.*;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.math.BigDecimal;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.List;
@@ -99,6 +101,8 @@ public class UserOrderController extends BaseController {
     private MerchantNotificationRouter merchantNotificationRouter;
     @Autowired  //029 商家自配送:配置解析与下单判定
     private SelfDeliveryService selfDeliveryService;
+    @Autowired  //008 满额免运:完整配送报价(版本2订单权威重算)
+    private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
 
     /**
      * 申请电子发票(订单完成后,客户主动申请:B2C 邮箱 / B2B 统编 / 载具)
@@ -190,8 +194,11 @@ public class UserOrderController extends BaseController {
             orderInvoiceService.validateCheckoutInvoiceIntent(input.getInvoiceChoice(), input.getCarrierNum(),
                     input.getLoveCode(), input.getBuyerUbn(), input.getBuyerEmail(), invoiceStoreId);
         }
-        createOrderParent(input,Long.valueOf(id),shdz);
-        createOrderChild(input,Long.valueOf(id),Long.valueOf(id),shdz);
+        // 008 满额免运:显式 promotionPricingVersion=2 的单店外送单先在同一事务内权威报价,
+        // 核对客户端应付/运费并确定父单金额,差异拒绝回滚;旧请求不经此分支。
+        PromotionCalcResponse versionTwoQuote = prepareVersionTwoQuote(input, Long.valueOf(id));
+        createOrderParent(input,Long.valueOf(id),shdz,versionTwoQuote);
+        createOrderChild(input,Long.valueOf(id),Long.valueOf(id),shdz,versionTwoQuote);
         // saveAsDefault:把载具/统编存为默认抬头(同一用户仅一条 is_default=1)
         if (Boolean.TRUE.equals(input.getSaveAsDefault()) && !StringUtils.isEmpty(input.getInvoiceChoice())) {
             saveDefaultInvoiceProfile(input, Long.valueOf(id));
@@ -202,13 +209,77 @@ public class UserOrderController extends BaseController {
         return super.success(result);
     }
 
-    //创建父订单
-    private void createOrderParent(OrderCreateInput input,Long UserId,String shdz){
+    /**
+     * 版本2(满额免运完整配送契约)下单前置:仅父/子 type=0 的单店请求接入;
+     * 同一事务内调用完整报价权威重算,核对子单应付A、运费R与父单应付,差异以
+     * promotion.freeDelivery.quoteChanged 拒绝并整体回滚;重复ddId不重算覆盖、不重复核销。
+     */
+    private PromotionCalcResponse prepareVersionTwoQuote(OrderCreateInput input, Long userId) {
+        // 任一 item 显式带 promotionPricingVersion=2 即视为请求完整配送契约;
+        // 多店或非外送类型不允许静默回落旧流程,直接拒绝
+        boolean versionTwoRequested = input.getItems() != null && input.getItems().stream()
+                .anyMatch(candidate -> Integer.valueOf(2).equals(candidate.getPromotionPricingVersion()));
+        if (!versionTwoRequested) {
+            return null;
+        }
+        if (input.getItems().size() != 1 || !Long.valueOf(0L).equals(input.getType())
+                || !Long.valueOf(0L).equals(input.getItems().get(0).getType())) {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.unsupportedOrder"));
+        }
+        OrderCreatItem item = input.getItems().get(0);
+        if (item.getCartItems() == null || item.getCartItems().isEmpty()) {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidProduct"));
+        }
+        if (input.getDdId() != null && orderParentService.count(new LambdaQueryWrapper<OrderParent>()
+                .eq(OrderParent::getDdId, input.getDdId().toString())) > 0) {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.duplicateOrder"));
+        }
+
+        FoodDeliveryPromotionQuoteRequest quoteRequest = new FoodDeliveryPromotionQuoteRequest();
+        quoteRequest.setStoreId(item.getMdId());
+        quoteRequest.setOrderType(0);
+        quoteRequest.setAddressId(input.getShdzId());
+        quoteRequest.setDelryTime(item.getDelryTime());
+        List<FoodDeliveryPromotionQuoteRequest.Item> quoteItems = new java.util.ArrayList<>();
+        for (com.ruoyi.system.dto.PromotionCalcRequest.CartItem cartItem : item.getCartItems()) {
+            FoodDeliveryPromotionQuoteRequest.Item quoteItem = new FoodDeliveryPromotionQuoteRequest.Item();
+            quoteItem.setProductId(cartItem.getProductId());
+            quoteItem.setQuantity(cartItem.getQuantity());
+            quoteItem.setSpecValueIds(cartItem.getSpecValueIds());
+            quoteItems.add(quoteItem);
+        }
+        quoteRequest.setItems(quoteItems);
+        quoteRequest.setCouponId(item.getCouponId());
+        quoteRequest.setForcePath(item.getForcePath());
+
+        PromotionCalcResponse quote = foodDeliveryPromotionQuoteService.quote(quoteRequest, userId);
+        if (quote == null || quote.getPayableAmount() == null || quote.getDeliveryFee() == null) {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.quoteChanged"));
+        }
+        if (item.getAmount() == null
+                || BigDecimal.valueOf(item.getAmount()).compareTo(quote.getPayableAmount()) != 0
+                || input.getActualPayAmount() == null
+                || BigDecimal.valueOf(input.getActualPayAmount()).compareTo(quote.getPayableAmount()) != 0
+                || item.getFreight() == null
+                || BigDecimal.valueOf(item.getFreight()).compareTo(quote.getDeliveryFee()) != 0) {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.quoteChanged"));
+        }
+        // 父单金额由服务端确定:totalAmount=O+R、actualPayAmount=A
+        input.setTotalAmount(quote.getOriginalAmount().add(quote.getDeliveryFee()).longValue());
+        input.setActualPayAmount(quote.getPayableAmount().longValue());
+        return quote;
+    }
+
+    //创建父订单;versionTwoQuote 非空时金额由服务端报价确定(totalAmount=O+R、actualPayAmount=A、totalDiscount=(O+R)-A)
+    private void createOrderParent(OrderCreateInput input,Long UserId,String shdz,PromotionCalcResponse versionTwoQuote){
         OrderParent orderParent = new OrderParent();
         orderParent.setUserId(UserId);
         orderParent.setOrderStatus(0L);
         orderParent.setTotalAmount(input.getTotalAmount());
         orderParent.setActualPayAmount(input.getActualPayAmount());
+        if (versionTwoQuote != null) {
+            orderParent.setTotalDiscount(input.getTotalAmount() - input.getActualPayAmount());
+        }
         orderParent.setPaymentMethod(input.getPaymentMethod());
 //        orderParent.setPaymentTime(new Date());
         orderParent.setCreateTime(new Date());
@@ -221,8 +292,8 @@ public class UserOrderController extends BaseController {
         orderParent.setRemarks(input.getRemarks());
         orderParentService.save(orderParent);
     }
-    //创建子订单
-    private void createOrderChild(OrderCreateInput input,Long orderParentId,Long userId,String shdz){
+    //创建子订单;versionTwoQuote 非空时按完整配送契约固化金额与免运快照
+    private void createOrderChild(OrderCreateInput input,Long orderParentId,Long userId,String shdz,PromotionCalcResponse versionTwoQuote){
         // 检查items是否为空
         if (input.getItems() == null || input.getItems().isEmpty()) {
             throw new ServiceException(MessageUtils.message("订单商品不能为空"));
@@ -329,8 +400,16 @@ public class UserOrderController extends BaseController {
             });
             // 保存子订单
             // --- 促销:算价 + 校验 + 回填优惠字段(save之前) ---
-            PromotionCalcResponse calc = orderPromotionHelper.calcPromotion(item, userId);
-            orderPromotionHelper.validatePromotionAmount(calc, item.getPromoReduceAmount());
+            PromotionCalcResponse calc;
+            if (versionTwoQuote != null) {
+                // 版本2:金额/food JSON/免运快照以服务端报价为准,不信客户端 food/amount/freight/specPrice
+                calc = versionTwoQuote;
+                orderPromotionHelper.applyVersion2Pricing(posOrder, calc);
+                posOrder.setFood(orderPromotionHelper.buildServerFoodJson(item.getCartItems()));
+            } else {
+                calc = orderPromotionHelper.calcPromotion(item, userId);
+                orderPromotionHelper.validatePromotionAmount(calc, item.getPromoReduceAmount());
+            }
             orderPromotionHelper.fillPromotionFields(posOrder, calc);
 
             createFootprint(userId, item.getMdId());
@@ -365,8 +444,10 @@ public class UserOrderController extends BaseController {
             // --- 促销:写入快照 + 标记券已使用(save之后,需要posOrder.id) ---
             if (calc != null) {
                 orderPromotionHelper.savePromotionSnapshots(posOrder.getId(), calc);
-                if (calc.getCouponId() != null) {
-                    orderPromotionHelper.markCouponUsed(calc.getCouponId(), posOrder.getId(), userId);
+                // 版本2只核销实际生效且实减>0的 appliedCouponId;旧流程沿用 couponId
+                Long couponToConsume = orderPromotionHelper.resolveCouponToConsume(calc, versionTwoQuote != null);
+                if (couponToConsume != null) {
+                    orderPromotionHelper.markCouponUsed(couponToConsume, posOrder.getId(), userId);
                 }
             }
 

+ 51 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/FoodDeliveryPromotionQuoteRequest.java

@@ -0,0 +1,51 @@
+package com.ruoyi.app.order.dto;
+
+import java.util.List;
+
+import lombok.Data;
+
+/**
+ * 满额免运完整配送报价请求(008 T075,POST /app/userPromotionCalc/deliveryQuote)。
+ *
+ * 只接收商品/地址标识与用户选择意图;商品价格、规格加价、配送费一律由服务端
+ * 按数据库与现行路线/运价口径核实,不接收客户端 freight、免运金额或商品单价
+ * 作为计费依据(spec FR-033)。仅支持单店平台骑手外送(orderType=0)。
+ */
+@Data
+public class FoodDeliveryPromotionQuoteRequest
+{
+    /** 门店ID(必填,单店) */
+    private Long storeId;
+
+    /** 订单类型:仅支持 0=外送;自取/堂食返回不支持 */
+    private Integer orderType;
+
+    /** 收货地址ID(必填,须归属当前用户) */
+    private Long addressId;
+
+    /** 预约配送时间段 yyyy-MM-dd HH:mm-HH:mm(即时单为null),配送模式按该时段判定 */
+    private String delryTime;
+
+    /** 购物车商品列表(必填) */
+    private List<Item> items;
+
+    /** 用户选择的优惠券ID(选填,表示意图;实际生效以响应 appliedCouponId 为准) */
+    private Long couponId;
+
+    /** 强制指定促销路径 "A"/"B"(选填) */
+    private String forcePath;
+
+    /**
+     * 购物车商品项
+     */
+    @Data
+    public static class Item
+    {
+        /** 商品ID */
+        private Long productId;
+        /** 数量 1~99 */
+        private Integer quantity;
+        /** 规格值ID列表(选填),由服务端核实归属并计算加价 */
+        private List<Long> specValueIds;
+    }
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderCreatItem.java

@@ -73,4 +73,11 @@ public class OrderCreatItem {
     /** 前端选择的促销路径: "A"(折扣/第二份半价) 或 "B"(满减) */
     private String forcePath;
 
+    /**
+     * 促销定价版本(008 满额免运,2026-10-19):显式传 2 表示接入完整配送金额契约,
+     * 服务端在同一事务内权威重算并核对 amount/freight/父单应付,差异拒绝;
+     * 不传或旧值走原流程,不静默换价。
+     */
+    private Integer promotionPricingVersion;
+
 }

+ 36 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCalcController.java

@@ -5,6 +5,8 @@ import org.springframework.web.bind.annotation.*;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.app.order.FoodDeliveryPromotionQuoteService;
+import com.ruoyi.app.order.dto.FoodDeliveryPromotionQuoteRequest;
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
 import com.ruoyi.system.service.IPromotionCalcService;
@@ -20,7 +22,11 @@ import com.ruoyi.system.utils.JwtUtil;
  * 3. 优惠券每单只能用一张(couponId 传单个值,不是数组)
  * 4. 同享券可与促销叠加,互斥券与促销二选一
  * 5. 商品券对应的商品如在折扣/第二份半价活动中,该券不可用于该商品
- * 6. 计算顺序:商品原价 → 促销(三选一) → 优惠券 → 新客立减 → 实付金额(最低¥0.01)
+ * 6. 计算顺序:商品原价 → 促销(三选一) → 新客立减 → 优惠券 → 实付金额(最低¥0.01);
+ *    预算开关开启时按该顺序层层封顶(第二份半价胜出不占预算不封顶)
+ * 7. 满额免运(2026-10-19,type=5):仅完整配送报价(/deliveryQuote)参与,
+ *    按商品原价判断门槛、最高优先级先全额预留免运费用(允许超过总预算),
+ *    其余受限优惠使用 max(0, 总预算−免运额);旧 /calculate 无配送上下文保持原行为
  *
  * 【入参说明】
  * storeId   (必填) 门店ID
@@ -33,6 +39,8 @@ import com.ruoyi.system.utils.JwtUtil;
  * - 进入结算页:不传 forcePath、不传 couponId → 返回两条路径对比 + 最优方案 + 可用券列表
  * - 用户切换促销方案:传 forcePath → 强制走用户选的路径
  * - 用户选了优惠券:传 couponId + forcePath(如有偏好) → 在当前路径基础上叠券计算
+ * - 完整配送报价(含满额免运):POST /deliveryQuote,商品/规格/地址/运费全部服务端核实,
+ *   返回 pricingVersion=2 与 R/F/D/A 及预算扩展字段;旧 /calculate 的 finalAmount 仍仅商品应付
  */
 @RestController
 @RequestMapping("/app/userPromotionCalc")
@@ -41,6 +49,9 @@ public class UserPromotionCalcController extends BaseController {
     @Autowired
     private IPromotionCalcService promotionCalcService;
 
+    @Autowired
+    private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
+
     /**
      * 计算订单优惠
      *
@@ -67,4 +78,28 @@ public class UserPromotionCalcController extends BaseController {
         PromotionCalcResponse result = promotionCalcService.calculate(request, Long.valueOf(userId));
         return AjaxResult.success("",result);
     }
+
+    /**
+     * 满额免运完整配送报价(008 T075,pricingVersion=2)
+     *
+     * 商品/规格价格、收货地址归属、配送模式与原配送费均由服务端核实;
+     * 响应含 deliveryFee(R)/deliveryDiscountAmount(F)/customerDeliveryFee(D)/payableAmount(A)
+     * 与预算扩展字段;仅支持单店平台骑手外送(orderType=0)。
+     *
+     * @param token   用户登录token
+     * @param request 完整配送报价请求DTO
+     * @return PromotionCalcResponse(pricingVersion=2)
+     */
+    @Anonymous
+    @Auth
+    @PostMapping("/deliveryQuote")
+    public AjaxResult deliveryQuote(@RequestHeader String token, @RequestBody FoodDeliveryPromotionQuoteRequest request) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String userId = jwtUtil.getusid(token);
+        if (userId == null) {
+            return AjaxResult.error("请先登录");
+        }
+        PromotionCalcResponse result = foodDeliveryPromotionQuoteService.quote(request, Long.valueOf(userId));
+        return AjaxResult.success("", result);
+    }
 }

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

@@ -469,3 +469,20 @@ multi.store.checkout.store.invalid=Cửa hàng không đủ điều kiện giao
 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
+# 008 T074 满额免运活动管理
+promotion.freeDelivery.invalidThreshold=满额免运门槛必须为正整数元
+promotion.freeDelivery.invalidRules=满额免运需且仅需一条门槛规则,且活动时间必须完整
+promotion.freeDelivery.forbiddenStore=无权管理该门店的满额免运活动
+promotion.freeDelivery.overlap=该门店该时间段内已存在满额免运活动,请调整活动时间
+promotion.freeDelivery.notEditable=满额免运活动已开始或已结束,不能修改或删除
+promotion.freeDelivery.notEndable=只能结束进行中的满额免运活动
+promotion.freeDelivery.invalidAddress=收货地址不存在或不属于当前用户
+promotion.freeDelivery.invalidProduct=商品无效或不属于该门店
+promotion.freeDelivery.invalidSpec=商品规格无效,请重新选择
+promotion.freeDelivery.unsupportedOrder=该订单类型暂不支持满额免运优惠
+promotion.freeDelivery.freightUnavailable=暂时无法计算配送费,请稍后再试
+promotion.freeDelivery.alreadyApplied=本单已享有满额免运
+promotion.freeDelivery.quoteChanged=报价已变化,请重新获取报价后再下单
+promotion.freeDelivery.invalidSnapshot=订单优惠快照数据不完整,无法继续处理
+promotion.freeDelivery.configConflict=门店满额免运配置冲突,请联系管理人员
+promotion.freeDelivery.duplicateOrder=订单已提交,请勿重复下单

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

@@ -455,3 +455,20 @@ multi.store.checkout.store.invalid=Store is not eligible for this multi-store de
 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
+# 008 T074 满额免运活动管理
+promotion.freeDelivery.invalidThreshold=Free delivery threshold must be a positive whole number of dollars
+promotion.freeDelivery.invalidRules=Free delivery requires exactly one threshold rule and complete activity dates
+promotion.freeDelivery.forbiddenStore=You are not allowed to manage free delivery activities for this store
+promotion.freeDelivery.overlap=A free delivery activity already exists in this period for this store, please adjust the dates
+promotion.freeDelivery.notEditable=The free delivery activity has started or ended and can no longer be modified or deleted
+promotion.freeDelivery.notEndable=Only ongoing free delivery activities can be ended
+promotion.freeDelivery.invalidAddress=The delivery address does not exist or does not belong to the current user
+promotion.freeDelivery.invalidProduct=Invalid item or the item does not belong to this store
+promotion.freeDelivery.invalidSpec=Invalid item specification, please select again
+promotion.freeDelivery.unsupportedOrder=This order type does not support the free delivery promotion yet
+promotion.freeDelivery.freightUnavailable=Unable to calculate the delivery fee right now, please try again later
+promotion.freeDelivery.alreadyApplied=This order already has free delivery applied
+promotion.freeDelivery.quoteChanged=The quote has changed, please get a new quote before placing the order
+promotion.freeDelivery.invalidSnapshot=The order promotion snapshot is incomplete and cannot be processed
+promotion.freeDelivery.configConflict=The store's free delivery configuration conflicts, please contact support
+promotion.freeDelivery.duplicateOrder=The order has already been submitted, please do not place it again

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

@@ -456,3 +456,20 @@ multi.store.checkout.store.invalid=ร้านค้านี้ไม่สา
 multi.store.checkout.food.invalid=ข้อมูลสินค้า ตัวเลือก หรือโปรโมชันมีการเปลี่ยนแปลง โปรดคำนวณใหม่
 multi.store.checkout.freight.invalid=ไม่สามารถคำนวณค่าจัดส่งได้ โปรดลองอีกครั้ง
 multi.store.checkout.quote.expired=ใบเสนอราคาหมดอายุ โปรดขอใหม่
+# 008 T074 满额免运活动管理
+promotion.freeDelivery.invalidThreshold=เกณฑ์การส่งฟรีต้องเป็นจำนวนเต็มบวกหน่วยหยวน
+promotion.freeDelivery.invalidRules=การส่งฟรีต้องมีกฎเกณฑ์เพียงหนึ่งข้อและระบุช่วงเวลากิจกรรมให้ครบถ้วน
+promotion.freeDelivery.forbiddenStore=คุณไม่มีสิทธิ์จัดการกิจกรรมส่งฟรีของร้านนี้
+promotion.freeDelivery.overlap=ร้านนี้มีกิจกรรมส่งฟรีในช่วงเวลานี้อยู่แล้ว กรุณาปรับช่วงเวลา
+promotion.freeDelivery.notEditable=กิจกรรมส่งฟรีเริ่มหรือสิ้นสุดแล้ว จึงแก้ไขหรือลบไม่ได้
+promotion.freeDelivery.notEndable=สามารถสิ้นสุดได้เฉพาะกิจกรรมส่งฟรีที่กำลังดำเนินอยู่
+promotion.freeDelivery.invalidAddress=ที่อยู่จัดส่งไม่มีอยู่หรือไม่ใช่ของผู้ใช้ปัจจุบัน
+promotion.freeDelivery.invalidProduct=รายการอาหารไม่ถูกต้องหรือไม่ได้อยู่ในร้านนี้
+promotion.freeDelivery.invalidSpec=ตัวเลือกสเปคสินค้าไม่ถูกต้อง กรุณาเลือกใหม่
+promotion.freeDelivery.unsupportedOrder=ประเภทคำสั่งซื้อนี้ยังไม่รองรับโปรโมชั่นส่งฟรี
+promotion.freeDelivery.freightUnavailable=ไม่สามารถคำนวณค่าจัดส่งได้ในขณะนี้ กรุณาลองใหม่ภายหลัง
+promotion.freeDelivery.alreadyApplied=คำสั่งซื้อนี้ได้รับส่งฟรีตามเกณฑ์แล้ว
+promotion.freeDelivery.quoteChanged=ราคาที่เสนอเปลี่ยนแปลง กรุณาขอราคาใหม่ก่อนสั่งซื้อ
+promotion.freeDelivery.invalidSnapshot=ข้อมูลส่วนลดของคำสั่งซื้อไม่ครบถ้วน ไม่สามารถดำเนินการต่อได้
+promotion.freeDelivery.configConflict=การตั้งค่าส่งฟรีของร้านขัดแย้งกัน กรุณาติดต่อผู้ดูแล
+promotion.freeDelivery.duplicateOrder=คำสั่งซื้อถูกส่งไปแล้ว กรุณาอย่าสั่งซื้อซ้ำ

+ 20 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -0,0 +1,20 @@
+# Vietnamese messages (008 T074/T075 free delivery additions).
+# Chi chua cac key moi cua tinh nang miem phi van chuyen; cac key con lai theo
+# ResourceBundle parent chain hoi ve goi mac dinh (messages.properties).
+# 008 满额免运:vi/vi_VN 的越南文父语言包,先覆盖本功能新增 key
+promotion.freeDelivery.invalidThreshold=Ngưỡng miễn phí vận chuyển phải là số nguyên dương (đồng)
+promotion.freeDelivery.invalidRules=Miễn phí vận chuyển cần đúng một quy tắc ngưỡng và thời gian hoạt động phải đầy đủ
+promotion.freeDelivery.forbiddenStore=Bạn không có quyền quản lý hoạt động miễn phí vận chuyển của cửa hàng này
+promotion.freeDelivery.overlap=Cửa hàng này đã có hoạt động miễn phí vận chuyển trong khoảng thời gian này, vui lòng điều chỉnh
+promotion.freeDelivery.notEditable=Hoạt động miễn phí vận chuyển đã bắt đầu hoặc kết thúc, không thể sửa hoặc xóa
+promotion.freeDelivery.notEndable=Chỉ có thể kết thúc hoạt động miễn phí vận chuyển đang diễn ra
+promotion.freeDelivery.invalidAddress=Địa chỉ giao hàng không tồn tại hoặc không thuộc về người dùng hiện tại
+promotion.freeDelivery.invalidProduct=Món hàng không hợp lệ hoặc không thuộc cửa hàng này
+promotion.freeDelivery.invalidSpec=Thông số món hàng không hợp lệ, vui lòng chọn lại
+promotion.freeDelivery.unsupportedOrder=Loại đơn này tạm chưa hỗ trợ ưu đãi miễn phí vận chuyển
+promotion.freeDelivery.freightUnavailable=Không thể tính phí vận chuyển lúc này, vui lòng thử lại sau
+promotion.freeDelivery.alreadyApplied=Đơn này đã được miễn phí vận chuyển theo ngưỡng
+promotion.freeDelivery.quoteChanged=Báo giá đã thay đổi, vui lòng lấy báo giá mới trước khi đặt hàng
+promotion.freeDelivery.invalidSnapshot=Dữ liệu ưu đãi của đơn hàng không đầy đủ, không thể xử lý tiếp
+promotion.freeDelivery.configConflict=Cấu hình miễn phí vận chuyển của cửa hàng bị xung đột, vui lòng liên hệ quản lý
+promotion.freeDelivery.duplicateOrder=Đơn hàng đã được gửi, vui lòng không đặt hàng lặp lại

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

@@ -457,3 +457,20 @@ multi.store.checkout.store.invalid=门店不可参与当前多店配送
 multi.store.checkout.food.invalid=商品、规格或优惠信息已变化,请重新结算
 multi.store.checkout.freight.invalid=当前配送费配置不可用,请稍后重试
 multi.store.checkout.quote.expired=报价已过期,请重新获取
+# 008 T074 满额免运活动管理
+promotion.freeDelivery.invalidThreshold=满额免运门槛必须为正整数元
+promotion.freeDelivery.invalidRules=满额免运需且仅需一条门槛规则,且活动时间必须完整
+promotion.freeDelivery.forbiddenStore=无权管理该门店的满额免运活动
+promotion.freeDelivery.overlap=该门店该时间段内已存在满额免运活动,请调整活动时间
+promotion.freeDelivery.notEditable=满额免运活动已开始或已结束,不能修改或删除
+promotion.freeDelivery.notEndable=只能结束进行中的满额免运活动
+promotion.freeDelivery.invalidAddress=收货地址不存在或不属于当前用户
+promotion.freeDelivery.invalidProduct=商品无效或不属于该门店
+promotion.freeDelivery.invalidSpec=商品规格无效,请重新选择
+promotion.freeDelivery.unsupportedOrder=该订单类型暂不支持满额免运优惠
+promotion.freeDelivery.freightUnavailable=暂时无法计算配送费,请稍后再试
+promotion.freeDelivery.alreadyApplied=本单已享有满额免运
+promotion.freeDelivery.quoteChanged=报价已变化,请重新获取报价后再下单
+promotion.freeDelivery.invalidSnapshot=订单优惠快照数据不完整,无法继续处理
+promotion.freeDelivery.configConflict=门店满额免运配置冲突,请联系管理人员
+promotion.freeDelivery.duplicateOrder=订单已提交,请勿重复下单

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

@@ -456,3 +456,20 @@ multi.store.checkout.store.invalid=門店無法參與目前多店配送
 multi.store.checkout.food.invalid=商品、規格或優惠資訊已變更,請重新結算
 multi.store.checkout.freight.invalid=目前運費設定無法使用,請稍後重試
 multi.store.checkout.quote.expired=報價已過期,請重新取得
+# 008 T074 满额免运活动管理
+promotion.freeDelivery.invalidThreshold=滿額免運門檻必須為正整數元
+promotion.freeDelivery.invalidRules=滿額免運需且僅需一條門檻規則,且活動時間必須完整
+promotion.freeDelivery.forbiddenStore=無權管理該門市的滿額免運活動
+promotion.freeDelivery.overlap=該門市該時間段內已存在滿額免運活動,請調整活動時間
+promotion.freeDelivery.notEditable=滿額免運活動已開始或已結束,不能修改或刪除
+promotion.freeDelivery.notEndable=只能結束進行中的滿額免運活動
+promotion.freeDelivery.invalidAddress=收貨地址不存在或不屬於當前使用者
+promotion.freeDelivery.invalidProduct=商品無效或不屬於該門市
+promotion.freeDelivery.invalidSpec=商品規格無效,請重新選擇
+promotion.freeDelivery.unsupportedOrder=該訂單類型暫不支援滿額免運優惠
+promotion.freeDelivery.freightUnavailable=暫時無法計算配送費,請稍後再試
+promotion.freeDelivery.alreadyApplied=本單已享有滿額免運
+promotion.freeDelivery.quoteChanged=報價已變化,請重新取得報價後再下單
+promotion.freeDelivery.invalidSnapshot=訂單優惠快照資料不完整,無法繼續處理
+promotion.freeDelivery.configConflict=門市滿額免運設定衝突,請聯絡管理人員
+promotion.freeDelivery.duplicateOrder=訂單已提交,請勿重複下單

+ 126 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/ShPromotionActivityFreeDeliveryTest.java

@@ -0,0 +1,126 @@
+package com.ruoyi.app.mendian;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PromotionActivity;
+import com.ruoyi.system.domain.PromotionActivityRule;
+import com.ruoyi.system.dto.PromotionActivitySaveRequest;
+import com.ruoyi.system.service.IPromotionActivityService;
+import com.ruoyi.system.utils.JwtUtil;
+
+/**
+ * T074 商家促销活动控制器:type=5 满额免运保存走 PromotionActivitySaveRequest DTO,
+ * 操作者ID从token解析并传入服务做门店归属校验。
+ */
+@ExtendWith(MockitoExtension.class)
+class ShPromotionActivityFreeDeliveryTest
+{
+    private static final Long OPERATOR_ID = 9L;
+
+    @Mock
+    private IPromotionActivityService promotionActivityService;
+
+    @InjectMocks
+    private ShPromotionActivityController controller;
+
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUpMessages()
+    {
+        // toAjax/error 内部使用 MessageUtils,单测无 Spring 上下文,静态mock返回key
+        messages = org.mockito.Mockito.mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+    }
+
+    @AfterEach
+    void closeMessages()
+    {
+        messages.close();
+    }
+
+    @Test
+    void add_shouldMapDtoAndPassOperatorForFreeDelivery()
+    {
+        String token = JwtUtil.token(String.valueOf(OPERATOR_ID), "merchant");
+        PromotionActivitySaveRequest request = new PromotionActivitySaveRequest();
+        request.setStoreId(1L);
+        request.setType(5);
+        request.setName("原价满100免配送费");
+        PromotionActivityRule rule = new PromotionActivityRule();
+        rule.setThreshold(new BigDecimal("100"));
+        request.setRules(List.of(rule));
+        when(promotionActivityService.createActivity(any(PromotionActivity.class), any(), eq(OPERATOR_ID))).thenReturn(true);
+
+        AjaxResult result = controller.add(token, request);
+
+        assertEquals(200, result.get("code"));
+        ArgumentCaptor<PromotionActivity> captor = ArgumentCaptor.forClass(PromotionActivity.class);
+        verify(promotionActivityService).createActivity(captor.capture(), any(), eq(OPERATOR_ID));
+        assertEquals(5, captor.getValue().getType());
+        assertEquals(1L, captor.getValue().getStoreId());
+        assertEquals("原价满100免配送费", captor.getValue().getName());
+    }
+
+    @Test
+    void add_shouldRejectInvalidTokenBeforeService()
+    {
+        AjaxResult result = controller.add("bad-token", new PromotionActivitySaveRequest());
+
+        assertEquals(500, result.get("code"));
+    }
+
+    @Test
+    void add_shouldPropagateOwnershipFailure()
+    {
+        String token = JwtUtil.token(String.valueOf(OPERATOR_ID), "merchant");
+        when(promotionActivityService.createActivity(any(PromotionActivity.class), any(), eq(OPERATOR_ID)))
+                .thenThrow(new ServiceException("promotion.freeDelivery.forbiddenStore"));
+
+        assertThrows(ServiceException.class,
+                () -> controller.add(token, new PromotionActivitySaveRequest()));
+    }
+
+    @Test
+    void edit_andDetail_shouldCarryOperator()
+    {
+        String token = JwtUtil.token(String.valueOf(OPERATOR_ID), "merchant");
+        PromotionActivitySaveRequest request = new PromotionActivitySaveRequest();
+        request.setId(51L);
+        when(promotionActivityService.updateActivity(any(PromotionActivity.class), any(), eq(OPERATOR_ID))).thenReturn(true);
+
+        controller.edit(token, request);
+        verify(promotionActivityService).updateActivity(any(PromotionActivity.class), any(), eq(OPERATOR_ID));
+
+        controller.getInfo(token, 51L);
+        verify(promotionActivityService).selectActivityWithRules(51L, OPERATOR_ID);
+
+        controller.end(token, 51L);
+        verify(promotionActivityService).endActivity(51L, OPERATOR_ID);
+
+        controller.remove(token, 51L);
+        verify(promotionActivityService).deleteActivity(51L, OPERATOR_ID);
+    }
+}

+ 320 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java

@@ -0,0 +1,320 @@
+package com.ruoyi.app.order;
+
+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.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+import com.ruoyi.app.order.dto.FoodDeliveryPromotionQuoteRequest;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.FoodSpecRelation;
+import com.ruoyi.system.domain.FoodSpecs;
+import com.ruoyi.system.domain.FoodSpecsValue;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PosFreight;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.dto.PromotionCalcRequest;
+import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionDeliveryContext;
+import com.ruoyi.system.mapper.FoodSpecRelationMapper;
+import com.ruoyi.system.mapper.FoodSpecsMapper;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
+import com.ruoyi.system.mapper.InfoAddressMapper;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PosFreightMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.IPromotionCalcService;
+
+/**
+ * T075 满额免运完整配送报价:地址归属/跨店规格/服务端规格加价、路线与运价不可用、
+ * 运费跳表边界(3km/3.49km/3.5km/4.25km/千元舍入)与仅平台骑手外送的范围限制。
+ */
+class FoodDeliveryPromotionQuoteServiceTest
+{
+    private static final Long USER_ID = 9L;
+    private static final Long STORE_ID = 1L;
+    private static final Long ADDRESS_ID = 8L;
+    private static final Long FOOD_ID = 10L;
+    private static final Long SPEC_VALUE_ID = 301L;
+
+    private MockedStatic<MessageUtils> messages;
+
+    private final PosStoreMapper stores = mock(PosStoreMapper.class);
+    private final InfoAddressMapper addresses = mock(InfoAddressMapper.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 SelfDeliveryService selfDelivery = mock(SelfDeliveryService.class);
+    private final IPromotionCalcService promotions = mock(IPromotionCalcService.class);
+
+    private FoodDeliveryPromotionQuoteService service;
+
+    @BeforeEach
+    void setUp()
+    {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+
+        when(stores.selectById(STORE_ID)).thenReturn(store());
+        when(addresses.selectById(ADDRESS_ID)).thenReturn(address(USER_ID));
+        when(selfDelivery.isSelfDelivery(any(), any())).thenReturn(false);
+        when(foods.selectById(FOOD_ID)).thenReturn(food(STORE_ID, "50"));
+        stubSpecGroup(STORE_ID);
+        when(freight.getyunjia()).thenReturn(List.of(fare(20L, 3L, 10L, 1L)));
+        when(promotions.calculate(any(), eq(USER_ID), any())).thenReturn(new PromotionCalcResponse());
+        service = subject();
+    }
+
+    @AfterEach
+    void closeMessages()
+    {
+        messages.close();
+    }
+
+    private FoodDeliveryPromotionQuoteService subject()
+    {
+        return new FoodDeliveryPromotionQuoteService(stores, addresses, foods, relations, specs,
+                values, freight, selfDelivery, promotions);
+    }
+
+    // ==================== 归属与输入校验 ====================
+
+    @Test
+    void quote_shouldRejectForeignAddressAndCrossStoreSpec()
+    {
+        FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
+
+        when(addresses.selectById(ADDRESS_ID)).thenReturn(address(77L));
+        ServiceException foreignAddress = assertThrows(ServiceException.class,
+                () -> spy.quote(request(), USER_ID));
+        assertEquals("promotion.freeDelivery.invalidAddress", foreignAddress.getMessage());
+
+        when(addresses.selectById(ADDRESS_ID)).thenReturn(address(USER_ID));
+        stubSpecGroup(2L);
+        ServiceException crossStoreSpec = assertThrows(ServiceException.class,
+                () -> spy.quote(request(), USER_ID));
+        assertEquals("promotion.freeDelivery.invalidSpec", crossStoreSpec.getMessage());
+    }
+
+    @Test
+    void quote_shouldRejectInvalidProductOrQuantity()
+    {
+        FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
+
+        when(foods.selectById(FOOD_ID)).thenReturn(food(2L, "50"));
+        ServiceException otherStore = assertThrows(ServiceException.class,
+                () -> spy.quote(request(), USER_ID));
+        assertEquals("promotion.freeDelivery.invalidProduct", otherStore.getMessage());
+
+        when(foods.selectById(FOOD_ID)).thenReturn(food(STORE_ID, "50"));
+        FoodDeliveryPromotionQuoteRequest zero = request();
+        zero.getItems().get(0).setQuantity(0);
+        ServiceException badQuantity = assertThrows(ServiceException.class,
+                () -> spy.quote(zero, USER_ID));
+        assertEquals("promotion.freeDelivery.invalidProduct", badQuantity.getMessage());
+    }
+
+    @Test
+    void quote_shouldRejectUnsupportedOrderTypes()
+    {
+        FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
+
+        FoodDeliveryPromotionQuoteRequest pickup = request();
+        pickup.setOrderType(1);
+        ServiceException selfPickup = assertThrows(ServiceException.class,
+                () -> spy.quote(pickup, USER_ID));
+        assertEquals("promotion.freeDelivery.unsupportedOrder", selfPickup.getMessage());
+
+        when(selfDelivery.isSelfDelivery(any(), any())).thenReturn(true);
+        ServiceException selfDeliveryOrder = assertThrows(ServiceException.class,
+                () -> spy.quote(request(), USER_ID));
+        assertEquals("promotion.freeDelivery.unsupportedOrder", selfDeliveryOrder.getMessage());
+
+        when(stores.selectById(STORE_ID)).thenReturn(null);
+        ServiceException missingStore = assertThrows(ServiceException.class,
+                () -> spy.quote(request(), USER_ID));
+        assertEquals("promotion.freeDelivery.unsupportedOrder", missingStore.getMessage());
+    }
+
+    @Test
+    void quote_shouldFailOnMissingRouteOrFare()
+    {
+        FoodDeliveryPromotionQuoteService spy = org.mockito.Mockito.spy(subject());
+        doThrow(new ServiceException("promotion.freeDelivery.freightUnavailable")).when(spy).routeDistanceMeters(any(), any());
+
+        ServiceException noRoute = assertThrows(ServiceException.class,
+                () -> spy.quote(request(), USER_ID));
+        assertEquals("promotion.freeDelivery.freightUnavailable", noRoute.getMessage());
+
+        when(freight.getyunjia()).thenReturn(Collections.emptyList());
+        when(freight.getmiuyunjia()).thenReturn(Collections.emptyList());
+        FoodDeliveryPromotionQuoteService withRoute = spyWithRoute(BigDecimal.valueOf(3000));
+        ServiceException noFare = assertThrows(ServiceException.class,
+                () -> withRoute.quote(request(), USER_ID));
+        assertEquals("promotion.freeDelivery.freightUnavailable", noFare.getMessage());
+    }
+
+    // ==================== 规格加价与运费边界 ====================
+
+    @Test
+    void quote_shouldIgnoreClientSpecPrice()
+    {
+        FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3500));
+
+        PromotionCalcResponse response = spy.quote(request(), USER_ID);
+
+        // 规格加价由服务端按数据库规格值计算(10元),商品原价取数据库价;客户端无报价字段可传
+        ArgumentCaptor<PromotionCalcRequest> captor = ArgumentCaptor.forClass(PromotionCalcRequest.class);
+        ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
+        verify(promotions).calculate(captor.capture(), eq(USER_ID), contextCaptor.capture());
+        assertEquals(1, captor.getValue().getItems().size());
+        assertEquals(FOOD_ID, captor.getValue().getItems().get(0).getProductId());
+        assertEquals(0, new BigDecimal("10").compareTo(captor.getValue().getItems().get(0).getSpecPrice()));
+        assertTrue(contextCaptor.getValue().isEligible());
+        // 3.5km:超起步3km半公里门槛按1km计,单价10 → 20+10=30
+        assertEquals(0, new BigDecimal("30").compareTo(contextCaptor.getValue().getDeliveryFee()));
+        assertTrue(response != null);
+    }
+
+    @Test
+    void fare_shouldFollowLegacyJumpTableBoundaries()
+    {
+        assertFreight(1, "3000", "20");
+        assertFreight(2, "3490", "20");
+        assertFreight(3, "3500", "30");
+        assertFreight(4, "4250", "32");
+    }
+
+    @Test
+    void fare_shouldRoundThousandAndUp()
+    {
+        // 起步1500:≥1000 按 HALF_UP 舍到千位 → 2000
+        FoodDeliveryPromotionQuoteService spy = spyWithRoute(BigDecimal.valueOf(3000));
+        when(freight.getyunjia()).thenReturn(List.of(fare(1500L, 3L, 10L, 1L)));
+        PromotionCalcResponse response = spy.quote(request(), USER_ID);
+        ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
+        verify(promotions).calculate(any(), eq(USER_ID), contextCaptor.capture());
+        assertEquals(0, new BigDecimal("2000").compareTo(contextCaptor.getValue().getDeliveryFee()));
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private void assertFreight(int callIndex, String meters, String expectedFee)
+    {
+        FoodDeliveryPromotionQuoteService spy = spyWithRoute(new BigDecimal(meters));
+        spy.quote(request(), USER_ID);
+        ArgumentCaptor<PromotionDeliveryContext> contextCaptor = ArgumentCaptor.forClass(PromotionDeliveryContext.class);
+        verify(promotions, org.mockito.Mockito.times(callIndex)).calculate(any(), eq(USER_ID), contextCaptor.capture());
+        PromotionDeliveryContext context = contextCaptor.getAllValues().get(callIndex - 1);
+        assertEquals(0, new BigDecimal(expectedFee).compareTo(context.getDeliveryFee()),
+                meters + "m should be " + expectedFee);
+    }
+
+    private FoodDeliveryPromotionQuoteService spyWithRoute(BigDecimal meters)
+    {
+        FoodDeliveryPromotionQuoteService spy = org.mockito.Mockito.spy(subject());
+        doReturn(meters).when(spy).routeDistanceMeters(any(), any());
+        return spy;
+    }
+
+    private FoodDeliveryPromotionQuoteRequest request()
+    {
+        FoodDeliveryPromotionQuoteRequest request = new FoodDeliveryPromotionQuoteRequest();
+        request.setStoreId(STORE_ID);
+        request.setOrderType(0);
+        request.setAddressId(ADDRESS_ID);
+        FoodDeliveryPromotionQuoteRequest.Item item = new FoodDeliveryPromotionQuoteRequest.Item();
+        item.setProductId(FOOD_ID);
+        item.setQuantity(2);
+        item.setSpecValueIds(List.of(SPEC_VALUE_ID));
+        request.setItems(new java.util.ArrayList<>(List.of(item)));
+        return request;
+    }
+
+    private PosStore store()
+    {
+        PosStore store = new PosStore();
+        store.setId(STORE_ID.intValue());
+        store.setUserId(5L);
+        store.setLatitude(new BigDecimal("25.0330"));
+        store.setLongitude(new BigDecimal("121.5654"));
+        return store;
+    }
+
+    private InfoAddress address(Long userId)
+    {
+        InfoAddress address = new InfoAddress();
+        address.setId(ADDRESS_ID);
+        address.setUserId(userId);
+        address.setLatitude(new BigDecimal("25.0400"));
+        address.setLongitude(new BigDecimal("121.5700"));
+        return address;
+    }
+
+    private PosFood food(Long mdId, String price)
+    {
+        PosFood food = new PosFood();
+        food.setId(FOOD_ID);
+        food.setMdid(mdId);
+        food.setPrice(new BigDecimal(price));
+        food.setName("测试商品");
+        food.setStackingUp("0");
+        food.setToExamine("0");
+        return food;
+    }
+
+    private PosFreight fare(Long startingFare, Long startingDistance, Long freightPerUnit, Long distanceUnit)
+    {
+        PosFreight row = new PosFreight();
+        row.setStartingFare(startingFare);
+        row.setStartingDistance(startingDistance);
+        row.setFreight(freightPerUnit);
+        row.setDistance(distanceUnit);
+        return row;
+    }
+
+    /** 规格组300(mdId 指定门店,必选/多选)+ 规格值301(加价10),经 foodId=10 关联 */
+    private void stubSpecGroup(Long mdId)
+    {
+        FoodSpecRelation relation = new FoodSpecRelation();
+        relation.setFoodId(FOOD_ID);
+        relation.setSpecsId(300L);
+        when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of(relation));
+        FoodSpecs group = new FoodSpecs();
+        group.setId(300L);
+        group.setMdId(mdId);
+        group.setIsOpen(true);
+        group.setIsDelete(false);
+        group.setState("1");
+        group.setType("0");
+        when(specs.selectById(300L)).thenReturn(group);
+        FoodSpecsValue value = new FoodSpecsValue();
+        value.setId(SPEC_VALUE_ID);
+        value.setParentId(300L);
+        value.setPrice(new BigDecimal("10"));
+        value.setIsOpen(true);
+        when(values.selectById(SPEC_VALUE_ID)).thenReturn(value);
+    }
+}

+ 192 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderFreeDeliverySettlementTest.java

@@ -0,0 +1,192 @@
+package com.ruoyi.app.order;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.ruoyi.app.service.WalletService;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.UserBilling;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.IUserBillingService;
+import com.ruoyi.system.service.IVipUserQuanyiService;
+
+/**
+ * T077 满额免运结算:商家分成以 G=A−D 为基数(免运不重复扣)、骑手毛配送费 R 与钱包幂等、
+ * 版本2快照缺失拒绝计费、三方派生交款方向(线上/到付/到付A&lt;R)。
+ */
+@ExtendWith(MockitoExtension.class)
+class OrderFreeDeliverySettlementTest
+{
+    @Mock
+    private IUserBillingService userBillingService;
+
+    @Mock
+    private IPosStoreService posStoreService;
+
+    @Mock
+    private IInfoUserService infoUserService;
+
+    @Mock
+    private WalletService walletService;
+
+    @Mock
+    private IVipUserQuanyiService userQuanyiService;
+
+    @InjectMocks
+    private OrderService orderService;
+
+    private MockedStatic<MessageUtils> messages;
+    private MockedStatic<DictUtils> dictCache;
+
+    @BeforeEach
+    void setUp()
+    {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+        dictCache = mockStatic(DictUtils.class);
+        lenient().when(DictUtils.getDictCache(anyString())).thenReturn(null);
+    }
+
+    @AfterEach
+    void tearDown()
+    {
+        messages.close();
+        dictCache.close();
+    }
+
+    @Test
+    void merchantBilling_shouldUseGoodsPayableWithoutDoubleSubsidy()
+    {
+        when(userBillingService.count(any())).thenReturn(0L);
+        PosStore store = new PosStore();
+        store.setId(1);
+        store.setIsStall(0);
+        when(posStoreService.getById(anyLong())).thenReturn(store);
+        InfoUser merchant = new InfoUser();
+        merchant.setUserId(5L);
+        merchant.setCommission(0.10);
+        when(infoUserService.getById(5L)).thenReturn(merchant);
+
+        PosOrder order = versionTwoOrder(100, 30.0, BigDecimal.ZERO, BigDecimal.valueOf(30));
+        order.setMdSalesReduction(10);
+        order.setMdYhId(100L);
+        order.setMdDiscountAmount(0);
+
+        orderService.setSanghuBilling(order);
+
+        // 基数 = A−D = 100(商品优惠已含在A内,免运F不重复扣;旧券ID不查 VipUserQuanyi)
+        ArgumentCaptor<UserBilling> captor = ArgumentCaptor.forClass(UserBilling.class);
+        verify(walletService).addBalance(eq(5L), any(BigDecimal.class), captor.capture());
+        assertEquals(90.0, captor.getValue().getAmount(), 0.001);
+        assertEquals(10.0, captor.getValue().getDivvy(), 0.001);
+        verify(userQuanyiService, never()).getById(anyLong());
+    }
+
+    @Test
+    void merchantBilling_shouldRejectIncompleteVersionTwoSnapshot()
+    {
+        PosOrder order = versionTwoOrder(100, 30.0, null, BigDecimal.valueOf(30));
+
+        ServiceException rejected = assertThrows(ServiceException.class,
+                () -> orderService.setSanghuBilling(order));
+        assertEquals("promotion.freeDelivery.invalidSnapshot", rejected.getMessage());
+        verify(walletService, never()).addBalance(anyLong(), any(BigDecimal.class), any(UserBilling.class));
+    }
+
+    @Test
+    void settle_shouldKeepRiderGrossFreightAndSingleWalletEntry()
+    {
+        when(userBillingService.count(any())).thenReturn(0L, 1L);
+        InfoUser rider = new InfoUser();
+        rider.setUserId(66L);
+        rider.setCommission(0.10);
+        when(infoUserService.getById(66L)).thenReturn(rider);
+
+        PosOrder order = versionTwoOrder(100, 30.0, BigDecimal.ZERO, BigDecimal.valueOf(30));
+        order.setQsId(66L);
+        order.setPayType("2");
+
+        orderService.setQishouBilling(order);
+        ArgumentCaptor<UserBilling> captor = ArgumentCaptor.forClass(UserBilling.class);
+        verify(walletService).addBalance(eq(66L), any(BigDecimal.class), captor.capture());
+        // 骑手毛配送费仍为 R=30:佣金10%后 27,线上单无代收 behalfAmount
+        assertEquals(27.0, captor.getValue().getAmount(), 0.001);
+        assertEquals(3.0, captor.getValue().getDivvy(), 0.001);
+        assertEquals(null, captor.getValue().getBehalfAmount());
+
+        // 重复送达不新增账单(幂等守卫命中)
+        orderService.setQishouBilling(order);
+        verify(walletService).addBalance(eq(66L), any(BigDecimal.class), any(UserBilling.class));
+    }
+
+    @Test
+    void cash_shouldShowDirectionForNegativeMerchantNet()
+    {
+        // 到付 A20/R30:商家倒贴骑手10、客户待收20
+        PosOrder cod = versionTwoOrder(20, 30.0, BigDecimal.ZERO, BigDecimal.valueOf(30));
+        cod.setPayType("1");
+        assertEquals(0, new BigDecimal("10").compareTo(cod.getMerchantPayToRider()));
+        assertEquals(0, BigDecimal.ZERO.compareTo(cod.getRiderPayToMerchant()));
+        assertEquals(0, new BigDecimal("20").compareTo(cod.getCustomerCollectAmount()));
+
+        // 在线已付 A100/R30:商家给骑手30、客户待收0
+        PosOrder online = versionTwoOrder(100, 30.0, BigDecimal.ZERO, BigDecimal.valueOf(30));
+        online.setPayType("2");
+        assertEquals(0, new BigDecimal("30").compareTo(online.getMerchantPayToRider()));
+        assertEquals(0, BigDecimal.ZERO.compareTo(online.getRiderPayToMerchant()));
+        assertEquals(0, BigDecimal.ZERO.compareTo(online.getCustomerCollectAmount()));
+
+        // 到付 A100/R30:骑手给商家70、客户待收100
+        PosOrder codRich = versionTwoOrder(100, 30.0, BigDecimal.ZERO, BigDecimal.valueOf(30));
+        codRich.setPayType("1");
+        assertEquals(0, BigDecimal.ZERO.compareTo(codRich.getMerchantPayToRider()));
+        assertEquals(0, new BigDecimal("70").compareTo(codRich.getRiderPayToMerchant()));
+        assertEquals(0, new BigDecimal("100").compareTo(codRich.getCustomerCollectAmount()));
+    }
+
+    /** 版本2订单:amount=A、freight=R、customerDeliveryFee=D、merchantDeliverySubsidy=F */
+    private PosOrder versionTwoOrder(Integer amount, Double freight, BigDecimal customerFee, BigDecimal subsidy)
+    {
+        PosOrder order = new PosOrder();
+        order.setId(500L);
+        order.setDdId("DD500");
+        order.setMdId(1L);
+        order.setShId(5L);
+        order.setUserId(9L);
+        order.setType(0L);
+        order.setAmount(amount);
+        order.setFreight(freight);
+        order.setPromotionPricingVersion(2);
+        order.setCustomerDeliveryFee(customerFee);
+        order.setMerchantDeliverySubsidy(subsidy);
+        return order;
+    }
+}

+ 92 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderInvoiceFreeDeliveryTest.java

@@ -0,0 +1,92 @@
+package com.ruoyi.app.order;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mockStatic;
+
+import java.math.BigDecimal;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrder;
+
+/**
+ * T077 满额免运发票:版本2按 A−D 取可开票金额(免运示例开100,不因 freight 仍为30 而开70),
+ * 版本2关键金额缺失拒绝开票;旧订单保持 amount−freight 口径。
+ */
+@ExtendWith(MockitoExtension.class)
+class OrderInvoiceFreeDeliveryTest
+{
+    @InjectMocks
+    private OrderInvoiceService orderInvoiceService;
+
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUp()
+    {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+    }
+
+    @AfterEach
+    void tearDown()
+    {
+        messages.close();
+    }
+
+    @Test
+    void invoice_shouldUseCustomerPaidFreight()
+    {
+        PosOrder order = new PosOrder();
+        order.setAmount(100);
+        order.setFreight(30.0);
+        order.setPromotionPricingVersion(2);
+        order.setCustomerDeliveryFee(BigDecimal.ZERO);
+        order.setMerchantDeliverySubsidy(BigDecimal.valueOf(30));
+
+        // 免运:客户实付运费D=0,发票开100(不是 amount−freight=70)
+        assertEquals(100, orderInvoiceService.invoiceGoodsTotal(order));
+
+        // 未命中免运:D=30,发票开70
+        order.setCustomerDeliveryFee(BigDecimal.valueOf(30));
+        assertEquals(70, orderInvoiceService.invoiceGoodsTotal(order));
+    }
+
+    @Test
+    void invoice_shouldRejectIncompleteVersionTwoSnapshot()
+    {
+        PosOrder order = new PosOrder();
+        order.setAmount(100);
+        order.setFreight(30.0);
+        order.setPromotionPricingVersion(2);
+        order.setCustomerDeliveryFee(null);
+
+        ServiceException rejected = assertThrows(ServiceException.class,
+                () -> orderInvoiceService.invoiceGoodsTotal(order));
+        assertEquals("promotion.freeDelivery.invalidSnapshot", rejected.getMessage());
+    }
+
+    @Test
+    void legacyInvoice_shouldKeepAmountMinusFreight()
+    {
+        PosOrder order = new PosOrder();
+        order.setAmount(100);
+        order.setFreight(30.0);
+
+        assertEquals(70, orderInvoiceService.invoiceGoodsTotal(order));
+
+        order.setFreight(null);
+        assertEquals(100, orderInvoiceService.invoiceGoodsTotal(order));
+    }
+}

+ 217 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderPromotionHelperFreeDeliveryTest.java

@@ -0,0 +1,217 @@
+package com.ruoyi.app.order;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.FoodSpecsValue;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderPromotion;
+import com.ruoyi.system.dto.PromotionCalcRequest;
+import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PosOrderPromotionMapper;
+import com.ruoyi.system.service.IPromotionCalcService;
+import com.ruoyi.system.service.IPromotionUserCouponService;
+
+/**
+ * T076 满额免运订单固化:商品优惠汇总排除 subType=5、版本2字段应用、
+ * 服务端 food JSON、appliedCouponId 才核销与 type=5 快照独立落库。
+ */
+@ExtendWith(MockitoExtension.class)
+class OrderPromotionHelperFreeDeliveryTest
+{
+    private static final Long ORDER_ID = 500L;
+    private static final Long FOOD_ID = 10L;
+
+    @Mock
+    private IPromotionCalcService promotionCalcService;
+
+    @Mock
+    private PosOrderPromotionMapper posOrderPromotionMapper;
+
+    @Mock
+    private IPromotionUserCouponService promotionUserCouponService;
+
+    @Mock
+    private PosFoodMapper posFoodMapper;
+
+    @Mock
+    private FoodSpecsValueMapper foodSpecsValueMapper;
+
+    @InjectMocks
+    private OrderPromotionHelper helper;
+
+    @Captor
+    private ArgumentCaptor<List<PosOrderPromotion>> snapshotsCaptor;
+
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUpMessages()
+    {
+        messages = org.mockito.Mockito.mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+    }
+
+    @AfterEach
+    void closeMessages()
+    {
+        messages.close();
+    }
+
+    @Test
+    void snapshot_shouldKeepShippingSeparateFromGoods()
+    {
+        PosOrder order = new PosOrder();
+        PromotionCalcResponse calc = quote();
+        calc.setCouponId(100L);
+
+        helper.fillPromotionFields(order, calc);
+        helper.savePromotionSnapshots(ORDER_ID, calc);
+
+        // 商品促销汇总只含满减10(type=5 免运30由商家承担,不进汇总,也不从餐费再扣)
+        assertEquals(Integer.valueOf(10), order.getMdSalesReduction());
+        assertEquals("满100减10", order.getMdSalesName());
+        assertEquals(Long.valueOf(61L), order.getMdActivity());
+        assertEquals(Long.valueOf(100L), order.getMdYhId());
+        assertEquals(Integer.valueOf(5), order.getMdDiscountAmount());
+
+        verify(posOrderPromotionMapper).insertBatch(snapshotsCaptor.capture());
+        List<PosOrderPromotion> snapshots = snapshotsCaptor.getValue();
+        assertEquals(3, snapshots.size());
+        PosOrderPromotion freeDelivery = snapshots.stream()
+                .filter(row -> Integer.valueOf(5).equals(row.getPromoSubType()))
+                .findFirst().orElseThrow();
+        assertEquals(0, new BigDecimal("30").compareTo(freeDelivery.getReduceAmount()));
+        assertEquals(Integer.valueOf(1), freeDelivery.getPromoType());
+        assertTrue(freeDelivery.getPromoDetail().contains("\"threshold\":100"));
+        // 满减快照独立存在
+        assertTrue(snapshots.stream().anyMatch(row -> Integer.valueOf(1).equals(row.getPromoSubType())));
+    }
+
+    @Test
+    void applyVersion2_shouldPersistAmountsAndSnapshotColumns()
+    {
+        PosOrder order = new PosOrder();
+        helper.applyVersion2Pricing(order, quote());
+
+        assertEquals(Integer.valueOf(100), order.getAmount());
+        assertEquals(Double.valueOf(30.0), order.getFreight());
+        assertEquals(Integer.valueOf(100), order.getFoodAmount());
+        assertEquals(Integer.valueOf(2), order.getPromotionPricingVersion());
+        assertEquals(0, new BigDecimal("30").compareTo(order.getMerchantDeliverySubsidy()));
+        assertEquals(0, new BigDecimal("0").compareTo(order.getCustomerDeliveryFee()));
+    }
+
+    @Test
+    void buildServerFoodJson_shouldUseDatabasePricesOnly()
+    {
+        PosFood food = new PosFood();
+        food.setId(FOOD_ID);
+        food.setName("测试商品");
+        food.setImage("/img.png");
+        food.setPrice(new BigDecimal("50"));
+        when(posFoodMapper.selectById(FOOD_ID)).thenReturn(food);
+        FoodSpecsValue value = new FoodSpecsValue();
+        value.setId(301L);
+        value.setPrice(new BigDecimal("10"));
+        when(foodSpecsValueMapper.selectById(301L)).thenReturn(value);
+
+        PromotionCalcRequest.CartItem item = new PromotionCalcRequest.CartItem();
+        item.setProductId(FOOD_ID);
+        item.setQuantity(2);
+        item.setSpecValueIds(List.of(301L));
+
+        String json = helper.buildServerFoodJson(List.of(item));
+        JSONArray foods = JSON.parseArray(json);
+        assertEquals(1, foods.size());
+        assertEquals(50, foods.getJSONObject(0).getIntValue("price"));
+        assertEquals(10, foods.getJSONObject(0).getIntValue("otherPrice"));
+        assertEquals(2, foods.getJSONObject(0).getIntValue("number"));
+        assertEquals("测试商品", foods.getJSONObject(0).getString("name"));
+    }
+
+    @Test
+    void resolveCoupon_shouldUseAppliedOnlyForVersionTwo()
+    {
+        PromotionCalcResponse calc = quote();
+        calc.setCouponId(100L);
+        assertNull(helper.resolveCouponToConsume(calc, true));
+
+        calc.setAppliedCouponId(100L);
+        assertEquals(Long.valueOf(100L), helper.resolveCouponToConsume(calc, true));
+        // 旧流程仍按 couponId 意图核销
+        assertEquals(Long.valueOf(100L), helper.resolveCouponToConsume(calc, false));
+        assertNull(helper.resolveCouponToConsume(null, true));
+    }
+
+    /** 报价样例:O100/R30/F30/D0/G100/A100,明细含 type=5 免运30、满减10、券5 */
+    private PromotionCalcResponse quote()
+    {
+        PromotionCalcResponse calc = new PromotionCalcResponse();
+        calc.setPricingVersion(2);
+        calc.setOriginalAmount(new BigDecimal("100"));
+        calc.setFinalAmount(new BigDecimal("100"));
+        calc.setDeliveryFee(new BigDecimal("30"));
+        calc.setDeliveryDiscountAmount(new BigDecimal("30"));
+        calc.setCustomerDeliveryFee(BigDecimal.ZERO);
+        calc.setPayableAmount(new BigDecimal("100"));
+
+        List<PromotionDetail> details = new ArrayList<>();
+        PromotionDetail freeDelivery = new PromotionDetail();
+        freeDelivery.setType("promotion");
+        freeDelivery.setSubType(5);
+        freeDelivery.setName("原价满100免配送费");
+        freeDelivery.setRefId(51L);
+        freeDelivery.setReduce(new BigDecimal("30"));
+        freeDelivery.setOriginalAmount(new BigDecimal("100"));
+        freeDelivery.setDeliveryFee(new BigDecimal("30"));
+        freeDelivery.setCustomerDeliveryFee(BigDecimal.ZERO);
+        freeDelivery.setThreshold(new BigDecimal("100"));
+        details.add(freeDelivery);
+
+        PromotionDetail manjian = new PromotionDetail();
+        manjian.setType("promotion");
+        manjian.setSubType(1);
+        manjian.setName("满100减10");
+        manjian.setRefId(61L);
+        manjian.setReduce(new BigDecimal("10"));
+        details.add(manjian);
+
+        PromotionDetail coupon = new PromotionDetail();
+        coupon.setType("coupon");
+        coupon.setSubType(0);
+        coupon.setName("满减券");
+        coupon.setRefId(200L);
+        coupon.setReduce(new BigDecimal("5"));
+        details.add(coupon);
+        calc.setDetails(details);
+        return calc;
+    }
+}

+ 396 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/UserOrderFreeDeliveryCreateTest.java

@@ -0,0 +1,396 @@
+package com.ruoyi.app.order;
+
+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.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.OperatingHours;
+import com.ruoyi.system.domain.OrderParent;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderPromotion;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.dto.PromotionCalcRequest;
+import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
+import com.ruoyi.system.mapper.FoodSpecsValueMapper;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PosOrderPromotionMapper;
+import com.ruoyi.system.service.IInfoAddressService;
+import com.ruoyi.system.service.IInfoInvoiceService;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IOperatingHoursService;
+import com.ruoyi.system.service.IOrderParentService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.IPromotionCalcService;
+import com.ruoyi.system.service.IPromotionUserCouponService;
+import com.ruoyi.system.service.IUserFootprintService;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import com.ruoyi.system.service.StallQualificationGateService;
+import com.ruoyi.system.utils.JwtUtil;
+import com.ruoyi.system.utils.OrderLogHelper;
+
+/**
+ * T076 版本2下单闭环:父子金额服务端固化、报价差异拒绝回滚、多店/非外送拒绝、
+ * 零实减券不核销、旧请求保持原行为。
+ */
+@ExtendWith(MockitoExtension.class)
+class UserOrderFreeDeliveryCreateTest
+{
+    private static final Long USER_ID = 9L;
+    private static final Long STORE_ID = 1L;
+    private static final Long ADDRESS_ID = 8L;
+    private static final Long FOOD_ID = 10L;
+    private static final String DD_ID = "DD10086";
+
+    @Mock private IOrderParentService orderParentService;
+    @Mock private PaymentMethodGateService paymentMethodGateService;
+    @Mock private IPosOrderService posOrderService;
+    @Mock private IPosStoreService posStoreService;
+    @Mock private StallQualificationGateService stallQualificationGateService;
+    @Mock private IInfoAddressService infoAddressService;
+    @Mock private IOperatingHoursService operatingHoursService;
+    @Mock private IUserFootprintService userFootprintService;
+    @Mock private IInfoUserService infoUserService;
+    @Mock private PushEventService pushEventService;
+    @Mock private OrderLogHelper orderLogHelper;
+    @Mock private PosOrderPromotionMapper posOrderPromotionMapper;
+    @Mock private OrderInvoiceService orderInvoiceService;
+    @Mock private IInfoInvoiceService infoInvoiceService;
+    @Mock private IPosOrderLinePaymentService linePaymentService;
+    @Mock private LinePayRefundService linePayRefundService;
+    @Mock private OrderLifecycleService orderLifecycleService;
+    @Mock private DeliveryOrderNotificationService deliveryOrderNotificationService;
+    @Mock private MerchantNotificationRouter merchantNotificationRouter;
+    @Mock private SelfDeliveryService selfDeliveryService;
+    @Mock private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
+    @Mock private IPromotionCalcService promotionCalcService;
+    @Mock private IPromotionUserCouponService promotionUserCouponService;
+    @Mock private PosFoodMapper posFoodMapper;
+    @Mock private FoodSpecsValueMapper foodSpecsValueMapper;
+
+    @Spy
+    private OrderPromotionHelper orderPromotionHelper = new OrderPromotionHelper();
+
+    @InjectMocks
+    private UserOrderController controller;
+
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUp()
+    {
+        messages = org.mockito.Mockito.mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+
+        // 真实 Helper 的依赖用 mock 反射注入(Spy 实例与控制器共享同一对象)
+        inject(orderPromotionHelper, "promotionCalcService", promotionCalcService);
+        inject(orderPromotionHelper, "posOrderPromotionMapper", posOrderPromotionMapper);
+        inject(orderPromotionHelper, "promotionUserCouponService", promotionUserCouponService);
+        inject(orderPromotionHelper, "posFoodMapper", posFoodMapper);
+        inject(orderPromotionHelper, "foodSpecsValueMapper", foodSpecsValueMapper);
+
+        lenient().when(orderParentService.count(any())).thenReturn(0L);
+        lenient().when(orderParentService.save(any(OrderParent.class))).thenReturn(true);
+        lenient().when(orderParentService.getOne(any())).thenReturn(new OrderParent());
+        lenient().when(infoAddressService.getById(ADDRESS_ID)).thenReturn(address());
+        lenient().when(posStoreService.list(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(store()));
+        lenient().when(operatingHoursService.list(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(hours()));
+        lenient().when(selfDeliveryService.isSelfDelivery(any(), any())).thenReturn(false);
+        lenient().when(posOrderService.getOne(any())).thenReturn(null);
+        lenient().when(posOrderService.save(any(PosOrder.class))).thenReturn(true);
+        lenient().when(userFootprintService.getOne(any())).thenReturn(null);
+        lenient().when(userFootprintService.saveOrUpdate(any())).thenReturn(true);
+        lenient().when(infoUserService.getOne(any())).thenReturn(null);
+        lenient().when(posFoodMapper.selectById(FOOD_ID)).thenReturn(food());
+        lenient().when(foodDeliveryPromotionQuoteService.quote(any(), eq(USER_ID))).thenReturn(quote());
+    }
+
+    @AfterEach
+    void closeMessages()
+    {
+        messages.close();
+    }
+
+    @Test
+    void create_shouldPersistConsistentParentAndChildAmounts() throws Exception
+    {
+        AjaxResult result = controller.createOrder(token(), input(100, 30.0, 100L));
+
+        assertEquals(200, result.get("code"));
+        ArgumentCaptor<OrderParent> parentCaptor = ArgumentCaptor.forClass(OrderParent.class);
+        verify(orderParentService).save(parentCaptor.capture());
+        assertEquals(Long.valueOf(130L), parentCaptor.getValue().getTotalAmount());
+        assertEquals(Long.valueOf(100L), parentCaptor.getValue().getActualPayAmount());
+        assertEquals(Long.valueOf(30L), parentCaptor.getValue().getTotalDiscount());
+
+        ArgumentCaptor<PosOrder> orderCaptor = ArgumentCaptor.forClass(PosOrder.class);
+        verify(posOrderService).save(orderCaptor.capture());
+        PosOrder order = orderCaptor.getValue();
+        assertEquals(Integer.valueOf(100), order.getAmount());
+        assertEquals(Double.valueOf(30.0), order.getFreight());
+        assertEquals(Integer.valueOf(100), order.getFoodAmount());
+        assertEquals(Integer.valueOf(2), order.getPromotionPricingVersion());
+        assertEquals(0, new BigDecimal("30").compareTo(order.getMerchantDeliverySubsidy()));
+        assertEquals(0, BigDecimal.ZERO.compareTo(order.getCustomerDeliveryFee()));
+        // food JSON 由服务端按数据库商品生成,不信客户端 food
+        JSONArray foods = JSON.parseArray(order.getFood());
+        assertEquals(1, foods.size());
+        assertEquals(50, foods.getJSONObject(0).getIntValue("price"));
+        assertEquals(2, foods.getJSONObject(0).getIntValue("number"));
+        // 商品促销汇总只含满减10,不含免运30
+        assertEquals(Integer.valueOf(10), order.getMdSalesReduction());
+
+        verify(posOrderPromotionMapper).insertBatch(any());
+        verify(promotionUserCouponService, never()).markAsUsed(any(), any(), any());
+        verify(deliveryOrderNotificationService).notifyOrderAvailable(any(PosOrder.class));
+    }
+
+    @Test
+    void create_shouldRejectChangedQuoteAndRollback()
+    {
+        ServiceException rejected = assertThrows(ServiceException.class,
+                () -> controller.createOrder(token(), input(120, 30.0, 100L)));
+        assertEquals("promotion.freeDelivery.quoteChanged", rejected.getMessage());
+        verify(orderParentService, never()).save(any(OrderParent.class));
+        verify(posOrderService, never()).save(any(PosOrder.class));
+        verify(posOrderPromotionMapper, never()).insertBatch(any());
+    }
+
+    @Test
+    void create_shouldRejectMismatchedOrderTypesOrMultipleStoresInV2()
+    {
+        OrderCreateInput pickup = input(100, 30.0, 100L);
+        pickup.setType(1L);
+        ServiceException typeRejected = assertThrows(ServiceException.class,
+                () -> controller.createOrder(token(), pickup));
+        assertEquals("promotion.freeDelivery.unsupportedOrder", typeRejected.getMessage());
+
+        OrderCreateInput multiStore = input(100, 30.0, 100L);
+        OrderCreatItem second = versionTwoItem(100, 30.0);
+        second.setMdId(2L);
+        multiStore.setItems(new ArrayList<>(List.of(versionTwoItem(100, 30.0), second)));
+        ServiceException multiRejected = assertThrows(ServiceException.class,
+                () -> controller.createOrder(token(), multiStore));
+        assertEquals("promotion.freeDelivery.unsupportedOrder", multiRejected.getMessage());
+
+        verify(foodDeliveryPromotionQuoteService, never()).quote(any(), any());
+        verify(posOrderService, never()).save(any(PosOrder.class));
+    }
+
+    @Test
+    void create_shouldNotUseZeroBenefitCoupon()
+    {
+        PromotionCalcResponse zeroBenefit = quote();
+        zeroBenefit.setCouponId(100L);
+        zeroBenefit.setAppliedCouponId(null);
+        when(foodDeliveryPromotionQuoteService.quote(any(), eq(USER_ID))).thenReturn(zeroBenefit);
+
+        controller.createOrder(token(), input(100, 30.0, 100L));
+
+        verify(promotionUserCouponService, never()).markAsUsed(any(), any(), any());
+    }
+
+    @Test
+    void legacyCreate_shouldKeepOriginalBehavior()
+    {
+        OrderCreateInput legacy = input(100, 30.0, 100L);
+        legacy.getItems().get(0).setPromotionPricingVersion(null);
+        legacy.getItems().get(0).setCartItems(null);
+
+        controller.createOrder(token(), legacy);
+
+        verify(foodDeliveryPromotionQuoteService, never()).quote(any(), any());
+        verify(promotionCalcService, never()).calculate(any(), any());
+        ArgumentCaptor<OrderParent> parentCaptor = ArgumentCaptor.forClass(OrderParent.class);
+        verify(orderParentService).save(parentCaptor.capture());
+        assertEquals(Long.valueOf(130L), parentCaptor.getValue().getTotalAmount());
+        assertEquals(Long.valueOf(100L), parentCaptor.getValue().getActualPayAmount());
+        assertNull(parentCaptor.getValue().getTotalDiscount());
+    }
+
+    // ==================== 夹具 ====================
+
+    private static void assertNull(Object value)
+    {
+        assertTrue(value == null, "expected null but " + value);
+    }
+
+    private String token()
+    {
+        return JwtUtil.token(String.valueOf(USER_ID), "user");
+    }
+
+    private OrderCreateInput input(Integer childAmount, Double freight, Long actualPay)
+    {
+        OrderCreateInput input = new OrderCreateInput();
+        input.setDdId(DD_ID);
+        input.setShdzId(ADDRESS_ID);
+        input.setType(0L);
+        input.setTotalAmount(130L);
+        input.setActualPayAmount(actualPay);
+        input.setPaymentMethod("2");
+        input.setItems(new ArrayList<>(List.of(versionTwoItem(childAmount, freight))));
+        return input;
+    }
+
+    private OrderCreatItem versionTwoItem(Integer amount, Double freight)
+    {
+        OrderCreatItem item = new OrderCreatItem();
+        item.setMdId(STORE_ID);
+        item.setType(0L);
+        item.setAmount(amount);
+        item.setFreight(freight);
+        item.setPromotionPricingVersion(2);
+        PromotionCalcRequest.CartItem cartItem = new PromotionCalcRequest.CartItem();
+        cartItem.setProductId(FOOD_ID);
+        cartItem.setQuantity(2);
+        item.setCartItems(new ArrayList<>(List.of(cartItem)));
+        return item;
+    }
+
+    private InfoAddress address()
+    {
+        InfoAddress address = new InfoAddress();
+        address.setId(ADDRESS_ID);
+        address.setUserId(USER_ID);
+        return address;
+    }
+
+    private PosStore store()
+    {
+        PosStore store = new PosStore();
+        store.setId(STORE_ID.intValue());
+        store.setUserId(5L);
+        store.setState(0L);
+        store.setType(1L);
+        store.setLongitude(new BigDecimal("121.5654"));
+        store.setLatitude(new BigDecimal("25.0330"));
+        return store;
+    }
+
+    private OperatingHours hours()
+    {
+        OperatingHours hours = new OperatingHours();
+        hours.setMdId(STORE_ID);
+        hours.setDayOfWeek(LocalDate.now().getDayOfWeek().getValue());
+        hours.setIsOpen(true);
+        hours.setStartTime("00:00");
+        hours.setEndTime("23:59");
+        return hours;
+    }
+
+    private PosFood food()
+    {
+        PosFood food = new PosFood();
+        food.setId(FOOD_ID);
+        food.setMdid(STORE_ID);
+        food.setPrice(new BigDecimal("50"));
+        food.setName("测试商品");
+        food.setStackingUp("0");
+        food.setToExamine("0");
+        return food;
+    }
+
+    /** 服务端报价:O100/G100/R30/F30/D0/A100,明细含免运30与满减10 */
+    private PromotionCalcResponse quote()
+    {
+        PromotionCalcResponse calc = new PromotionCalcResponse();
+        calc.setPricingVersion(2);
+        calc.setOriginalAmount(new BigDecimal("100"));
+        calc.setFinalAmount(new BigDecimal("100"));
+        calc.setDeliveryFee(new BigDecimal("30"));
+        calc.setDeliveryDiscountAmount(new BigDecimal("30"));
+        calc.setCustomerDeliveryFee(BigDecimal.ZERO);
+        calc.setPayableAmount(new BigDecimal("100"));
+
+        List<PromotionDetail> details = new ArrayList<>();
+        PromotionDetail freeDelivery = new PromotionDetail();
+        freeDelivery.setType("promotion");
+        freeDelivery.setSubType(5);
+        freeDelivery.setName("原价满100免配送费");
+        freeDelivery.setRefId(51L);
+        freeDelivery.setReduce(new BigDecimal("30"));
+        freeDelivery.setOriginalAmount(new BigDecimal("100"));
+        freeDelivery.setDeliveryFee(new BigDecimal("30"));
+        freeDelivery.setCustomerDeliveryFee(BigDecimal.ZERO);
+        freeDelivery.setThreshold(new BigDecimal("100"));
+        details.add(freeDelivery);
+        PromotionDetail manjian = new PromotionDetail();
+        manjian.setType("promotion");
+        manjian.setSubType(1);
+        manjian.setName("满100减10");
+        manjian.setRefId(61L);
+        manjian.setReduce(new BigDecimal("10"));
+        details.add(manjian);
+        calc.setDetails(details);
+        return calc;
+    }
+
+    private static void inject(Object target, String fieldName, Object value)
+    {
+        try
+        {
+            java.lang.reflect.Field field = findField(target.getClass(), fieldName);
+            field.setAccessible(true);
+            field.set(target, value);
+        }
+        catch (Exception e)
+        {
+            throw new IllegalStateException("inject " + fieldName, e);
+        }
+    }
+
+    private static java.lang.reflect.Field findField(Class<?> type, String fieldName) throws NoSuchFieldException
+    {
+        for (Class<?> current = type; current != null; current = current.getSuperclass())
+        {
+            try
+            {
+                return current.getDeclaredField(fieldName);
+            }
+            catch (NoSuchFieldException ignored)
+            {
+                // 继续向父类查找
+            }
+        }
+        throw new NoSuchFieldException(fieldName);
+    }
+}

+ 34 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayServiceCreateTest.java

@@ -97,6 +97,40 @@ class LinePayServiceCreateTest {
                 any(), any(), any());
     }
 
+    @Test
+    void freeDeliveryVersionTwoOrderRequestsOnlyCustomerPayable() throws Exception {
+        // 008 满额免运:版本2订单渠道请求额=A(100),不含商家承担的免运F(30)与原配送费R(30),
+        // 后续退款上限即渠道实际交易A,不把补贴F退给客户(spec FR-036)
+        PosOrder order = order();
+        order.setPromotionPricingVersion(2);
+        order.setAmount(100);
+        order.setFreight(30.0);
+        order.setCustomerDeliveryFee(java.math.BigDecimal.ZERO);
+        order.setMerchantDeliverySubsidy(java.math.BigDecimal.valueOf(30));
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderService.count(any(Wrapper.class))).thenReturn(1L);
+        when(credentialService.getEnabledCurrent(9L)).thenReturn(credential());
+        when(paymentService.getActiveByDdId("DD-100")).thenReturn(null);
+        when(paymentService.createRequesting(eq("DD-100"), anyString(), eq(5L), eq(9L),
+                eq(100), eq("TWD"), eq("ONLINE"), any())).thenReturn(payment("REQUESTING"));
+        JSONObject paymentUrl = new JSONObject();
+        paymentUrl.put("web", "https://pay.example/web");
+        JSONObject info = new JSONObject();
+        info.put("transactionId", "2026081200000000002");
+        info.put("paymentUrl", paymentUrl);
+        when(linePayClient.request(any(), any())).thenReturn(new LinePayResponse(
+                200, "0000", "Success", "2026081200000000002", info, "{}"));
+        when(paymentService.markRequestSucceeded(eq(70L), eq(0L),
+                eq("2026081200000000002"), eq("https://pay.example/web"),
+                any(), any(), any())).thenReturn(1);
+
+        LinePayCreateResult result = service.create(101L, "DD-100");
+
+        assertEquals("WAITING_AUTH", result.getStatus());
+        verify(paymentService).createRequesting(eq("DD-100"), anyString(), eq(5L), eq(9L),
+                eq(100), eq("TWD"), eq("ONLINE"), any());
+    }
+
     @Test
     void transportUnknownKeepsSameAttemptBlockedForReadOnlyRecovery() throws Exception {
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order());

+ 41 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java

@@ -244,6 +244,47 @@ class PayMethodSwitchServiceTest {
         assertEquals("no.pay.switch.order.invalid", exception.getMessage());
     }
 
+    @Test
+    void switchFreeDeliveryOrderToCodKeepsWaiverSnapshotAndChangeOnlyCashDirection() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        // 版本2满额免运单:A100/R30/F30/D0,LINE Pay 待支付,切到付
+        PosOrder order = baseOrder();
+        order.setType(0L);
+        order.setDeliveryStatus(0L);
+        order.setAfterSaleStatus(0L);
+        order.setAmount(100);
+        order.setFreight(30.0);
+        order.setPromotionPricingVersion(2);
+        order.setCustomerDeliveryFee(java.math.BigDecimal.ZERO);
+        order.setMerchantDeliverySubsidy(java.math.BigDecimal.valueOf(30));
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orders.update(any(Wrapper.class))).thenReturn(true);
+        PayMethodSwitchService service = new PayMethodSwitchService(orders,
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        // 切换前(线上):商家给骑手R=30、客户待收0
+        org.junit.jupiter.api.Assertions.assertEquals(0,
+                java.math.BigDecimal.valueOf(30).compareTo(order.getMerchantPayToRider()));
+        org.junit.jupiter.api.Assertions.assertEquals(0,
+                java.math.BigDecimal.ZERO.compareTo(order.getCustomerCollectAmount()));
+
+        AjaxResult result = service.switchMethod(7L, request("dd-1", "1"));
+
+        org.junit.jupiter.api.Assertions.assertEquals("1", ((java.util.Map<?, ?>) result.get("data")).get("payType"));
+        // 快照字段不被切换改写:A/R/F/D 原样,只有交款方向随当前 payType 变化
+        org.junit.jupiter.api.Assertions.assertEquals(Integer.valueOf(100), order.getAmount());
+        org.junit.jupiter.api.Assertions.assertEquals(Double.valueOf(30.0), order.getFreight());
+        org.junit.jupiter.api.Assertions.assertEquals(Integer.valueOf(2), order.getPromotionPricingVersion());
+        order.setPayType("1");
+        org.junit.jupiter.api.Assertions.assertEquals(0,
+                java.math.BigDecimal.valueOf(70).compareTo(order.getRiderPayToMerchant()));
+        org.junit.jupiter.api.Assertions.assertEquals(0,
+                java.math.BigDecimal.valueOf(100).compareTo(order.getCustomerCollectAmount()));
+        org.junit.jupiter.api.Assertions.assertEquals(0,
+                java.math.BigDecimal.ZERO.compareTo(order.getMerchantPayToRider()));
+    }
+
     @Test
     void multiStoreSubOrderIsRejected() {
         IPosOrderService orderService = mock(IPosOrderService.class);

+ 66 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java

@@ -361,4 +361,70 @@ public class PosOrder {
      */
     private Long afterSaleStatus;
 
+    /**
+     * 促销定价版本:2=满额免运完整配送金额契约(008,2026-10-19);NULL=旧流程,
+     * 历史订单不回填不重算。
+     */
+    private Integer promotionPricingVersion;
+
+    /**
+     * 商家承担的免运金额 F(promotionPricingVersion=2 时固化;未命中免运为0);
+     * 旧流程 NULL。不与商品优惠汇总混算,不从餐费重复扣减。
+     */
+    private BigDecimal merchantDeliverySubsidy;
+
+    /**
+     * 客户实付配送费 D(promotionPricingVersion=2 时固化);旧流程 NULL,
+     * 读取时按既有 freight 回退。freight 仍存原配送费 R,不清零。
+     */
+    private BigDecimal customerDeliveryFee;
+
+    // ==================== 满额免运派生交款字段(008 T077,2026-10-19) ====================
+    // 三个数值均为 @TableField(exist=false) 语义的派生 getter,不落库、不新存结算状态;
+    // 由订单快照(A=freight 同行的 amount、R=freight)与当前 payType 推导,
+    // 支付方式切换后查询即时反映新方向(spec FR-035)。仅外送单(type=0)返回,其余为 null。
+
+    /** 到付判定:按当前 payType=1(历史 collectPayment 语义在切换后以 payType 为准) */
+    private boolean collectOnDelivery() {
+        return "1".equals(payType) && Long.valueOf(0L).equals(type);
+    }
+
+    private BigDecimal freightAmount() {
+        return freight == null ? BigDecimal.ZERO : BigDecimal.valueOf(freight);
+    }
+
+    private BigDecimal payableAmountValue() {
+        return amount == null ? BigDecimal.ZERO : BigDecimal.valueOf(amount);
+    }
+
+    /** 取餐交付:商家给骑手金额 = 线上已付 R;到付 A&lt;R 时 R−A(非外送或到付A≥R为0语义由另一字段表达) */
+    public BigDecimal getMerchantPayToRider() {
+        if (!Long.valueOf(0L).equals(type)) {
+            return null;
+        }
+        if (!collectOnDelivery()) {
+            return freightAmount();
+        }
+        return freightAmount().subtract(payableAmountValue()).max(BigDecimal.ZERO);
+    }
+
+    /** 取餐交付:骑手给商家金额 = 到付且 A&gt;R 时 A−R;线上已付为 0 */
+    public BigDecimal getRiderPayToMerchant() {
+        if (!Long.valueOf(0L).equals(type)) {
+            return null;
+        }
+        if (!collectOnDelivery()) {
+            return BigDecimal.ZERO;
+        }
+        return payableAmountValue().subtract(freightAmount()).max(BigDecimal.ZERO);
+    }
+
+    /** 客户待收金额 = 到付 A;线上已付为 0 */
+    public BigDecimal getCustomerCollectAmount() {
+        if (!Long.valueOf(0L).equals(type)) {
+            return null;
+        }
+        return collectOnDelivery() ? payableAmountValue() : BigDecimal.ZERO;
+    }
+
 }

+ 59 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionActivitySaveRequest.java

@@ -0,0 +1,59 @@
+package com.ruoyi.system.dto;
+
+import java.util.Date;
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.ruoyi.system.domain.PromotionActivity;
+import com.ruoyi.system.domain.PromotionActivityRule;
+
+import lombok.Data;
+
+/**
+ * 商家促销活动保存请求DTO(2026-10-19 满额免运,spec FR-028)。
+ *
+ * 字段与既有 POST/PUT /system/shPromotionActivity 的请求体完全兼容:
+ * 商家PC旧四种类型继续用相同结构提交;type=5 满额免运仅填单条 threshold 规则。
+ * 仅承载请求数据,业务校验在 Service 中完成。
+ */
+@Data
+public class PromotionActivitySaveRequest
+{
+    /** 活动ID(修改时必填) */
+    private Long id;
+
+    /** 门店ID */
+    private Long storeId;
+
+    /** 类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 5=满额免运 */
+    private Integer type;
+
+    /** 活动名称 */
+    private String name;
+
+    /** 开始时间(yyyy-MM-dd,兼容旧客户端补带的时分秒) */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date startTime;
+
+    /** 结束时间(yyyy-MM-dd,兼容旧客户端补带的时分秒) */
+    @JsonFormat(pattern = "yyyy-MM-dd")
+    private Date endTime;
+
+    /** 活动规则 */
+    private List<PromotionActivityRule> rules;
+
+    /**
+     * 转为活动实体(规则单独返回,沿用服务层既有入参形态)。
+     */
+    public PromotionActivity toActivity()
+    {
+        PromotionActivity activity = new PromotionActivity();
+        activity.setId(id);
+        activity.setStoreId(storeId);
+        activity.setType(type);
+        activity.setName(name);
+        activity.setStartTime(startTime);
+        activity.setEndTime(endTime);
+        return activity;
+    }
+}

+ 4 - 1
ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcRequest.java

@@ -10,7 +10,8 @@ import lombok.Data;
  * 入参说明:
  * - storeId   (必填) 门店ID
  * - items     (必填) 购物车商品列表 [{productId, quantity, specPrice}],不传商品价格,后端从数据库查;
- *               specPrice(选填)规格加价,可为0或不传,实付单价=商品价格+规格价格
+ *               specPrice(选填)规格加价,可为0或不传,实付单价=商品价格+规格价格;
+ *               specValueIds(选填)规格值ID列表,完整配送报价由服务端按数据库核实规格并覆盖加价
  * - couponId  (选填) 用户要使用的优惠券ID,对应 promotion_user_coupon.id,每单只能用一张
  * - forcePath (选填) 强制指定路径 "A"(折扣/第二份半价) 或 "B"(满减),不传则系统自动选最优
  */
@@ -40,5 +41,7 @@ public class PromotionCalcRequest {
         private Integer quantity;
         /** 规格加价(选填, 可为0或不传) — 有规格商品时除商品价外需加上的规格价格,实付单价=商品价格+规格价格 */
         private BigDecimal specPrice;
+        /** 规格值ID列表(选填) — 完整配送报价入口由服务端核实归属后自行计算加价,不以客户端报价为准 */
+        private List<Long> specValueIds;
     }
 }

+ 41 - 1
ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcResponse.java

@@ -75,6 +75,38 @@ public class PromotionCalcResponse {
     /** 商品券参与优惠的商品明细(仅商品券生效时返回, 含名称/原价/优惠后价) */
     private List<LineItem> couponProductItems;
 
+    // ==================== 满额免运完整配送报价扩展(2026-10-19,pricingVersion=2) ====================
+
+    /** 完整配送报价版本;新流程固定2,旧接口不返回 */
+    private Integer pricingVersion;
+
+    /** 原配送费 R(整数元,骑手毛配送费基数) */
+    private BigDecimal deliveryFee;
+
+    /** 满额免运减免额 F(不从商品金额扣除) */
+    private BigDecimal deliveryDiscountAmount;
+
+    /** 客户实际支付配送费 D */
+    private BigDecimal customerDeliveryFee;
+
+    /** 客户整单应付 A = finalAmount(G) + D */
+    private BigDecimal payableAmount;
+
+    /** 命中的满额免运活动ID(未命中为null) */
+    private Long freeDeliveryActivityId;
+
+    /** 命中的满额免运原价门槛(未命中为null) */
+    private BigDecimal freeDeliveryThreshold;
+
+    /** 总优惠预算 B(限制开关关闭时为null表示无限,不用0表示) */
+    private BigDecimal discountBudget;
+
+    /** 全部受限优惠分配后剩余预算(开关关闭时为null) */
+    private BigDecimal remainingDiscountBudget;
+
+    /** 实际生效且实减>0的优惠券ID(核销以此为准;couponId仅表示用户选择意图) */
+    private Long appliedCouponId;
+
     /**
      * 路径结果
      */
@@ -134,7 +166,7 @@ public class PromotionCalcResponse {
     public static class PromotionDetail {
         /** 优惠类型: promotion=促销, coupon=优惠券 */
         private String type;
-        /** 促销子类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 (coupon时为0) */
+        /** 促销子类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 5=满额免运 (coupon时为0) */
         private Integer subType;
         /** 优惠名称 */
         private String name;
@@ -144,6 +176,14 @@ public class PromotionCalcResponse {
         private Long refId;
         /** 是否被优惠幅度限制封顶(仅封顶时返回, 对应"已达优惠上限") */
         private Boolean limitCapped;
+        /** 商品原价合计 O(type=5满额免运快照扩展字段) */
+        private BigDecimal originalAmount;
+        /** 原配送费 R(type=5满额免运快照扩展字段) */
+        private BigDecimal deliveryFee;
+        /** 客户实付配送费 D(type=5满额免运快照扩展字段) */
+        private BigDecimal customerDeliveryFee;
+        /** 免运门槛(type=5满额免运快照扩展字段) */
+        private BigDecimal threshold;
     }
 
     /**

+ 36 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionDeliveryContext.java

@@ -0,0 +1,36 @@
+package com.ruoyi.system.dto;
+
+import java.math.BigDecimal;
+
+/**
+ * 内部可信配送上下文(008 满额免运,2026-10-19)。
+ *
+ * 只能由服务端应用层(完整配送报价)构造:deliveryFee 为按现行路线/运价口径
+ * 计算出的原配送费 R(整数元),eligible 表示该单是否具备满额免运参与资格
+ * (本轮仅单店平台骑手外送为 true)。旧两参数 calculate 不注入该上下文,
+ * 客户端传入的运费/免运金额一律不作为计费依据。
+ */
+public class PromotionDeliveryContext
+{
+    /** 原配送费 R(整数元,骑手毛配送费基数) */
+    private final BigDecimal deliveryFee;
+
+    /** 是否具备满额免运参与资格 */
+    private final boolean eligible;
+
+    public PromotionDeliveryContext(BigDecimal deliveryFee, boolean eligible)
+    {
+        this.deliveryFee = deliveryFee;
+        this.eligible = eligible;
+    }
+
+    public BigDecimal getDeliveryFee()
+    {
+        return deliveryFee;
+    }
+
+    public boolean isEligible()
+    {
+        return eligible;
+    }
+}

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreMapper.java

@@ -52,6 +52,10 @@ public interface PosStoreMapper extends BaseMapper<PosStore>
     @Select("SELECT id FROM pos_store WHERE id = #{storeId} AND is_stall = 1 AND del_flag = '0' FOR UPDATE")
     Long lockStallForPayment(@Param("storeId") Long storeId);
 
+    /** 满额免运活动创建/修改的门店行事务锁(008 T074):同店并发时串行化“重叠检查→写入”。 */
+    @Select("SELECT id FROM pos_store WHERE id = #{storeId} AND del_flag = '0' FOR UPDATE")
+    Long lockStoreRow(@Param("storeId") Long storeId);
+
     //按照评分查询门店
     @Select("SELECT *,(st_distance(point(longitude,latitude),point(#{longitude},#{latitude}))*111195/1000 ) as juli,(SELECT del_flag FROM info_user WHERE info_user.user_id = pos_store.user_id) as xxzt,(SELECT IFNULL(AVG(score),0) FROM pos_review WHERE pos_review.md_id = pos_store.id) as pingf,(SELECT IFNULL(COUNT(id),0) FROM pos_order WHERE pos_order.md_id = pos_store.id) as ddsl  FROM pos_store WHERE del_flag='0' and off_shelf='0' HAVING xxzt=0 ORDER BY pingf DESC limit #{page},10")  //查询语句
     List<PosStore> getPingfStore(@Param("longitude")BigDecimal longitude, @Param("latitude")BigDecimal latitude,@Param("page")Integer page,@Param("juli")Integer juli);  //反回结果

+ 25 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityMapper.java

@@ -1,7 +1,10 @@
 package com.ruoyi.system.mapper;
 
+import java.util.Date;
 import java.util.List;
+import org.apache.ibatis.annotations.Param;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.ruoyi.system.domain.PromotionActivity;
 
 /**
@@ -50,4 +53,26 @@ public interface PromotionActivityMapper extends BaseMapper<PromotionActivity> {
      * @return 促销活动列表
      */
     public List<PromotionActivity> selectActiveByStoreId(Long storeId);
+
+    /**
+     * 按日期查询门店生效中的满额免运活动(type=5,不依赖状态刷新任务)
+     *
+     * 条件:store_id = storeId 且 type = 5 且 status in (0,1) 且 start_time <= now <= end_time。
+     * 人工结束(status=2)不参与;存量重叠数据可能返回多条,由调用方报配置冲突。
+     *
+     * @param storeId 门店ID
+     * @param now 判定时刻
+     * @return 生效中的满额免运活动列表(0或1条为正常,多条为存量配置冲突)
+     */
+    public List<PromotionActivity> selectActiveFreeDeliveryByStoreId(@Param("storeId") Long storeId, @Param("now") Date now);
+
+    /**
+     * 商家端活动分页查询;type=5 行的 status 列按本地日期计算有效状态(0未开始/1进行中/2已结束),
+     * 状态筛选同样按有效状态匹配。
+     */
+    public IPage<PromotionActivity> selectActivityPage(IPage<PromotionActivity> page,
+                                                       @Param("storeId") Long storeId,
+                                                       @Param("type") Integer type,
+                                                       @Param("status") Integer status,
+                                                       @Param("now") Date now);
 }

+ 21 - 19
ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityService.java

@@ -1,6 +1,7 @@
 package com.ruoyi.system.service;
 
 import java.util.List;
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.ruoyi.system.domain.PromotionActivity;
 import com.ruoyi.system.domain.PromotionActivityRule;
@@ -16,42 +17,43 @@ public interface IPromotionActivityService extends IService<PromotionActivity>
     /**
      * 创建促销活动(含规则)
      *
+     * 2026-10-19 满额免运(type=5):operatorUserId 用于门店管理归属校验,
+     * 并在同一事务内锁定门店行后做同店同类型日期重叠检查;旧类型不改变历史行为。
+     *
      * @param activity 活动主体
      * @param rules 活动规则列表
+     * @param operatorUserId 操作者用户ID(type=5 必须归属目标门店)
      * @return 是否成功
      */
-    boolean createActivity(PromotionActivity activity, List<PromotionActivityRule> rules);
+    boolean createActivity(PromotionActivity activity, List<PromotionActivityRule> rules, Long operatorUserId);
+
+    /**
+     * 分页查询门店促销活动;type=5 行按本地日期计算有效状态(0未开始/1进行中/2已结束),
+     * 状态筛选同样按有效状态匹配,不依赖不存在的状态刷新任务。
+     */
+    IPage<PromotionActivity> selectActivityPage(IPage<PromotionActivity> page, Long storeId, Integer type, Integer status);
 
     /**
-     * 查询活动详情(含规则)
+     * 查询活动详情(含规则);type=5 返回按日期计算的有效状态并校验操作者门店归属。
      *
      * @param id 活动ID
+     * @param operatorUserId 操作者用户ID(type=5 校验归属)
      * @return 活动详情
      */
-    PromotionActivity selectActivityWithRules(Long id);
+    PromotionActivity selectActivityWithRules(Long id, Long operatorUserId);
 
     /**
-     * 修改促销活动(仅未开始)
-     *
-     * @param activity 活动主体
-     * @param rules 活动规则列表
-     * @return 是否成功
+     * 修改促销活动(type=5 仅有效状态未开始可改;旧类型仅存量状态未开始可改)
      */
-    boolean updateActivity(PromotionActivity activity, List<PromotionActivityRule> rules);
+    boolean updateActivity(PromotionActivity activity, List<PromotionActivityRule> rules, Long operatorUserId);
 
     /**
-     * 删除促销活动(仅未开始)
-     *
-     * @param id 活动ID
-     * @return 是否成功
+     * 删除促销活动(仅未开始;type=5 按有效状态判定并校验归属)
      */
-    boolean deleteActivity(Long id);
+    boolean deleteActivity(Long id, Long operatorUserId);
 
     /**
-     * 结束活动
-     *
-     * @param id 活动ID
-     * @return 是否成功
+     * 结束活动(type=5 有效状态进行中可结束;旧类型仅存量状态进行中可结束)
      */
-    boolean endActivity(Long id);
+    boolean endActivity(Long id, Long operatorUserId);
 }

+ 17 - 1
ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCalcService.java

@@ -2,6 +2,7 @@ package com.ruoyi.system.service;
 
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionDeliveryContext;
 
 /**
  * 促销算价Service接口
@@ -9,11 +10,26 @@ import com.ruoyi.system.dto.PromotionCalcResponse;
 public interface IPromotionCalcService {
 
     /**
-     * 计算订单优惠
+     * 计算订单优惠(旧契约:finalAmount 仅商品应付金额,无配送上下文,行为保持不变)
      *
      * @param request 算价请求DTO (storeId, items, couponId, forcePath)
      * @param userId  用户ID(用于新客判断)
      * @return 算价结果DTO
      */
     PromotionCalcResponse calculate(PromotionCalcRequest request, Long userId);
+
+    /**
+     * 计算订单优惠(完整配送报价,pricingVersion=2,2026-10-19 满额免运)。
+     *
+     * 在旧算价之上叠加:按商品原价 O 判断满额免运门槛并全额预留免运费用 F(允许 F 超过总预算 B),
+     * 其余受限优惠使用 max(0, B−F);路径选择按应用预算与指定券后的整单应付 A=G+D 取低者(相等选B);
+     * 命中免运时免配送费券不可用且不核销;appliedCouponId 仅实际实减>0时返回。
+     * finalAmount 仍为商品应付 G,不含配送费。
+     *
+     * @param request         算价请求DTO
+     * @param userId          用户ID(用于新客判断)
+     * @param deliveryContext 服务端构造的可信配送上下文(原配送费 R 与免运资格)
+     * @return 算价结果DTO(含配送费、免运减免、客户应付配送费与整单应付等扩展字段)
+     */
+    PromotionCalcResponse calculate(PromotionCalcRequest request, Long userId, PromotionDeliveryContext deliveryContext);
 }

+ 235 - 18
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java

@@ -1,29 +1,49 @@
 package com.ruoyi.system.service.impl;
 
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
 import java.util.Date;
 import java.util.List;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.mapper.PromotionActivityMapper;
 import com.ruoyi.system.mapper.PromotionActivityRuleMapper;
 import com.ruoyi.system.domain.PromotionActivity;
 import com.ruoyi.system.domain.PromotionActivityRule;
 import com.ruoyi.system.service.IPromotionActivityService;
+import com.ruoyi.system.service.MerchantAccessContext;
+import com.ruoyi.system.service.MerchantStoreAccessService;
 
 /**
  * 促销活动Service业务层处理
  *
+ * 2026-10-19 满额免运(type=5,spec FR-028):单条正整数门槛规则,按商品原价生效;
+ * 创建/修改在同一事务内锁定门店行后做同店同类型日期重叠检查,读写都校验操作者门店归属;
+ * 有效状态按本地日期计算(未到开始日=0、生效中=1、明确结束或已过结束日=2),
+ * 不依赖不存在的状态刷新任务。旧类型(1~4)保持历史行为不变。
+ *
  * @author ruoyi
  * @date 2024-05-30
  */
 @Service
 public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<PromotionActivity>, PromotionActivity> implements IPromotionActivityService
 {
+    /** 满额免运活动类型 */
+    private static final int TYPE_FREE_DELIVERY = 5;
+
+    /** 满额免运日期按台北本地整天(首日00:00:00 ~ 末日23:59:59) */
+    private static final ZoneId FREE_DELIVERY_ZONE = ZoneId.of("Asia/Taipei");
+
     @Autowired
     private PromotionActivityMapper promotionActivityMapper;
 
@@ -33,14 +53,29 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
     @Autowired
     private PromotionDiscountLimitChecker discountLimitChecker;
 
+    @Autowired
+    private MerchantStoreAccessService storeAccessService;
+
+    @Autowired
+    private PosStoreMapper posStoreMapper;
+
     /**
      * 创建促销活动(含规则)
      */
     @Override
     @Transactional
-    public boolean createActivity(PromotionActivity activity, List<PromotionActivityRule> rules)
+    public boolean createActivity(PromotionActivity activity, List<PromotionActivityRule> rules, Long operatorUserId)
     {
         Date now = new Date();
+        boolean freeDelivery = isFreeDelivery(activity);
+        if (freeDelivery)
+        {
+            validateFreeDeliveryRules(activity, rules);
+            normalizeFreeDeliveryDates(activity);
+            requireStoreOwner(operatorUserId, activity.getStoreId());
+            // 门店行锁:同店并发创建/修改满额免运时串行化“重叠检查→写入”
+            posStoreMapper.lockStoreRow(activity.getStoreId());
+        }
         activity.setCreateTime(now);
         activity.setUpdateTime(now);
         // 根据开始时间设置状态:开始时间早于当前→进行中(1),否则→未开始(0)
@@ -53,8 +88,15 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
             activity.setStatus(0);
         }
         // 校验:同一门店同一促销类型,同一时间段内只允许一个活动(时间段重叠即拒绝)
-        checkTimeOverlap(activity, null);
-        // 校验:优惠幅度限制(平台开关控制,满减档位/折扣率不超上限)
+        if (freeDelivery)
+        {
+            checkFreeDeliveryOverlap(activity, null, now);
+        }
+        else
+        {
+            checkTimeOverlap(activity, null);
+        }
+        // 校验:优惠幅度限制(平台开关控制,满减档位/折扣率不超上限;type=5 只校验门槛合法性不预估免运额)
         discountLimitChecker.checkActivity(activity, rules);
         int rows = promotionActivityMapper.insert(activity);
         Long activityId = activity.getId();
@@ -67,6 +109,15 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
         return rows > 0;
     }
 
+    /**
+     * 分页查询活动列表;type=5 行返回按日期计算的有效状态
+     */
+    @Override
+    public IPage<PromotionActivity> selectActivityPage(IPage<PromotionActivity> page, Long storeId, Integer type, Integer status)
+    {
+        return promotionActivityMapper.selectActivityPage(page, storeId, type, status, new Date());
+    }
+
     /**
      * 校验:同一门店同一促销类型,时间段不与其他活动重叠(排除 excludeId)。
      * 重叠判定:existing.start &le; newEnd 且 existing.end &ge; newStart(端点相接也视为重叠,禁止 06-16 当天两个活动都进行中)
@@ -90,14 +141,42 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
     }
 
     /**
-     * 查询活动详情(含规则)
+     * 满额免运重叠检查:同店 type=5 且尚未按日期结束(status&ne;2 且 end_time 未过)的活动,
+     * 日期区间重叠即拒绝;已过结束日的活动不占用后续档期。
+     */
+    private void checkFreeDeliveryOverlap(PromotionActivity activity, Long excludeId, Date now)
+    {
+        LambdaQueryWrapper<PromotionActivity> w = new LambdaQueryWrapper<>();
+        w.eq(PromotionActivity::getStoreId, activity.getStoreId())
+                .eq(PromotionActivity::getType, TYPE_FREE_DELIVERY)
+                .ne(PromotionActivity::getStatus, 2)
+                .ge(PromotionActivity::getEndTime, now)
+                .le(PromotionActivity::getStartTime, activity.getEndTime())
+                .ge(PromotionActivity::getEndTime, activity.getStartTime());
+        if (excludeId != null)
+        {
+            w.ne(PromotionActivity::getId, excludeId);
+        }
+        if (promotionActivityMapper.selectCount(w) > 0)
+        {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.overlap"));
+        }
+    }
+
+    /**
+     * 查询活动详情(含规则);type=5 返回按日期计算的有效状态并校验门店归属
      */
     @Override
-    public PromotionActivity selectActivityWithRules(Long id)
+    public PromotionActivity selectActivityWithRules(Long id, Long operatorUserId)
     {
         PromotionActivity activity = promotionActivityMapper.selectById(id);
         if (activity != null)
         {
+            if (isFreeDelivery(activity))
+            {
+                requireStoreOwner(operatorUserId, activity.getStoreId());
+                activity.setStatus(effectiveStatus(activity, new Date()));
+            }
             List<PromotionActivityRule> rules = ruleMapper.selectRulesByActivityId(id);
             activity.setRules(rules);
         }
@@ -105,19 +184,44 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
     }
 
     /**
-     * 修改促销活动(仅未开始)
+     * 修改促销活动(仅未开始;type=5 按有效状态判定)
      */
     @Override
     @Transactional
-    public boolean updateActivity(PromotionActivity activity, List<PromotionActivityRule> rules)
+    public boolean updateActivity(PromotionActivity activity, List<PromotionActivityRule> rules, Long operatorUserId)
     {
         Long activityId = activity.getId();
         PromotionActivity existing = promotionActivityMapper.selectById(activityId);
-        if (existing == null || existing.getStatus() != 0)
+        if (existing == null)
         {
             throw new ServiceException("只能修改未开始的活动");
         }
+        // 编辑入参可能不带 type/storeId,回填现有值(写回同值无副作用)
+        if (activity.getType() == null)
+        {
+            activity.setType(existing.getType());
+        }
+        if (activity.getStoreId() == null)
+        {
+            activity.setStoreId(existing.getStoreId());
+        }
         Date now = new Date();
+        boolean freeDelivery = isFreeDelivery(activity);
+        if (freeDelivery)
+        {
+            requireStoreOwner(operatorUserId, activity.getStoreId());
+            posStoreMapper.lockStoreRow(activity.getStoreId());
+            if (effectiveStatus(existing, now) != 0)
+            {
+                throw new ServiceException(MessageUtils.message("promotion.freeDelivery.notEditable"));
+            }
+            validateFreeDeliveryRules(activity, rules);
+            normalizeFreeDeliveryDates(activity);
+        }
+        else if (existing.getStatus() == null || existing.getStatus() != 0)
+        {
+            throw new ServiceException("只能修改未开始的活动");
+        }
         activity.setUpdateTime(now);
         // 根据开始时间设置状态:开始时间早于当前→进行中(1),否则→未开始(0)
         if (activity.getStartTime() != null && activity.getStartTime().before(now))
@@ -129,11 +233,13 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
             activity.setStatus(0);
         }
         // 校验:同一门店同一促销类型,同一时间段内只允许一个活动(排除自身)
-        checkTimeOverlap(activity, activityId);
-        // 编辑入参可能不带 type,回填现有值供优惠幅度校验用(写回同值无副作用)
-        if (activity.getType() == null)
+        if (freeDelivery)
         {
-            activity.setType(existing.getType());
+            checkFreeDeliveryOverlap(activity, activityId, now);
+        }
+        else
+        {
+            checkTimeOverlap(activity, activityId);
         }
         // 校验:优惠幅度限制(平台开关控制,满减档位/折扣率不超上限)
         discountLimitChecker.checkActivity(activity, rules);
@@ -150,14 +256,26 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
     }
 
     /**
-     * 删除促销活动(仅未开始)
+     * 删除促销活动(仅未开始;type=5 按有效状态判定并校验归属)
      */
     @Override
     @Transactional
-    public boolean deleteActivity(Long id)
+    public boolean deleteActivity(Long id, Long operatorUserId)
     {
         PromotionActivity existing = promotionActivityMapper.selectById(id);
-        if (existing == null || existing.getStatus() != 0)
+        if (existing == null)
+        {
+            throw new ServiceException("只能删除未开始的活动");
+        }
+        if (isFreeDelivery(existing))
+        {
+            requireStoreOwner(operatorUserId, existing.getStoreId());
+            if (effectiveStatus(existing, new Date()) != 0)
+            {
+                throw new ServiceException(MessageUtils.message("promotion.freeDelivery.notEditable"));
+            }
+        }
+        else if (existing.getStatus() == null || existing.getStatus() != 0)
         {
             throw new ServiceException("只能删除未开始的活动");
         }
@@ -168,13 +286,26 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
     }
 
     /**
-     * 结束活动(仅进行中)
+     * 结束活动(仅进行中;type=5 按有效状态判定并校验归属)
      */
     @Override
-    public boolean endActivity(Long id)
+    @Transactional
+    public boolean endActivity(Long id, Long operatorUserId)
     {
         PromotionActivity existing = promotionActivityMapper.selectById(id);
-        if (existing == null || existing.getStatus() != 1)
+        if (existing == null)
+        {
+            throw new ServiceException("只能结束进行中的活动");
+        }
+        if (isFreeDelivery(existing))
+        {
+            requireStoreOwner(operatorUserId, existing.getStoreId());
+            if (effectiveStatus(existing, new Date()) != 1)
+            {
+                throw new ServiceException(MessageUtils.message("promotion.freeDelivery.notEndable"));
+            }
+        }
+        else if (existing.getStatus() == null || existing.getStatus() != 1)
         {
             throw new ServiceException("只能结束进行中的活动");
         }
@@ -184,4 +315,90 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
         activity.setUpdateTime(new Date());
         return promotionActivityMapper.updateById(activity) > 0;
     }
+
+    // ==================== 满额免运(type=5)内部方法 ====================
+
+    private boolean isFreeDelivery(PromotionActivity activity)
+    {
+        return activity != null && Integer.valueOf(TYPE_FREE_DELIVERY).equals(activity.getType());
+    }
+
+    /**
+     * type=5 规则校验:有且仅有一条规则,仅 threshold 为正整数元;
+     * productId/reduceAmount/discountRate/minQuantity 不适用,携带即拒绝;活动日期必须完整。
+     */
+    private void validateFreeDeliveryRules(PromotionActivity activity, List<PromotionActivityRule> rules)
+    {
+        if (rules == null || rules.size() != 1
+                || activity.getStartTime() == null || activity.getEndTime() == null)
+        {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidRules"));
+        }
+        PromotionActivityRule rule = rules.get(0);
+        if (rule == null || rule.getProductId() != null || rule.getReduceAmount() != null
+                || rule.getDiscountRate() != null || rule.getMinQuantity() != null)
+        {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidRules"));
+        }
+        BigDecimal threshold = rule.getThreshold();
+        if (threshold == null || threshold.compareTo(BigDecimal.ZERO) <= 0
+                || threshold.stripTrailingZeros().scale() > 0)
+        {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.invalidThreshold"));
+        }
+    }
+
+    /** 日期归一化到台北本地首日00:00:00和末日23:59:59 */
+    private void normalizeFreeDeliveryDates(PromotionActivity activity)
+    {
+        activity.setStartTime(startOfDay(activity.getStartTime()));
+        activity.setEndTime(endOfDay(activity.getEndTime()));
+    }
+
+    private Date startOfDay(Date date)
+    {
+        LocalDate day = LocalDateTime.ofInstant(date.toInstant(), FREE_DELIVERY_ZONE).toLocalDate();
+        return Date.from(day.atStartOfDay(FREE_DELIVERY_ZONE).toInstant());
+    }
+
+    private Date endOfDay(Date date)
+    {
+        LocalDate day = LocalDateTime.ofInstant(date.toInstant(), FREE_DELIVERY_ZONE).toLocalDate();
+        return Date.from(day.atTime(23, 59, 59).atZone(FREE_DELIVERY_ZONE).toInstant());
+    }
+
+    /**
+     * type=5 有效状态(不依赖状态刷新任务):
+     * 人工结束(status=2)优先→2;未到开始日→0;已过结束日→2;否则→1。
+     */
+    private Integer effectiveStatus(PromotionActivity activity, Date now)
+    {
+        if (activity.getStatus() != null && activity.getStatus() == 2)
+        {
+            return 2;
+        }
+        if (activity.getStartTime() != null && now.before(activity.getStartTime()))
+        {
+            return 0;
+        }
+        if (activity.getEndTime() != null && now.after(activity.getEndTime()))
+        {
+            return 2;
+        }
+        return 1;
+    }
+
+    /** type=5 读写前校验操作者对目标门店的管理归属,不能依赖浏览器传来的 storeId */
+    private void requireStoreOwner(Long operatorUserId, Long storeId)
+    {
+        if (operatorUserId == null || storeId == null)
+        {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.forbiddenStore"));
+        }
+        MerchantAccessContext context = storeAccessService.resolve(operatorUserId);
+        if (!context.storeIds().contains(storeId))
+        {
+            throw new ServiceException(MessageUtils.message("promotion.freeDelivery.forbiddenStore"));
+        }
+    }
 }

+ 686 - 81
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCalcServiceImpl.java

@@ -14,6 +14,8 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.PosFood;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PromotionActivity;
@@ -23,6 +25,7 @@ import com.ruoyi.system.domain.PromotionCouponRule;
 import com.ruoyi.system.domain.PromotionUserCoupon;
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionDeliveryContext;
 import com.ruoyi.system.dto.PromotionCalcResponse.AvailableCoupon;
 import com.ruoyi.system.dto.PromotionCalcResponse.LineItem;
 import com.ruoyi.system.dto.PromotionCalcResponse.MatchedRule;
@@ -79,83 +82,17 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         Long couponId = request.getCouponId();
         String forcePath = request.getForcePath();
 
-        // 将 DTO items 转为内部 Map 格式
-        List<Map<String, Object>> items = new ArrayList<>();
-        if (request.getItems() != null)
-        {
-            for (PromotionCalcRequest.CartItem ci : request.getItems())
-            {
-                Map<String, Object> m = new LinkedHashMap<>();
-                m.put("productId", ci.getProductId());
-                m.put("quantity", ci.getQuantity());
-                m.put("specPrice", ci.getSpecPrice());
-                items.add(m);
-            }
-        }
-
-        // ---- 1. 获取商品真实价格,计算 originalAmount ----
-        Map<Long, BigDecimal> priceMap = new HashMap<>();
-        Map<Long, String> nameMap = new HashMap<>();
-        for (Map<String, Object> item : items)
-        {
-            Long productId = toLong(item.get("productId"));
-            if (productId != null && !priceMap.containsKey(productId))
-            {
-                PosFood food = posFoodMapper.selectById(productId);
-                if (food != null)
-                {
-                    priceMap.put(productId, food.getPrice());
-                    nameMap.put(productId, food.getName());
-                }
-            }
-        }
-
-        BigDecimal originalAmount = BigDecimal.ZERO;
-        List<Map<String, Object>> itemsWithPrice = new ArrayList<>();
-        for (Map<String, Object> item : items)
-        {
-            Long productId = toLong(item.get("productId"));
-            int quantity = toInt(item.get("quantity"));
-            // 实付单价 = 商品价格 + 规格价格(规格价可为0/不传), 按行计算以支持同商品多规格
-            BigDecimal basePrice = priceMap.getOrDefault(productId, BigDecimal.ZERO);
-            BigDecimal specPrice = (BigDecimal) item.get("specPrice");
-            if (specPrice == null)
-            {
-                specPrice = BigDecimal.ZERO;
-            }
-            BigDecimal unitPrice = basePrice.add(specPrice);
-            originalAmount = originalAmount.add(unitPrice.multiply(BigDecimal.valueOf(quantity)));
-
-            Map<String, Object> ip = new LinkedHashMap<>();
-            ip.put("productId", productId);
-            ip.put("quantity", quantity);
-            ip.put("unitPrice", unitPrice);
-            ip.put("name", nameMap.getOrDefault(productId, ""));
-            itemsWithPrice.add(ip);
-        }
-        originalAmount = originalAmount.setScale(0, RoundingMode.HALF_UP);
-
-        // ---- 2. 查询门店生效的促销活动 ----
-        List<PromotionActivity> activeActivities = activityMapper.selectActiveByStoreId(storeId);
-        List<PromotionActivityRule> activeRules = activityRuleMapper.selectActiveRulesByStoreId(storeId);
-
-        Map<Long, PromotionActivity> activityMap = activeActivities.stream()
-                .collect(Collectors.toMap(PromotionActivity::getId, a -> a, (a, b) -> a));
-
-        Map<Long, List<PromotionActivityRule>> rulesByActivity = activeRules.stream()
-                .collect(Collectors.groupingBy(PromotionActivityRule::getActivityId));
-
-        Map<Long, List<PromotionActivityRule>> rulesByProduct = activeRules.stream()
-                .filter(r -> r.getProductId() != null)
-                .collect(Collectors.groupingBy(PromotionActivityRule::getProductId));
-
-        // ---- 3. PATH A: 折扣(type=2) / 第二份半价(type=3) ----
-        Map<String, Object> pathAResult = calculatePathA(itemsWithPrice, activeActivities,
-                rulesByActivity, rulesByProduct, activityMap);
-
-        // ---- 4. PATH B: 满减(type=1) ----
-        Map<String, Object> pathBResult = calculatePathB(itemsWithPrice, originalAmount,
-                activeActivities, rulesByActivity, activityMap);
+        CalculationBase base = loadCalculationBase(request);
+        List<Map<String, Object>> itemsWithPrice = base.itemsWithPrice;
+        BigDecimal originalAmount = base.originalAmount;
+        List<PromotionActivity> activeActivities = base.activeActivities;
+        Map<Long, List<PromotionActivityRule>> rulesByActivity = base.rulesByActivity;
+        Map<Long, List<PromotionActivityRule>> rulesByProduct = base.rulesByProduct;
+        Map<Long, PromotionActivity> activityMap = base.activityMap;
+        Map<String, Object> pathAResult = base.pathAResult;
+        Map<String, Object> pathBResult = base.pathBResult;
+        Map<Long, BigDecimal> priceMap = base.priceMap;
+        Map<Long, String> nameMap = base.nameMap;
 
         // ---- 5. 比较两条路径 ----
         BigDecimal subtotalA = (BigDecimal) pathAResult.get("subtotal");
@@ -480,6 +417,640 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         return response;
     }
 
+    /**
+     * 完整配送算价(pricingVersion=2,2026-10-19 满额免运,spec FR-028~FR-036)。
+     *
+     * 与旧 calculate 的差异仅在于配送上下文存在时的叠加规则:
+     * - 满额免运按商品原价 O 判断门槛,命中即全额预留 F=R(允许 F&gt;B,绝不部分免运);
+     * - 受限优惠(促销/新客/券)的可用预算为 max(0, B−F),互斥券路径同样不释放 F;
+     * - 路径选择比较应用预算与指定券后的整单应付 A=G+D,取低者,相等选 B;forcePath 尊重用户选择;
+     * - 命中免运时免配送费券不可用、不核销;appliedCouponId 仅在券实减&gt;0时返回;
+     * - 第二份半价胜出的预算豁免保留(不占预算、不封顶)。
+     * finalAmount 语义与旧接口一致:商品应付 G,不含配送费。
+     */
+    @Override
+    public PromotionCalcResponse calculate(PromotionCalcRequest request, Long userId, PromotionDeliveryContext deliveryContext)
+    {
+        Long storeId = request.getStoreId();
+        Long couponId = request.getCouponId();
+        String forcePath = request.getForcePath();
+
+        CalculationBase base = loadCalculationBase(request);
+        List<Map<String, Object>> itemsWithPrice = base.itemsWithPrice;
+        BigDecimal originalAmount = base.originalAmount;
+        List<PromotionActivity> activeActivities = base.activeActivities;
+        Map<Long, List<PromotionActivityRule>> rulesByActivity = base.rulesByActivity;
+        Map<Long, List<PromotionActivityRule>> rulesByProduct = base.rulesByProduct;
+        Map<Long, PromotionActivity> activityMap = base.activityMap;
+        Map<String, Object> pathAResult = base.pathAResult;
+        Map<String, Object> pathBResult = base.pathBResult;
+        Map<Long, BigDecimal> priceMap = base.priceMap;
+        Map<Long, String> nameMap = base.nameMap;
+
+        // ---- 1. 满额免运判定(最高优先级,按商品原价 O 判断,不含配送费/促销/券) ----
+        BigDecimal deliveryFee = deliveryContext == null || deliveryContext.getDeliveryFee() == null
+                ? BigDecimal.ZERO : deliveryContext.getDeliveryFee();
+        boolean deliveryEligible = deliveryContext == null || deliveryContext.isEligible();
+        PromotionActivity freeDeliveryActivity = null;
+        BigDecimal freeDeliveryThreshold = null;
+        if (deliveryEligible && deliveryFee.compareTo(BigDecimal.ZERO) > 0)
+        {
+            List<PromotionActivity> fdActivities = activityMapper.selectActiveFreeDeliveryByStoreId(storeId, new Date());
+            if (fdActivities != null && fdActivities.size() > 1)
+            {
+                throw new ServiceException(MessageUtils.message("promotion.freeDelivery.configConflict"));
+            }
+            if (fdActivities != null && fdActivities.size() == 1)
+            {
+                PromotionActivity candidate = fdActivities.get(0);
+                List<PromotionActivityRule> fdRules = activityRuleMapper.selectRulesByActivityId(candidate.getId());
+                BigDecimal threshold = fdRules == null || fdRules.isEmpty() ? null : fdRules.get(0).getThreshold();
+                if (threshold == null || threshold.compareTo(BigDecimal.ZERO) <= 0)
+                {
+                    throw new ServiceException(MessageUtils.message("promotion.freeDelivery.configConflict"));
+                }
+                if (originalAmount.compareTo(threshold) >= 0)
+                {
+                    freeDeliveryActivity = candidate;
+                    freeDeliveryThreshold = threshold;
+                }
+            }
+        }
+        // 命中:F=R、D=0;未命中:F=0、D=R
+        BigDecimal freeDeliveryReduce = freeDeliveryActivity == null ? BigDecimal.ZERO : deliveryFee;
+        BigDecimal customerDeliveryFee = deliveryFee.subtract(freeDeliveryReduce);
+
+        // ---- 2. 总优惠预算 B 与受限优惠可用预算 max(0, B−F) ----
+        BigDecimal budgetLimit = discountLimitChecker.isEnabled()
+                ? originalAmount.multiply(discountLimitChecker.getRatioPercent().movePointLeft(2))
+                        .setScale(0, RoundingMode.HALF_UP)
+                : null;
+        BigDecimal goodsBudget = budgetLimit == null
+                ? null : budgetLimit.subtract(freeDeliveryReduce).max(BigDecimal.ZERO);
+
+        // ---- 3. 预加载指定券(两条路径共用,避免重复查询) ----
+        SelectedCoupon selectedCoupon = loadSelectedCoupon(couponId, userId);
+        // 命中满额免运时免配送费券不生效、不核销(spec FR-036);couponId 仍保留用户选择意图
+        if (selectedCoupon != null && freeDeliveryReduce.compareTo(BigDecimal.ZERO) > 0
+                && selectedCoupon.batch.getCouponType() != null
+                && selectedCoupon.batch.getCouponType() == 3)
+        {
+            selectedCoupon = null;
+        }
+
+        // ---- 4. 逐路径按预算+指定券计算,比较整单应付 A=G+D 取低者(相等选B) ----
+        PathEvaluation evalA = evaluatePath("A", pathAResult, originalAmount, storeId, activeActivities,
+                rulesByActivity, rulesByProduct, activityMap, itemsWithPrice, priceMap, nameMap,
+                userId, goodsBudget, selectedCoupon);
+        PathEvaluation evalB = evaluatePath("B", pathBResult, originalAmount, storeId, activeActivities,
+                rulesByActivity, rulesByProduct, activityMap, itemsWithPrice, priceMap, nameMap,
+                userId, goodsBudget, selectedCoupon);
+
+        String optimalPath;
+        if (forcePath != null && ("A".equalsIgnoreCase(forcePath) || "B".equalsIgnoreCase(forcePath)))
+        {
+            optimalPath = forcePath.toUpperCase();
+        }
+        else if (evalA.finalAmount.add(customerDeliveryFee)
+                .compareTo(evalB.finalAmount.add(customerDeliveryFee)) < 0)
+        {
+            optimalPath = "A";
+        }
+        else
+        {
+            optimalPath = "B";
+        }
+        PathEvaluation winner = "A".equals(optimalPath) ? evalA : evalB;
+
+        // ---- 5. 响应基础字段(finalAmount=G 保持旧含义,不含配送费) ----
+        PromotionCalcResponse response = new PromotionCalcResponse();
+        response.setPricingVersion(2);
+        response.setOriginalAmount(originalAmount);
+        response.setPathA(toPathResult(pathAResult));
+        response.setPathB(toPathResult(pathBResult));
+        response.setOptimalPath(optimalPath);
+        response.setFinalAmount(winner.finalAmount);
+        response.setDeliveryFee(deliveryFee);
+        response.setDeliveryDiscountAmount(freeDeliveryReduce);
+        response.setCustomerDeliveryFee(customerDeliveryFee);
+        response.setPayableAmount(winner.finalAmount.add(customerDeliveryFee));
+        if (freeDeliveryActivity != null)
+        {
+            response.setFreeDeliveryActivityId(freeDeliveryActivity.getId());
+            response.setFreeDeliveryThreshold(freeDeliveryThreshold);
+        }
+        response.setDiscountBudget(budgetLimit);
+        if (budgetLimit != null)
+        {
+            // 剩余预算 = max(0, B − F − 受限优惠已占用);半价豁免部分不占预算不计入
+            response.setRemainingDiscountBudget(budgetLimit
+                    .subtract(freeDeliveryReduce)
+                    .subtract(winner.budgetUsedTotal)
+                    .max(BigDecimal.ZERO));
+        }
+        if (winner.newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            response.setNewCustomerReduce(winner.newCustomerReduce);
+        }
+        if (couponId != null)
+        {
+            response.setCouponId(couponId);
+        }
+        if (winner.couponName != null)
+        {
+            response.setCouponName(winner.couponName);
+        }
+        if (winner.couponReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            response.setCouponReduce(winner.couponReduce);
+        }
+        if (winner.couponConflict != null)
+        {
+            response.setCouponConflict(winner.couponConflict);
+        }
+        if (winner.conflictNote != null)
+        {
+            response.setConflictNote(winner.conflictNote);
+        }
+        if (selectedCoupon != null && selectedCoupon.batch != null)
+        {
+            response.setCouponBatchId(selectedCoupon.batch.getId());
+        }
+        // 仅实际生效且实减>0的券返回 appliedCouponId,核销以此为准
+        if (couponId != null && winner.couponReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            response.setAppliedCouponId(couponId);
+        }
+        if (winner.couponProductItems != null && !winner.couponProductItems.isEmpty())
+        {
+            response.setCouponProductItems(winner.couponProductItems.stream()
+                    .map(this::toLineItem).collect(Collectors.toList()));
+        }
+
+        // ---- 6. 明细列表(type=5 满额免运独立于商品促销汇总,最先列出) ----
+        List<PromotionDetail> details = new ArrayList<>();
+        if (freeDeliveryReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            PromotionDetail fdDetail = new PromotionDetail();
+            fdDetail.setType("promotion");
+            fdDetail.setSubType(5);
+            fdDetail.setName(freeDeliveryActivity.getName());
+            fdDetail.setRefId(freeDeliveryActivity.getId());
+            fdDetail.setReduce(freeDeliveryReduce);
+            fdDetail.setOriginalAmount(originalAmount);
+            fdDetail.setDeliveryFee(deliveryFee);
+            fdDetail.setCustomerDeliveryFee(customerDeliveryFee);
+            fdDetail.setThreshold(freeDeliveryThreshold);
+            details.add(fdDetail);
+        }
+        appendGoodsDetails(details, winner, optimalPath, activeActivities, pathAResult, pathBResult, selectedCoupon);
+        response.setDetails(details);
+
+        // ---- 7. 可用优惠券列表(与选中路径同一剩余预算;命中免运时免配送费券不可用) ----
+        List<Map<String, Object>> availableCouponMaps = buildAvailableCoupons(storeId, userId,
+                originalAmount, winner.afterPromotion, winner.promotionReduce, itemsWithPrice,
+                rulesByProduct, activityMap, priceMap, nameMap, optimalPath,
+                goodsBudget, winner.budgetUsedBeforeCoupon, freeDeliveryReduce);
+        response.setAvailableCoupons(availableCouponMaps.stream()
+                .map(this::toAvailableCoupon)
+                .collect(Collectors.toList()));
+
+        return response;
+    }
+
+    // ==================== 共享装载与完整配送内部方法 ====================
+
+    /** 旧/新算价共用的计算基底(纯抽取自原 calculate 前四步,行为不变) */
+    private CalculationBase loadCalculationBase(PromotionCalcRequest request)
+    {
+        Long storeId = request.getStoreId();
+
+        // 将 DTO items 转为内部 Map 格式
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (request.getItems() != null)
+        {
+            for (PromotionCalcRequest.CartItem ci : request.getItems())
+            {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("productId", ci.getProductId());
+                m.put("quantity", ci.getQuantity());
+                m.put("specPrice", ci.getSpecPrice());
+                items.add(m);
+            }
+        }
+
+        // ---- 1. 获取商品真实价格,计算 originalAmount ----
+        Map<Long, BigDecimal> priceMap = new HashMap<>();
+        Map<Long, String> nameMap = new HashMap<>();
+        for (Map<String, Object> item : items)
+        {
+            Long productId = toLong(item.get("productId"));
+            if (productId != null && !priceMap.containsKey(productId))
+            {
+                PosFood food = posFoodMapper.selectById(productId);
+                if (food != null)
+                {
+                    priceMap.put(productId, food.getPrice());
+                    nameMap.put(productId, food.getName());
+                }
+            }
+        }
+
+        BigDecimal originalAmount = BigDecimal.ZERO;
+        List<Map<String, Object>> itemsWithPrice = new ArrayList<>();
+        for (Map<String, Object> item : items)
+        {
+            Long productId = toLong(item.get("productId"));
+            int quantity = toInt(item.get("quantity"));
+            // 实付单价 = 商品价格 + 规格价格(规格价可为0/不传), 按行计算以支持同商品多规格
+            BigDecimal basePrice = priceMap.getOrDefault(productId, BigDecimal.ZERO);
+            BigDecimal specPrice = (BigDecimal) item.get("specPrice");
+            if (specPrice == null)
+            {
+                specPrice = BigDecimal.ZERO;
+            }
+            BigDecimal unitPrice = basePrice.add(specPrice);
+            originalAmount = originalAmount.add(unitPrice.multiply(BigDecimal.valueOf(quantity)));
+
+            Map<String, Object> ip = new LinkedHashMap<>();
+            ip.put("productId", productId);
+            ip.put("quantity", quantity);
+            ip.put("unitPrice", unitPrice);
+            ip.put("name", nameMap.getOrDefault(productId, ""));
+            itemsWithPrice.add(ip);
+        }
+        originalAmount = originalAmount.setScale(0, RoundingMode.HALF_UP);
+
+        // ---- 2. 查询门店生效的促销活动 ----
+        List<PromotionActivity> activeActivities = activityMapper.selectActiveByStoreId(storeId);
+        List<PromotionActivityRule> activeRules = activityRuleMapper.selectActiveRulesByStoreId(storeId);
+
+        Map<Long, PromotionActivity> activityMap = activeActivities.stream()
+                .collect(Collectors.toMap(PromotionActivity::getId, a -> a, (a, b) -> a));
+
+        Map<Long, List<PromotionActivityRule>> rulesByActivity = activeRules.stream()
+                .collect(Collectors.groupingBy(PromotionActivityRule::getActivityId));
+
+        Map<Long, List<PromotionActivityRule>> rulesByProduct = activeRules.stream()
+                .filter(r -> r.getProductId() != null)
+                .collect(Collectors.groupingBy(PromotionActivityRule::getProductId));
+
+        // ---- 3. PATH A: 折扣(type=2) / 第二份半价(type=3) ----
+        Map<String, Object> pathAResult = calculatePathA(itemsWithPrice, activeActivities,
+                rulesByActivity, rulesByProduct, activityMap);
+
+        // ---- 4. PATH B: 满减(type=1) ----
+        Map<String, Object> pathBResult = calculatePathB(itemsWithPrice, originalAmount,
+                activeActivities, rulesByActivity, activityMap);
+
+        CalculationBase base = new CalculationBase();
+        base.itemsWithPrice = itemsWithPrice;
+        base.originalAmount = originalAmount;
+        base.activeActivities = activeActivities;
+        base.rulesByActivity = rulesByActivity;
+        base.rulesByProduct = rulesByProduct;
+        base.activityMap = activityMap;
+        base.pathAResult = pathAResult;
+        base.pathBResult = pathBResult;
+        base.priceMap = priceMap;
+        base.nameMap = nameMap;
+        return base;
+    }
+
+    /** 计算基底(loadCalculationBase 的产物,新旧算价共用) */
+    private static class CalculationBase
+    {
+        List<Map<String, Object>> itemsWithPrice;
+        BigDecimal originalAmount;
+        List<PromotionActivity> activeActivities;
+        Map<Long, List<PromotionActivityRule>> rulesByActivity;
+        Map<Long, List<PromotionActivityRule>> rulesByProduct;
+        Map<Long, PromotionActivity> activityMap;
+        Map<String, Object> pathAResult;
+        Map<String, Object> pathBResult;
+        Map<Long, BigDecimal> priceMap;
+        Map<Long, String> nameMap;
+    }
+
+    /** 指定券预加载结果(null 表示无有效券) */
+    private static class SelectedCoupon
+    {
+        PromotionUserCoupon userCoupon;
+        PromotionCouponBatch batch;
+        PromotionCouponRule rule;
+    }
+
+    private SelectedCoupon loadSelectedCoupon(Long couponId, Long userId)
+    {
+        if (couponId == null)
+        {
+            return null;
+        }
+        PromotionUserCoupon userCoupon = userCouponMapper.selectById(couponId);
+        if (userCoupon == null || !userCoupon.getUserId().equals(userId)
+                || userCoupon.getStatus() == null || userCoupon.getStatus() != 0)
+        {
+            return null;
+        }
+        if (userCoupon.getExpireTime() != null && userCoupon.getExpireTime().before(new Date()))
+        {
+            return null;
+        }
+        PromotionCouponBatch batch = couponBatchMapper.selectById(userCoupon.getBatchId());
+        PromotionCouponRule rule = couponRuleMapper.selectRuleByBatchId(userCoupon.getBatchId());
+        if (batch == null || rule == null)
+        {
+            return null;
+        }
+        SelectedCoupon selected = new SelectedCoupon();
+        selected.userCoupon = userCoupon;
+        selected.batch = batch;
+        selected.rule = rule;
+        return selected;
+    }
+
+    /** 单条路径在预算+指定券下的完整评估结果 */
+    private static class PathEvaluation
+    {
+        String path;
+        BigDecimal afterPromotion = BigDecimal.ZERO;
+        BigDecimal promotionReduce = BigDecimal.ZERO;
+        boolean promotionCapped = false;
+        BigDecimal newCustomerReduce = BigDecimal.ZERO;
+        boolean newCustomerCapped = false;
+        BigDecimal couponReduce = BigDecimal.ZERO;
+        boolean couponCapped = false;
+        String couponName;
+        Boolean couponConflict;
+        String conflictNote;
+        List<Map<String, Object>> couponProductItems;
+        BigDecimal budgetUsedBeforeCoupon = BigDecimal.ZERO;
+        BigDecimal budgetUsedTotal = BigDecimal.ZERO;
+        BigDecimal finalAmount = BigDecimal.ZERO;
+    }
+
+    /**
+     * 按路径应用预算与指定券:
+     * 促销(三选一)→ 新客立减 → 券,层层封顶于 goodsBudget=max(0,B−F);
+     * 半价胜出豁免;互斥券清促销/新客但不释放免运预留。
+     */
+    private PathEvaluation evaluatePath(String path, Map<String, Object> pathResult,
+                                        BigDecimal originalAmount, Long storeId,
+                                        List<PromotionActivity> activeActivities,
+                                        Map<Long, List<PromotionActivityRule>> rulesByActivity,
+                                        Map<Long, List<PromotionActivityRule>> rulesByProduct,
+                                        Map<Long, PromotionActivity> activityMap,
+                                        List<Map<String, Object>> itemsWithPrice,
+                                        Map<Long, BigDecimal> priceMap, Map<Long, String> nameMap,
+                                        Long userId, BigDecimal goodsBudget,
+                                        SelectedCoupon selectedCoupon)
+    {
+        PathEvaluation eval = new PathEvaluation();
+        eval.path = path;
+
+        BigDecimal subtotal = (BigDecimal) pathResult.get("subtotal");
+        BigDecimal promotionReduce = originalAmount.subtract(subtotal);
+        BigDecimal afterPromotion = subtotal;
+        boolean halfPriceExempt = "A".equals(path)
+                && Integer.valueOf(3).equals(pathResult.get("appliedType"));
+
+        // 促销层:非半价豁免时受预算封顶
+        BigDecimal promotionUsed = BigDecimal.ZERO;
+        if (goodsBudget != null && !halfPriceExempt && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            BigDecimal capped = promotionReduce.min(goodsBudget);
+            eval.promotionCapped = capped.compareTo(promotionReduce) < 0;
+            promotionUsed = capped;
+            promotionReduce = capped;
+            afterPromotion = originalAmount.subtract(promotionReduce);
+        }
+        eval.promotionReduce = promotionReduce;
+        eval.afterPromotion = afterPromotion;
+        eval.budgetUsedBeforeCoupon = eval.budgetUsedBeforeCoupon.add(promotionUsed);
+
+        // 新客立减层(该门店无已支付订单则为新客;实减 = min(设置额, 剩余预算))
+        BigDecimal newCustomerReduce = BigDecimal.ZERO;
+        if (userId != null)
+        {
+            LambdaQueryWrapper<PosOrder> orderWrapper = new LambdaQueryWrapper<>();
+            orderWrapper.eq(PosOrder::getUserId, userId)
+                        .eq(PosOrder::getMdId, storeId)
+                        .eq(PosOrder::getPayStatus, 1L);
+            Long paidCount = posOrderMapper.selectCount(orderWrapper);
+            if (paidCount == 0)
+            {
+                for (PromotionActivity act : activeActivities)
+                {
+                    if (act.getType() != null && act.getType() == 4)
+                    {
+                        List<PromotionActivityRule> ncRules = rulesByActivity.get(act.getId());
+                        if (ncRules != null && !ncRules.isEmpty())
+                        {
+                            BigDecimal reduce = ncRules.get(0).getReduceAmount();
+                            if (reduce != null && reduce.compareTo(BigDecimal.ZERO) > 0)
+                            {
+                                newCustomerReduce = reduce;
+                            }
+                        }
+                        break;
+                    }
+                }
+            }
+        }
+        if (goodsBudget != null && newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            BigDecimal remaining = goodsBudget.subtract(eval.budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
+            BigDecimal capped = newCustomerReduce.min(remaining);
+            eval.newCustomerCapped = capped.compareTo(newCustomerReduce) < 0;
+            eval.budgetUsedBeforeCoupon = eval.budgetUsedBeforeCoupon.add(capped);
+            newCustomerReduce = capped;
+        }
+        else if (newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            // 开关关闭:不封顶,但保持占用计数与旧逻辑口径一致(不进入剩余预算计算)
+            eval.budgetUsedBeforeCoupon = eval.budgetUsedBeforeCoupon.add(newCustomerReduce);
+        }
+        eval.newCustomerReduce = newCustomerReduce;
+
+        // 券层
+        if (selectedCoupon != null)
+        {
+            PromotionCouponRule couponRule = selectedCoupon.rule;
+            PromotionCouponBatch batch = selectedCoupon.batch;
+            eval.couponName = batch.getName();
+            int isMutex = couponRule.getIsMutex() != null ? couponRule.getIsMutex() : 0;
+
+            if (isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
+            {
+                eval.couponConflict = true;
+                eval.conflictNote = "互斥券不可与满减/折扣叠加,选择此券将取消促销优惠";
+                CouponCalcResult mutexResult = calcCouponReduce(couponRule, batch, itemsWithPrice, originalAmount, priceMap, nameMap);
+                eval.couponReduce = mutexResult.reduce;
+                eval.couponProductItems = mutexResult.items;
+                // 互斥券路径:促销/新客清零,券自身以 goodsBudget 封顶(免运预留 F 不释放,spec FR-030)
+                if (goodsBudget != null && eval.couponReduce.compareTo(goodsBudget) > 0)
+                {
+                    eval.couponReduce = goodsBudget;
+                    eval.couponCapped = true;
+                    applyCappedCouponReduce(eval.couponProductItems, eval.couponReduce);
+                }
+                eval.budgetUsedBeforeCoupon = BigDecimal.ZERO;
+                promotionReduce = BigDecimal.ZERO;
+                afterPromotion = originalAmount;
+                newCustomerReduce = BigDecimal.ZERO;
+                eval.promotionCapped = false;
+                eval.newCustomerCapped = false;
+                eval.promotionReduce = BigDecimal.ZERO;
+                eval.afterPromotion = afterPromotion;
+                eval.newCustomerReduce = BigDecimal.ZERO;
+            }
+            else
+            {
+                BigDecimal baseForCoupon = afterPromotion.subtract(newCustomerReduce);
+                baseForCoupon = baseForCoupon.max(BigDecimal.ZERO);
+
+                if (couponRule.getThreshold() == null
+                        || baseForCoupon.compareTo(couponRule.getThreshold()) >= 0)
+                {
+                    if (batch.getCouponType() != null && batch.getCouponType() == 2
+                            && couponRule.getProductId() != null && "A".equals(path))
+                    {
+                        List<PromotionActivityRule> productRules = rulesByProduct.get(couponRule.getProductId());
+                        if (productRules != null)
+                        {
+                            boolean inDiscount = productRules.stream().anyMatch(r ->
+                            {
+                                PromotionActivity act = activityMap.get(r.getActivityId());
+                                return act != null && (act.getType() == 2 || act.getType() == 3);
+                            });
+                            if (inDiscount)
+                            {
+                                eval.couponConflict = true;
+                                eval.conflictNote = "商品券对应的商品在折扣活动中,不可与Path A叠加";
+                            }
+                        }
+                    }
+
+                    if (eval.couponConflict == null)
+                    {
+                        CouponCalcResult normalResult = calcCouponReduce(couponRule, batch, itemsWithPrice,
+                                baseForCoupon, priceMap, nameMap);
+                        eval.couponReduce = normalResult.reduce;
+                        eval.couponProductItems = normalResult.items;
+                        // 普通券路径:实减 = min(可抵金额, 券前占用后的剩余预算),部分抵扣
+                        if (goodsBudget != null && eval.couponReduce.compareTo(BigDecimal.ZERO) > 0)
+                        {
+                            BigDecimal remaining = goodsBudget.subtract(eval.budgetUsedBeforeCoupon)
+                                    .max(BigDecimal.ZERO);
+                            if (eval.couponReduce.compareTo(remaining) > 0)
+                            {
+                                eval.couponReduce = remaining;
+                                eval.couponCapped = true;
+                                applyCappedCouponReduce(eval.couponProductItems, eval.couponReduce);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        // 总占用(仅预算开启时有意义):券前占用 + 券实减;半价豁免部分不占预算不计入
+        eval.budgetUsedTotal = eval.budgetUsedBeforeCoupon
+                .add(eval.couponReduce.max(BigDecimal.ZERO))
+                .max(BigDecimal.ZERO);
+
+        // G = O − 促销 − 新客 − 券,最低兜底与取整口径与旧逻辑一致
+        eval.finalAmount = originalAmount
+                .subtract(eval.promotionReduce)
+                .subtract(eval.newCustomerReduce)
+                .subtract(eval.couponReduce)
+                .max(MIN_AMOUNT)
+                .setScale(0, RoundingMode.HALF_UP);
+        return eval;
+    }
+
+    /** 追加商品侧优惠明细(促销/新客/券;type=5 满额免运单独快照,不在此汇总) */
+    @SuppressWarnings("unchecked")
+    private void appendGoodsDetails(List<PromotionDetail> details, PathEvaluation eval, String optimalPath,
+                                    List<PromotionActivity> activeActivities,
+                                    Map<String, Object> pathAResult, Map<String, Object> pathBResult,
+                                    SelectedCoupon selectedCoupon)
+    {
+        if (eval.promotionReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            PromotionDetail detail = new PromotionDetail();
+            detail.setType("promotion");
+            if ("A".equals(optimalPath))
+            {
+                detail.setSubType((Integer) pathAResult.get("appliedType"));
+                detail.setName((String) pathAResult.get("label"));
+                Integer appliedType = (Integer) pathAResult.get("appliedType");
+                for (PromotionActivity act : activeActivities)
+                {
+                    if (act.getType() != null && act.getType().equals(appliedType))
+                    {
+                        detail.setRefId(act.getId());
+                        break;
+                    }
+                }
+            }
+            else
+            {
+                detail.setSubType(1);
+                detail.setName((String) pathBResult.get("label"));
+                Map<String, Object> matchedRule = (Map<String, Object>) pathBResult.get("matchedRule");
+                if (matchedRule != null)
+                {
+                    detail.setRefId(toLong(matchedRule.get("activityId")));
+                }
+            }
+            detail.setReduce(eval.promotionReduce);
+            if (eval.promotionCapped)
+            {
+                detail.setLimitCapped(true);
+            }
+            details.add(detail);
+        }
+        if (eval.newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            PromotionDetail detail = new PromotionDetail();
+            detail.setType("promotion");
+            detail.setSubType(4);
+            detail.setName("新客立减");
+            detail.setReduce(eval.newCustomerReduce);
+            if (eval.newCustomerCapped)
+            {
+                detail.setLimitCapped(true);
+            }
+            for (PromotionActivity act : activeActivities)
+            {
+                if (act.getType() != null && act.getType() == 4)
+                {
+                    detail.setRefId(act.getId());
+                    break;
+                }
+            }
+            details.add(detail);
+        }
+        if (eval.couponReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            PromotionDetail detail = new PromotionDetail();
+            detail.setType("coupon");
+            detail.setSubType(0);
+            detail.setName(eval.couponName);
+            detail.setReduce(eval.couponReduce);
+            if (eval.couponCapped)
+            {
+                detail.setLimitCapped(true);
+            }
+            if (selectedCoupon != null && selectedCoupon.batch != null)
+            {
+                detail.setRefId(selectedCoupon.batch.getId());
+            }
+            details.add(detail);
+        }
+    }
+
     // ==================== DTO转换方法 ====================
 
     private PathResult toPathResult(Map<String, Object> pathMap)
@@ -926,7 +1497,7 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
     }
 
     /**
-     * 构建用户可用优惠券列表
+     * 构建用户可用优惠券列表(旧契约入口:无满额免运上下文)
      *
      * budgetLimit 为 null(限制开关关闭)时不算预算,行为与现状完全一致;
      * budgetUsedBeforeCoupon 为促销+新客已占预算(候选券与当前选中券互为替代,故只看券前占用)
@@ -943,6 +1514,29 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                                                             String optimalPath,
                                                             BigDecimal budgetLimit,
                                                             BigDecimal budgetUsedBeforeCoupon)
+    {
+        return buildAvailableCoupons(storeId, userId, originalAmount, afterPromotion, promotionReduce,
+                itemsWithPrice, rulesByProduct, activityMap, priceMap, nameMap, optimalPath,
+                budgetLimit, budgetUsedBeforeCoupon, BigDecimal.ZERO);
+    }
+
+    /**
+     * 构建用户可用优惠券列表(完整配送报价:budgetBase 为免运预留后的受限预算 max(0,B−F);
+     * freeDeliveryReduce&gt;0 即本单已享满额免运,免配送费券不可用也不核销,spec FR-036)
+     */
+    private List<Map<String, Object>> buildAvailableCoupons(Long storeId, Long userId,
+                                                            BigDecimal originalAmount,
+                                                            BigDecimal afterPromotion,
+                                                            BigDecimal promotionReduce,
+                                                            List<Map<String, Object>> itemsWithPrice,
+                                                            Map<Long, List<PromotionActivityRule>> rulesByProduct,
+                                                            Map<Long, PromotionActivity> activityMap,
+                                                            Map<Long, BigDecimal> priceMap,
+                                                            Map<Long, String> nameMap,
+                                                            String optimalPath,
+                                                            BigDecimal budgetBase,
+                                                            BigDecimal budgetUsedBeforeCoupon,
+                                                            BigDecimal freeDeliveryReduce)
     {
         List<Map<String, Object>> available = new ArrayList<>();
         if (userId == null)
@@ -980,6 +1574,16 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             couponInfo.put("discountRate", rule.getDiscountRate());
             couponInfo.put("expireTime", uc.getExpireTime());
 
+            // 命中满额免运时免配送费券不可用、不核销(spec FR-036),直接短路后续判断
+            if (freeDeliveryReduce != null && freeDeliveryReduce.compareTo(BigDecimal.ZERO) > 0
+                    && batch.getCouponType() != null && batch.getCouponType() == 3)
+            {
+                couponInfo.put("usable", false);
+                couponInfo.put("unusableReason", MessageUtils.message("promotion.freeDelivery.alreadyApplied"));
+                available.add(couponInfo);
+                continue;
+            }
+
             boolean usable = true;
             int isMutex = rule.getIsMutex() != null ? rule.getIsMutex() : 0;
 
@@ -1036,14 +1640,15 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             }
 
             // 预算封顶(spec FR-026):候选实抵 = min(原可抵金额, 剩余预算);互斥券选中会清掉促销,用全额预算
+            // 完整配送报价时 budgetBase 已是免运预留后的受限预算 max(0,B−F),互斥券同样不释放免运预留
             // 放在商品券预览之后,封顶后的实抵值覆盖未封顶预览
-            if (budgetLimit != null)
+            if (budgetBase != null)
             {
                 // 互斥券只有在实际会取消促销时(促销>0,与 calculate 互斥分支进入条件一致)才享受全额预算;
                 // 无促销时互斥券走普通分支,按促销+新客占用后的剩余预算封顶
                 boolean mutexActive = isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0;
-                BigDecimal candidateBudget = mutexActive ? budgetLimit
-                        : budgetLimit.subtract(budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
+                BigDecimal candidateBudget = mutexActive ? budgetBase
+                        : budgetBase.subtract(budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
                 BigDecimal baseForCalc = mutexActive ? originalAmount : afterPromotion;
                 CouponCalcResult budgetCalc = calcCouponReduce(rule, batch, itemsWithPrice,
                         baseForCalc, priceMap, nameMap);

+ 13 - 1
ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml

@@ -62,10 +62,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="deliveryStatus" column="delivery_status" />
         <result property="payStatus" column="pay_status" />
         <result property="afterSaleStatus" column="after_sale_status" />
+        <result property="promotionPricingVersion" column="promotion_pricing_version" />
+        <result property="merchantDeliverySubsidy" column="merchant_delivery_subsidy" />
+        <result property="customerDeliveryFee" column="customer_delivery_fee" />
     </resultMap>
 
     <sql id="selectPosOrderVo">
-        select id, dd_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,order_source,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,effective_at,delivery_status,pay_status,after_sale_status from pos_order
+        select id, dd_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,order_source,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,effective_at,delivery_status,pay_status,after_sale_status,promotion_pricing_version,merchant_delivery_subsidy,customer_delivery_fee from pos_order
     </sql>
 
     <select id="selectPosOrderList" parameterType="PosOrder" resultMap="PosOrderResult">
@@ -198,6 +201,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="deliveryStatus != null">delivery_status,</if>
             <if test="payStatus != null">pay_status,</if>
             <if test="afterSaleStatus != null">after_sale_status,</if>
+            <if test="promotionPricingVersion != null">promotion_pricing_version,</if>
+            <if test="merchantDeliverySubsidy != null">merchant_delivery_subsidy,</if>
+            <if test="customerDeliveryFee != null">customer_delivery_fee,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="ddId != null">#{ddId},</if>
@@ -243,6 +249,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="deliveryStatus != null">#{deliveryStatus},</if>
             <if test="payStatus != null">#{payStatus},</if>
             <if test="afterSaleStatus != null">#{afterSaleStatus},</if>
+            <if test="promotionPricingVersion != null">#{promotionPricingVersion},</if>
+            <if test="merchantDeliverySubsidy != null">#{merchantDeliverySubsidy},</if>
+            <if test="customerDeliveryFee != null">#{customerDeliveryFee},</if>
          </trim>
     </insert>
 
@@ -294,6 +303,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="deliveryStatus != null">delivery_status = #{deliveryStatus},</if>
             <if test="payStatus != null">pay_status = #{payStatus},</if>
             <if test="afterSaleStatus != null">after_sale_status = #{afterSaleStatus},</if>
+            <if test="promotionPricingVersion != null">promotion_pricing_version = #{promotionPricingVersion},</if>
+            <if test="merchantDeliverySubsidy != null">merchant_delivery_subsidy = #{merchantDeliverySubsidy},</if>
+            <if test="customerDeliveryFee != null">customer_delivery_fee = #{customerDeliveryFee},</if>
         </trim>
         where id = #{id}
     </update>

+ 32 - 0
ruoyi-system/src/main/resources/mapper/system/PromotionActivityMapper.xml

@@ -81,4 +81,36 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         order by type asc
     </select>
 
+    <!-- 2026-10-19 满额免运(008 T074):按日期查生效活动,不依赖状态刷新任务 -->
+    <select id="selectActiveFreeDeliveryByStoreId" resultMap="PromotionActivityResult">
+        <include refid="selectPromotionActivityVo"/>
+        where store_id = #{storeId} and type = 5 and status in (0, 1)
+          and start_time &lt;= #{now} and end_time &gt;= #{now}
+        order by id asc
+    </select>
+
+    <!-- 商家端活动分页:type=5 行的展示状态按本地日期计算(人工结束优先),状态筛选同口径 -->
+    <select id="selectActivityPage" resultMap="PromotionActivityResult">
+        select id, store_id, type, name,
+            case when type = 5 and status &lt;&gt; 2 then
+                case when start_time &gt; #{now} then 0
+                     when end_time &lt; #{now} then 2
+                     else 1 end
+            else status end as status,
+            start_time, end_time, create_time, update_time
+        from promotion_activity
+        <where>
+            <if test="storeId != null">and store_id = #{storeId}</if>
+            <if test="type != null">and type = #{type}</if>
+            <if test="status != null">
+                and (case when type = 5 and status &lt;&gt; 2 then
+                        case when start_time &gt; #{now} then 0
+                             when end_time &lt; #{now} then 2
+                             else 1 end
+                    else status end) = #{status}
+            </if>
+        </where>
+        order by create_time desc
+    </select>
+
 </mapper>

+ 388 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionActivityServiceImplFreeDeliveryTest.java

@@ -0,0 +1,388 @@
+package com.ruoyi.system.service.impl;
+
+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.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PromotionActivity;
+import com.ruoyi.system.domain.PromotionActivityRule;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.mapper.PromotionActivityMapper;
+import com.ruoyi.system.mapper.PromotionActivityRuleMapper;
+import com.ruoyi.system.service.MerchantAccessContext;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+
+/**
+ * T074 满额免运活动管理:type=5 规则校验、门店归属、门店行锁、时间重叠与按日期有效状态。
+ */
+@ExtendWith(MockitoExtension.class)
+class PromotionActivityServiceImplFreeDeliveryTest
+{
+    private static final Long OPERATOR_ID = 9L;
+    private static final Long STORE_ID = 1L;
+    private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
+
+    @Mock
+    private PromotionActivityMapper promotionActivityMapper;
+
+    @Mock
+    private PromotionActivityRuleMapper ruleMapper;
+
+    @Mock
+    private PromotionDiscountLimitChecker discountLimitChecker;
+
+    @Mock
+    private MerchantStoreAccessService storeAccessService;
+
+    @Mock
+    private PosStoreMapper posStoreMapper;
+
+    @InjectMocks
+    private PromotionActivityServiceImpl service;
+
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUpMessages()
+    {
+        messages = org.mockito.Mockito.mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+        lenient().when(storeAccessService.resolve(OPERATOR_ID))
+                .thenReturn(new MerchantAccessContext(OPERATOR_ID, OPERATOR_ID, true, Set.of(STORE_ID)));
+    }
+
+    @AfterEach
+    void closeMessages()
+    {
+        messages.close();
+    }
+
+    @Test
+    void create_shouldAcceptThresholdWithoutFixedReduction()
+    {
+        when(promotionActivityMapper.selectCount(any())).thenReturn(0L);
+        when(promotionActivityMapper.insert(any(PromotionActivity.class))).thenAnswer(inv -> {
+            inv.getArgument(0, PromotionActivity.class).setId(51L);
+            return 1;
+        });
+
+        boolean created = service.createActivity(freeDeliveryActivity(futureRange()), freeDeliveryRules("100"), OPERATOR_ID);
+
+        assertTrue(created);
+        ArgumentCaptor<PromotionActivity> captor = ArgumentCaptor.forClass(PromotionActivity.class);
+        verify(promotionActivityMapper).insert(captor.capture());
+        assertEquals(5, captor.getValue().getType());
+        assertEquals(0, captor.getValue().getStatus());
+        verify(ruleMapper).insert(any(PromotionActivityRule.class));
+        verify(posStoreMapper).lockStoreRow(STORE_ID);
+        verify(discountLimitChecker).checkActivity(any(PromotionActivity.class), any());
+    }
+
+    @Test
+    void create_shouldNormalizeDatesToWholeLocalDays()
+    {
+        when(promotionActivityMapper.selectCount(any())).thenReturn(0L);
+        when(promotionActivityMapper.insert(any(PromotionActivity.class))).thenReturn(1);
+
+        PromotionActivity activity = freeDeliveryActivity(futureRange());
+        activity.setStartTime(date(2026, Calendar.NOVEMBER, 2, 10, 30));
+        activity.setEndTime(date(2026, Calendar.NOVEMBER, 9, 15, 0));
+
+        service.createActivity(activity, freeDeliveryRules("100"), OPERATOR_ID);
+
+        ArgumentCaptor<PromotionActivity> captor = ArgumentCaptor.forClass(PromotionActivity.class);
+        verify(promotionActivityMapper).insert(captor.capture());
+        assertEquals(LocalDateTime.of(2026, 11, 2, 0, 0, 0),
+                LocalDateTime.ofInstant(captor.getValue().getStartTime().toInstant(), TAIPEI));
+        assertEquals(LocalDateTime.of(2026, 11, 9, 23, 59, 59),
+                LocalDateTime.ofInstant(captor.getValue().getEndTime().toInstant(), TAIPEI));
+    }
+
+    @Test
+    void create_shouldRejectEmptyOrMultipleRules()
+    {
+        ServiceException empty = assertThrows(ServiceException.class,
+                () -> service.createActivity(freeDeliveryActivity(futureRange()), Collections.emptyList(), OPERATOR_ID));
+        assertEquals("promotion.freeDelivery.invalidRules", empty.getMessage());
+
+        ServiceException multiple = assertThrows(ServiceException.class,
+                () -> service.createActivity(freeDeliveryActivity(futureRange()),
+                        freeDeliveryRules("100", "200"), OPERATOR_ID));
+        assertEquals("promotion.freeDelivery.invalidRules", multiple.getMessage());
+        verify(promotionActivityMapper, never()).insert(any(PromotionActivity.class));
+    }
+
+    @Test
+    void create_shouldRejectInvalidThresholdOrForbiddenRuleFields()
+    {
+        assertInvalidThreshold(null);
+        assertInvalidThreshold(BigDecimal.ZERO);
+        assertInvalidThreshold(new BigDecimal("-1"));
+        assertInvalidThreshold(new BigDecimal("99.5"));
+
+        PromotionActivityRule withReduce = freeDeliveryRules("100").get(0);
+        withReduce.setReduceAmount(new BigDecimal("10"));
+        ServiceException forbiddenField = assertThrows(ServiceException.class,
+                () -> service.createActivity(freeDeliveryActivity(futureRange()), List.of(withReduce), OPERATOR_ID));
+        assertEquals("promotion.freeDelivery.invalidRules", forbiddenField.getMessage());
+    }
+
+    @Test
+    void save_shouldRejectOtherStoreOwner()
+    {
+        when(storeAccessService.resolve(OPERATOR_ID))
+                .thenReturn(new MerchantAccessContext(OPERATOR_ID, OPERATOR_ID, true, Set.of(77L)));
+
+        ServiceException denied = assertThrows(ServiceException.class,
+                () -> service.createActivity(freeDeliveryActivity(futureRange()), freeDeliveryRules("100"), OPERATOR_ID));
+        assertEquals("promotion.freeDelivery.forbiddenStore", denied.getMessage());
+        verify(promotionActivityMapper, never()).insert(any(PromotionActivity.class));
+    }
+
+    @Test
+    void create_shouldRejectOverlappingConcurrentActivity()
+    {
+        when(promotionActivityMapper.selectCount(any())).thenReturn(1L);
+
+        ServiceException overlap = assertThrows(ServiceException.class,
+                () -> service.createActivity(freeDeliveryActivity(futureRange()), freeDeliveryRules("100"), OPERATOR_ID));
+        assertEquals("promotion.freeDelivery.overlap", overlap.getMessage());
+        verify(promotionActivityMapper, never()).insert(any(PromotionActivity.class));
+    }
+
+    @Test
+    void state_shouldStartAndExpireWithoutScheduler()
+    {
+        // 存量 status=0 但日期已开始 → 有效状态 1(进行中)
+        assertEquals(1, detailOf(activeStored(0)).getStatus());
+        // 存量 status=1 但已过结束日 → 有效状态 2(已结束)
+        assertEquals(2, detailOf(expiredStored(1)).getStatus());
+        // status=2 人工结束优先,即使日期仍在范围内
+        assertEquals(2, detailOf(activeStored(2)).getStatus());
+        // 未到开始日仍为 0
+        PromotionActivity notStarted = activeStored(0);
+        notStarted.setStartTime(tomorrow());
+        notStarted.setEndTime(dayAfterTomorrow());
+        assertEquals(0, detailOf(notStarted).getStatus());
+    }
+
+    @Test
+    void detail_shouldRejectOtherStoreOwnerForFreeDelivery()
+    {
+        when(storeAccessService.resolve(OPERATOR_ID))
+                .thenReturn(new MerchantAccessContext(OPERATOR_ID, OPERATOR_ID, true, Set.of(77L)));
+        PromotionActivity stored = activeStored(0);
+        when(promotionActivityMapper.selectById(stored.getId())).thenReturn(stored);
+
+        ServiceException denied = assertThrows(ServiceException.class,
+                () -> service.selectActivityWithRules(stored.getId(), OPERATOR_ID));
+        assertEquals("promotion.freeDelivery.forbiddenStore", denied.getMessage());
+    }
+
+    @Test
+    void edit_shouldRejectEffectivelyStartedActivity()
+    {
+        PromotionActivity existing = activeStored(0);
+        when(promotionActivityMapper.selectById(51L)).thenReturn(existing);
+
+        PromotionActivity update = freeDeliveryActivity(futureRange());
+        update.setId(51L);
+
+        ServiceException rejected = assertThrows(ServiceException.class,
+                () -> service.updateActivity(update, freeDeliveryRules("100"), OPERATOR_ID));
+        assertEquals("promotion.freeDelivery.notEditable", rejected.getMessage());
+    }
+
+    @Test
+    void edit_shouldAllowNotStartedActivityAndKeepStoreGuard()
+    {
+        PromotionActivity existing = freeDeliveryActivity(futureRange());
+        existing.setId(51L);
+        when(promotionActivityMapper.selectById(51L)).thenReturn(existing);
+        when(promotionActivityMapper.selectCount(any())).thenReturn(0L);
+        when(promotionActivityMapper.updateById(any(PromotionActivity.class))).thenReturn(1);
+
+        PromotionActivity update = freeDeliveryActivity(futureRange());
+        update.setId(51L);
+        update.setName("新名称");
+
+        assertTrue(service.updateActivity(update, freeDeliveryRules("120"), OPERATOR_ID));
+        verify(posStoreMapper).lockStoreRow(STORE_ID);
+        verify(ruleMapper).delete(any());
+        verify(ruleMapper).insert(any(PromotionActivityRule.class));
+    }
+
+    @Test
+    void end_shouldFollowEffectiveStatus()
+    {
+        // 存量 status=0 但按日期进行中:允许结束
+        PromotionActivity ongoing = activeStored(0);
+        ongoing.setId(51L);
+        when(promotionActivityMapper.selectById(51L)).thenReturn(ongoing);
+        when(promotionActivityMapper.updateById(any(PromotionActivity.class))).thenReturn(1);
+        assertTrue(service.endActivity(51L, OPERATOR_ID));
+
+        // 已过结束日(有效状态2):不允许再结束
+        PromotionActivity expired = expiredStored(1);
+        expired.setId(52L);
+        when(promotionActivityMapper.selectById(52L)).thenReturn(expired);
+        ServiceException rejected = assertThrows(ServiceException.class,
+                () -> service.endActivity(52L, OPERATOR_ID));
+        assertEquals("promotion.freeDelivery.notEndable", rejected.getMessage());
+    }
+
+    @Test
+    void legacyCreate_shouldKeepOriginalBehaviorWithoutGuards()
+    {
+        when(promotionActivityMapper.selectCount(any())).thenReturn(0L);
+        when(promotionActivityMapper.insert(any(PromotionActivity.class))).thenAnswer(inv -> {
+            inv.getArgument(0, PromotionActivity.class).setId(60L);
+            return 1;
+        });
+
+        PromotionActivity manjian = new PromotionActivity();
+        manjian.setStoreId(STORE_ID);
+        manjian.setType(1);
+        manjian.setName("满20减5");
+        manjian.setStartTime(new Date(System.currentTimeMillis() - 1000L));
+        manjian.setEndTime(new Date(System.currentTimeMillis() + 86400000L));
+        PromotionActivityRule rule = new PromotionActivityRule();
+        rule.setThreshold(new BigDecimal("20"));
+        rule.setReduceAmount(new BigDecimal("5"));
+
+        assertTrue(service.createActivity(manjian, List.of(rule), OPERATOR_ID));
+        verify(storeAccessService, never()).resolve(anyLong());
+        verify(posStoreMapper, never()).lockStoreRow(anyLong());
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private void assertInvalidThreshold(BigDecimal threshold)
+    {
+        ServiceException error = assertThrows(ServiceException.class,
+                () -> service.createActivity(freeDeliveryActivity(futureRange()), freeDeliveryRules(threshold), OPERATOR_ID));
+        assertEquals("promotion.freeDelivery.invalidThreshold", error.getMessage());
+    }
+
+    /** 详情读取路径验证按日期有效状态(selectActivityWithRules 对 type=5 应用有效状态)。 */
+    private PromotionActivity detailOf(PromotionActivity stored)
+    {
+        when(promotionActivityMapper.selectById(stored.getId())).thenReturn(stored);
+        lenient().when(ruleMapper.selectRulesByActivityId(stored.getId())).thenReturn(Collections.emptyList());
+        return service.selectActivityWithRules(stored.getId(), OPERATOR_ID);
+    }
+
+    private PromotionActivity activeStored(int storedStatus)
+    {
+        PromotionActivity activity = freeDeliveryActivity(new Date[] {
+                new Date(System.currentTimeMillis() - 86400000L),
+                new Date(System.currentTimeMillis() + 86400000L) });
+        activity.setId(51L);
+        activity.setStatus(storedStatus);
+        return activity;
+    }
+
+    /** 已过结束日的存量活动(start/end 均在过去)。 */
+    private PromotionActivity expiredStored(int storedStatus)
+    {
+        PromotionActivity activity = freeDeliveryActivity(new Date[] {
+                new Date(System.currentTimeMillis() - 2 * 86400000L),
+                new Date(System.currentTimeMillis() - 86400000L) });
+        activity.setId(51L);
+        activity.setStatus(storedStatus);
+        return activity;
+    }
+
+    private PromotionActivity freeDeliveryActivity(Date[] range)
+    {
+        PromotionActivity activity = new PromotionActivity();
+        activity.setStoreId(STORE_ID);
+        activity.setType(5);
+        activity.setName("原价满100免配送费");
+        activity.setStartTime(range[0]);
+        activity.setEndTime(range[1]);
+        return activity;
+    }
+
+    private List<PromotionActivityRule> freeDeliveryRules(String... thresholds)
+    {
+        if (thresholds.length == 0)
+        {
+            return Collections.emptyList();
+        }
+        return thresholds.length == 1
+                ? List.of(freeDeliveryRule(new BigDecimal(thresholds[0])))
+                : List.of(freeDeliveryRule(new BigDecimal(thresholds[0])),
+                        freeDeliveryRule(new BigDecimal(thresholds[1])));
+    }
+
+    private List<PromotionActivityRule> freeDeliveryRules(BigDecimal threshold)
+    {
+        return threshold == null ? List.of(new PromotionActivityRule()) : List.of(freeDeliveryRule(threshold));
+    }
+
+    private PromotionActivityRule freeDeliveryRule(BigDecimal threshold)
+    {
+        PromotionActivityRule rule = new PromotionActivityRule();
+        rule.setThreshold(threshold);
+        return rule;
+    }
+
+    /** 未开始的日期区间(明天起,到后天结束)。 */
+    private Date[] futureRange()
+    {
+        return new Date[] { tomorrow(), dayAfterTomorrow() };
+    }
+
+    private Date tomorrow()
+    {
+        return dateOffset(1);
+    }
+
+    private Date dayAfterTomorrow()
+    {
+        return dateOffset(2);
+    }
+
+    private Date dateOffset(int days)
+    {
+        LocalDateTime base = LocalDateTime.now(TAIPEI).toLocalDate().plusDays(days).atStartOfDay();
+        return Date.from(base.atZone(TAIPEI).toInstant());
+    }
+
+    private Date date(int year, int month, int day, int hour, int minute)
+    {
+        Calendar calendar = Calendar.getInstance();
+        calendar.set(year, month, day, hour, minute, 0);
+        calendar.set(Calendar.MILLISECOND, 0);
+        return calendar.getTime();
+    }
+}

+ 515 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionCalcServiceImplFreeDeliveryTest.java

@@ -0,0 +1,515 @@
+package com.ruoyi.system.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PromotionActivity;
+import com.ruoyi.system.domain.PromotionActivityRule;
+import com.ruoyi.system.domain.PromotionCouponBatch;
+import com.ruoyi.system.domain.PromotionCouponRule;
+import com.ruoyi.system.domain.PromotionUserCoupon;
+import com.ruoyi.system.dto.PromotionCalcRequest;
+import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionCalcResponse.AvailableCoupon;
+import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
+import com.ruoyi.system.dto.PromotionDeliveryContext;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PromotionActivityMapper;
+import com.ruoyi.system.mapper.PromotionActivityRuleMapper;
+import com.ruoyi.system.mapper.PromotionCouponBatchMapper;
+import com.ruoyi.system.mapper.PromotionCouponRuleMapper;
+import com.ruoyi.system.mapper.PromotionUserCouponMapper;
+
+/**
+ * T075 满额免运完整配送算价:原价门槛、全免优先允许超预算、其余受限优惠用 max(0,B−F)、
+ * 半价豁免保留、互斥券不释放免运预留、免运命中时免配送费券不可用、appliedCouponId 仅实减&gt;0返回。
+ */
+@ExtendWith(MockitoExtension.class)
+class PromotionCalcServiceImplFreeDeliveryTest
+{
+    private static final Long USER_ID = 9L;
+    private static final Long STORE_ID = 1L;
+    private static final Long FOOD_ID = 10L;
+    private static final Long FD_ACTIVITY_ID = 51L;
+
+    @Mock
+    private PromotionActivityMapper activityMapper;
+
+    @Mock
+    private PromotionActivityRuleMapper activityRuleMapper;
+
+    @Mock
+    private PromotionCouponBatchMapper couponBatchMapper;
+
+    @Mock
+    private PromotionCouponRuleMapper couponRuleMapper;
+
+    @Mock
+    private PromotionUserCouponMapper userCouponMapper;
+
+    @Mock
+    private PosFoodMapper posFoodMapper;
+
+    @Mock
+    private PosOrderMapper posOrderMapper;
+
+    @Mock
+    private PromotionDiscountLimitChecker discountLimitChecker;
+
+    @InjectMocks
+    private PromotionCalcServiceImpl service;
+
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUp()
+    {
+        messages = org.mockito.Mockito.mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class)))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+
+        lenient().when(posFoodMapper.selectById(FOOD_ID)).thenReturn(food("100"));
+        lenient().when(activityMapper.selectActiveByStoreId(STORE_ID)).thenReturn(Collections.emptyList());
+        lenient().when(activityRuleMapper.selectActiveRulesByStoreId(STORE_ID)).thenReturn(Collections.emptyList());
+        lenient().when(posOrderMapper.selectCount(any())).thenReturn(1L);
+        lenient().when(userCouponMapper.selectList(any())).thenReturn(Collections.emptyList());
+    }
+
+    @AfterEach
+    void closeMessages()
+    {
+        messages.close();
+    }
+
+    // ==================== 预算优先矩阵 ====================
+
+    @Test
+    void calculate_shouldFullyWaiveThirtyWithTwentyBudget()
+    {
+        stubFreeDelivery("100");
+        stubBudget(true, "20");
+
+        PromotionCalcResponse response = service.calculate(request(), USER_ID, context("30"));
+
+        assertEquals(Integer.valueOf(2), response.getPricingVersion());
+        assertAmount("100", response.getOriginalAmount());
+        assertAmount("100", response.getFinalAmount());
+        assertAmount("30", response.getDeliveryFee());
+        assertAmount("30", response.getDeliveryDiscountAmount());
+        assertAmount("0", response.getCustomerDeliveryFee());
+        assertAmount("100", response.getPayableAmount());
+        assertEquals(FD_ACTIVITY_ID, response.getFreeDeliveryActivityId());
+        assertAmount("100", response.getFreeDeliveryThreshold());
+        assertAmount("20", response.getDiscountBudget());
+        assertAmount("0", response.getRemainingDiscountBudget());
+        assertNull(response.getAppliedCouponId());
+
+        PromotionDetail fd = findDetail(response, "promotion", 5);
+        assertAmount("30", fd.getReduce());
+        assertAmount("100", fd.getOriginalAmount());
+        assertAmount("30", fd.getDeliveryFee());
+        assertAmount("0", fd.getCustomerDeliveryFee());
+        assertAmount("100", fd.getThreshold());
+        assertEquals(FD_ACTIVITY_ID, fd.getRefId());
+    }
+
+    @Test
+    void calculate_shouldReserveTenBeforeOtherDiscounts()
+    {
+        stubFreeDelivery("100");
+        stubBudget(true, "20");
+        stubManjian("100", "15");
+
+        PromotionCalcResponse response = service.calculate(request(), USER_ID, context("10"));
+
+        assertAmount("10", response.getDeliveryDiscountAmount());
+        assertAmount("0", response.getCustomerDeliveryFee());
+        PromotionDetail manjian = findDetail(response, "promotion", 1);
+        assertAmount("10", manjian.getReduce());
+        assertTrue(manjian.getLimitCapped());
+        assertAmount("90", response.getFinalAmount());
+        assertAmount("90", response.getPayableAmount());
+        assertAmount("0", response.getRemainingDiscountBudget());
+    }
+
+    @Test
+    void calculate_shouldUseUpBudgetExactlyWhenFeeEqualsBudget()
+    {
+        // contracts 数值矩阵第2行:O100/R20/B20 → F20/D0/G100/A100,剩余预算0
+        stubFreeDelivery("100");
+        stubBudget(true, "20");
+
+        PromotionCalcResponse response = service.calculate(request(), USER_ID, context("20"));
+
+        assertAmount("20", response.getDeliveryDiscountAmount());
+        assertAmount("0", response.getCustomerDeliveryFee());
+        assertAmount("100", response.getFinalAmount());
+        assertAmount("100", response.getPayableAmount());
+        assertAmount("0", response.getRemainingDiscountBudget());
+    }
+
+    @Test
+    void calculate_shouldNotWaiveBelowOriginalThreshold()
+    {
+        stubFreeDelivery("100");
+        stubBudget(true, "20");
+        when(posFoodMapper.selectById(FOOD_ID)).thenReturn(food("99"));
+
+        PromotionCalcResponse response = service.calculate(request(), USER_ID, context("30"));
+
+        assertAmount("0", response.getDeliveryDiscountAmount());
+        assertAmount("30", response.getCustomerDeliveryFee());
+        assertNull(response.getFreeDeliveryActivityId());
+        assertAmount("129", response.getPayableAmount());
+        assertEquals(0, countDetail(response, "promotion", 5));
+        assertAmount("20", response.getRemainingDiscountBudget());
+    }
+
+    @Test
+    void calculate_shouldKeepWaiverAfterGoodsDiscount()
+    {
+        stubFreeDelivery("100");
+        stubBudget(false, null);
+        stubManjian("100", "30");
+
+        PromotionCalcResponse response = service.calculate(request(), USER_ID, context("30"));
+
+        // 商品优惠后应付低于门槛仍保留免运资格;预算关闭不封顶
+        assertAmount("30", response.getDeliveryDiscountAmount());
+        assertAmount("0", response.getCustomerDeliveryFee());
+        assertAmount("70", response.getFinalAmount());
+        assertAmount("70", response.getPayableAmount());
+        assertNull(response.getDiscountBudget());
+        assertNull(response.getRemainingDiscountBudget());
+    }
+
+    @Test
+    void calculate_shouldKeepShippingReservationForMutexCoupon()
+    {
+        stubFreeDelivery("100");
+        stubBudget(true, "20");
+        stubManjian("100", "30");
+        Long couponId = stubSelectedCoupon("20", 1, null, 1L, 200L, 1);
+
+        PromotionCalcResponse response = service.calculate(request(couponId), USER_ID, context("10"));
+
+        // 互斥券取消促销但不释放免运预留:券只能用 goodsBudget=10,不是全额 B=20
+        assertAmount("10", response.getDeliveryDiscountAmount());
+        assertAmount("10", response.getCouponReduce());
+        assertAmount("90", response.getFinalAmount());
+        assertAmount("90", response.getPayableAmount());
+        assertEquals(0, countDetail(response, "promotion", 1));
+        assertEquals(couponId, response.getAppliedCouponId());
+        assertTrue(response.getCouponConflict());
+        assertAmount("0", response.getRemainingDiscountBudget());
+    }
+
+    @Test
+    void availableCoupons_shouldUseSameRemainingBudgetAndBlockFreeDeliveryCoupon()
+    {
+        stubFreeDelivery("100");
+        stubBudget(true, "20");
+        stubManjian("100", "30");
+        stubAvailableCoupon(101L, 201L, "30", 0, null, 1);
+        stubAvailableCoupon(102L, 202L, "10", 0, null, 3);
+
+        PromotionCalcResponse response = service.calculate(request(), USER_ID, context("10"));
+
+        AvailableCoupon normal = findAvailable(response, 101L);
+        assertFalse(normal.getUsable());
+        assertEquals("本单优惠已达优惠上限", normal.getUnusableReason());
+        assertAmount("0", normal.getCouponPreviewReduce());
+        AvailableCoupon delivery = findAvailable(response, 102L);
+        assertFalse(delivery.getUsable());
+        assertEquals("promotion.freeDelivery.alreadyApplied", delivery.getUnusableReason());
+    }
+
+    @Test
+    void calculate_shouldKeepHalfPriceExemption()
+    {
+        stubFreeDelivery("100");
+        stubBudget(true, "20");
+        stubHalfPrice();
+        when(posFoodMapper.selectById(FOOD_ID)).thenReturn(food("100"));
+
+        PromotionCalcRequest request = request();
+        request.getItems().get(0).setQuantity(2);
+
+        PromotionCalcResponse response = service.calculate(request, USER_ID, context("30"));
+
+        // O=200、B=40、F=30 → goodsBudget=10;半价减免50豁免不封顶
+        assertAmount("200", response.getOriginalAmount());
+        assertAmount("30", response.getDeliveryDiscountAmount());
+        assertAmount("0", response.getCustomerDeliveryFee());
+        PromotionDetail half = findDetail(response, "promotion", 3);
+        assertAmount("50", half.getReduce());
+        assertNull(half.getLimitCapped());
+        assertAmount("150", response.getFinalAmount());
+        assertAmount("150", response.getPayableAmount());
+        assertEquals("A", response.getOptimalPath());
+        assertAmount("10", response.getRemainingDiscountBudget());
+    }
+
+    @Test
+    void calculate_shouldPreferHalfPriceAfterBudgetCap()
+    {
+        stubFreeDelivery("100");
+        stubBudget(true, "20");
+        stubManjian("100", "30");
+        stubHalfPrice();
+        when(posFoodMapper.selectById(FOOD_ID)).thenReturn(food("50"));
+
+        PromotionCalcRequest request = request();
+        request.getItems().get(0).setQuantity(2);
+
+        PromotionCalcResponse response = service.calculate(request, USER_ID, context("30"));
+
+        // O=100、F=30、goodsBudget=0:满减30被截0,半价25豁免保留;按应用预算后的整单应付选路径A
+        assertEquals("A", response.getOptimalPath());
+        assertAmount("75", response.getFinalAmount());
+        assertAmount("75", response.getPayableAmount());
+        PromotionDetail half = findDetail(response, "promotion", 3);
+        assertAmount("25", half.getReduce());
+        assertNull(half.getLimitCapped());
+        assertEquals(0, countDetail(response, "promotion", 1));
+        assertAmount("0", response.getRemainingDiscountBudget());
+
+        // forcePath=B 尊重用户选择:满减被预算截0,G=100
+        PromotionCalcRequest forced = request();
+        forced.getItems().get(0).setQuantity(2);
+        forced.setForcePath("B");
+        PromotionCalcResponse forcedResponse = service.calculate(forced, USER_ID, context("30"));
+        assertEquals("B", forcedResponse.getOptimalPath());
+        assertAmount("100", forcedResponse.getFinalAmount());
+        assertAmount("100", forcedResponse.getPayableAmount());
+        assertEquals(0, countDetail(forcedResponse, "promotion", 1));
+    }
+
+    @Test
+    void calculate_shouldNotConsumeDeliveryCouponWhenWaived()
+    {
+        stubFreeDelivery("100");
+        stubBudget(true, "20");
+        Long couponId = stubSelectedCoupon("10", 0, null, 100L, 200L, 3);
+
+        PromotionCalcResponse response = service.calculate(request(couponId), USER_ID, context("30"));
+
+        // 命中满额免运时免配送费券不生效、不核销
+        assertNull(response.getAppliedCouponId());
+        assertNull(response.getCouponReduce());
+        assertAmount("100", response.getFinalAmount());
+        assertAmount("100", response.getPayableAmount());
+        assertEquals(0, countDetail(response, "coupon", 0));
+    }
+
+    @Test
+    void legacyCalculate_shouldKeepTwoArgBehaviorWithoutDelivery()
+    {
+        stubBudget(false, null);
+        stubManjian("100", "30");
+
+        PromotionCalcResponse response = service.calculate(request(), USER_ID);
+
+        // 旧契约:无配送上下文,type=5 不参与,finalAmount 仅商品应付
+        assertNull(response.getPricingVersion());
+        assertNull(response.getDeliveryFee());
+        assertAmount("100", response.getOriginalAmount());
+        assertAmount("70", response.getFinalAmount());
+        verify(activityMapper, never()).selectActiveFreeDeliveryByStoreId(eq(STORE_ID), any(Date.class));
+        assertEquals(0, countDetail(response, "promotion", 5));
+    }
+
+    // ==================== 桩与工具 ====================
+
+    private PromotionCalcRequest request()
+    {
+        return request(null);
+    }
+
+    private PromotionCalcRequest request(Long couponId)
+    {
+        PromotionCalcRequest request = new PromotionCalcRequest();
+        request.setStoreId(STORE_ID);
+        PromotionCalcRequest.CartItem item = new PromotionCalcRequest.CartItem();
+        item.setProductId(FOOD_ID);
+        item.setQuantity(1);
+        request.setItems(new ArrayList<>(List.of(item)));
+        request.setCouponId(couponId);
+        return request;
+    }
+
+    private PromotionDeliveryContext context(String deliveryFee)
+    {
+        return new PromotionDeliveryContext(new BigDecimal(deliveryFee), true);
+    }
+
+    private PosFood food(String price)
+    {
+        PosFood food = new PosFood();
+        food.setId(FOOD_ID);
+        food.setMdid(STORE_ID);
+        food.setPrice(new BigDecimal(price));
+        food.setName("测试商品");
+        return food;
+    }
+
+    private void stubBudget(boolean enabled, String ratio)
+    {
+        when(discountLimitChecker.isEnabled()).thenReturn(enabled);
+        if (ratio != null)
+        {
+            lenient().when(discountLimitChecker.getRatioPercent()).thenReturn(new BigDecimal(ratio));
+        }
+    }
+
+    private void stubFreeDelivery(String threshold)
+    {
+        PromotionActivity activity = new PromotionActivity();
+        activity.setId(FD_ACTIVITY_ID);
+        activity.setStoreId(STORE_ID);
+        activity.setType(5);
+        activity.setName("原价满100免配送费");
+        when(activityMapper.selectActiveFreeDeliveryByStoreId(eq(STORE_ID), any(Date.class)))
+                .thenReturn(List.of(activity));
+        PromotionActivityRule rule = new PromotionActivityRule();
+        rule.setActivityId(FD_ACTIVITY_ID);
+        rule.setThreshold(new BigDecimal(threshold));
+        when(activityRuleMapper.selectRulesByActivityId(FD_ACTIVITY_ID)).thenReturn(List.of(rule));
+    }
+
+    private void stubManjian(String threshold, String reduce)
+    {
+        PromotionActivity activity = new PromotionActivity();
+        activity.setId(61L);
+        activity.setStoreId(STORE_ID);
+        activity.setType(1);
+        activity.setName("满减");
+        when(activityMapper.selectActiveByStoreId(STORE_ID)).thenReturn(List.of(activity));
+        PromotionActivityRule rule = new PromotionActivityRule();
+        rule.setActivityId(61L);
+        rule.setThreshold(new BigDecimal(threshold));
+        rule.setReduceAmount(new BigDecimal(reduce));
+        when(activityRuleMapper.selectActiveRulesByStoreId(STORE_ID)).thenReturn(List.of(rule));
+    }
+
+    private void stubHalfPrice()
+    {
+        PromotionActivity activity = new PromotionActivity();
+        activity.setId(62L);
+        activity.setStoreId(STORE_ID);
+        activity.setType(3);
+        activity.setName("第二份半价");
+        when(activityMapper.selectActiveByStoreId(STORE_ID)).thenReturn(List.of(activity));
+        PromotionActivityRule rule = new PromotionActivityRule();
+        rule.setActivityId(62L);
+        rule.setProductId(FOOD_ID);
+        rule.setMinQuantity(2);
+        when(activityRuleMapper.selectActiveRulesByStoreId(STORE_ID)).thenReturn(List.of(rule));
+    }
+
+    /** 选中券桩:返回 couponId;isMutex/couponType/threshold 自定 */
+    private Long stubSelectedCoupon(String amount, int isMutex, String threshold,
+                                    Long couponId, Long batchId, int couponType)
+    {
+        PromotionUserCoupon userCoupon = new PromotionUserCoupon();
+        userCoupon.setId(couponId);
+        userCoupon.setUserId(USER_ID);
+        userCoupon.setStatus(0);
+        userCoupon.setBatchId(batchId);
+        when(userCouponMapper.selectById(couponId)).thenReturn(userCoupon);
+        PromotionCouponBatch batch = new PromotionCouponBatch();
+        batch.setId(batchId);
+        batch.setName("测试券");
+        batch.setCouponType(couponType);
+        when(couponBatchMapper.selectById(batchId)).thenReturn(batch);
+        PromotionCouponRule rule = new PromotionCouponRule();
+        rule.setIsMutex(isMutex);
+        rule.setAmount(new BigDecimal(amount));
+        rule.setThreshold(threshold == null ? null : new BigDecimal(threshold));
+        when(couponRuleMapper.selectRuleByBatchId(batchId)).thenReturn(rule);
+        return couponId;
+    }
+
+    /** 候选券列表桩:可多次调用累积多张 */
+    private void stubAvailableCoupon(Long userCouponId, Long batchId, String amount, int isMutex,
+                                     String threshold, int couponType)
+    {
+        PromotionUserCoupon userCoupon = new PromotionUserCoupon();
+        userCoupon.setId(userCouponId);
+        userCoupon.setUserId(USER_ID);
+        userCoupon.setStatus(0);
+        userCoupon.setBatchId(batchId);
+        userCoupon.setStoreId(STORE_ID);
+        userCoupon.setExpireTime(new Date(System.currentTimeMillis() + 86400000L));
+
+        List<PromotionUserCoupon> existing = new ArrayList<>(userCouponMapper.selectList(any()));
+        existing.add(userCoupon);
+        when(userCouponMapper.selectList(any())).thenReturn(existing);
+
+        PromotionCouponBatch batch = new PromotionCouponBatch();
+        batch.setId(batchId);
+        batch.setName("券" + batchId);
+        batch.setCouponType(couponType);
+        batch.setStatus(1);
+        when(couponBatchMapper.selectById(batchId)).thenReturn(batch);
+        PromotionCouponRule rule = new PromotionCouponRule();
+        rule.setIsMutex(isMutex);
+        rule.setAmount(new BigDecimal(amount));
+        rule.setThreshold(threshold == null ? null : new BigDecimal(threshold));
+        when(couponRuleMapper.selectRuleByBatchId(batchId)).thenReturn(rule);
+    }
+
+    private PromotionDetail findDetail(PromotionCalcResponse response, String type, int subType)
+    {
+        return response.getDetails().stream()
+                .filter(d -> type.equals(d.getType()) && Integer.valueOf(subType).equals(d.getSubType()))
+                .findFirst().orElseThrow(() -> new AssertionError("missing detail " + type + "/" + subType));
+    }
+
+    private int countDetail(PromotionCalcResponse response, String type, int subType)
+    {
+        return (int) response.getDetails().stream()
+                .filter(d -> type.equals(d.getType()) && Integer.valueOf(subType).equals(d.getSubType()))
+                .count();
+    }
+
+    private AvailableCoupon findAvailable(PromotionCalcResponse response, Long id)
+    {
+        return response.getAvailableCoupons().stream()
+                .filter(c -> id.equals(c.getId()))
+                .findFirst().orElseThrow(() -> new AssertionError("missing coupon " + id));
+    }
+
+    private void assertAmount(String expected, BigDecimal actual)
+    {
+        assertNotNull(actual);
+        assertEquals(0, new BigDecimal(expected).compareTo(actual), "expected " + expected + " but " + actual);
+    }
+}

+ 16 - 1
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionDiscountLimitCheckerTest.java

@@ -2,6 +2,8 @@ package com.ruoyi.system.service.impl;
 
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PromotionActivity;
+import com.ruoyi.system.domain.PromotionActivityRule;
 import com.ruoyi.system.domain.PromotionCouponBatch;
 import com.ruoyi.system.domain.PromotionCouponRule;
 import com.ruoyi.system.mapper.PosFoodMapper;
@@ -19,6 +21,7 @@ import java.util.List;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.reset;
 
@@ -38,7 +41,8 @@ class PromotionDiscountLimitCheckerTest {
     void enableTwentyPercentLimit() {
         when(configService.selectConfigByKey("promotion.discount.limit.enabled"))
                 .thenReturn("true");
-        when(configService.selectConfigByKey("promotion.discount.limit.ratio"))
+        // type=5 满额免运创建侧不读比例(不预估免运额),lenient 避免严格stub误报
+        lenient().when(configService.selectConfigByKey("promotion.discount.limit.ratio"))
                 .thenReturn("20");
     }
 
@@ -132,6 +136,17 @@ class PromotionDiscountLimitCheckerTest {
                 () -> checker.checkCouponBatch(batch(2), rules));
     }
 
+    @Test
+    void freeDeliveryActivityCreationSkipsReductionRatioCheck() {
+        // T074:type=5 满额免运只有门槛没有减免额,创建侧不预估/不限制实际免运额
+        PromotionActivity activity = new PromotionActivity();
+        activity.setType(5);
+        PromotionActivityRule rule = new PromotionActivityRule();
+        rule.setThreshold(new BigDecimal("100"));
+
+        assertDoesNotThrow(() -> checker.checkActivity(activity, List.of(rule)));
+    }
+
     private PromotionCouponBatch batch(int type) {
         PromotionCouponBatch batch = new PromotionCouponBatch();
         batch.setCouponType(type);

+ 63 - 0
specs/008-promotion-coupon/claude-code-handoff.md

@@ -0,0 +1,63 @@
+# Claude Code 实施交接:满额免运(2026-10-19)
+
+## 直接任务
+
+请在当前仓库的现有分支实施008促销功能新增“满额免运”,按spec-kit已有增量文档推进。后端和商家PC一起做,App/平台PC不动,不创建或切换分支/工作树,不恢复暂停的036工作。无需重新询问已确认的原价门槛与免运优先规则;本轮没有自动commit/push授权。
+
+**必读顺序**:仓库AGENTS.md → [spec.md](./spec.md) FR-028~FR-036 → [plan.md](./plan.md) 2026-10-19章节 → [contracts/free-delivery.md](./contracts/free-delivery.md) → [tasks.md](./tasks.md) T074~T078。
+
+工作目录为`D:/QtwCode/foodie/foodie-server`,商家PC为`D:/QtwCode/foodie/foodie-store`(实际D盘路径已核对;AGENTS中E盘是历史路径)。规划时服务端分支为`codex/036-multi-store-checkout`,商家PC分支为`test-202609v2`;执行前只确认位置,两个仓库都不自行创建或切换分支。`PosFoodController.java`存在原有未提交修改,保留、不混入本功能。
+
+## 已确定规则
+
+- 商家在现有“促销活动”创建type=5满额免运,不新增领券流程;门槛由商家填,按商品原价判断,100为示例。
+- 命中后先全免R,允许R超过预算B;其余受限优惠使用max(0,B−R),用完则不再追加;保留原第二份半价预算豁免。
+- 用户支付商品应付G及客户实际配送费D;免运D=0,商家承担R,骑手毛配送费仍R,不额外入账第二次。
+- 商家PC含创建、编辑、详情、列表、结束及四语言。后端旧finalAmount仍为商品金额,新完整报价与订单显式version2接入;App本轮未接入,旧请求保持原行为。
+
+## 执行与验收
+
+按T074→T075→T076→T077→T078,每批先写有业务断言的测试、确认红测,再实现并验证;文档规划不是已通过测试。SQL只在updatesql/sql.md最后按执行日期追加,由开发者手动执行,禁止直接迁移测试/生产数据库。
+
+以下为实施完成后的命令示例,本轮规划**没有执行**这些构建或测试:
+
+```powershell
+$env:JAVA_HOME = 'C:/Users/99029/.jdks/graalvm-jdk-21.0.12+7.1'
+$env:PATH = "$env:JAVA_HOME/bin;$env:PATH"
+java -version
+# 在foodie-server目录,命令行参数整体加引号以符合PowerShell。
+mvn -pl ruoyi-system -am test '-Dtest=PromotionActivityServiceImplFreeDeliveryTest,PromotionDiscountLimitCheckerTest,PromotionCalcServiceImplFreeDeliveryTest,PromotionCalcServiceImplDiscountLimitTest' '-Dsurefire.failIfNoSpecifiedTests=false'
+mvn -pl ruoyi-admin -am test '-Dtest=ShPromotionActivityFreeDeliveryTest,FoodDeliveryPromotionQuoteServiceTest,OrderPromotionHelperFreeDeliveryTest,UserOrderFreeDeliveryCreateTest,OrderFreeDeliverySettlementTest,OrderInvoiceFreeDeliveryTest,PayMethodSwitchServiceTest' '-Dsurefire.failIfNoSpecifiedTests=false'
+mvn -pl ruoyi-admin -am verify
+# 在D:/QtwCode/foodie/foodie-store目录
+npm run build:test
+```
+
+定向用例名与最终测试类一致;运行后读取测试计数与FAILURE/ERROR,不能仅根据命令退出或surefire未找到测试的选项宣称通过。前端没有现成test/lint脚本,不编造npm test;构建与手工四语言验收按T078完成。
+
+远程地图/支付/退款在单元测试中mock,不把外部测试环境退款失败改成假成功。需要手工联调的项目明确标为未验证。只触及本功能相关支付金额读取,不重做OMG全套接口或启用废弃支付代码。
+
+## 最终交付需列出
+
+1. T074~T078实际完成项、修改文件与接口/字段;未执行任务保持未勾选。
+2. 实际运行的测试/构建结果及仍受外部服务限制的验证项;SQL待手动执行的日期和位置。
+3. 商家PC四语言、免运/预算/真实核销、线上/到付、切换支付、旧订单、发票/退款的验收结果;明确App代码未修改、旧App未完成新结算展示。
+4. 运行git diff --check并检查范围/CRLF;不含原有PosFoodController改动。用户另行要求提交后才按各仓库实际分支提交,中文commit,报告SHA与push目标。
+
+## 实施结果(2026-10-19,Claude Code)
+
+**已完成**:T074~T077 全部子项与 T078 自动化项;未创建分支/工作树、未改 App、未动平台 PC、未触碰 PosFoodController(该文件当前已不在工作区改动中,系并行会话处理)。
+
+**代码落点**:
+- 活动:`PromotionActivityServiceImpl`(type=5 校验/台北整天归一化/有效状态/归属+`PosStoreMapper.lockStoreRow` 行锁/重叠)、`PromotionActivityMapper.selectActiveFreeDeliveryByStoreId`+`selectActivityPage`(SQL 内 CASE 计算有效状态)、`PromotionActivitySaveRequest` DTO、`ShPromotionActivityController`(DTO+操作者ID)。
+- 算价:`IPromotionCalcService` 三参重载(`PromotionDeliveryContext`)、`PromotionCalcServiceImpl` 逐路径预算评估(goodsBudget=max(0,B−F)、半价豁免、互斥不释放F、按 A=G+D 选路、type=5 明细、剩余预算、appliedCouponId)、`PromotionCalcRequest.CartItem.specValueIds`、`PromotionCalcResponse` 版本2扩展字段。
+- 报价:`FoodDeliveryPromotionQuoteRequest`/`FoodDeliveryPromotionQuoteService`(门店/自配送/地址/商品/规格核实、Google 路线+跳表运费、`POST /app/userPromotionCalc/deliveryQuote`)。
+- 订单:`OrderCreatItem.promotionPricingVersion`、`UserOrderController.prepareVersionTwoQuote`(权威重算+quoteChanged 拒绝+ddId 幂等+父单金额服务端确定)、`OrderPromotionHelper`(汇总排除 subType=5、`applyVersion2Pricing`/`buildServerFoodJson`/`resolveCouponToConsume`)、`PosOrder` 三列+三派生 getter、`PosOrderMapper.xml`。
+- 结算:`OrderService.setSanghuBilling` 版本2按 G=A−D(不查 VipUserQuanyi)、`OrderInvoiceService.invoiceGoodsTotal`(两处 A−D)、骑手分成沿用 R 不变。
+- i18n:后端 6 包新增16个 `promotion.freeDelivery.*` key(新键 `duplicateOrder`/`configConflict` 超出列表下限)+新建 `messages_vi.properties`;商家PC `promoActivity` 6 key×4语言。
+
+**已运行验证**(JDK 21.0.12,均通过):定向 ruoyi-system 40/40(含矩阵第2行 F=B 边界);定向 ruoyi-admin 45/45;`mvn -pl ruoyi-admin -am verify` 全量 1109/1109;`npm run build:test` 成功(dist 2026-10-19 11:34 产出,webpack 缓存告警不影响);后端6包/前端4语言 key 一致性脚本核对通过;`git diff --check` 无空白错误(4个 LF 文件已转回 CRLF)。
+
+**待人工**:①`updatesql/sql.md` 末尾 2026-10-19 三条 ALTER 由开发者手动执行;②商家 PC 浏览器手工验收(第五Tab/四语言/重叠拒绝/结束操作);③Google 路线与运价真实调用仅单测 mock,联调未做;④App 端未接入 deliveryQuote/version2,旧App保持旧行为。
+
+**未提交**:本轮无 commit/push 授权,全部改动保留在工作区(foodie-server 分支 codex/036-multi-store-checkout、foodie-store 分支 test-202609v2)。

+ 141 - 0
specs/008-promotion-coupon/contracts/free-delivery.md

@@ -0,0 +1,141 @@
+# 满额免运接口与金额契约(2026-10-19,待实现)
+
+对应spec.md FR-028~FR-036与plan.md本次变更;本文是Claude Code实施契约,接口/字段当前尚未上线。所有金额为与现有订单一致的新台币元,整数元口径,禁止擅自乘100。
+
+## 1. 范围与兼容
+
+- 商家PC在“营销管理 → 促销活动”创建type=5;用户无需领券,不在CouponBatch新增免运券。
+- 只做后端和商家PC;App代码不改。本轮新增完整报价接口供后续接入;旧App保持旧商品算价和旧下单契约,不承诺已支持免运展示。
+- 新金额流程通过单店`promotionPricingVersion=2`显式接入;不接入036批次、商家自配送、闪送、自取或堂食。
+- 原`POST /app/userPromotionCalc/calculate`的body和`finalAmount`含义保持,不能偷偷让finalAmount包含/扣除配送费。新配送上下文只能由服务端应用层构造。
+
+## 2. 商家活动管理
+
+沿用`/system/shPromotionActivity`的POST/PUT和既有GET/结束/删除接口,不重新拆参数。登录token在Header;新增业务参数全部在body。
+
+创建示例:
+
+```json
+{
+  "storeId": 1,
+  "type": 5,
+  "name": "原价满100免配送费",
+  "startTime": "2026-10-19",
+  "endTime": "2026-10-31",
+  "rules": [{ "threshold": 100 }]
+}
+```
+
+修改在同body增加id,仅允许有效状态未开始的活动;详情同样返回type=5和threshold。规则只能一条且threshold为正整数元,不填写reduceAmount、productId、discountRate。日期按本地整天含首尾日;人工结束优先,状态展示与算价必须一致;同店同类型日期不能重叠。普通商家与夜市场景均验证真实门店管理归属。
+
+## 3. 新增完整配送报价
+
+`POST /app/userPromotionCalc/deliveryQuote`,Header `token`,明确`FoodDeliveryPromotionQuoteRequest` DTO。
+
+```json
+{
+  "storeId": 1,
+  "orderType": 0,
+  "addressId": 8,
+  "delryTime": null,
+  "items": [{ "productId": 10, "quantity": 2, "specValueIds": [] }],
+  "couponId": null,
+  "forcePath": null
+}
+```
+
+- 不接收客户端freight、免运金额或商品单价作为计费依据。地址必须归当前用户,规格ID必须归商品且有效;items每项数量1~99。
+- 服务端查商品原价/规格、门店配送模式、坐标、路线和运价;传入既有CartItem.specPrice也不能覆盖服务端结果。
+- delryTime沿既有`yyyy-MM-dd HH:mm-HH:mm`预约格式;实际配送模式按预约时段判定,活动资格按报价/创建时日期。
+- 只支持本轮平台骑手单店外送,其余返回本地化unsupportedOrder错误;未知/缺失坐标、路线/运价失败返回业务错误,不默认运费0。
+
+以下为`AjaxResult.data`内关键字段示例(原价100、配送费30、预算20,普通商品优惠实减0):
+
+```json
+{
+  "pricingVersion": 2,
+  "originalAmount": 100,
+  "finalAmount": 100,
+  "deliveryFee": 30,
+  "deliveryDiscountAmount": 30,
+  "customerDeliveryFee": 0,
+  "payableAmount": 100,
+  "freeDeliveryActivityId": 51,
+  "freeDeliveryThreshold": 100,
+  "discountBudget": 20,
+  "remainingDiscountBudget": 0,
+  "appliedCouponId": null,
+  "details": [
+    {
+      "type": "promotion",
+      "subType": 5,
+      "name": "原价满100免配送费",
+      "refId": 51,
+      "reduce": 30,
+      "originalAmount": 100,
+      "deliveryFee": 30,
+      "customerDeliveryFee": 0,
+      "threshold": 100
+    }
+  ]
+}
+```
+
+| 字段 | Java类型 | 定义 |
+|---|---|---|
+| pricingVersion | Integer | 新完整配送报价固定2;旧接口可不返回 |
+| originalAmount / finalAmount | BigDecimal | O商品原价 / G优惠后商品金额,保留旧含义 |
+| deliveryFee | BigDecimal | R原配送费,也是骑手毛配送费基数 |
+| deliveryDiscountAmount | BigDecimal | F本活动免运减免;不再从G扣 |
+| customerDeliveryFee | BigDecimal | D客户实际支付配送费 |
+| payableAmount | BigDecimal | A=G+D,客户整单应付 |
+| freeDeliveryActivityId / freeDeliveryThreshold | Long / BigDecimal | 命中活动ID/原价门槛;未命中可为null |
+| discountBudget / remainingDiscountBudget | BigDecimal | 总预算B / 全部受限优惠分配后剩余预算;开关关闭用null表示无限,不能用0表示 |
+| couponId / appliedCouponId | Long / Long | 原字段保留用户选择意图;新增字段仅实际生效且实减>0时返回,核销以appliedCouponId为准 |
+
+PromotionDetail的originalAmount/deliveryFee/customerDeliveryFee/threshold为type=5快照扩展字段;其他旧明细可为null。活动未命中时不返回虚假type=5优惠记录;R=0时优惠额为0,不核销券。路径A/B保持原字段,新增完整报价默认选应用预算后的A最小路径;forcePath保留手选。
+
+免运命中时免配送费券`usable=false`,理由为本地化“本单已享受满额免运”;不消费该券。预算耗尽的其他券预估实减0、不可用;选择这些券也不生成appliedCouponId。既有无免运场景的旧配送券偏差不借本次扩展全局改写。
+
+## 4. 新订单与快照
+
+继续使用`UserOrderController`现有`POST /system/userOrder/createOrder`,不创建第二套订单生命周期。新单在`items[0]`增加`promotionPricingVersion: 2`,cartItems提供productId/quantity/specValueIds;父/子type都必须0且只有一家门店。沿用已有shdzId作为addressId。
+
+- item.amount=A、item.freight=R,父actualPayAmount=A;服务端重算并核对,过期/篡改返回`promotion.freeDelivery.quoteChanged`,事务回滚。不要由前端把原freight改为0。
+- 商品food JSON、商品原价、优惠汇总、父totalAmount/totalDiscount均由服务端确定;不接受客户端food中的price/otherPrice作为来源。
+- 持久化:`promotion_pricing_version=2`、`merchant_delivery_subsidy=F`、`customer_delivery_fee=D`;freight继续R、amount继续A。type=5活动明细写已有pos_order_promotion并固化门槛与金额,不占商品减免汇总。
+- 历史订单三字段NULL:不回填、不重算。读取D时仅对旧流程按既有freight回退;version=2却缺少关键金额视为不完整数据,应拒绝计费处理并报告业务错误,不能伪装成普通订单。
+- 活动结束或支付方式切换后仍沿快照,不重选活动、重新扣预算或改付款总额;只重算由当前支付方式决定的交款方向。
+
+## 5. 三方资金与派生响应
+
+仅新增可选响应,不要求App本轮渲染;金额由后端快照/支付方式派生,不新存现金确认状态。
+
+| 场景(G100/R30/F30/D0/A100) | merchantPayToRider | riderPayToMerchant | customerCollectAmount |
+|---|---:|---:|---:|
+| 在线已付 | 30 | 0 | 0 |
+| 到付 | 0 | 70 | 100 |
+| 到付G20/R30/F30/D0/A20 | 10 | 0 | 20 |
+
+返回的交款字段均为BigDecimal且非负,不能只给一个无方向的负数。字段作为`PosOrder`新增的`@TableField(exist=false)`派生属性/getter从快照计算,保留现有返回PosOrder的查询路径;legacy订单可不返回这些新字段,禁止为三个派生数值新增数据库列。到付依现有collectPayment/payType语义判断;支付切换后的查询即时反映新方向。骑手净收入仍按R减原佣金/税,不把毛运费说成到手金额。现金交付和现有钱包账单不是两次支付。
+
+发票按既有不含客户配送费口径取A−D,免运示例为100;退款最多退渠道实际支付A,不另外退F。新流程商家商品分成基数为G,不重复从餐费减去免运。
+
+## 6. 验收数值
+
+门槛100、预算比例20%,无半价例外,商品优惠/券按既有可用规则:
+
+| O | R | 商品受限优惠 | F | D | G | A | 说明 |
+|---:|---:|---:|---:|---:|---:|---:|---|
+| 100 | 30 | 0 | 30 | 0 | 100 | 100 | 免运可超预算 |
+| 100 | 20 | 0 | 20 | 0 | 100 | 100 | 预算刚好用完 |
+| 100 | 10 | 10 | 10 | 0 | 90 | 90 | 其他优惠最多剩10 |
+| 99 | 30 | 0 | 0 | 30 | 99 | 129 | 原价未达标 |
+| 100 | 30 | 30(预算关闭) | 30 | 0 | 70 | 70 | 优惠后不足门槛仍免运 |
+| 100 | 30 | 半价25(豁免) | 30 | 0 | 75 | 75 | 保留原半价例外 |
+
+## 7. 错误与交接
+
+新增本地化key至少包含`promotion.freeDelivery.invalidThreshold`、`invalidRules`、`forbiddenStore`、`overlap`、`invalidAddress`、`invalidProduct`、`invalidSpec`、`unsupportedOrder`、`freightUnavailable`、`alreadyApplied`、`quoteChanged`、`invalidSnapshot`(后三段同前缀)。沿用AjaxResult业务错误机制,不硬编码中文。
+
+本轮验收覆盖商家PC及后端新接口;App用户端、商家App、骑手App的渲染与新报价接入未实施,不得在交付中标记已完成。将来App只需按本契约对接,不重新讨论门槛与预算规则。

+ 145 - 3
specs/008-promotion-coupon/plan.md

@@ -1,17 +1,25 @@
 # Implementation Plan: 促销 + 优惠券系统
 
-**Branch**: `008-promotion-coupon` | **Date**: 2026-05-29 | **Spec**: [spec.md](./spec.md)
+> **For agentic workers:** 在现有分支按 tasks.md 的 T074~T078 执行;需要执行工作流时使用 superpowers:executing-plans(如环境提供)。本轮规划由 Codex 完成,代码由 Claude Code 实现,不创建分支或工作树。
+
+**Goal:** 扩展商家自动满额免运,商品原价达标后由商家承担全额配送费,并优先占用优惠预算。
+**Architecture:** 复用现有促销表和商家页面;商品促销计算接收可信的内部配送上下文;应用层负责地址、规格及运费,订单固化三方金额后沿用支付与结算。
+**Tech Stack:** Java 21、Spring Boot、MyBatis/MyBatis-Plus、MySQL、Vue 2、Element UI、vue-i18n。
+**Spec:** [spec.md](./spec.md) FR-028~FR-036;[contracts/free-delivery.md](./contracts/free-delivery.md)。
+
+**Original Feature**: `008-promotion-coupon` | **Created**: 2026-05-29 | **Plan updated**: 2026-10-19
+**Current branch**: `codex/036-multi-store-checkout`(仅沿用工作目录,不恢复036实施;执行时不新建/切换分支)。
 **Input**: Feature specification from `/specs/008-promotion-coupon/spec.md`
 
 ## Summary
 
-为 foodie 商家端新增促销活动管理(满减/折扣商品/第二份半价/新客立减 4 种类型)和优惠券管理(满减券/商品券/免配送费券),商家可在后台创建和管理促销活动与优惠券。旧促销/优惠券代码(SalesPromotion、VipQuanyi)不动,全部新建文件、新表(promotion_ 前缀)、新接口。前端在商家端侧边栏新增独立的「营销管理」菜单。用户端(小程序)的领券、下单优惠计算不在本范围。
+为 foodie 商家端新增促销活动管理(满减/折扣商品/第二份半价/新客立减 4 种类型)和优惠券管理(满减券/商品券/免配送费券),商家可在后台创建和管理促销活动与优惠券。旧促销/优惠券代码(SalesPromotion、VipQuanyi)不动,全部新建文件、新表(promotion_ 前缀)、新接口。前端在商家端侧边栏新增独立的「营销管理」菜单。用户端(App/小程序)页面代码不在本范围;后端用户接口、算价和下单金额属于后端范围。2026-10-19 新增满额免运按文末实施约定扩展,商家 PC 必须同步交付。
 
 **技术方案**:后端跟随现有 CRUD 分层(Entity + MyBatis XML Mapper + Service + Controller),6 张新表 + 2 个商家端 Controller + 2 个用户端 Controller(领券/查券 + 算价计算)。前端新增 2 个 Vue 页面,促销活动创建页面用 el-tabs 切换 4 种活动类型表单,每种类型有不同的表单字段和数据提交结构。折扣类型采用**逐商品设折扣**方式(参考美团),每个商品独立设置折扣率,不使用折扣区分组。
 
 ## Technical Context
 
-**Language/Version**: Java 17 (Spring Boot 3.x, MyBatis-Plus)
+**Language/Version**: Java 21 (Spring Boot 3.x, MyBatis-Plus)
 **Primary Dependencies**: Spring Boot, MyBatis-Plus, Vue.js 2.6, Element UI 2.15, vue-i18n
 **Storage**: MySQL (6 张新表: promotion_activity, promotion_activity_rule, promotion_coupon_batch, promotion_coupon_rule, promotion_user_coupon, pos_order_promotion)
 **Testing**: 手动测试 (Postman + 前端页面验证)
@@ -379,3 +387,137 @@ promotion_coupon_batch 1 → N promotion_user_coupon
 - 启用后存量超标门店的实际减免立刻被压到 20%(目的本身,但商家端会感知"优惠变小")
 - 路径选择仍按封顶前金额对比(封顶后满减路径可能劣于半价路径的极端场景接受,不做二次选择)
 - 免配送费券"按实际配送费封顶"是规格与实现的既有偏差(FR-008a 的 deliveryFee 参数从未实现),本次不修,由创建侧门槛校验+预算封顶兜住
+
+
+## 需求变更 2026-10-19:满额免运实施计划(Claude Code 执行)
+
+**状态**(2026-10-19 更新):已由 Claude Code 在现有分支实现(T074~T077 完成并过定向测试与回归;T078 自动化验证完成,商家 PC 浏览器手工验收与 SQL 手动执行待开发者)。详细任务、测试和执行顺序见 [tasks.md](./tasks.md#phase-511-满额免运优先2026-10-19-需求追加),交接入口见 [claude-code-handoff.md](./claude-code-handoff.md)。
+
+### Global Constraints
+
+- 直接沿用当前分支;不创建分支/工作树,不自动 commit/push,不混入已有 `PosFoodController.java` 改动。
+- 只实现后端和商家 PC。App、平台 PC、闪送、036多店履约和已废弃订单/支付入口均不修改。
+- Controller 新请求使用明确 DTO、`@RequestBody`、token Header;GET 参数显式注解,不使用 Map 请求参数、Bean Validation 或单语言硬编码错误;新文案四语言齐全。
+- 依赖保持 ruoyi-admin → ruoyi-system;应用层外部路线调用留在 ruoyi-admin,ruoyi-system 不导入 com.ruoyi.app。
+- 本机 JDK21 实际路径 `C:/Users/99029/.jdks/graalvm-jdk-21.0.12+7.1`,仅在当前终端设置环境;前端保留 CRLF。
+- SQL 仅由实施者按实际日期追加到 `updatesql/sql.md` 最后,开发者手动执行;本次规划不生成已可执行迁移、不连接数据库。
+- 现有金额为新台币元口径、订单整数字段沿用当前取整;不要依据旧注释“分”擅自乘100。服务内使用 BigDecimal,边界转换复用渠道现有规则,并以实际请求金额断言验证。
+
+### Review Focus
+
+| 易出错输入/条件 | 必须满足的行为 | 归属 |
+|---|---|---|
+| 原价100、运费30、预算20;互斥券切换;候选券列表 | 全免30,不能释放免运预留;实减0的券不核销 | T075/T076 |
+| 新活动 status=0 已到开始日、已到期但 status=1;并发创建重叠活动 | 按本地日期与未结束状态判断;同店同类型同日仅一个活动 | T074/T075 |
+| 跨店商品/规格、别人地址、客户端freight=0、商品价格/amount篡改 | 新契约服务端查价和地址归属;差异拒绝,不只记日志 | T075/T076 |
+| 第2份半价预算豁免与普通满减并存,普通满减被预算截为0 | 新配送流程按应用预算后实际应付选路径,保留半价豁免和手动forcePath | T075 |
+| 活动结束后的旧订单、支付方式切换、到付交款为负、发票/退款 | 快照不变;交款方向明确;扣客户实际运费;不多退、不重复入账 | T076/T077 |
+
+### A. 现状与接入位置(已核对源码)
+
+| 现状 | 实施要求 |
+|---|---|
+| `PromotionActivity.type` 目前1~4,规则已有threshold | 增加type=5,复用单条threshold规则,无需新优惠券表 |
+| `PromotionActivityMapper.selectActiveByStoreId` 与规则查询仅看status=1 | 新type=5使用独立按日期查询,不依赖不存在的状态刷新任务;不顺手重写旧四种活动 |
+| `PromotionCalcServiceImpl` 177行起已按促销→新客→券分配预算,半价豁免 | 新可信配送上下文先占F;所有普通/互斥/候选券分支一致 |
+| `UserPromotionCalcController` 类注释仍写“促销→券→新客” | 实施时同步修正为实际顺序及新增免运优先规则;本轮规划没有修改生产注释 |
+| `PromotionCalcResponse.finalAmount` 实际只有商品应付,没有运费 | 保留旧语义;新增配送和整单应付字段,禁止把免运从商品再减一次 |
+| `OrderPromotionHelper` 累加全部promotion,且返回couponId不表示实际用券 | 排除type=5商品汇总;增加appliedCouponId,只核销有真实优惠的券 |
+| `UserOrderController` 直接用item.amount/item.freight,优惠校验仅警告 | 版本2分支须权威重算、比较、固化;旧分支保持原契约 |
+| `OrderService.setQishouBilling` 以freight计算佣金、税及钱包记录 | 保留原毛配送费和幂等职责,不新增第二笔骑手运费入账 |
+| `OrderInvoiceService` 两处使用amount−freight | 版本2改用amount−customerDeliveryFee;历史订单保留原口径 |
+
+### B. 范围与实施选择
+
+1. 本次自动免运是type=5促销,沿用“营销管理 → 促销活动”,不向CouponBatch.vue增加领券型功能。商家PC创建、编辑回填、详情、列表、结束、未开始删除均覆盖。
+2. 单店平台骑手外送(type=0、服务端判定selfDelivery=0)接入本次新金额流程。自取/堂食、商家自配送和闪送不计算该优惠;036保持暂停。活动门槛按单个门店商品原价及合法规格加价合计,不计配送费,也不跨店合并。
+3. 活动按Asia/Taipei本地日期判断;使用日期范围含首尾日。报价/创建时处于范围即生效,预约单在创建时锁权益;后续活动修改/结束不追改订单。status=2明确结束则不参与。
+4. 旧App代码不改。新增完整报价契约,版本2订单使用权威金额;旧请求不自动切入,也不在旧订单上静默降价或改运费。此为兼容设计选择,本轮交付不能宣称旧App已展示/使用新免运规则。
+5. 原活动/优惠券CRUD路径不重新设计。新完整报价业务参数全部放body;GET简单参数沿用查询参数。
+
+### C. 活动管理与权限
+
+- `type=5` 固定仅一条规则 `threshold>0`,按现有整数元展示;productId、reduceAmount、discountRate、minQuantity不适用且不得携入其他优惠。门槛由商家填写,100不是硬编码常量。
+- 复用活动名称、门店和日期范围。新增 `PromotionActivitySaveRequest` DTO适配既有POST/PUT字段,不破坏原四种类型的请求格式;新增type=5日期按日期范围归一化到本地首日00:00:00和末日23:59:59,响应保持现有yyyy-MM-dd。
+- 新活动创建/编辑/详情/删除/结束必须校验操作者对门店的管理权限;避免依赖浏览器传来的storeId。普通商家、夜市主/摊位按仓库既有归属规则解析,不新增角色模型。
+- type=5创建/编辑在同一事务内锁定目标门店行,再检查时间重叠并写规则,避免两个请求同时通过检查;不改变旧类型历史行为。
+- type=5列表/详情的展示状态及编辑/删除/结束权限统一使用按日期计算的有效状态:未到开始日=0、生效中=1、明确结束或已过结束日=2。不能只在算价时生效、商家页面却一直显示“未开始”或允许修改进行中活动;状态2的人工结束优先,且不增加刷新定时任务。
+- 新增mapper方法 `List<PromotionActivity> selectActiveFreeDeliveryByStoreId(Long storeId, Date now)`,XML条件为type=5、status in(0,1)、start_time<=now、end_time>=now;随后按activityId读已有规则。返回多条重叠存量活动应报配置冲突,不能随机挑一条。
+- 优惠比例检查器对type=5只校验门槛合法,不预估或限制实际免运额;实减金额取权威配送费。
+
+### D. 金额模型与计算
+
+设 `O`=商品原价(含服务端核实的规格加价),`R`=原配送费/骑手毛配送费基数,`F`=本活动免运金额,`D`=客户应付配送费,`G`=优惠后商品应付,`A`=客户应付总额。
+
+- O沿既有商品原价汇总规则HALF_UP到整数元后用于门槛与预算,报价/下单必须一致;不重新设计旧商品取整。
+- 命中:`F=R`、`D=0`;未命中:`F=0`、`D=R`;`A=G+D`。无配送费时F=0,不产生虚假金额优惠或用券记录。
+- 开启预算时 `B=roundHalfUp(O×比例)`(沿现有整数元规则),供商品优惠使用的初始预算 `max(0,B−F)`。F允许大于B,绝不部分免运。
+- 后续按商品促销→新客→券占预算;半价胜出仍豁免。互斥券仅释放商品促销/新客占用,不释放F;候选券也用相同预算。开关关闭不做预算封顶。
+- 新配送上下文中默认比较应用预算、用户指定券后的两条路径的A,取低者;相等仍选B。forcePath=A/B尊重用户选择。不自动消费未指定的券,不改legacy两参数calculate的历史选路行为。
+- 命中免运时couponType=3不可用,返回本地化原因;selected coupon仍可保留在couponId表示请求意图,`appliedCouponId`只在实际减免>0且校验通过时返回。新订单只按appliedCouponId核销;实减0、预算耗尽、无效/过期券不得被核销。
+- 未命中时旧免配送费券的既有偏差(历史从餐费扣减)不在本轮全面重做;保留legacy并明确不宣称已修复。新活动命中时必须阻断该券组合,防止重复补贴。
+
+### E. 可信配送上下文与报价边界
+
+- 在ruoyi-system新增内部DTO `PromotionDeliveryContext(BigDecimal deliveryFee, boolean eligible)`;`IPromotionCalcService` 保留 `calculate(PromotionCalcRequest, Long)`,新增重载 `calculate(PromotionCalcRequest, Long, PromotionDeliveryContext)`。旧调用不自动注入客户端运费;完整报价适配层构造该可信上下文。
+- 在ruoyi-admin新增 `FoodDeliveryPromotionQuoteService.quote(FoodDeliveryPromotionQuoteRequest request, Long userId): PromotionCalcResponse`,由新POST报价方法及版本2下单共用。请求详情见contracts,不能接受客户自行给出的免运额或运费作为权威输入。
+- 校验商品属于门店、在售、数量1~99;按数据库pos_food价格与FoodSpecRelation/FoodSpecs/FoodSpecsValue关系读取实际规格加价。商品数量、必选/单选约束和规格归属须验证,不能信CartItem.specPrice。已有036查询代码可读作字段参考,不调用其多店配置或恢复036任务。
+- 收货地址按InfoAddress.userId验证;门店/地址坐标必须存在且合法。外送/自配送判定以SelfDeliveryService及实际用餐类型为准,不信客户端selfDelivery。
+- 原配送费采用现行路线和运价口径:由服务端门店/地址坐标调用现有Google路线能力读取routes[0].legs距离,不接收客户端quli作为权威距离;沿`SysFileInfoController.getZuiyyfei`现有跳表规则(含半公里与千元舍入),用小范围方法封装到报价Service,保留旧GET入口。新调用不得使用已停用Mapbox,也不把地图响应/密钥输出到日志。
+- 运价用PosFreightMapper.getyunjia,缺省用getmiuyunjia;数据库当前时间选择与现有口径一致,路线/运价不可用时返回本地化错误,不回退成免费运费或接受客户端freight。单元测试mock路线,不调用真实地图。
+
+### F. 持久化与创建
+
+| 位置 | 拟新增/保留字段 | 约束 |
+|---|---|---|
+| promotion_activity / rule | type增加5、rule.threshold复用 | 不建免运券或新活动表 |
+| pos_order.freight | 保留既有Double/原列 | 仍是R,不清零、不改单位 |
+| pos_order | promotion_pricing_version TINYINT NULL | 2=本次完整配送金额契约;历史NULL保留旧行为 |
+| pos_order | merchant_delivery_subsidy DECIMAL(10,2) NULL | 新流程F,未命中填0;不从餐费再扣 |
+| pos_order | customer_delivery_fee DECIMAL(10,2) NULL | 新流程D;旧流程NULL读取时回退freight |
+| pos_order_promotion | 已有promo_type=1、promo_sub_type=5 | JSON记录门槛、O/R/F/D/B、活动ID和名称;不改旧枚举值 |
+
+- `PromotionCalcResponse` 新增字段与单位详见contracts;`OrderCreatItem` 新增promotionPricingVersion及cartItems.specValueIds,保留旧字段。新流程food JSON根据真实商品/规格生成兼容原price/otherPrice/number结构,不信客户端food中的价格。
+- `UserOrderController.createOrderChild` 对显式version=2单店请求先调用完整报价,校验item.amount、父单actualPayAmount与服务端A一致;原freight字段应提交R,不是0。差异用错误key `promotion.freeDelivery.quoteChanged` 返回并回滚,不静默采用旧金额。
+- 新流程同步父/子订单:子amount=A,foodAmount=O(保持商品原价含规格的约定),freight=R、F/D及version=2固化;父totalAmount=O+R、actualPayAmount=A、totalDiscount=(O+R)−A。单店父子一致后才允许后续支付,不重写旧多店输入流程。
+- 预算/活动在报价后变化时创建重算并拒绝不一致;创建成功后的支付重试、切换支付方式、取消退款均用订单快照,不再按当前促销重算。
+- `OrderPromotionHelper` 增加新流程回填入口,商品减免汇总排除subType=5,写一条type=5快照;按appliedCouponId和实际减免核销,保留事务中的归属/使用状态检查。失败整笔回滚,不留下可支付父单或已核销券。
+- Entity、XML resultMap/select/insert/update(含BaseMapper读取)、DTO和查询响应同步;SQL仅追加,不追溯批量修改旧订单。
+
+### G. 结算、支付与发票
+
+| 场景 | 取餐交付 | 客户端待收 | 配送基数 |
+|---|---|---|---|
+| 在线已付 | 商家给骑手R | 0 | R |
+| 到付A≥R | 骑手给商家A−R | A | R |
+| 到付A<R | 商家给骑手R−A | A | R |
+
+- 示例:G=100、R=30、F=30、D=0、A=100。线上商家收100再给骑手30;到付骑手给商家70、向客户收100。原佣金/税另算。
+- 新流程商家商品分成以G=A−D为基数,免运是另项配送成本,不能把F同时当商品优惠扣一次又从分成扣一次;不要拿promotion用户券ID查询旧VipUserQuanyi判定新券。
+- 骑手账单仍由既有setQishouBilling和wallet幂等入口处理R,保留原佣金/税。不得因为新增F再新增一笔钱包奖励、平台补足或重复扣商家余额。
+- 查询响应在PosOrder增加三个`@TableField(exist=false)`派生属性/getter,沿既有查询路径返回;可选字段为 `merchantPayToRider=max(0,到付?R−A:R)`、`riderPayToMerchant=到付?max(0,A−R):0`、`customerCollectAmount=到付?A:0`;这些为派生值,不另存结算状态。由订单快照与当前payType推导,解决切换支付方式后方向变化。
+- 电子发票沿用既有“不含客户支付运费”口径,新流程invoiceTotal=A−D;原G100/R30免运例应开100,不能因freight仍为30而开70。两处计算同步。
+- LINE Pay、OMG、退款以及037无人接单取消只引用A与渠道实际交易;回调/退款用现有幂等和真实渠道能力。只增加回归断言,不重新设计支付接口,也不在规划阶段运行真实支付/退款。
+
+### H. 文件映射与实施顺序
+
+详见tasks的每批Files/Interfaces/Test。核心新增文件为系统内部PromotionDeliveryContext、商家保存DTO、应用层完整报价请求与Service及对应定向测试;其余只在现有促销、订单、Mapper、发票、账单与商家PromotionActivity.vue挂接。不为该活动新建配置开关、券库存、领券记录、菜单、定时任务或批处理迁移。
+
+顺序:T074 商家PC与活动API → T075可信报价与预算 → T076订单/快照/SQL → T077结算/发票/支付回归 → T078统一验证和文档交接。禁止把文档已写、用例已列或SQL已生成标成代码已验证。
+
+### I. 需求覆盖与验收边界
+
+| 规格 | 实施任务 |
+|---|---|
+| FR-028 活动配置自动生效 | T074/T075 |
+| FR-029 商品原价门槛 | T075 |
+| FR-030 免运优先、预算与豁免 | T075 |
+| FR-031 商家承担与金额分离 | T076/T077 |
+| FR-032 商家PC、App不动 | T074/T078 |
+| FR-033 权威报价与创建校验 | T075/T076 |
+| FR-034 旧接口兼容与新契约 | T075/T076/T078 |
+| FR-035 快照、交款与支付切换 | T076/T077 |
+| FR-036 防重复抵扣/核销、发票与退款 | T075/T076/T077 |
+
+
+执行命令、用例矩阵和未执行项记录见handoff。必须区分:商家PC配置可用、后端新契约通过、App展示未改、SQL待手动执行。App不改意味着完整用户端展示与新报价接入不由本轮完成;不得让Claude为了“看起来上线”修改App,或在旧接口里更换finalAmount语义。后续App工作应按已固定的contracts接入,无需重新确认原价门槛和免运优先规则。

+ 64 - 3
specs/008-promotion-coupon/spec.md

@@ -55,9 +55,11 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 
 本需求只实现以下功能:
 
-**促销(4种)**:满减、折扣商品(逐商品设折扣)、第二份半价、新客立减
+**现有促销(4种)**:满减、折扣商品(逐商品设折扣)、第二份半价、新客立减
 
-**不做**:爆品、买赠、减配送费、满减运费、好友助力等
+**2026-10-19 追加(需求已确认,功能未开始)**:满额免运,在促销活动中配置,用户满足门槛自动享受,详见 FR-028~FR-036。本轮交付后端和商家 PC;App 代码不动,代码实现交由 Claude Code,直接使用现有分支。
+
+**不做**:爆品、买赠、无门槛减配送费、阶梯减配送费、好友助力等;满额免运按 2026-10-19 追加规则纳入。
 
 **优惠券**:只做"店内领券"一种发放方式
 
@@ -71,6 +73,7 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 |------|---------|-------------|
 | 订单级 | 订单总价 | 满减、新客立减 |
 | 商品级 | 指定商品单价 | 折扣商品、第二份半价 |
+| 配送费级(2026-10-19 追加,待实现) | 客户应付配送费 | 满额免运;商家承担配送费,骑手按原规则结算 |
 
 ### 互斥规则
 
@@ -90,6 +93,8 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 一个订单最多:1个促销 + 1张满减券
 ```
 
+**2026-10-19 追加**:命中满额免运时,先全额预留免运费用,再按上述既有商品优惠顺序分配剩余预算;满额免运不占商品促销三选一名额。FR-030 的预算优先规则覆盖本节既有顺序中涉及预算分配的部分,功能尚未实现。
+
 **同享券门槛基准**:满减券的 threshold(使用门槛)基于促销后的金额判断(如促销后¥33,券要求满30,¥33≥¥30 可用)。
 
 **商品券与促销互斥规则**:商品券对应的商品如果正在参加折扣或第二份半价活动,该商品券不可用于该商品(放入不可用券列表),避免折上折。
@@ -190,6 +195,20 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 - 结束活动不可逆,已结束活动不可重新激活
 - 商品价格由后端根据 productId 从数据库查询,不信任前端传入的价格
 
+### User Story 4 - 商家 PC 配置满额免运,后端计算与结算一致 (Priority: P1)
+
+商家在现有“营销管理 → 促销活动”中创建满额免运,设置门槛、开始日期和结束日期,并按现有活动状态查看、编辑、删除或结束。这里增加的是自动生效的促销类型,不另外创建券批次、领券入口或新菜单。App 页面及 App 代码均不在本轮范围。
+
+**Independent Test**: 商家 PC 创建原价满100免运的活动;后端完整报价和创建订单使用同一金额规则。商品原价100、配送费30、预算20时,客户付100,商家承担30,骑手配送毛收入基数仍为30。实际支付、退款、发票及订单优惠快照按实付核对。
+
+**Acceptance Scenarios**:
+
+1. **Given** 商家有权限管理该门店,**When** 创建门槛100的满额免运活动,**Then** 活动列表、详情、编辑回填和结束入口完整,客户端无需领券。
+2. **Given** 原价100、配送费30、优惠预算20,**When** 通过新报价契约计算,**Then** 全免30,其余受限优惠为0;原价99则不触发免运。
+3. **Given** 原价100、配送费10、预算20且商品促销减8,**When** 还有新客立减或优惠券,**Then** 后续受限优惠最多共减2,客户实付90,配送费优惠不再从餐费扣一次。
+4. **Given** 免运已创建订单快照,**When** 活动结束、跨越活动日期或切换支付方式,**Then** 已创建订单金额和免运承担不重新计算。
+5. **Given** 旧 App 请求未接入完整配送报价,**When** 使用既有商品算价接口,**Then** 保留旧字段语义和金额行为;不得将本轮商家 PC 和后端交付描述为 App 结算展示已完成。
+
 ## Clarifications
 
 ### Session 2026-06-01
@@ -225,6 +244,26 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 - Q: 存量怎么处理? → A: 创建侧不追溯(已被领取且规则未变的编辑不受影响,仅规则实际被替换时校验新值);算价侧存量自动被预算封顶
 - Q: 互斥券路径怎么算? → A: 互斥券取消促销+新客后,其自身减免同样受预算封顶
 
+### Session 2026-10-19(满额免运:已确认规则,待实现)
+
+- Q: 满额免运放促销活动还是优惠券? → A: 在现有促销活动中增加满额免运,满足条件自动享受,不要求领券;配送费由商家承担,骑手取餐时从商家取得。
+- Q: “满100”的门槛按原价还是优惠后金额? → A: 按该门店订单的商品原价合计判断,不扣除商品促销、新客立减或优惠券,不将配送费计入门槛;100 为示例,由商家设置满额门槛。
+- Q: 满额免运的优先级? → A: 先全额免除客户配送费,再按商品促销(三选一)→ 新客立减 → 优惠券的既有顺序计算其余受预算限制的优惠。
+- Q: 免运费用超过总优惠预算怎么办? → A: 优先保证全免,允许免运金额本身超过预算。其他受预算限制的优惠只能使用 `max(0, 总预算 - 免运金额)`;免运已用完或超过预算时,这些优惠不再追加,不能把全免改成部分减运费。
+- Q: 总优惠预算开关关闭或已有预算豁免怎么处理? → A: 关闭时不限制后续优惠预算;现有第二份半价胜出时“不占预算、不封顶”的例外继续保留。本次“其他优惠不再追加”仅指受预算限制的优惠,不撤销原有豁免。
+- Q: 骑手配送费是否归零? → A: 仅客户应付配送费归零;商家承担金额、原配送费和客户实付必须区分,骑手配送费仍按原规则结算,避免影响骑手收入或重复结算。
+- Q: 本轮实施范围与执行方式? → A: 后端和商家 PC 一起规划;App 端代码不动。继续更新现有 008 spec-kit 文档,不创建分支;Claude Code 负责后续代码实现。本轮由 Codex 完成规划,不编写生产代码。
+
+**验收示例**(预算开关开启、比例20%,仅列受预算限制的优惠):
+
+| 商品原价 | 配送费 | 总预算 | 免运金额 | 其余优惠最多可用预算 |
+|----------|--------|--------|----------|----------------------|
+| 100 | 30 | 20 | 30(全免) | 0 |
+| 100 | 20 | 20 | 20(全免) | 0 |
+| 100 | 10 | 20 | 10(全免) | 10 |
+
+商品原价未达到门槛时,不触发满额免运,沿用既有优惠规则。上述规则为本项目用户确认的业务选择,不作为美团现行规则的描述。
+
 ## Requirements
 
 ### Functional Requirements
@@ -281,10 +320,23 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
   - 商品抵用券:减免 ≤ 比例 × 商品价格
   - 免配送费券:必须设使用门槛,且减免 ≤ 比例 × 门槛
   - 第二杯半价、新客立减:创建时不校验
-- **FR-025**: 开关启用时,算价 MUST 执行总优惠预算:预算 = 比例 × 订单商品原价合计,按 促销(三选一)→ 新客立减 → 优惠券 顺序层层封顶,每层实减 = min(该层优惠额度, 剩余预算)(部分抵扣);第二杯半价胜出的促销减免不占预算也不封顶;免配送费券计入预算;互斥券路径同样受预算约束;实付最低兜底沿用现状
+- **FR-025**: 开关启用时,算价 MUST 执行总优惠预算:预算 = 比例 × 订单商品原价合计,按 促销(三选一)→ 新客立减 → 优惠券 顺序层层封顶,每层实减 = min(该层优惠额度, 剩余预算)(部分抵扣);第二杯半价胜出的促销减免不占预算也不封顶;免配送费券计入预算;互斥券路径同样受预算约束;实付最低兜底沿用现状。2026-10-19 追加:命中满额免运时,先按 FR-030 预留免运费用,再执行本条的其余预算分配
 - **FR-026**: 结算页可用券列表 MUST 按剩余预算反映实际可抵额度,可抵为 0 的券标记不可用(原因"本单优惠已达优惠上限")
 - **FR-027**: 算价明细与 pos_order_promotion 快照 MUST 反映预算封顶后的实减金额,被封顶的项标注"已达优惠上限";预览与下单走同一算价入口,金额一致
 
+#### 满额免运(2026-10-19 追加,需求已确认,功能未开始)
+
+- **FR-028**: 商家 MUST 能在促销活动中配置满额免运门槛及活动有效期;符合条件的订单自动享受,无需用户领取优惠券。
+- **FR-029**: 免运门槛 MUST 按该门店订单商品原价合计判断(例如原价满100),不扣除商品促销、新客立减或优惠券,不包含配送费;不得因后续商品优惠降低商品应付金额而撤销免运资格。
+- **FR-030**: 满额免运 MUST 具有最高预算分配优先级。令 `B` 为按既有比例和取整规则计算的总优惠预算,`F` 为本单免运金额:客户配送费全免 `F`,即使 `F > B` 也不得截断;其余受限优惠按既有顺序使用 `max(0, B - F)`,`F >= B` 时不再追加受限优惠。切换商品促销路径或互斥券路径时不得释放已预留的免运费用;预算开关关闭时不封顶后续优惠,现有第二份半价预算豁免继续保留。满额免运独立于原有商品促销三选一名额。
+- **FR-031**: 满额免运成本 MUST 由商家承担,客户应付配送费为0,骑手配送费按原规则结算。算价、下单金额及优惠快照 MUST 区分配送费原金额、商家承担的免运金额与客户实付,不得通过清零骑手配送费或从商品金额重复扣减来实现免运。
+
+- **FR-032**: 本轮 MUST 包含商家 PC 的满额免运创建、编辑回填、详情、列表类型展示和结束入口,使用现有“促销活动”页面及四种语言;MUST NOT 新增必须领券的免运流程、修改 App 源码、改动平台 PC 或继续实施已暂停的 036 多店下单。
+- **FR-033**: 新金额流程 MUST 由服务端核实商品/规格、门店归属、数量、收货地址和配送费,预览与创建复用同一能力;支付只用已固化的客户应付额。报价变化必须返回可识别错误要求重新报价,不得仅记日志后接受客户端伪造或过期的优惠金额。
+- **FR-034**: 旧算价接口 `finalAmount` MUST 保持商品应付金额含义。完整配送报价及订单创建的新契约见 [contracts/free-delivery.md](./contracts/free-delivery.md);未接入该契约的旧 App 请求保留旧行为,本轮仅完成商家配置和后端可调用能力,App 的完整免运结算展示另行交付。
+- **FR-035**: 订单创建后 MUST 固化免运金额和规则快照;活动结束及支付方式切换不得追改已有金额。到付客户待收=客户应付总额;取餐给商家金额=客户应付总额−原配送费,若为负则方向改为商家给骑手其绝对值。在线已付时商家给骑手原配送费;现金交付和账单/钱包不得重复支付同笔配送费。佣金、税仍依既有规则计算。
+- **FR-036**: 命中满额免运时免配送费券 MUST 标为不可用,不重复减免、不核销;因预算不足实减为0的普通券同样不核销。新流程的商品优惠汇总不包含免运,发票原有“不含运费”口径应扣除客户实际支付的配送费,退款上限按实际付款,不将商家承担的免运补贴额外退给用户。
+
 ### Key Entities
 
 - **促销活动 (promotion_activity)**: 商家创建的促销规则,包含类型(满减/折扣/第二份半价/新客立减)、状态、时间范围
@@ -494,3 +546,12 @@ CREATE TABLE pos_order_promotion (
 - 大厂优惠券系统设计(掘金)
 - Demo文件:`meituan-promotion-types-demo.html`、`merchant-promotion-ui-demo.html`、`meituan-checkout-demo.html`
 - [美团折扣商品调研报告](../../docs/research-meituan-discount-product.md) — 美团/饿了么折扣商品设置方式对比,逐商品设折扣 vs 折扣区分组
+
+
+## 2026-10-19 实施约定与需求来源
+
+**用户已确认**:促销自动生效、原价门槛、免运预算最高优先且允许超预算、商家承担与骑手取餐收取、保留既有预算豁免;本轮包含商家 PC,App 不动,Claude Code 实施,不建分支。
+
+**本次规划选择**(供执行者遵守,不冒称用户原话):活动类型采用5、单个门槛;复用活动规则表,不增加券批次;以独立内部配送上下文和新增完整配送报价接口保护旧算价契约;新金额流程用显式版本2接入,旧请求不静默改价;首期只覆盖平台骑手配送的单店外送,自取、堂食、商家自配送、闪送和036合并配送不接入;所有金额与 API 契约、订单快照字段和兼容规则以 plan.md 本次变更及 contracts/free-delivery.md 为准。活动按报价/创建时的本地日期生效,预约单在创建时锁定权益,不等到预约时段再判断活动。
+
+**实现状态**(2026-10-19 更新,Claude Code 实施):后端与商家 PC 已实现并通过定向测试与模块回归(T074~T077 全部勾选)。type=5 活动 CRUD/归属/行锁/按日期有效状态、三参加价重载与预算优先、POST /app/userPromotionCalc/deliveryQuote、版本2订单固化与真实用券、商家分成 G=A−D、发票 A−D、三方派生交款字段、支付切换方向均落地;SQL 已登记 updatesql/sql.md 末尾待手动执行。**未完成**:商家 PC 浏览器手工验收(四语言/重叠拒绝等,T078 人工项)、App 端未接入(本轮明确不改)、生产数据库迁移未执行。历史章节中“四种促销”“只看 status=1”“互斥券使用完整预算”“总优惠不超过预算”等说明,在本次新增类型/新金额契约命中时按 FR-028~FR-036 覆盖;未接入新契约的历史行为保留。

+ 95 - 3
specs/008-promotion-coupon/tasks.md

@@ -340,6 +340,94 @@
 
 ---
 
+## Phase 5.11: 满额免运优先(2026-10-19 需求追加)
+
+**功能状态**(2026-10-19 更新):代码已由 Claude Code 实现(T074~T077 全部完成;T078 自动化项完成,浏览器手工验收与 SQL 手动执行待开发者)。未创建分支/工作树、未改App。
+
+**必读**:[spec.md](./spec.md) FR-028~FR-036、[plan.md](./plan.md)「需求变更2026-10-19」、[contracts/free-delivery.md](./contracts/free-delivery.md)、[claude-code-handoff.md](./claude-code-handoff.md)。表中的`S/`指`ruoyi-system/src/main/java/com/ruoyi/system/`,`A/`指`ruoyi-admin/src/main/java/com/ruoyi/app/`;测试路径按各自模块`src/test/java`同包。商家前端根目录为`D:/QtwCode/foodie/foodie-store`。
+
+- [x] T073 记录促销自动生效、原价门槛、全免优先允许超预算、半价豁免保留及商家承担规则;补充商家PC/App不动/Claude Code执行边界。
+
+### T074 商家PC与活动管理API(完整管理闭环)
+
+**Files**:
+- Modify `S/domain/PromotionActivity.java`、`PromotionActivityRule.java`、`S/mapper/PromotionActivityMapper.java`、`S/service/impl/PromotionActivityServiceImpl.java`、`PromotionDiscountLimitChecker.java`。
+- Modify `ruoyi-system/src/main/resources/mapper/system/PromotionActivityMapper.xml`、必要的规则Mapper映射、`A/mendian/ShPromotionActivityController.java`。
+- Create `S/dto/PromotionActivitySaveRequest.java`(字段兼容现有POST/PUT,body DTO,不用Map/Bean Validation)。
+- Modify 商家`src/views/PromotionActivity.vue`、`src/lang/{zh,tw,en,vi}.js`,必要时仅扩展`src/api/promotionActivity.js`;不修改CouponBatch.vue、菜单或平台PC。
+- Test create `ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionActivityServiceImplFreeDeliveryTest.java`,extend `PromotionDiscountLimitCheckerTest.java`;create `ruoyi-admin/src/test/java/com/ruoyi/app/mendian/ShPromotionActivityFreeDeliveryTest.java`。
+
+**Interfaces**:沿用既有创建/修改/详情/列表/结束/删除路径,增加type=5与单条threshold。产出`PromotionActivityMapper.selectActiveFreeDeliveryByStoreId(Long storeId, Date now): List<PromotionActivity>`供T075调用;规则仍由既有selectRulesByActivityId读取。
+
+- [x] 先写定向测试:`create_shouldAcceptThresholdWithoutFixedReduction`、`create_shouldRejectEmptyOrMultipleRules`、`save_shouldRejectOtherStoreOwner`、`create_shouldRejectOverlappingConcurrentActivity`;预算20%下门槛100的免运活动允许创建,不要求reduceAmount<=20。
+- [x] 运行上述测试确认失败由未实现行为引起,再实现type=5验证、门店权限、门店行事务锁和重叠检查。保留旧类型请求格式及历史行为。
+- [x] 增加按日期查询及type=5有效状态统一处理;补`state_shouldStartAndExpireWithoutScheduler`和`edit_shouldRejectEffectivelyStartedActivity`,覆盖status=0但日期已开始、status=1但已过期、status=2人工结束及首尾日期。
+- [x] 商家PC增加第五个Tab:活动名、有效日期、原价满额门槛;创建/编辑/详情/列表/结束/删除和reset回填齐全。四语言key全部位于promoActivity中,建议typeFreeDelivery、freeDeliveryThreshold、freeDeliveryOriginalPriceHint、freeDeliveryMerchantCostHint;用现有页面样式,不新建免运券批次。
+- [ ] 定向测试通过,手工验证创建/编辑/详情/结束及四语言;保留CRLF,核对diff与i18n key一致性。本批可独立验收,但不宣称用户App已有免运展示。(定向测试12/12+10/10+4/4已过;CRLF与四语言key一致性已核对;浏览器手工验收待开发者执行)
+
+### T075 可信完整报价与预算计算(含兼容契约)
+
+**Files**:
+- Create `S/dto/PromotionDeliveryContext.java`(内部可信配送上下文);modify `S/service/IPromotionCalcService.java`、`S/service/impl/PromotionCalcServiceImpl.java`、`S/dto/PromotionCalcRequest.java`(CartItem增加specValueIds)、`PromotionCalcResponse.java`。
+- Create `A/order/dto/FoodDeliveryPromotionQuoteRequest.java`、`A/order/FoodDeliveryPromotionQuoteService.java`;modify `A/user/UserPromotionCalcController.java`新增body报价方法并修正过时Javadoc。
+- Modify `ruoyi-admin/src/main/resources/i18n/messages{,_zh_CN,_zh_TW,_en_US,_th_TH}.properties`并create `messages_vi.properties`;现有资源包新key一致,新增vi父语言包为vi/vi_VN提供越南文,不能用th_TH泰文代替vi。商家PC仍须zh/tw/en/vi四份文案。
+- Test create `ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PromotionCalcServiceImplFreeDeliveryTest.java`、`ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java`;extend原`PromotionCalcServiceImplDiscountLimitTest.java`。
+
+**Interfaces**:保留`calculate(PromotionCalcRequest, Long): PromotionCalcResponse`;新增`calculate(PromotionCalcRequest, Long, PromotionDeliveryContext): PromotionCalcResponse`。产出`FoodDeliveryPromotionQuoteService.quote(FoodDeliveryPromotionQuoteRequest, Long): PromotionCalcResponse`供新`POST /app/userPromotionCalc/deliveryQuote`及T076调用。新增响应字段、旧finalAmount语义和JSON见contracts。
+
+- [x] 先写预算断言:`calculate_shouldFullyWaiveThirtyWithTwentyBudget`(O100/R30/B20/F30/D0/G100/A100)、`calculate_shouldReserveTenBeforeOtherDiscounts`(R10,受限优惠合计最多10)、`calculate_shouldNotWaiveBelowOriginalThreshold`(O99)、`calculate_shouldKeepWaiverAfterGoodsDiscount`(关预算,O100商品优惠30仍免运)。
+- [x] 补`calculate_shouldKeepShippingReservationForMutexCoupon`、`availableCoupons_shouldUseSameRemainingBudget`、`calculate_shouldKeepHalfPriceExemption`、`calculate_shouldPreferHalfPriceAfterBudgetCap`(O100/R30、普通满减30被截0、半价优惠25获保留,默认选半价、A75)。旧接口回归无配送上下文仍保持旧结果。
+- [x] 运行红测后实现免运独立金额、优先占预算、各路径和候选券一致、forcePath与新上下文预算后选路;命中免运时配送券不可用、appliedCouponId仅实际减免>0才产生;未命中时不借机全面重做旧配送券缺陷。
+- [x] 报价Service从数据库核实门店、在售商品、规格归属和数量、地址归属与实际配送模式,服务端取路线/运价并构造内部配送上下文。`quote_shouldRejectForeignAddressAndCrossStoreSpec`、`quote_shouldIgnoreClientSpecPrice`、`quote_shouldFailOnMissingRouteOrFare`;remote全部mock,不调用真实地图。
+- [x] 运费边界用受控运价起步20/含3km/每km10,验证3km=20、3.49km=20、3.5km=30、4.25km=32(沿旧HALF_EVEN整数舍入),并覆盖千元舍入;不得改旧getyunfei/getZuiyyfei协议或改用已废弃地图。
+- [x] 类型5按单店平台骑手外送生效;新入口拒绝自取/堂食/商家自配送,不接入036。修正Controller/DTO关于商品金额、整单金额及顺序的注释。定向测试通过后核对contracts示例。
+
+### T076 订单固化、真实用券与SQL(创建闭环)
+
+**Files**:
+- Modify `S/domain/PosOrder.java`、`ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml`及受影响的订单VO/映射;保留既有freight和金额列单位。
+- Modify `A/order/dto/OrderCreatItem.java`、`A/order/UserOrderController.java`、`A/order/OrderPromotionHelper.java`;必要的父单映射按现有OrderParent接口更新。
+- Modify `S/dto/PromotionCalcResponse.java`(PromotionDetail快照扩展)、`S/domain/PosOrderPromotion.java`及其Mapper注释/字段映射(如需要);modify `updatesql/sql.md`仅在最后按执行当日登记3个订单快照字段及枚举说明。
+- Test create `ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderPromotionHelperFreeDeliveryTest.java`、`UserOrderFreeDeliveryCreateTest.java`。
+
+**Interfaces**:显式`OrderCreatItem.promotionPricingVersion=2`接入T075.quote;持久化`PosOrder.promotionPricingVersion: Integer`、`merchantDeliverySubsidy: BigDecimal`、`customerDeliveryFee: BigDecimal`;JSON与SQL名见plan。历史NULL保持原行为。产出A/R/F/D、活动快照和仅实际使用的券供T077结算。
+
+- [x] 先写`create_shouldPersistConsistentParentAndChildAmounts`:O100/R30免运,子amount100/freight30/F30/D0/version2;父totalAmount130/actualPayAmount100/totalDiscount30;商品优惠汇总不得减30。
+- [x] 补`create_shouldRejectChangedQuoteAndRollback`、`create_shouldRejectMismatchedOrderTypesOrMultipleStoresInV2`、`create_shouldNotUseZeroBenefitCoupon`、`snapshot_shouldKeepShippingSeparateFromGoods`、`legacyCreate_shouldKeepOriginalBehavior`;先运行确认失败,再实现。
+- [x] 版本2单店在同一创建事务中权威重算,核对父子客户端应付与服务端结果;将可信价格生成兼容food JSON,使用数据库结果写快照,不信food/amount/freight/specPrice。旧请求维持旧流程,不静默换价。
+- [x] 商品促销汇总排除subType=5,免运单独快照;核销使用appliedCouponId,并再次以归属/状态守卫防并发重复使用,失败回滚父单、子单、快照及核销。无实际优惠不凭传入couponId核销。重复ddId按原唯一性/幂等守卫拒绝或返回已固化结果,不重算覆盖或重复核销;新增对应回归用例。
+- [x] 新增nullable字段并补Entity/resultMap/select/insert/update/BaseMapper/查询响应链;SQL日期置末,不增加新券表、不重建活动表、不回填旧订单。SQL由开发者手动执行,代码测试用mock或隔离fixture,不直连测试/生产库迁移。
+- [x] 定向测试通过,确认同一订单创建后结束活动或跨日期不再重算F/D;完整新字段只来自服务端快照。
+
+### T077 结算、支付方式切换、退款与发票(资金闭环)
+
+**Files**:
+- Modify `A/order/OrderService.java`、`A/order/OrderInvoiceService.java`、`S/domain/PosOrder.java`(三个`@TableField(exist=false)`派生交款属性/getter,不另存列);不修改已废弃PosOrderController.add/edit或PayController/TestTask等入口。
+- 支付/退款生产代码仅在确有金额读取问题时改最小入口,`A/pay/PayMethodSwitchService.java`、LINE/OMG和037沿用既有流程,不重新实现支付功能。
+- Test create `ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderFreeDeliverySettlementTest.java`、`OrderInvoiceFreeDeliveryTest.java`;extend `ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java`及现有相关支付/取消测试。
+
+**Interfaces**:读取T076的version、A/R/F/D;历史D为NULL回退freight。派生merchantPayToRider、riderPayToMerchant、customerCollectAmount见contracts。不新增人工“已收运费”状态、支付接口或第二个钱包入账入口。
+
+- [x] 先写`settle_shouldKeepRiderGrossFreightAndSingleWalletEntry`、`merchantBilling_shouldUseGoodsPayableWithoutDoubleSubsidy`、`invoice_shouldUseCustomerPaidFreight`:A100/R30/D0时商品基数及发票100、骑手毛配送费30,重复送达不新增账单。
+- [x] 补在线/到付:`cash_shouldShowDirectionForNegativeMerchantNet`(A20/R30,商家给骑手10、向客户收20);在线A100商家给骑手30、客户待收0;到付A100骑手给商家70、客户待收100。
+- [x] 补`switch_shouldKeepWaiverSnapshotAndChangeOnlyCashDirection`、`refund_shouldNotReturnMerchantSubsidyToCustomer`、`legacyInvoice_shouldKeepAmountMinusFreight`;先确认红测再实现新version分支。
+- [x] 按G=A−D处理新订单商家商品分成,避免新券ID走旧VipUserQuanyi查询;骑手仍走既有freight佣金/税及钱包幂等。两处发票改扣客户实付运费D;线上支付/退款只用A及原渠道交易金额,不额外向客户退F。
+- [x] 定向测试通过,核对LINE/OMG创建请求额、切换到付金额、037取消退款上限;第三方测试环境退款失败只能记外部验证限制,不用“状态改成功”替代真实退款。
+
+### T078 统一验证、商家PC验收与交接文档
+
+**Files**:更新本目录spec/plan/tasks/contracts/handoff的实际实现记录;不修改App源码,不创建新分支。
+
+- [x] 按[handoff](./claude-code-handoff.md)的JDK21命令运行新定向测试与受影响模块回归/构建,执行商家`npm run build:test`。失败直接修复对应问题;未经实际运行的测试不得勾选完成。
+- [ ] 在商家PC核验第五Tab、列表/详情、编辑回填、重叠拒绝、活动日期与结束操作;切换zh/tw/en/vi,覆盖门槛必填、非法值和其他门店权限;确认无CouponBatch领券流程、无App/平台PC改动。
+- [x] 执行contracts数值矩阵和legacy接口/旧订单回归,核对商家、客户、骑手三方金额与快照、发票;本轮不依赖真实外部付款/退款证明单元用例成功。
+- [x] `git diff --check`与文件范围检查,确认CRLF、i18n key和SQL时间顺序;分别列出已运行检查、失败项/外部未验证项、SQL待手动执行、App未接入的交付边界。
+- [ ] 更新任务勾选与实际验收结果。除非用户另行要求,不自行commit/push;用户要求提交时使用中文提交信息,只暂存当前功能文件并报告SHA。
+
+**依赖顺序**:T074 → T075 → T076 → T077 → T078。实现前后按同一FR/契约核对,不重新询问已确认的原价门槛与免运优先规则。
+
+---
+
 ## Phase 6: Polish & 验证
 
 **Purpose**: 端到端验证
@@ -362,7 +450,8 @@
 - **Phase 5 (Menu/i18n)**: 依赖 Phase 3 + Phase 4 的页面文件存在
 - **Phase 5.5 (用户端接口)**: 依赖 Phase 2 的实体/Mapper + Phase 3 的促销 Activity Service + Phase 4 的优惠券 Service
 - **Phase 5.10 (优惠幅度限制)**: 依赖 Phase 3/4 的两个 Service 与 Phase 5.5 的算价 Service
-- **Phase 6 (验证)**: 依赖全部完成(包括 Phase 5.9 折扣改动)
+- **Phase 5.11 (满额免运)**: 依赖现有促销管理、算价预算、订单优惠快照及订单结算;T074~T077 已完成,T078 剩浏览器手工验收
+- **Phase 6 (验证)**: 依赖全部完成(包括 Phase 5.9 折扣改动、Phase 5.11 满额免运)
 
 ### Parallel Opportunities
 
@@ -382,14 +471,17 @@ T001 → T002(手动)
   → T030-T035 (Phase 5, 菜单路由i18n)
   → T039-T045 (Phase 5.5, 用户端接口)
   → T061-T063 (Phase 5.9, 折扣商品改为逐商品设折扣)
-  → T064-T069 (Phase 5.10, 优惠幅度20%限制)
-  → T036-T038 (Phase 6, 验证) ← 当前待实施
+  → T064-T072 (Phase 5.10, 优惠幅度20%限制)
+  → T073 (已确认规则记录) → T074-T078 (Phase 5.11, 满额免运,代码已实现)
+  → T036-T038 (Phase 6, 验证)
 ```
 
 ---
 
 ## Notes
 
+- **2026-10-19增量优先**:T074~T078按本轮handoff执行,用户未另行要求前不自动commit/push、不创建分支;下列历史提交约定不覆盖本轮交接边界。
+
 - [P] tasks = 不同文件,无依赖,可并行
 - [Story] label 映射到 spec 中的 User Story (US1=促销, US2=优惠券)
 - 每个任务完成后 commit

+ 8 - 0
updatesql/sql.md

@@ -2107,3 +2107,11 @@ CREATE TABLE IF NOT EXISTS order_checkout_refund_intent (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='036多店子单原路退款意图及恢复';
 
 ```
+
+-- 2026-10-19 008满额免运(spec FR-031/FR-035):订单免运快照字段。
+-- promotion_pricing_version=2 表示完整配送金额契约(freight 仍存原配送费R,不清零;
+-- merchant_delivery_subsidy=F 商家承担免运额(未命中为0);customer_delivery_fee=D 客户实付运费,
+-- 旧流程 NULL 读取时按既有 freight 回退)。历史订单不回填、不重算。
+ALTER TABLE pos_order ADD COLUMN promotion_pricing_version TINYINT NULL DEFAULT NULL COMMENT '促销定价版本:2=满额免运完整配送契约,NULL=旧流程';
+ALTER TABLE pos_order ADD COLUMN merchant_delivery_subsidy DECIMAL(10,2) NULL DEFAULT NULL COMMENT '商家承担免运金额F(完整配送契约,未命中为0)';
+ALTER TABLE pos_order ADD COLUMN customer_delivery_fee DECIMAL(10,2) NULL DEFAULT NULL COMMENT '客户实付配送费D(完整配送契约,旧流程NULL回退freight)';