Sfoglia il codice sorgente

修改计算订单优惠

qmj 2 settimane fa
parent
commit
d648de22cb

BIN
checkout_screenshot.png


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

@@ -0,0 +1,205 @@
+package com.ruoyi.app.order;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import com.alibaba.fastjson.JSON;
+import com.ruoyi.app.order.dto.OrderCreatItem;
+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.PosOrderPromotionMapper;
+import com.ruoyi.system.service.IPromotionCalcService;
+import com.ruoyi.system.service.IPromotionUserCouponService;
+
+/**
+ * 订单促销辅助类
+ *
+ * 在用户下单时处理促销算价、优惠字段回填、快照写入、优惠券标记等逻辑。
+ * 每个方法职责单一,由 UserOrderController.createOrderChild 按顺序调用。
+ */
+@Component
+public class OrderPromotionHelper {
+
+    private static final Logger logger = LoggerFactory.getLogger(OrderPromotionHelper.class);
+
+    @Autowired
+    private IPromotionCalcService promotionCalcService;
+
+    @Autowired
+    private PosOrderPromotionMapper posOrderPromotionMapper;
+
+    @Autowired
+    private IPromotionUserCouponService promotionUserCouponService;
+
+    /**
+     * 调用促销算价服务
+     *
+     * @param item   子订单输入项(含 cartItems, couponId, forcePath)
+     * @param userId 用户ID
+     * @return 算价结果;如果前端未传 cartItems 则返回 null(跳过促销)
+     */
+    public PromotionCalcResponse calcPromotion(OrderCreatItem item, Long userId) {
+        if (item.getCartItems() == null || item.getCartItems().isEmpty()) {
+            return null;
+        }
+        PromotionCalcRequest request = new PromotionCalcRequest();
+        request.setStoreId(item.getMdId());
+        request.setItems(item.getCartItems());
+        request.setCouponId(item.getCouponId());
+        request.setForcePath(item.getForcePath());
+
+        return promotionCalcService.calculate(request, userId);
+    }
+
+    /**
+     * 校验前端传入的优惠金额与后端算价结果是否一致(宽松校验,仅记录差异不阻断下单)
+     *
+     * @param calc           后端算价结果
+     * @param frontendReduce 前端传入的优惠金额(分)
+     */
+    public void validatePromotionAmount(PromotionCalcResponse calc, Integer frontendReduce) {
+        if (calc == null || frontendReduce == null) {
+            return;
+        }
+        BigDecimal backendReduce = calc.getOriginalAmount().subtract(calc.getFinalAmount());
+        int backendReduceCents = backendReduce.multiply(new BigDecimal("100")).intValue();
+        int diff = Math.abs(backendReduceCents - frontendReduce);
+        if (diff > 1) {
+            logger.warn("订单优惠金额校验差异:后端计算优惠={}分, 前端传入={}分, 差异={}分",
+                    backendReduceCents, frontendReduce, diff);
+        }
+    }
+
+    /**
+     * 将算价结果回填到 PosOrder 实体的优惠字段
+     *
+     * PosOrder 优惠字段说明:
+     * - mdActivity / mdSalesName / mdSalesReduction(分) — 门店促销
+     * - mdYhId / mdYhName / mdDiscountAmount(分) — 门店优惠券
+     *
+     * @param posOrder 目标订单实体(会被修改)
+     * @param calc     算价结果,可为 null
+     */
+    public void fillPromotionFields(PosOrder posOrder, PromotionCalcResponse calc) {
+        if (calc == null || calc.getDetails() == null) {
+            return;
+        }
+        int totalPromotionReduceCents = 0;
+        String promoName = null;
+        Long promoActivityId = null;
+
+        for (PromotionDetail detail : calc.getDetails()) {
+            if ("promotion".equals(detail.getType())) {
+                // 累加所有促销减免(满减+新客立减)
+                totalPromotionReduceCents += toCents(detail.getReduce());
+                // 取第一个非新客立减的促销名称作为主名称
+                if (promoName == null && detail.getSubType() != null && detail.getSubType() != 4) {
+                    promoName = detail.getName();
+                    promoActivityId = detail.getRefId();
+                }
+            }
+            else if ("coupon".equals(detail.getType())) {
+                posOrder.setMdYhId(calc.getCouponId());
+                posOrder.setMdYhName(detail.getName());
+                posOrder.setMdDiscountAmount(toCents(detail.getReduce()));
+            }
+        }
+
+        if (totalPromotionReduceCents > 0) {
+            posOrder.setMdActivity(promoActivityId);
+            posOrder.setMdSalesName(promoName);
+            posOrder.setMdSalesReduction(totalPromotionReduceCents);
+        }
+    }
+
+    /**
+     * 将促销明细批量写入 pos_order_promotion 快照表
+     *
+     * @param orderId       PosOrder 保存后的自增 ID
+     * @param calc          算价结果
+     */
+    public void savePromotionSnapshots(Long orderId, PromotionCalcResponse calc) {
+        if (calc == null || calc.getDetails() == null || calc.getDetails().isEmpty()) {
+            return;
+        }
+        List<PosOrderPromotion> snapshots = new ArrayList<>();
+        boolean isFirst = true;
+
+        for (PromotionDetail detail : calc.getDetails()) {
+            PosOrderPromotion snapshot = new PosOrderPromotion();
+            snapshot.setOrderId(orderId);
+            snapshot.setPromoType("promotion".equals(detail.getType()) ? 1 : 2);
+            snapshot.setPromoSubType(detail.getSubType());
+            snapshot.setPromoId(detail.getRefId());
+            snapshot.setPromoName(detail.getName());
+            snapshot.setPromoDetail(JSON.toJSONString(detail));
+            snapshot.setReduceAmount(detail.getReduce());
+            snapshot.setCreateTime(new Date());
+
+            // 优惠券时关联用户券ID
+            if ("coupon".equals(detail.getType()) && calc.getCouponId() != null) {
+                snapshot.setUserCouponId(calc.getCouponId());
+            }
+
+            // 第一条记录写入路径对比摘要
+            if (isFirst) {
+                snapshot.setPathSummary(buildPathSummary(calc));
+                isFirst = false;
+            }
+
+            snapshots.add(snapshot);
+        }
+
+        posOrderPromotionMapper.insertBatch(snapshots);
+    }
+
+    /**
+     * 标记用户优惠券为已使用
+     *
+     * @param couponId 用户优惠券 ID(promotion_user_coupon.id)
+     * @param orderId  关联的订单 ID
+     * @param userId   用户 ID(校验归属)
+     */
+    public void markCouponUsed(Long couponId, Long orderId, Long userId) {
+        if (couponId == null) {
+            return;
+        }
+        promotionUserCouponService.markAsUsed(couponId, orderId, userId);
+    }
+
+    // ==================== 内部工具方法 ====================
+
+    /**
+     * 构建路径对比摘要
+     * 示例:"满减路径¥33.00 vs 折扣路径¥39.00, 选择满减"
+     */
+    private String buildPathSummary(PromotionCalcResponse calc) {
+        if (calc.getPathA() == null || calc.getPathB() == null) {
+            return null;
+        }
+        String pathALabel = calc.getPathA().getLabel();
+        String pathBLabel = calc.getPathB().getLabel();
+        String chosen = "A".equals(calc.getOptimalPath()) ? pathALabel : pathBLabel;
+        return pathBLabel + "路径¥" + calc.getPathB().getSubtotal()
+                + " vs " + pathALabel + "路径¥" + calc.getPathA().getSubtotal()
+                + ", 选择" + chosen;
+    }
+
+    /**
+     * BigDecimal(元) 转 Integer(分)
+     */
+    private int toCents(BigDecimal amount) {
+        if (amount == null) {
+            return 0;
+        }
+        return amount.multiply(new BigDecimal("100")).intValue();
+    }
+}

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -24,6 +24,7 @@ import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.GetArea;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.OrderLogHelper;
+import com.ruoyi.system.dto.PromotionCalcResponse;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
@@ -62,6 +63,8 @@ public class UserOrderController extends BaseController {
     private PushEventService pushEventService;
     @Autowired
     private OrderLogHelper orderLogHelper;
+    @Autowired
+    private OrderPromotionHelper orderPromotionHelper;
 
     /**
      * 创建订单
@@ -201,10 +204,23 @@ public class UserOrderController extends BaseController {
                 posOrder.setOrderCategory(String.valueOf(st.getType()));
             });
             // 保存子订单
+            // --- 促销:算价 + 校验 + 回填优惠字段(save之前) ---
+            PromotionCalcResponse calc = orderPromotionHelper.calcPromotion(item, userId);
+            orderPromotionHelper.validatePromotionAmount(calc, item.getPromoReduceAmount());
+            orderPromotionHelper.fillPromotionFields(posOrder, calc);
+
             createFootprint(userId, item.getMdId());
             setPickUpNum(posOrder);
             posOrderService.save(posOrder);
 
+            // --- 促销:写入快照 + 标记券已使用(save之后,需要posOrder.id) ---
+            if (calc != null) {
+                orderPromotionHelper.savePromotionSnapshots(posOrder.getId(), calc);
+                if (calc.getCouponId() != null) {
+                    orderPromotionHelper.markCouponUsed(calc.getCouponId(), posOrder.getId(), userId);
+                }
+            }
+
             // 创建订单成功,添加操作日志
             InfoUser uUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, userId));
             String uName = uUser != null ? uUser.getNickName() : "";

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

@@ -1,6 +1,7 @@
 package com.ruoyi.app.order.dto;
 
 import com.ruoyi.common.annotation.Excel;
+import com.ruoyi.system.dto.PromotionCalcRequest;
 import lombok.Data;
 
 import java.util.List;
@@ -58,4 +59,18 @@ public class OrderCreatItem {
 
     private Double freight;
 
+    // ========== 促销相关字段(可选,前端未传则跳过促销逻辑) ==========
+
+    /** 购物车商品列表(用于促销算价),复用 PromotionCalcRequest.CartItem */
+    private List<PromotionCalcRequest.CartItem> cartItems;
+
+    /** 用户选择的优惠券ID(对应 promotion_user_coupon.id) */
+    private Long couponId;
+
+    /** 前端计算的优惠金额(分),用于后端校验 */
+    private Integer promoReduceAmount;
+
+    /** 前端选择的促销路径: "A"(折扣/第二份半价) 或 "B"(满减) */
+    private String forcePath;
+
 }

