diff --git a/backend/app/api/controller/v1/order/CenterOrderController.php b/backend/app/api/controller/v1/order/CenterOrderController.php index de137046d..2bc7cc3ae 100644 --- a/backend/app/api/controller/v1/order/CenterOrderController.php +++ b/backend/app/api/controller/v1/order/CenterOrderController.php @@ -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) { diff --git a/backend/app/services/order/OrderComputedServices.php b/backend/app/services/order/OrderComputedServices.php index d19623be1..a6e7ecde5 100644 --- a/backend/app/services/order/OrderComputedServices.php +++ b/backend/app/services/order/OrderComputedServices.php @@ -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