|
@@ -23,6 +23,7 @@ import com.etotem.cfc.entity.ProductPickupLocation;
|
|
|
import com.etotem.cfc.entity.ProductPickupTimeSlot;
|
|
import com.etotem.cfc.entity.ProductPickupTimeSlot;
|
|
|
import com.etotem.cfc.entity.ProductServicePerson;
|
|
import com.etotem.cfc.entity.ProductServicePerson;
|
|
|
import com.etotem.cfc.entity.ProductServicePersonSlot;
|
|
import com.etotem.cfc.entity.ProductServicePersonSlot;
|
|
|
|
|
+import com.etotem.cfc.entity.SpecialPriceDeal;
|
|
|
import com.etotem.cfc.entity.User;
|
|
import com.etotem.cfc.entity.User;
|
|
|
import com.etotem.cfc.service.FamilyPlatformPointsService;
|
|
import com.etotem.cfc.service.FamilyPlatformPointsService;
|
|
|
import com.etotem.cfc.service.PlatformPointsService;
|
|
import com.etotem.cfc.service.PlatformPointsService;
|
|
@@ -205,7 +206,14 @@ public class ProductOrderService {
|
|
|
@Resource
|
|
@Resource
|
|
|
private CouponService couponService;
|
|
private CouponService couponService;
|
|
|
|
|
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private SpecialPriceService specialPriceService;
|
|
|
|
|
+
|
|
|
public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
|
|
public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
|
|
|
|
|
+ // 限时特价(扫码闪购):委托独立事务入口(规格 §5.2)
|
|
|
|
|
+ if (dto.getSpecialDealId() != null) {
|
|
|
|
|
+ return createSpecialDealOrder(dto, buyerId);
|
|
|
|
|
+ }
|
|
|
if (buyerId == null) {
|
|
if (buyerId == null) {
|
|
|
return Result.error("请先登录");
|
|
return Result.error("请先登录");
|
|
|
}
|
|
}
|
|
@@ -321,6 +329,190 @@ public class ProductOrderService {
|
|
|
return Result.success(ProductOrderDTO.from(order));
|
|
return Result.success(ProductOrderDTO.from(order));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 限时特价下单(扫码闪购):独立事务入口(规格 §5.2)。
|
|
|
|
|
+ * - 特价覆盖会员价,保留积分抵扣与赠品;优惠券按 allow_coupon 开关
|
|
|
|
|
+ * - 原子扣量 + 订单插入同一事务(任一异常整体回滚)
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>校验顺序裁决(2026-10-21):先做全部可失败前校验(每人限购/券开关/商品/履约/赠品),
|
|
|
|
|
+ * 原子扣量紧跟订单 insert 之前 —— 规格 §5.2「每人限购(先查再扣,与扣量同事务)」,
|
|
|
|
|
+ * 避免"扣量成功但后续校验失败返回 Result.error"导致的额度泄漏(@Transactional 仅对异常回滚,
|
|
|
|
|
+ * Result.error 返回会提交事务)。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
|
|
+ private Result<ProductOrderDTO> createSpecialDealOrder(CreateProductOrderDTO dto, Long buyerId) {
|
|
|
|
|
+ if (buyerId == null) {
|
|
|
|
|
+ return Result.error("请先登录");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 1. 特价仅支持单商品直购,不走购物车
|
|
|
|
|
+ if (dto.getItems() != null && !dto.getItems().isEmpty()) {
|
|
|
|
|
+ return Result.error("特价商品请单独购买");
|
|
|
|
|
+ }
|
|
|
|
|
+ Long dealId = dto.getSpecialDealId();
|
|
|
|
|
+ SpecialPriceDeal deal = specialPriceService.getById(dealId);
|
|
|
|
|
+ if (deal == null) {
|
|
|
|
|
+ return Result.error("特价活动不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!"active".equals(deal.getStatus())) {
|
|
|
|
|
+ return Result.error("特价活动已结束或不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+ Date now = new Date();
|
|
|
|
|
+ if (deal.getStartTime() != null && now.before(deal.getStartTime())) {
|
|
|
|
|
+ return Result.error("特价活动未开始/已结束");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (deal.getEndTime() != null && now.after(deal.getEndTime())) {
|
|
|
|
|
+ return Result.error("特价活动未开始/已结束");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (deal.getSpecialPrice() == null) {
|
|
|
|
|
+ return Result.error("特价价格异常");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dto.getQuantity() == null || dto.getQuantity() < 1) {
|
|
|
|
|
+ return Result.error("购买数量不合法");
|
|
|
|
|
+ }
|
|
|
|
|
+ int quantity = dto.getQuantity();
|
|
|
|
|
+ if (dto.getProductId() == null || !dto.getProductId().equals(deal.getProductId())) {
|
|
|
|
|
+ return Result.error("该特价活动不适用于此商品");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 2. 每人限购(先查再扣)
|
|
|
|
|
+ int perUserLimit = deal.getPerUserLimit() != null ? deal.getPerUserLimit() : 1;
|
|
|
|
|
+ int userPurchased = specialPriceService.getUserPurchased(dealId, buyerId);
|
|
|
|
|
+ if (userPurchased + quantity > perUserLimit) {
|
|
|
|
|
+ return Result.error("每人限购" + perUserLimit + "件,您已购买" + userPurchased + "件");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 3. 优惠券开关
|
|
|
|
|
+ if ((deal.getAllowCoupon() == null || deal.getAllowCoupon() == 0) && dto.getCouponId() != null) {
|
|
|
|
|
+ return Result.error("该特价商品不可使用优惠券");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 4. 商品校验(复用 create 单商品逻辑:在架/库存/套餐/最低购买量)
|
|
|
|
|
+ Product product = productMapper.selectById(dto.getProductId());
|
|
|
|
|
+ if (product == null) {
|
|
|
|
|
+ return Result.error("商品不存在");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!"on_shelf".equals(product.getStatus())) {
|
|
|
|
|
+ return Result.error("商品已下架");
|
|
|
|
|
+ }
|
|
|
|
|
+ if ("physical".equals(product.getProductType())
|
|
|
|
|
+ && product.getStock() != null
|
|
|
|
|
+ && product.getStock() < quantity) {
|
|
|
|
|
+ return Result.error("库存不足");
|
|
|
|
|
+ }
|
|
|
|
|
+ if ("bundle".equals(product.getProductType())) {
|
|
|
|
|
+ Result<String> bundleCheck = inventoryService.checkBundleStock(product.getId(), quantity);
|
|
|
|
|
+ if (bundleCheck.getCode() != 200) {
|
|
|
|
|
+ return Result.error(bundleCheck.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (product.getMinQuantity() != null && product.getMinQuantity() > 1
|
|
|
|
|
+ && quantity < product.getMinQuantity()) {
|
|
|
|
|
+ return Result.error("该商品最少需购买" + product.getMinQuantity() + "件");
|
|
|
|
|
+ }
|
|
|
|
|
+ // 5. 配送方式与履约校验
|
|
|
|
|
+ FulfillValidation fv = validateFulfill(product, dto, buyerId);
|
|
|
|
|
+ if (fv.error != null) return Result.error(fv.error);
|
|
|
|
|
+
|
|
|
|
|
+ // 6. 构建订单(定价=特价,覆盖会员价)
|
|
|
|
|
+ User buyer = userMapper.selectById(buyerId);
|
|
|
|
|
+ ProductOrder order = new ProductOrder();
|
|
|
|
|
+ order.setOrderNo(generateOrderNo());
|
|
|
|
|
+ order.setProductId(product.getId());
|
|
|
|
|
+ order.setProductName(product.getName());
|
|
|
|
|
+ order.setProductType(product.getProductType());
|
|
|
|
|
+ order.setBuyerId(buyerId);
|
|
|
|
|
+ order.setFamilyId(buyer != null ? buyer.getFamilyId() : null);
|
|
|
|
|
+ order.setQuantity(quantity);
|
|
|
|
|
+ order.setUnitPrice(deal.getSpecialPrice());
|
|
|
|
|
+ order.setTotalAmount(deal.getSpecialPrice() * quantity);
|
|
|
|
|
+ order.setPointsUsed(dto.getPointsUsed() != null ? dto.getPointsUsed() : 0);
|
|
|
|
|
+ int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
|
|
|
|
|
+ order.setPointsCost(pointsCost);
|
|
|
|
|
+ int discountAmount = dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0;
|
|
|
|
|
+ Long appliedCouponId = null;
|
|
|
|
|
+ if (dto.getCouponId() != null) {
|
|
|
|
|
+ Integer couponDiscount = couponService.apply(buyerId, dto.getCouponId(), "PRODUCT", order.getTotalAmount());
|
|
|
|
|
+ if (couponDiscount != null && couponDiscount > 0) {
|
|
|
|
|
+ discountAmount += couponDiscount;
|
|
|
|
|
+ appliedCouponId = dto.getCouponId();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ int moneyAmount = order.getTotalAmount() - pointsCost - discountAmount;
|
|
|
|
|
+ order.setMoneyAmount(moneyAmount > 0 ? moneyAmount : 0);
|
|
|
|
|
+ order.setGiftAmount(productGiftRuleService.getActiveByProductId(product.getId()) != null
|
|
|
|
|
+ ? product.getPrice() * quantity : 0);
|
|
|
|
|
+ order.setStatus("pending");
|
|
|
|
|
+ order.setPaymentMethod(dto.getPaymentMethod() != null ? dto.getPaymentMethod() : "wechat");
|
|
|
|
|
+ order.setRemark(dto.getRemark());
|
|
|
|
|
+ order.setCoverImage(product.getCoverImage());
|
|
|
|
|
+ order.setDiscountAmount(discountAmount);
|
|
|
|
|
+ order.setCouponId(appliedCouponId);
|
|
|
|
|
+ resolveOrderAddress(order, dto, buyerId);
|
|
|
|
|
+ if (dto.getPurchaseInfo() != null && !dto.getPurchaseInfo().isEmpty()) {
|
|
|
|
|
+ order.setPurchaseInfo(JSON.toJSONString(dto.getPurchaseInfo()));
|
|
|
|
|
+ }
|
|
|
|
|
+ order.setDeliveryMethod(fv.deliveryMethod != null ? fv.deliveryMethod
|
|
|
|
|
+ : (dto.getDeliveryMethod() != null ? dto.getDeliveryMethod() : 1));
|
|
|
|
|
+ // 履约扩展校验结果(与 create 一致)
|
|
|
|
|
+ if (fv.unitPrice != null) {
|
|
|
|
|
+ order.setUnitPrice(fv.unitPrice);
|
|
|
|
|
+ order.setTotalAmount(fv.unitPrice * quantity);
|
|
|
|
|
+ int ma = order.getTotalAmount() - order.getPointsCost() - discountAmount;
|
|
|
|
|
+ order.setMoneyAmount(ma > 0 ? ma : 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (fv.pickupLocationId != null) order.setPickupLocationId(fv.pickupLocationId);
|
|
|
|
|
+ if (fv.pickupLocationSnapshot != null) order.setPickupLocationSnapshot(fv.pickupLocationSnapshot);
|
|
|
|
|
+ if (fv.timeSlotId != null) order.setTimeSlotId(fv.timeSlotId);
|
|
|
|
|
+ if (fv.timeSlotSnapshot != null) order.setTimeSlotSnapshot(fv.timeSlotSnapshot);
|
|
|
|
|
+ if (fv.servicePersonId != null) order.setServicePersonId(fv.servicePersonId);
|
|
|
|
|
+ if (fv.servicePersonSnapshot != null) order.setServicePersonSnapshot(fv.servicePersonSnapshot);
|
|
|
|
|
+ order.setFulfillStatus("pending");
|
|
|
|
|
+ if (order.getDeliveryMethod() != null && order.getDeliveryMethod() == 2) {
|
|
|
|
|
+ order.setPickupCode(generatePickupCode());
|
|
|
|
|
+ }
|
|
|
|
|
+ order.setSpecialDealId(dealId);
|
|
|
|
|
+ order.setCancelAt(new Date(System.currentTimeMillis() + 30 * 60 * 1000));
|
|
|
|
|
+ order.setCreatedAt(new Date());
|
|
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
|
|
+
|
|
|
|
|
+ // 7. 原子扣量(紧跟 insert 之前;insert 或其后异常由事务整体回滚,扣量一并回滚)
|
|
|
|
|
+ try {
|
|
|
|
|
+ specialPriceService.consumeQuota(dealId, quantity);
|
|
|
|
|
+ } catch (IllegalStateException e) {
|
|
|
|
|
+ // 并发售罄/状态漂移:重查活动给出明确原因(规格 §5.2:影响行数=0 时重查判定原因)
|
|
|
|
|
+ return Result.error(specialDealUnavailableReason(dealId));
|
|
|
|
|
+ }
|
|
|
|
|
+ orderMapper.insert(order);
|
|
|
|
|
+ // 占用已选优惠券(避免 pending 期间其他订单重复使用同一张)
|
|
|
|
|
+ if (appliedCouponId != null) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ couponService.reserve(appliedCouponId, order.getId());
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("占用优惠券失败: orderNo={}, couponId={}, error={}", order.getOrderNo(), appliedCouponId, e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return Result.success(ProductOrderDTO.from(order));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 扣量失败后的原因判定(规格 §5.2:影响行数=0 → 重查活动判定已售罄/已结束/未开始/非活动期)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private String specialDealUnavailableReason(Long dealId) {
|
|
|
|
|
+ SpecialPriceDeal deal = specialPriceService.getById(dealId);
|
|
|
|
|
+ if (deal == null) {
|
|
|
|
|
+ return "特价活动不存在";
|
|
|
|
|
+ }
|
|
|
|
|
+ String runStatus = SpecialPriceService.computeRunStatus(
|
|
|
|
|
+ deal.getStatus(),
|
|
|
|
|
+ deal.getStartTime(),
|
|
|
|
|
+ deal.getEndTime(),
|
|
|
|
|
+ deal.getSoldQuantity() != null ? deal.getSoldQuantity() : 0,
|
|
|
|
|
+ deal.getTotalQuantity() != null ? deal.getTotalQuantity() : 0);
|
|
|
|
|
+ if ("NOT_STARTED".equals(runStatus)) return "特价活动未开始";
|
|
|
|
|
+ if ("ENDED".equals(runStatus)) return "特价活动已结束";
|
|
|
|
|
+ if ("SOLD_OUT".equals(runStatus)) return "特价活动已售罄";
|
|
|
|
|
+ if ("DRAFT".equals(runStatus)) return "特价活动待启用";
|
|
|
|
|
+ if ("DISABLED".equals(runStatus)) return "特价活动已停用";
|
|
|
|
|
+ return "特价活动不可用";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 配送方式与履约校验(设计文档十一节)
|
|
* 配送方式与履约校验(设计文档十一节)
|
|
|
* 分支校验 + 生成快照 + 扣减时间段余量;返回 error!=null 表示校验失败
|
|
* 分支校验 + 生成快照 + 扣减时间段余量;返回 error!=null 表示校验失败
|
|
@@ -1027,6 +1219,13 @@ public class ProductOrderService {
|
|
|
// 记录退款入库流水
|
|
// 记录退款入库流水
|
|
|
inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
|
|
inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
|
|
|
"order", order.getId(), null, "取消订单:" + orderNo);
|
|
"order", order.getId(), null, "取消订单:" + orderNo);
|
|
|
|
|
+ // 特价订单回滚扣量(与恢复库存并列,失败仅记日志不阻断取消)
|
|
|
|
|
+ try {
|
|
|
|
|
+ specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());
|
|
|
|
|
+ } catch (Exception ex) {
|
|
|
|
|
+ log.warn("特价扣量回滚失败: dealId={}, orderNo={}, err={}",
|
|
|
|
|
+ order.getSpecialDealId(), order.getOrderNo(), ex.getMessage());
|
|
|
|
|
+ }
|
|
|
return Result.success("订单已取消");
|
|
return Result.success("订单已取消");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1300,6 +1499,13 @@ public class ProductOrderService {
|
|
|
"order", order.getId(), adminId, "退款:" + orderNo);
|
|
"order", order.getId(), adminId, "退款:" + orderNo);
|
|
|
// 退款成功:归还优惠券;CF 兑换来的券同时退还 CF 值
|
|
// 退款成功:归还优惠券;CF 兑换来的券同时退还 CF 值
|
|
|
releaseCouponForCancelledOrder(order.getCouponId());
|
|
releaseCouponForCancelledOrder(order.getCouponId());
|
|
|
|
|
+ // 特价订单回滚扣量(与恢复库存并列,失败仅记日志不阻断退款)
|
|
|
|
|
+ try {
|
|
|
|
|
+ specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());
|
|
|
|
|
+ } catch (Exception ex) {
|
|
|
|
|
+ log.warn("特价扣量回滚失败(refund): dealId={}, orderNo={}, err={}",
|
|
|
|
|
+ order.getSpecialDealId(), order.getOrderNo(), ex.getMessage());
|
|
|
|
|
+ }
|
|
|
syncAfterSalesRequestStatus(orderNo, "approved", adminId);
|
|
syncAfterSalesRequestStatus(orderNo, "approved", adminId);
|
|
|
log.info("退款完成: orderNo={}, adminId={}", orderNo, adminId);
|
|
log.info("退款完成: orderNo={}, adminId={}", orderNo, adminId);
|
|
|
return Result.success("退款已处理");
|
|
return Result.success("退款已处理");
|
|
@@ -1625,6 +1831,10 @@ public class ProductOrderService {
|
|
|
if (order == null) {
|
|
if (order == null) {
|
|
|
return Result.error("订单不存在");
|
|
return Result.error("订单不存在");
|
|
|
}
|
|
}
|
|
|
|
|
+ // 特价订单禁止供应商改价(含非价格字段,保持简单——扫码闪购定价由活动决定)
|
|
|
|
|
+ if (order.getSpecialDealId() != null) {
|
|
|
|
|
+ return Result.error("特价订单不允许修改价格");
|
|
|
|
|
+ }
|
|
|
if (!"pending".equals(order.getStatus())) {
|
|
if (!"pending".equals(order.getStatus())) {
|
|
|
return Result.error("仅待支付订单可审核修改");
|
|
return Result.error("仅待支付订单可审核修改");
|
|
|
}
|
|
}
|