+ 30 - 12
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCalcController.java

@@ -1,18 +1,37 @@
 package com.ruoyi.app.user;
 
-import java.util.List;
-import java.util.Map;
 import org.springframework.beans.factory.annotation.Autowired;
 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.system.dto.PromotionCalcRequest;
+import com.ruoyi.system.dto.PromotionCalcResponse;
 import com.ruoyi.system.service.IPromotionCalcService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 
 /**
  * 用户端优惠计算接口
+ *
+ * 【优惠叠加规则】
+ * 1. 一个订单最多使用 1个促销 + 1张优惠券 + 新客立减(如适用)
+ * 2. 促销三选一(满减/折扣/第二份半价),系统自动算两条路径选最优,用户可手动切换(forcePath)
+ * 3. 优惠券每单只能用一张(couponId 传单个值,不是数组)
+ * 4. 同享券可与促销叠加,互斥券与促销二选一
+ * 5. 商品券对应的商品如在折扣/第二份半价活动中,该券不可用于该商品
+ * 6. 计算顺序:商品原价 → 促销(三选一) → 优惠券 → 新客立减 → 实付金额(最低¥0.01)
+ *
+ * 【入参说明】
+ * storeId   (必填) 门店ID
+ * items     (必填) 购物车商品列表 [{productId, quantity}],不传价格,后端从数据库查
+ * couponId  (选填) 用户要使用的优惠券ID,对应 promotion_user_coupon.id,每单只能用一张
+ * forcePath (选填) 强制指定路径 "A"(折扣/第二份半价) 或 "B"(满减),不传则系统自动选最优
+ *
+ * 【调用场景】
+ * - 进入结算页:不传 forcePath、不传 couponId → 返回两条路径对比 + 最优方案 + 可用券列表
+ * - 用户切换促销方案:传 forcePath → 强制走用户选的路径
+ * - 用户选了优惠券:传 couponId + forcePath(如有偏好) → 在当前路径基础上叠券计算
  */
 @RestController
 @RequestMapping("/app/userPromotionCalc")
