feat(订单): 下单未选券时自动领取并使用整单最优优惠券

新增 OrderComputedServices::autoMatchCoupon:候选=已持有未使用+可领取(领取窗口/
库存/限领/会员券身份与手动领取同口径),整单门槛按购物车适用子集价格计算,折扣相同
优先已持有;confirm 进入确认页、computed 价格重算、create 提交下单三处挂钩,预览即
真实领取,create 后走既有扣减与订单事务内核销链路,失败降级为无券不阻断下单。
顺带修复品类券分支引用不存在的 StoreCategoryServices(改用 StoreCategoryDao)。
This commit is contained in:
吴昊天 2026-10-10 16:14:59 +08:00
parent 8022bd9864
commit 5defa5ef2e
2 changed files with 219 additions and 11 deletions

View File

@ -97,8 +97,8 @@ class CenterOrderController
// 待发货/待收货/待使用:均为进行中状态,产品类型与发货状态过滤交由 list 方法处理
'pending_delivery' => OrderCenter::STATUS_PROCESSING,
'pending_use' => OrderCenter::STATUS_PROCESSING,
// 待评价:按已完成状态筛选(与前端原 getStatusMap 逻辑一致)
'pending_comment' => OrderCenter::STATUS_COMPLETED,
// 待评价:履约完成待评价(评价后流转 completed;与统计接口 grouped.pending_comment 口径一致)
'pending_comment' => OrderCenter::STATUS_PENDING_REVIEW,
// 售后:退款中 + 已退款
'after_sale' => OrderCenter::STATUS_REFUNDING . ',' . OrderCenter::STATUS_REFUNDED,
];
@ -227,6 +227,9 @@ class CenterOrderController
$integralRatio = sys_config('integral_ratio', 0);
$integralOpen = (float) $integralRatio > 0 || $usableIntegral > 0;
// 活动订单不走优惠券自动匹配,标记前置(优惠券列表构建时使用)
$isActivityOrder = $activityType !== 'normal' && $activityId > 0;
// 5. 获取用户可用优惠券
$couponList = [];
try {
@ -237,6 +240,30 @@ class CenterOrderController
} catch (\Throwable $e) {
// 优惠券列表获取失败不影响主流程
}
// 5.1 用户尚无可用券时,自动领取整单最优优惠券并入列表(预览即真实领取,与 computed/create 自动用券同口径)
if (!$isActivityOrder && empty($couponList)) {
try {
$autoCoupon = app()->make(OrderComputedServices::class)->autoMatchCoupon($uid, $cartInfo, true);
if (!empty($autoCoupon['coupon_user_id'])) {
$autoRow = $couponServices->get((int)$autoCoupon['coupon_user_id'], ['*'], ['issue']);
if ($autoRow) {
$autoRow = $autoRow->toArray();
// 行结构与 getUsableCouponList 对齐(前端优惠券选择器字段)
$autoRow['title'] = $autoRow['coupon_title'];
$autoRow['type'] = $autoRow['applicable_type'] ?? 0;
$autoRow['use_min_price'] = floatval($autoRow['use_min_price'] ?? 0);
$autoRow['coupon_price'] = floatval($autoRow['coupon_price'] ?? 0);
$autoRow['start_time'] = $autoRow['start_time'] ? date('Y/m/d', $autoRow['start_time']) : date('Y/m/d', $autoRow['add_time']);
$autoRow['add_time'] = date('Y/m/d', (int)$autoRow['add_time']);
$autoRow['end_time'] = date('Y/m/d', (int)$autoRow['end_time']);
$autoRow['is_use'] = 0;
$couponList[] = $autoRow;
}
}
} catch (\Throwable $e) {
// 自动领券失败不影响确认页主流程
}
}
// 6. 获取商品详情扩展数据(自定义表单、商品参数)
$detailExtend = $handler->getDetailExtend($productId);
@ -259,7 +286,6 @@ class CenterOrderController
$activityExtraInfo = [];
$freePayType = '';
$payRedirectUrl = '';
$isActivityOrder = $activityType !== 'normal' && $activityId > 0;
// 活动订单信息
if ($isActivityOrder) {
$activityAllowedPayTypes = $tradeModeHandler->getAllowedPayTypes($activityId);
@ -743,8 +769,19 @@ class CenterOrderController
];
}
// 用户未选券时自动领取并使用整单最优优惠券(折扣对应真实领取的券,与商品详情到手价口径一致;失败降级为无券)
if (!$couponId && !$isActivityOrder && ($pricingPolicy['coupon'] ?? true)) {
/** @var OrderComputedServices $computedServices */
$computedServices = app()->make(OrderComputedServices::class);
$autoCoupon = $computedServices->autoMatchCoupon($uid, $cartInfo, true);
if (!empty($autoCoupon['coupon_user_id'])) {
$couponId = (int)$autoCoupon['coupon_user_id'];
}
}
// 手动校验并计算优惠券金额(不依赖 useCouponId 的 getOne 查询)
$couponPrice = 0;
$couponTitle = '';
if ($couponId) {
/** @var \app\services\activity\coupon\StoreCouponUserServices $couponServices */
$couponServices = app()->make(\app\services\activity\coupon\StoreCouponUserServices::class);
@ -753,6 +790,7 @@ class CenterOrderController
if (!$couponInfo) {
return app('json')->fail('选择的优惠劵无效');
}
$couponTitle = (string)$couponInfo['coupon_title'];
// 使用 getData 获取原始值,避免 Model 访问器转换(status→"未使用"、type→"后台发放"等)
$rawStatus = $couponInfo->getData('status');
$rawIsFail = $couponInfo->getData('is_fail');
@ -782,12 +820,11 @@ class CenterOrderController
}
break;
case 1:
/** @var \app\services\activity\coupon\StoreCategoryServices $storeCategoryServices */
$storeCategoryServices = app()->make(\app\services\activity\coupon\StoreCategoryServices::class);
$couponCategory = explode(',', (string)$couponInfo['category_id']);
$categoryIds = $storeCategoryServices->getAllById($couponCategory);
if ($categoryIds) {
$cateIds = array_column($categoryIds, 'id');
$storeCategoryServices = app()->make(\app\dao\product\product\StoreCategoryDao::class);
$coupon_category = explode(',', (string)$couponInfo['category_id']);
$category_ids = $storeCategoryServices->getAllById($coupon_category);
if ($category_ids) {
$cateIds = array_column($category_ids, 'id');
foreach ($cartInfo as $cart) {
if (isset($cart['productInfo']['cate_id']) && array_intersect(explode(',', $cart['productInfo']['cate_id']), $cateIds)) {
$price = bcadd($price, bcmul((string)$cart['truePrice'], (string)$cart['cart_num'], 2), 2);
@ -841,6 +878,9 @@ class CenterOrderController
// 修正返回字段:total_price 用原始总价,coupon_price 用计算值
$result['total_price'] = $originalTotalPrice;
$result['coupon_price'] = $couponPrice;
// 自动用券回显:前端据此展示已选中的优惠券
$result['coupon_id'] = $couponId;
$result['coupon_title'] = $couponTitle;
// 自动领取发生在确认接口返回之后时,重新查询可用券并返回给前端。
// 否则金额和标题已经更新,但优惠券选择弹窗仍使用确认阶段的旧列表。
@ -1137,6 +1177,21 @@ class CenterOrderController
// 记录实时原始总价(优惠券抵扣不影响 total_price 记录)
$originalTotalPrice = $priceData['totalPrice'] ?? 0;
// 用户未选券时自动匹配整单最优优惠券(可领取的先领取,失败降级为无券;活动订单与关闭券能力的商品类型不参与)
if (!$couponId && !$isActivityOrder) {
$couponPolicyEnabled = true;
$autoHandler = $registry->get($productType);
if (method_exists($autoHandler, 'getPricingPolicy')) {
$couponPolicyEnabled = ($autoHandler->getPricingPolicy()['coupon'] ?? true) === true;
}
if ($couponPolicyEnabled) {
$autoCoupon = app()->make(OrderComputedServices::class)->autoMatchCoupon($uid, $cartInfo, true);
if (!empty($autoCoupon['coupon_user_id'])) {
$couponId = (int)$autoCoupon['coupon_user_id'];
}
}
}
// 1.5 手动校验并计算优惠券金额
// 活动订单默认不支持优惠券
if ($couponId && !$isActivityOrder) {
@ -1160,8 +1215,7 @@ class CenterOrderController
}
break;
case 1:
/** @var \app\services\activity\coupon\StoreCategoryServices $storeCategoryServices */
$storeCategoryServices = app()->make(\app\services\activity\coupon\StoreCategoryServices::class);
$storeCategoryServices = app()->make(\app\dao\product\product\StoreCategoryDao::class);
$couponCategory = explode(',', (string)$couponInfo['category_id']);
$categoryIds = $storeCategoryServices->getAllById($couponCategory);
if ($categoryIds) {

View File

@ -22,6 +22,9 @@ use app\services\user\UserServices;
use crmeb\exceptions\ApiException;
use app\services\user\UserAddressServices;
use app\services\activity\coupon\StoreCouponUserServices;
use app\services\activity\coupon\StoreCouponIssueServices;
use app\dao\activity\coupon\StoreCouponUserDao;
use think\facade\Log;
/**
* 订单计算金额
@ -269,6 +272,157 @@ class OrderComputedServices extends BaseService
return [$payPrice, $couponPrice];
}
/**
* 未显式选券时自动匹配整单最优优惠券
* 候选 = 已持有未使用 + 可领取(与手动领取同口径:领取窗口/库存/限领次数/付费会员身份);
* 折扣相同优先用已持有(无领取副作用);整单门槛按购物车适用子集价格计算(与显式用券同口径)
* @param int $uid
* @param array $cartInfo 购物车条目(须含 truePrice/cart_num/product_id/productInfo.cate_id)
* @param bool $claim true=真实下单(可领取券先领取并返回用户券id);false=预下单试算(可领取券返回伪券信息,不产生副作用)
* @return array 已持有/已领取:['coupon_user_id' => int];可领取试算:['pseudo_coupon' => 发券行+applicable_type+discount];无可用券:[]
*/
public function autoMatchCoupon(int $uid, array $cartInfo, bool $claim = false): array
{
try {
if (!$uid || empty($cartInfo)) {
return [];
}
$now = time();
$items = [];
foreach ($cartInfo as $cart) {
if (is_object($cart)) $cart = $cart->toArray();
$items[] = [
'product_id' => (int)($cart['product_id'] ?? 0),
'cate_ids' => array_map('intval', explode(',', (string)($cart['productInfo']['cate_id'] ?? ''))),
'price' => bcmul((string)($cart['truePrice'] ?? '0'), (string)($cart['cart_num'] ?? '0'), 2),
];
}
$isMember = (int)(app()->make(UserServices::class)->value(['uid' => $uid], 'is_money_level') ?? 0) > 0;
/** @var StoreCouponUserDao $couponUserDao */
$couponUserDao = app()->make(StoreCouponUserDao::class);
// 候选1:已持有未使用(有效性条件与显式用券一致;start_time 用 <= 兼容当秒领取后立即下单)
$heldBest = null;
$heldList = $couponUserDao->selectList(
[['uid', '=', $uid], ['status', '=', 0], ['is_fail', '=', 0], ['start_time', '<=', $now], ['end_time', '>', $now]],
'*', 0, 0, '', ['issue']
);
foreach ($heldList as $coupon) {
if (is_object($coupon)) $coupon = $coupon->toArray();
[$subset, $count] = $this->couponMatchedSubset($coupon, $items);
if (!$count || bccomp((string)($coupon['use_min_price'] ?? '0'), $subset, 2) > 0) {
continue;
}
$discount = bccomp((string)$coupon['coupon_price'], $subset, 2) > 0 ? $subset : (string)$coupon['coupon_price'];
if (!$heldBest || bccomp($discount, $heldBest['discount'], 2) > 0
|| (bccomp($discount, $heldBest['discount'], 2) === 0 && (int)$coupon['end_time'] < (int)$heldBest['end_time'])) {
$heldBest = ['coupon_user_id' => (int)$coupon['id'], 'discount' => $discount, 'end_time' => (int)$coupon['end_time']];
}
}
// 候选2:可领取(receive_type 1/4、领取窗口内、有库存;使用窗口与限领次数逐张过滤)
$issueBest = null;
$issueList = app()->make(StoreCouponIssueServices::class)->getPcIssueCouponList($uid, [], 0);
foreach ($issueList as $issue) {
if (($issue['receive_type'] ?? 0) == 4 && !$isMember) {
continue;
}
if (!empty($issue['start_use_time']) && ((int)$issue['start_use_time'] > $now || (int)$issue['end_use_time'] < $now)) {
continue;
}
if (!$couponUserDao->getUserCouponCanUse($uid, (int)$issue['id'], (int)($issue['receive_limit'] ?? 0))) {
continue;
}
$candidate = array_merge($issue, ['applicable_type' => $issue['type'] ?? 0]);
[$subset, $count] = $this->couponMatchedSubset($candidate, $items);
if (!$count || bccomp((string)($issue['use_min_price'] ?? '0'), $subset, 2) > 0) {
continue;
}
$discount = bccomp((string)$issue['coupon_price'], $subset, 2) > 0 ? $subset : (string)$issue['coupon_price'];
if (!$issueBest || bccomp($discount, $issueBest['discount'], 2) > 0
|| (bccomp($discount, $issueBest['discount'], 2) === 0 && (int)($issue['end_use_time'] ?? 0) < (int)$issueBest['end_time'])) {
$issueBest = ['cid' => (int)$issue['id'], 'discount' => $discount, 'end_time' => (int)($issue['end_use_time'] ?? 0), 'row' => $candidate];
}
}
// 择优:折扣相同优先用已持有(无领取副作用)
if ($heldBest && (!$issueBest || bccomp($heldBest['discount'], $issueBest['discount'], 2) >= 0)) {
return ['coupon_user_id' => $heldBest['coupon_user_id']];
}
if ($issueBest) {
if (!$claim) {
return ['pseudo_coupon' => array_merge($issueBest['row'], ['discount' => $issueBest['discount']])];
}
// 真实下单:复用手动领取服务(库存/限领/会员身份校验内聚其中),领取后返回用户券id,由调用方走既有核销链路
$user = app()->make(UserServices::class)->getUserInfo($uid);
app()->make(StoreCouponIssueServices::class)->issueUserCoupon($issueBest['cid'], $user, true);
$ids = $couponUserDao->getColumn(['uid' => $uid, 'cid' => $issueBest['cid'], 'status' => 0], 'id');
if (empty($ids)) {
return [];
}
return ['coupon_user_id' => (int)max($ids)];
}
return [];
} catch (\Throwable $e) {
// 自动匹配/领取失败降级为无券,不阻断下单
Log::error('[OrderComputed] 自动匹配优惠券失败: ' . $e->getMessage());
return [];
}
}
/**
* 计算优惠券对购物车条目的适用子集金额与命中条数
* 口径与显式用券一致:applicable_type 0/3 全部、1 品类(含子分类展开)、2 商品
* @param array $coupon 含 applicable_type/category_id/product_id 的券信息(用户券或发券行)
* @param array $items 商品条目(product_id/cate_ids/price)
* @return array [subsetPrice, matchedCount]
*/
protected function couponMatchedSubset(array $coupon, array $items): array
{
$subset = '0';
$count = 0;
$type = (int)($coupon['applicable_type'] ?? 0);
if (in_array($type, [0, 3], true)) {
foreach ($items as $item) {
$subset = bcadd($subset, $item['price'], 2);
$count++;
}
return [$subset, $count];
}
if ($type === 1) {
$couponCategory = array_values(array_filter(explode(',', (string)($coupon['category_id'] ?? ''))));
$cateIds = [];
if ($couponCategory) {
$categoryIds = app()->make(\app\dao\product\product\StoreCategoryDao::class)->getAllById($couponCategory);
$cateIds = $categoryIds ? array_map('intval', array_column($categoryIds, 'id')) : [];
}
if (!$cateIds) {
return [$subset, 0];
}
foreach ($items as $item) {
if ($item['cate_ids'] && array_intersect($item['cate_ids'], $cateIds)) {
$subset = bcadd($subset, $item['price'], 2);
$count++;
}
}
return [$subset, $count];
}
if ($type === 2) {
$productIds = array_map('intval', array_filter(explode(',', (string)($coupon['product_id'] ?? ''))));
if (!$productIds) {
return [$subset, 0];
}
foreach ($items as $item) {
if (in_array($item['product_id'], $productIds, true)) {
$subset = bcadd($subset, $item['price'], 2);
$count++;
}
}
}
return [$subset, $count];
}
/**
* 使用积分
* @param $useIntegral