@@ -23,29 +42,28 @@ public class UserPromotionCalcController extends BaseController {
 
     /**
      * 计算订单优惠
+     *
+     * @param token   用户登录token
+     * @param request 算价请求DTO
+     * @return PromotionCalcResponse 含 originalAmount, pathA, pathB, optimalPath, finalAmount, details, availableCoupons
      */
     @Anonymous
     @Auth
     @PostMapping("/calculate")
-    public AjaxResult calculate(@RequestHeader String token,@RequestBody Map<String, Object> params) {
-        JwtUtil jwtUtil=new JwtUtil();
+    public AjaxResult calculate(@RequestHeader String token, @RequestBody PromotionCalcRequest request) {
+        JwtUtil jwtUtil = new JwtUtil();
         String userId = jwtUtil.getusid(token);
         if (userId == null) {
             return AjaxResult.error("请先登录");
         }
-        if (params.get("storeId") == null) {
+        if (request.getStoreId() == null) {
             return AjaxResult.error("参数错误:缺少storeId");
         }
-        if (params.get("items") == null) {
+        if (request.getItems() == null || request.getItems().isEmpty()) {
             return AjaxResult.error("参数错误:缺少items");
         }
 
-        Long storeId = Long.valueOf(params.get("storeId").toString());
-        List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
-        Long couponId = params.get("couponId") != null ? Long.valueOf(params.get("couponId").toString()) : null;
-        String forcePath = params.get("forcePath") != null ? params.get("forcePath").toString() : null;
-
-        Map<String, Object> result = promotionCalcService.calculate(storeId, items, Long.valueOf(userId), couponId, forcePath);
+        PromotionCalcResponse result = promotionCalcService.calculate(request, Long.valueOf(userId));
         return AjaxResult.success(result);
     }
 }

+ 25 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCouponController.java

@@ -14,6 +14,14 @@ import com.ruoyi.system.utils.JwtUtil;
 
 /**
  * 用户端优惠券接口
+ *
+ * 【优惠券规则】
+ * 1. 每个用户每批次限领1张优惠券
+ * 2. 每个订单最多使用1张优惠券(满减券/商品券/免配送费券三选一)
+ * 3. 同享券(isMutex=0)可与促销叠加,互斥券(isMutex=1)与促销二选一
+ * 4. 已领取的券在有效期内可用,过期自动标记不可用
+ * 5. 领取后商家修改/下架券批次,已领取的券仍可使用(快照机制)
+ * 6. 并发领取使用乐观锁扣库存(UPDATE remain_count=remain_count-1 WHERE remain_count>0)
  */
 @RestController
 @RequestMapping("/app/userPromotionCoupon")
@@ -24,6 +32,10 @@ public class UserPromotionCouponController extends BaseController {
 
     /**
      * 查询门店可领优惠券列表
+     *
+     * @param token   用户登录token
+     * @param storeId 门店ID
+     * @return 券批次列表,每条含 id,name,couponType,threshold,amount,validDays,received(是否已领)
      */
     @Anonymous
     @Auth
@@ -40,6 +52,12 @@ public class UserPromotionCouponController extends BaseController {
 
     /**
      * 领取优惠券
+     *
+     * 规则:每用户每批次限领1张,乐观锁扣库存,领取后计算过期时间(领取日期+validDays天23:59:59)
+     *
+     * @param token  用户登录token
+     * @param params batchId(必填), storeId(必填)
+     * @return 领取成功的券实例
      */
     @Anonymous
     @Auth
@@ -61,6 +79,13 @@ public class UserPromotionCouponController extends BaseController {
 
     /**
      * 查询我的优惠券列表
+     *
+     * 实时判断过期:expire_time < NOW() 的券自动标记为已过期(status=2)
+     *
+     * @param token   用户登录token
+     * @param status  券状态(选填): 0=未使用 1=已使用 2=已过期,不传则返回全部
+     * @param storeId 门店ID(选填),不传则返回所有门店的券
+     * @return 用户券列表,含 storeName, batchName, couponType, status, expireTime 等
      */
     @Anonymous
     @Auth

+ 40 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcRequest.java

@@ -0,0 +1,40 @@
+package com.ruoyi.system.dto;
+
+import java.util.List;
+import lombok.Data;
+
+/**
+ * 优惠算价请求DTO
+ *
+ * 入参说明:
+ * - storeId   (必填) 门店ID
+ * - items     (必填) 购物车商品列表 [{productId, quantity}],不传价格,后端从数据库查
+ * - couponId  (选填) 用户要使用的优惠券ID,对应 promotion_user_coupon.id,每单只能用一张
+ * - forcePath (选填) 强制指定路径 "A"(折扣/第二份半价) 或 "B"(满减),不传则系统自动选最优
+ */
+@Data
+public class PromotionCalcRequest {
+
+    /** 门店ID (必填) */
+    private Long storeId;
+
+    /** 购物车商品列表 (必填) */
+    private List<CartItem> items;
+
+    /** 优惠券ID (选填, 对应 promotion_user_coupon.id, 每单只能用一张) */
+    private Long couponId;
+
+    /** 强制指定路径 "A" 或 "B" (选填, 不传则系统自动选最优) */
+    private String forcePath;
+
+    /**
+     * 购物车商品项
+     */
+    @Data
+    public static class CartItem {
+        /** 商品ID */
+        private Long productId;
+        /** 购买数量 */
+        private Integer quantity;
+    }
+}

+ 161 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcResponse.java

@@ -0,0 +1,161 @@
+package com.ruoyi.system.dto;
+
+import java.math.BigDecimal;
+import java.util.List;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+
+/**
+ * 优惠算价响应DTO
+ *
+ * 返回字段:
+ * - originalAmount    商品原价总额
+ * - pathA             路径A(折扣/第二份半价)
+ * - pathB             路径B(满减)
+ * - optimalPath       最优路径 "A" 或 "B"
+ * - finalAmount       最终实付金额(最低¥0.01)
+ * - newCustomerReduce 新客立减金额(新客时返回)
+ * - couponId          使用的优惠券ID(选券时返回)
+ * - couponName        使用的券名称
+ * - couponReduce      优惠券减免金额
+ * - couponConflict    优惠券是否与促销冲突
+ * - conflictNote      冲突说明
+ * - details           优惠明细列表
+ * - availableCoupons  可用优惠券列表
+ */
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PromotionCalcResponse {
+
+    /** 商品原价总额 */
+    private BigDecimal originalAmount;
+
+    /** 路径A(折扣/第二份半价) */
+    private PathResult pathA;
+
+    /** 路径B(满减) */
+    private PathResult pathB;
+
+    /** 最优路径 "A" 或 "B" */
+    private String optimalPath;
+
+    /** 最终实付金额(最低¥0.01) */
+    private BigDecimal finalAmount;
+
+    /** 新客立减金额(新客时返回) */
+    private BigDecimal newCustomerReduce;
+
+    /** 使用的优惠券ID(选券时返回) */
+    private Long couponId;
+
+    /** 使用的券名称 */
+    private String couponName;
+
+    /** 优惠券减免金额 */
+    private BigDecimal couponReduce;
+
+    /** 优惠券是否与促销冲突 */
+    private Boolean couponConflict;
+
+    /** 冲突说明 */
+    private String conflictNote;
+
+    /** 优惠明细列表 */
+    private List<PromotionDetail> details;
+
+    /** 可用优惠券列表 */
+    private List<AvailableCoupon> availableCoupons;
+
+    /** 优惠券批次ID(使用券时返回, 对应 promotion_coupon_batch.id) */
+    private Long couponBatchId;
+
+    /**
+     * 路径结果
+     */
+    @Data
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class PathResult {
+        /** 路径标签(折扣/第二份半价/满减) */
+        private String label;
+        /** 促销子类型(仅路径A): 2=折扣 3=第二份半价 */
+        private Integer appliedType;
+        /** 路径小计金额 */
+        private BigDecimal subtotal;
+        /** 促销减免金额 */
+        private BigDecimal promotionReduce;
+        /** 商品行明细 */
+        private List<LineItem> items;
+        /** 匹配到的满减规则(仅路径B) */
+        private MatchedRule matchedRule;
+    }
+
+    /**
+     * 商品行明细
+     */
+    @Data
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class LineItem {
+        private Long productId;
+        private Integer quantity;
+        private BigDecimal unitPrice;
+        private String name;
+        private BigDecimal originalLineTotal;
+        private BigDecimal finalLineTotal;
+        /** 折扣率(仅折扣路径, 如 0.70=7折) */
+        private BigDecimal discountRate;
+        /** 是否命中第二份半价 */
+        private Boolean halfPriceApplied;
+    }
+
+    /**
+     * 匹配到的满减规则(仅路径B)
+     */
+    @Data
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class MatchedRule {
+        private Long id;
+        private Long activityId;
+        private String activityName;
+        private BigDecimal threshold;
+        private BigDecimal reduceAmount;
+    }
+
+    /**
+     * 优惠明细
+     */
+    @Data
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class PromotionDetail {
+        /** 优惠类型: promotion=促销, coupon=优惠券 */
+        private String type;
+        /** 促销子类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 (coupon时为0) */
+        private Integer subType;
+        /** 优惠名称 */
+        private String name;
+        /** 减免金额 */
+        private BigDecimal reduce;
+        /** 关联ID: 促销活动ID(promo_type=1) 或 券批次ID(promo_type=2) */
+        private Long refId;
+    }
+
+    /**
+     * 可用优惠券
+     */
+    @Data
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class AvailableCoupon {
+        private Long id;
+        private String name;
+        /** 券类型: 1=满减券 2=商品券 3=免配送费券 */
+        private Integer couponType;
+        /** 是否互斥: 0=同享 1=互斥 */
+        private Integer isMutex;
+        private BigDecimal threshold;
+        private BigDecimal amount;
+        private BigDecimal discountRate;
+        /** 是否可用 */
+        private Boolean usable;
+        /** 是否与当前促销冲突 */
+        private Boolean conflictWithPromotion;
+    }
+}

+ 7 - 9
ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCalcService.java

@@ -1,7 +1,7 @@
 package com.ruoyi.system.service;
 
-import java.util.List;
-import java.util.Map;
+import com.ruoyi.system.dto.PromotionCalcRequest;
+import com.ruoyi.system.dto.PromotionCalcResponse;
 
 /**
  * 促销算价Service接口
@@ -10,12 +10,10 @@ public interface IPromotionCalcService {
 
     /**
      * 计算订单优惠
-     * @param storeId 门店ID
-     * @param items 商品列表 [{productId, quantity}]
-     * @param userId 用户ID(用于新客判断)
-     * @param couponId 可选,用户券ID
-     * @param forcePath 可选,强制路径 "A"或"B"
-     * @return 算价结果
+     *
+     * @param request 算价请求DTO (storeId, items, couponId, forcePath)
+     * @param userId  用户ID(用于新客判断)
+     * @return 算价结果DTO
      */
-    Map<String, Object> calculate(Long storeId, List<Map<String, Object>> items, Long userId, Long couponId, String forcePath);
+    PromotionCalcResponse calculate(PromotionCalcRequest request, Long userId);
 }

+ 9 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionUserCouponService.java

@@ -24,4 +24,13 @@ public interface IPromotionUserCouponService extends IService<PromotionUserCoupo
      * 查询用户优惠券列表
      */
     public List<Map<String, Object>> selectMyCoupons(Long userId, Integer status, Long storeId);
+
+    /**
+     * 标记优惠券为已使用
+     *
+     * @param couponId promotion_user_coupon.id
+     * @param orderId  关联的订单ID
+     * @param userId   用户ID(校验归属)
+     */
+    public void markAsUsed(Long couponId, Long orderId, Long userId);
 }

+ 181 - 79
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCalcServiceImpl.java

@@ -21,6 +21,13 @@ 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.LineItem;
+import com.ruoyi.system.dto.PromotionCalcResponse.MatchedRule;
+import com.ruoyi.system.dto.PromotionCalcResponse.PathResult;
+import com.ruoyi.system.dto.PromotionCalcResponse.PromotionDetail;
 import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PromotionActivityMapper;
@@ -35,8 +42,6 @@ import com.ruoyi.system.service.IPromotionCalcService;
  *
  * 两路算价:Path A(折扣/第二份半价)vs Path B(满减),取最优路径。
  * 叠加新客立减、优惠券后得出最终金额。
- *
- * @author ruoyi
  */
 @Service
 public class PromotionCalcServiceImpl implements IPromotionCalcService
@@ -65,10 +70,24 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
     private PosOrderMapper posOrderMapper;
 
     @Override
-    public Map<String, Object> calculate(Long storeId, List<Map<String, Object>> items,
-                                         Long userId, Long couponId, String forcePath)
+    public PromotionCalcResponse calculate(PromotionCalcRequest request, Long userId)
     {
-        Map<String, Object> result = new LinkedHashMap<>();
+        Long storeId = request.getStoreId();
+        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());
+                items.add(m);
+            }
+        }
 
         // ---- 1. 获取商品真实价格,计算 originalAmount ----
         Map<Long, BigDecimal> priceMap = new HashMap<>();
@@ -88,7 +107,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         }
 
         BigDecimal originalAmount = BigDecimal.ZERO;
-        // itemsWithPrice: [{productId, quantity, unitPrice, name}]
         List<Map<String, Object>> itemsWithPrice = new ArrayList<>();
         for (Map<String, Object> item : items)
         {
@@ -105,21 +123,17 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             itemsWithPrice.add(ip);
         }
         originalAmount = originalAmount.setScale(2, RoundingMode.HALF_UP);
-        result.put("originalAmount", originalAmount);
 
         // ---- 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));
 
-        // activityId -> rules
         Map<Long, List<PromotionActivityRule>> rulesByActivity = activeRules.stream()
                 .collect(Collectors.groupingBy(PromotionActivityRule::getActivityId));
 
-        // productId -> rules (for discount/half-price matching)
         Map<Long, List<PromotionActivityRule>> rulesByProduct = activeRules.stream()
                 .filter(r -> r.getProductId() != null)
                 .collect(Collectors.groupingBy(PromotionActivityRule::getProductId));
@@ -127,12 +141,10 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         // ---- 3. PATH A: 折扣(type=2) / 第二份半价(type=3) ----
         Map<String, Object> pathAResult = calculatePathA(itemsWithPrice, activeActivities,
                 rulesByActivity, rulesByProduct, activityMap);
-        result.put("pathA", pathAResult);
 
         // ---- 4. PATH B: 满减(type=1) ----
         Map<String, Object> pathBResult = calculatePathB(itemsWithPrice, originalAmount,
                 activeActivities, rulesByActivity, activityMap);
-        result.put("pathB", pathBResult);
 
         // ---- 5. 比较两条路径 ----
         BigDecimal subtotalA = (BigDecimal) pathAResult.get("subtotal");
@@ -147,7 +159,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         {
             optimalPath = subtotalA.compareTo(subtotalB) < 0 ? "A" : "B";
         }
-        result.put("optimalPath", optimalPath);
 
         BigDecimal afterPromotion = "A".equals(optimalPath) ? subtotalA : subtotalB;
         BigDecimal promotionReduce = originalAmount.subtract(afterPromotion);
@@ -159,12 +170,11 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             LambdaQueryWrapper<PosOrder> orderWrapper = new LambdaQueryWrapper<>();
             orderWrapper.eq(PosOrder::getUserId, userId)
                         .eq(PosOrder::getMdId, storeId)
-                        .eq(PosOrder::getState, 3L); // state=3 表示已完成
+                        .eq(PosOrder::getState, 3L);
             Long completedCount = posOrderMapper.selectCount(orderWrapper);
 
             if (completedCount == 0)
             {
-                // 新客
                 for (PromotionActivity act : activeActivities)
                 {
                     if (act.getType() != null && act.getType() == 4)
@@ -183,16 +193,13 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                 }
             }
         }
-        if (newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
-        {
-            result.put("newCustomerReduce", newCustomerReduce);
-        }
 
         // ---- 7. 优惠券 ----
         BigDecimal couponReduce = BigDecimal.ZERO;
         String couponName = null;
         Boolean couponConflict = null;
         String conflictNote = null;
+        PromotionCouponBatch batch = null;
 
         if (couponId != null)
         {
@@ -200,49 +207,40 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             if (userCoupon != null && userCoupon.getUserId().equals(userId)
                     && userCoupon.getStatus() != null && userCoupon.getStatus() == 0)
             {
-                // 检查是否过期
                 if (userCoupon.getExpireTime() != null && userCoupon.getExpireTime().before(new Date()))
                 {
                     // 已过期,不处理
                 }
                 else
                 {
-                    PromotionCouponBatch batch = couponBatchMapper.selectById(userCoupon.getBatchId());
+                    batch = couponBatchMapper.selectById(userCoupon.getBatchId());
                     PromotionCouponRule couponRule = couponRuleMapper.selectRuleByBatchId(userCoupon.getBatchId());
 
                     if (batch != null && couponRule != null)
                     {
                         couponName = batch.getName();
-
                         int isMutex = couponRule.getIsMutex() != null ? couponRule.getIsMutex() : 0;
 
-                        // 互斥检查
                         if (isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
                         {
                             couponConflict = true;
                             conflictNote = "互斥券不可与满减/折扣叠加,选择此券将取消促销优惠";
-                            // 互斥券:以原价为基础计算优惠
                             couponReduce = calcCouponReduce(couponRule, batch, itemsWithPrice, originalAmount, priceMap);
-                            // 使用互斥券时,促销优惠取消,以原价减去券优惠
                             afterPromotion = originalAmount;
                             promotionReduce = BigDecimal.ZERO;
                             newCustomerReduce = BigDecimal.ZERO;
                         }
                         else
                         {
-                            // 同享券:基于促销后金额判断门槛
                             BigDecimal baseForCoupon = afterPromotion.subtract(newCustomerReduce);
                             baseForCoupon = baseForCoupon.max(BigDecimal.ZERO);
 
-                            // 门槛检查
                             if (couponRule.getThreshold() != null
                                     && baseForCoupon.compareTo(couponRule.getThreshold()) >= 0)
                             {
-                                // 商品券(type=2)特殊检查:商品是否在折扣/半价活动中
                                 if (batch.getCouponType() != null && batch.getCouponType() == 2
                                         && couponRule.getProductId() != null && "A".equals(optimalPath))
                                 {
-                                    // 检查商品券的商品是否在当前Path A的活动中
                                     List<PromotionActivityRule> productRules = rulesByProduct.get(couponRule.getProductId());
                                     if (productRules != null)
                                     {
@@ -271,80 +269,199 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             }
         }
 
+        // ---- 8. 最终金额 ----
+        BigDecimal finalAmount = afterPromotion.subtract(newCustomerReduce).subtract(couponReduce);
+        finalAmount = finalAmount.max(MIN_AMOUNT).setScale(2, RoundingMode.HALF_UP);
+
+        // ---- 9. 构建响应DTO ----
+        PromotionCalcResponse response = new PromotionCalcResponse();
+        response.setOriginalAmount(originalAmount);
+        response.setPathA(toPathResult(pathAResult));
+        response.setPathB(toPathResult(pathBResult));
+        response.setOptimalPath(optimalPath);
+        response.setFinalAmount(finalAmount);
+
+        if (newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            response.setNewCustomerReduce(newCustomerReduce);
+        }
         if (couponId != null)
         {
-            result.put("couponId", couponId);
+            response.setCouponId(couponId);
         }
         if (couponName != null)
         {
-            result.put("couponName", couponName);
+            response.setCouponName(couponName);
+        }
+        if (couponReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            response.setCouponReduce(couponReduce);
         }
         if (couponConflict != null)
         {
-            result.put("couponConflict", couponConflict);
+            response.setCouponConflict(couponConflict);
         }
         if (conflictNote != null)
         {
-            result.put("conflictNote", conflictNote);
+            response.setConflictNote(conflictNote);
         }
-        if (couponReduce.compareTo(BigDecimal.ZERO) > 0)
+        // 设置优惠券批次ID(用于下单快照写入)
+        if (couponId != null && batch != null)
         {
-            result.put("couponReduce", couponReduce);
+            response.setCouponBatchId(batch.getId());
         }
 
-        // ---- 8. 最终金额 ----
-        BigDecimal finalAmount = afterPromotion.subtract(newCustomerReduce).subtract(couponReduce);
-        finalAmount = finalAmount.max(MIN_AMOUNT).setScale(2, RoundingMode.HALF_UP);
-        result.put("finalAmount", finalAmount);
-
-        // ---- 9. 明细列表 ----
-        List<Map<String, Object>> details = new ArrayList<>();
+        // ---- 10. 明细列表 ----
+        List<PromotionDetail> details = new ArrayList<>();
         if (promotionReduce.compareTo(BigDecimal.ZERO) > 0)
         {
-            Map<String, Object> detail = new LinkedHashMap<>();
-            detail.put("type", "promotion");
+            PromotionDetail detail = new PromotionDetail();
+            detail.setType("promotion");
             if ("A".equals(optimalPath))
             {
-                detail.put("subType", pathAResult.get("appliedType"));
-                detail.put("name", pathAResult.get("label"));
+                detail.setSubType((Integer) pathAResult.get("appliedType"));
+                detail.setName((String) pathAResult.get("label"));
+                // 找到对应类型的促销活动ID
+                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.put("subType", 1);
-                detail.put("name", pathBResult.get("label"));
+                detail.setSubType(1);
+                detail.setName((String) pathBResult.get("label"));
+                // 从满减匹配规则中获取活动ID
+                @SuppressWarnings("unchecked")
+                Map<String, Object> matchedRule = (Map<String, Object>) pathBResult.get("matchedRule");
+                if (matchedRule != null)
+                {
+                    detail.setRefId(toLong(matchedRule.get("activityId")));
+                }
             }
-            detail.put("reduce", promotionReduce);
+            detail.setReduce(promotionReduce);
             details.add(detail);
         }
         if (newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
         {
-            Map<String, Object> detail = new LinkedHashMap<>();
-            detail.put("type", "promotion");
-            detail.put("subType", 4);
-            detail.put("name", "新客立减");
-            detail.put("reduce", newCustomerReduce);
+            PromotionDetail detail = new PromotionDetail();
+            detail.setType("promotion");
+            detail.setSubType(4);
+            detail.setName("新客立减");
+            detail.setReduce(newCustomerReduce);
+            // 找到新客立减活动ID
+            for (PromotionActivity act : activeActivities)
+            {
+                if (act.getType() != null && act.getType() == 4)
+                {
+                    detail.setRefId(act.getId());
+                    break;
+                }
+            }
             details.add(detail);
         }
         if (couponReduce.compareTo(BigDecimal.ZERO) > 0)
         {
-            Map<String, Object> detail = new LinkedHashMap<>();
-            detail.put("type", "coupon");
-            detail.put("subType", 0);
-            detail.put("name", couponName);
-            detail.put("reduce", couponReduce);
+            PromotionDetail detail = new PromotionDetail();
+            detail.setType("coupon");
+            detail.setSubType(0);
+            detail.setName(couponName);
+            detail.setReduce(couponReduce);
+            // 优惠券的refId设为券批次ID
+            if (batch != null)
+            {
+                detail.setRefId(batch.getId());
+            }
             details.add(detail);
         }
-        result.put("details", details);
+        response.setDetails(details);
 
-        // ---- 10. 可用优惠券列表 ----
-        List<Map<String, Object>> availableCoupons = buildAvailableCoupons(storeId, userId,
+        // ---- 11. 可用优惠券列表 ----
+        List<Map<String, Object>> availableCouponMaps = buildAvailableCoupons(storeId, userId,
                 originalAmount, afterPromotion, promotionReduce, itemsWithPrice,
                 rulesByProduct, activityMap);
-        result.put("availableCoupons", availableCoupons);
+        response.setAvailableCoupons(availableCouponMaps.stream()
+                .map(this::toAvailableCoupon)
+                .collect(Collectors.toList()));
 
-        return result;
+        return response;
+    }
+
+    // ==================== DTO转换方法 ====================
+
+    private PathResult toPathResult(Map<String, Object> pathMap)
+    {
+        PathResult pr = new PathResult();
+        pr.setLabel((String) pathMap.get("label"));
+        pr.setSubtotal((BigDecimal) pathMap.get("subtotal"));
+        if (pathMap.containsKey("appliedType"))
+        {
+            pr.setAppliedType((Integer) pathMap.get("appliedType"));
+        }
+        if (pathMap.containsKey("promotionReduce"))
+        {
+            pr.setPromotionReduce((BigDecimal) pathMap.get("promotionReduce"));
+        }
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> itemList = (List<Map<String, Object>>) pathMap.get("items");
+        if (itemList != null)
+        {
+            pr.setItems(itemList.stream().map(this::toLineItem).collect(Collectors.toList()));
+        }
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> matchedRuleMap = (Map<String, Object>) pathMap.get("matchedRule");
+        if (matchedRuleMap != null)
+        {
+            MatchedRule mr = new MatchedRule();
+            mr.setId(toLong(matchedRuleMap.get("id")));
+            mr.setActivityId(toLong(matchedRuleMap.get("activityId")));
+            mr.setActivityName((String) matchedRuleMap.get("activityName"));
+            mr.setThreshold((BigDecimal) matchedRuleMap.get("threshold"));
+            mr.setReduceAmount((BigDecimal) matchedRuleMap.get("reduceAmount"));
+            pr.setMatchedRule(mr);
+        }
+
+        return pr;
+    }
+
+    private LineItem toLineItem(Map<String, Object> itemMap)
+    {
+        LineItem li = new LineItem();
+        li.setProductId(toLong(itemMap.get("productId")));
+        li.setQuantity(toInt(itemMap.get("quantity")));
+        li.setUnitPrice((BigDecimal) itemMap.get("unitPrice"));
+        li.setName((String) itemMap.get("name"));
+        li.setOriginalLineTotal((BigDecimal) itemMap.get("originalLineTotal"));
+        li.setFinalLineTotal((BigDecimal) itemMap.get("finalLineTotal"));
+        li.setDiscountRate((BigDecimal) itemMap.get("discountRate"));
+        li.setHalfPriceApplied((Boolean) itemMap.get("halfPriceApplied"));
+        return li;
     }
 
+    private AvailableCoupon toAvailableCoupon(Map<String, Object> couponMap)
+    {
+        AvailableCoupon ac = new AvailableCoupon();
+        ac.setId(toLong(couponMap.get("id")));
+        ac.setName((String) couponMap.get("name"));
+        ac.setCouponType((Integer) couponMap.get("couponType"));
+        ac.setIsMutex((Integer) couponMap.get("isMutex"));
+        ac.setThreshold((BigDecimal) couponMap.get("threshold"));
+        ac.setAmount((BigDecimal) couponMap.get("amount"));
+        ac.setDiscountRate((BigDecimal) couponMap.get("discountRate"));
+        ac.setUsable((Boolean) couponMap.get("usable"));
+        ac.setConflictWithPromotion((Boolean) couponMap.get("conflictWithPromotion"));
+        return ac;
+    }
+
+    // ==================== 内部计算方法(保持Map) ====================
+
     /**
      * PATH A: 折扣(type=2) vs 第二份半价(type=3),取更优
      */
@@ -358,7 +475,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         pathResult.put("label", "折扣");
         pathResult.put("appliedType", 2);
 
-        // 分别计算折扣和第二份半价
         Map<String, Object> discountResult = calcDiscount(itemsWithPrice, rulesByProduct, activityMap);
         Map<String, Object> halfPriceResult = calcHalfPrice(itemsWithPrice, rulesByProduct, activityMap);
 
@@ -367,7 +483,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
 
         if (halfPriceSubtotal.compareTo(discountSubtotal) < 0)
         {
-            // 第二份半价更优
             pathResult.put("label", "第二份半价");
             pathResult.put("appliedType", 3);
             pathResult.put("items", halfPriceResult.get("items"));
@@ -379,7 +494,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         }
         else
         {
-            // 折扣更优或相等
             pathResult.put("items", discountResult.get("items"));
             pathResult.put("subtotal", discountSubtotal);
             pathResult.put("promotionReduce",
@@ -412,7 +526,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             BigDecimal lineOriginal = unitPrice.multiply(BigDecimal.valueOf(quantity));
             originalRef = originalRef.add(lineOriginal);
 
-            // 查找此商品是否有折扣规则
             BigDecimal finalPrice = lineOriginal;
             PromotionActivityRule matchedRule = null;
 
@@ -424,7 +537,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                     PromotionActivity act = activityMap.get(rule.getActivityId());
                     if (act != null && act.getType() == 2 && rule.getDiscountRate() != null)
                     {
-                        // 满足最低数量
                         if (rule.getMinQuantity() == null || quantity >= rule.getMinQuantity())
                         {
                             BigDecimal discounted = unitPrice.multiply(rule.getDiscountRate())
@@ -495,7 +607,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                     {
                         if (rule.getMinQuantity() == null || quantity >= rule.getMinQuantity())
                         {
-                            // 每2件中第2件半价: pairs * 1.5 * unitPrice + remainder * unitPrice
                             int pairs = quantity / 2;
                             int remainder = quantity % 2;
                             BigDecimal halfPriceTotal = unitPrice.multiply(BigDecimal.valueOf(pairs))
@@ -562,7 +673,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         }
         pathResult.put("items", itemList);
 
-        // 查找满减活动
         PromotionActivityRule bestMatch = null;
         PromotionActivity bestActivity = null;
 
@@ -624,11 +734,9 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
     {
         if (batch.getCouponType() != null && batch.getCouponType() == 2)
         {
-            // 商品券: 基于商品原价和折扣率
             if (couponRule.getProductId() != null)
             {
                 BigDecimal productPrice = priceMap.getOrDefault(couponRule.getProductId(), BigDecimal.ZERO);
-                // 找到此商品在购物车中的数量
                 int quantity = 0;
                 for (Map<String, Object> item : itemsWithPrice)
                 {
@@ -655,7 +763,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         }
         else
         {
-            // 满减券: 直接减 amount
             if (couponRule.getAmount() != null)
             {
                 return couponRule.getAmount().min(baseAmount).setScale(2, RoundingMode.HALF_UP);
@@ -681,7 +788,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             return available;
         }
 
-        // 查询用户未使用、未过期的优惠券
         LambdaQueryWrapper<PromotionUserCoupon> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(PromotionUserCoupon::getUserId, userId)
                .eq(PromotionUserCoupon::getStoreId, storeId)
@@ -711,17 +817,14 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             couponInfo.put("amount", rule.getAmount());
             couponInfo.put("discountRate", rule.getDiscountRate());
 
-            // 判断是否可用
             boolean usable = true;
             int isMutex = rule.getIsMutex() != null ? rule.getIsMutex() : 0;
 
             if (isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
             {
-                // 互斥券且当前有促销优惠 → 仍返回但标记
                 couponInfo.put("conflictWithPromotion", true);
             }
 
-            // 门槛检查
             if (rule.getThreshold() != null)
             {
                 BigDecimal baseForCheck = isMutex == 1 ? originalAmount : afterPromotion;
@@ -731,7 +834,6 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                 }
             }
 
-            // 商品券: 检查商品是否在购物车中
             if (batch.getCouponType() != null && batch.getCouponType() == 2 && rule.getProductId() != null)
             {
                 boolean inCart = itemsWithPrice.stream()

+ 18 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionUserCouponServiceImpl.java

@@ -182,4 +182,22 @@ public class PromotionUserCouponServiceImpl extends ServiceImpl<PromotionUserCou
         }
         return result;
     }
+
+    @Override
+    public void markAsUsed(Long couponId, Long orderId, Long userId) {
+        PromotionUserCoupon coupon = userCouponMapper.selectById(couponId);
+        if (coupon == null) {
+            throw new ServiceException("优惠券不存在");
+        }
+        if (!coupon.getUserId().equals(userId)) {
+            throw new ServiceException("优惠券不属于当前用户");
+        }
+        if (coupon.getStatus() == null || coupon.getStatus() != 0) {
+            throw new ServiceException("优惠券状态异常,无法使用");
+        }
+        coupon.setStatus(1);
+        coupon.setOrderId(orderId);
+        coupon.setUseTime(new Date());
+        userCouponMapper.updateById(coupon);
+    }
 }