llp 1 هفته پیش
والد
کامیت
fdc53daca2

+ 59 - 14
packages/Longyi/FreeGift/src/Http/Controllers/Admin/ProductPickerController.php

@@ -4,6 +4,7 @@ namespace Longyi\FreeGift\Http\Controllers\Admin;
 
 
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+use Longyi\FreeGift\Services\GiftProductResolver;
 use Longyi\FreeGift\Services\GiftProductValidator;
 use Longyi\FreeGift\Services\GiftProductValidator;
 use Webkul\Admin\Http\Controllers\Controller;
 use Webkul\Admin\Http\Controllers\Controller;
 use Webkul\Product\Models\ProductFlat;
 use Webkul\Product\Models\ProductFlat;
@@ -13,6 +14,10 @@ use Webkul\Product\Models\ProductFlat;
  *
  *
  * 只读接口,空关键词返回全部商品;数据取当前渠道 + 当前语言的 product_flat,
  * 只读接口,空关键词返回全部商品;数据取当前渠道 + 当前语言的 product_flat,
  * 一个商品一行(product_flat 是「商品 + 渠道 + 语言」的展开表)。
  * 一个商品一行(product_flat 是「商品 + 渠道 + 语言」的展开表)。
+ *
+ * 每行都会带 `giftable` + `giftable_reason`:赠品能不能真的送出去
+ * 取决于「启用 + 类型放行 + flexible_variant 有可售变体」,
+ * 提前提示可以避免运营配出一个永远送不出去的规则(见 GiftProductValidator::explain)。
  */
  */
 class ProductPickerController extends Controller
 class ProductPickerController extends Controller
 {
 {
@@ -21,6 +26,10 @@ class ProductPickerController extends Controller
      */
      */
     protected const PER_PAGE = 20;
     protected const PER_PAGE = 20;
 
 
+    public function __construct(
+        protected GiftProductResolver $productResolver
+    ) {}
+
     /**
     /**
      * 商品列表。
      * 商品列表。
      */
      */
@@ -47,20 +56,33 @@ class ProductPickerController extends Controller
             ->orderBy('name')
             ->orderBy('name')
             ->paginate(self::PER_PAGE);
             ->paginate(self::PER_PAGE);
 
 
-        $products->through(fn ($product) => [
-            'id'       => (int) $product->product_id,
-            'sku'      => (string) $product->sku,
-            'name'     => (string) $product->name,
-            'type'     => (string) $product->type,
-            'price'    => core()->formatBasePrice((float) $product->price),
-            /*
-             * 只有「启用 + 简单/虚拟」的商品才真的能被当赠品送出去
-             * (判定口径见 GiftProductValidator::ALLOWED_TYPES),
-             * 这里只是给后台一个提示,不拦着不让选。
-             */
-            'giftable' => (bool) $product->status
-                && in_array($product->type, GiftProductValidator::ALLOWED_TYPES, true),
-        ]);
+        /*
+         * flexible_variant 还要求「至少一个启用且有货的变体」,否则赠品行写不进
+         * 购物车。这里按当前页批量查一次,避免逐行 N+1。
+         */
+        $flexibleIds = $products->getCollection()
+            ->where('type', 'flexible_variant')
+            ->pluck('product_id')
+            ->map(fn ($id) => (int) $id)
+            ->all();
+
+        $withSaleableVariant = $this->productResolver->productIdsWithSaleableVariant($flexibleIds);
+
+        $products->through(function ($product) use ($withSaleableVariant) {
+            $reason = $this->giftableReason($product, $withSaleableVariant);
+
+            return [
+                'id'              => (int) $product->product_id,
+                'sku'             => (string) $product->sku,
+                'name'            => (string) $product->name,
+                'type'            => (string) $product->type,
+                'price'           => core()->formatBasePrice((float) $product->price),
+                'giftable'        => $reason === null,
+                'giftable_reason' => $reason === null
+                    ? null
+                    : trans('free_gift::admin.picker-'.$reason),
+            ];
+        });
 
 
         return response()->json([
         return response()->json([
             'data' => $products->items(),
             'data' => $products->items(),
@@ -72,6 +94,29 @@ class ProductPickerController extends Controller
         ]);
         ]);
     }
     }
 
 
+    /**
+     * 不能当赠品的原因(null = 可以),返回的是翻译 key 的后缀。
+     *
+     * @param  array<int, true>  $withSaleableVariant
+     */
+    protected function giftableReason($product, array $withSaleableVariant): ?string
+    {
+        if (! $product->status) {
+            return 'reason-inactive';
+        }
+
+        if (! in_array($product->type, GiftProductValidator::ALLOWED_TYPES, true)) {
+            return 'reason-type';
+        }
+
+        if ($product->type === 'flexible_variant'
+            && ! isset($withSaleableVariant[(int) $product->product_id])) {
+            return 'reason-no-variant';
+        }
+
+        return null;
+    }
+
     /**
     /**
      * 转义 LIKE 里的通配符(SKU 里下划线/百分号很常见,不转义会搜错)。
      * 转义 LIKE 里的通配符(SKU 里下划线/百分号很常见,不转义会搜错)。
      */
      */

+ 3 - 0
packages/Longyi/FreeGift/src/Resources/lang/en/admin.php

@@ -32,6 +32,9 @@ return [
     'picker-no-name'           => '(Unnamed product)',
     'picker-no-name'           => '(Unnamed product)',
     'picker-load-more'         => 'Load more',
     'picker-load-more'         => 'Load more',
     'picker-not-giftable'      => 'Cannot be given as a gift',
     'picker-not-giftable'      => 'Cannot be given as a gift',
+    'picker-reason-inactive'   => 'Cannot be a gift: product is disabled',
+    'picker-reason-type'       => 'Cannot be a gift: unsupported product type (only simple / virtual / flexible variant)',
+    'picker-reason-no-variant' => 'Cannot be a gift: no enabled variant in stock, so it never reaches the cart',
     'picker-confirm'           => 'Confirm',
     'picker-confirm'           => 'Confirm',
     'picker-cancel'            => 'Cancel',
     'picker-cancel'            => 'Cancel',
     'picker-selected'          => 'Selected :count: :skus',
     'picker-selected'          => 'Selected :count: :skus',

+ 3 - 0
packages/Longyi/FreeGift/src/Resources/lang/zh_CN/admin.php

@@ -32,6 +32,9 @@ return [
     'picker-no-name'           => '(未命名商品)',
     'picker-no-name'           => '(未命名商品)',
     'picker-load-more'         => '加载更多',
     'picker-load-more'         => '加载更多',
     'picker-not-giftable'      => '不可作为赠品',
     'picker-not-giftable'      => '不可作为赠品',
+    'picker-reason-inactive'   => '不可作为赠品:商品已停用',
+    'picker-reason-type'       => '不可作为赠品:该商品类型不支持(仅支持普通 / 虚拟 / 多变体商品)',
+    'picker-reason-no-variant' => '不可作为赠品:该多变体商品没有「启用且有库存」的变体,赠品加不进购物车',
     'picker-confirm'           => '确定',
     'picker-confirm'           => '确定',
     'picker-cancel'            => '取消',
     'picker-cancel'            => '取消',
     'picker-selected'          => '已选 :count 个::skus',
     'picker-selected'          => '已选 :count 个::skus',

+ 2 - 1
packages/Longyi/FreeGift/src/Resources/views/admin/cart-rules/gift-settings.blade.php

@@ -1016,7 +1016,8 @@
                         + '<span class="free-gift-picker__type">' + escapeHtml(product.type) + '</span>'
                         + '<span class="free-gift-picker__type">' + escapeHtml(product.type) + '</span>'
                         + (product.giftable
                         + (product.giftable
                             ? ''
                             ? ''
-                            : '<span class="free-gift-picker__warn">' + escapeHtml(TEXT.notGiftable) + '</span>')
+                            : '<span class="free-gift-picker__warn">'
+                                + escapeHtml(product.giftable_reason || TEXT.notGiftable) + '</span>')
                         + '</span>'
                         + '</span>'
                         + '</span>'
                         + '</span>'
                         + '<span class="free-gift-picker__price">' + escapeHtml(product.price) + '</span>'
                         + '<span class="free-gift-picker__price">' + escapeHtml(product.price) + '</span>'

+ 28 - 2
packages/Longyi/FreeGift/src/Services/GiftProcessor.php

@@ -2,6 +2,7 @@
 
 
 namespace Longyi\FreeGift\Services;
 namespace Longyi\FreeGift\Services;
 
 
+use Illuminate\Support\Facades\Log;
 use Longyi\FreeGift\Config\Gift;
 use Longyi\FreeGift\Config\Gift;
 use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
 use Longyi\FreeGift\Repositories\FreeGiftItemRepository;
 use Longyi\FreeGift\Repositories\FreeGiftRuleRepository;
 use Longyi\FreeGift\Repositories\FreeGiftRuleRepository;
@@ -305,7 +306,11 @@ class GiftProcessor
             $product = $resolved['product'] ?? null;
             $product = $resolved['product'] ?? null;
             $variant = $resolved['variant'] ?? null;
             $variant = $resolved['variant'] ?? null;
 
 
-            if (! $this->productValidator->isEligible($product, $qty, $variant)) {
+            $reason = $this->productValidator->explain($product, $qty, $variant);
+
+            if ($reason !== null) {
+                $this->logGiftSkipped($gift->getSku(), $gift->getRuleId(), $qty, $reason, $product, $variant);
+
                 continue;
                 continue;
             }
             }
 
 
@@ -366,7 +371,11 @@ class GiftProcessor
         $product = $resolved['product'] ?? null;
         $product = $resolved['product'] ?? null;
         $variant = $resolved['variant'] ?? null;
         $variant = $resolved['variant'] ?? null;
 
 
-        if (! $this->productValidator->isEligible($product, $qty, $variant)) {
+        $reason = $this->productValidator->explain($product, $qty, $variant);
+
+        if ($reason !== null) {
+            $this->logGiftSkipped($sku, $ruleId, $qty, $reason, $product, $variant);
+
             return ['success' => false, 'message' => trans('free_gift::app.cart.gift-unavailable'), 'cart_item_id' => null];
             return ['success' => false, 'message' => trans('free_gift::app.cart.gift-unavailable'), 'cart_item_id' => null];
         }
         }
 
 
@@ -623,6 +632,23 @@ class GiftProcessor
         }
         }
     }
     }
 
 
+    /**
+     * 赠品被跳过的日志。
+     *
+     * 「规则命中了、候选也在,但赠品就是不出现在购物车」这类问题全靠它定位:
+     * reason 由 GiftProductValidator::explain() 给出,与后台选择器提示同一套口径。
+     */
+    protected function logGiftSkipped(string $sku, int $ruleId, float $qty, string $reason, $product = null, $variant = null): void
+    {
+        Log::info('[FreeGift] 赠品不可用,跳过加购:'.$reason, [
+            'sku'        => $sku,
+            'rule_id'    => $ruleId,
+            'qty'        => $qty,
+            'product_id' => $product?->id,
+            'variant_id' => $variant?->id,
+        ]);
+    }
+
     /**
     /**
      * 给分组里的每个候选解析「商品 + 变体」。
      * 给分组里的每个候选解析「商品 + 变体」。
      *
      *

+ 57 - 3
packages/Longyi/FreeGift/src/Services/GiftProductResolver.php

@@ -2,6 +2,7 @@
 
 
 namespace Longyi\FreeGift\Services;
 namespace Longyi\FreeGift\Services;
 
 
+use Illuminate\Support\Facades\Log;
 use Longyi\Core\Models\ProductVariant;
 use Longyi\Core\Models\ProductVariant;
 use Longyi\Core\Repositories\ProductVariantRepository;
 use Longyi\Core\Repositories\ProductVariantRepository;
 use Webkul\Product\Repositories\ProductRepository;
 use Webkul\Product\Repositories\ProductRepository;
@@ -16,6 +17,9 @@ use Webkul\Product\Repositories\ProductRepository;
  *
  *
  * 单请求内按 SKU 缓存:一次 collectTotals 里 syncGiftItems / autoAddGiftItems
  * 单请求内按 SKU 缓存:一次 collectTotals 里 syncGiftItems / autoAddGiftItems
  * 会反复问同一个 SKU。
  * 会反复问同一个 SKU。
+ *
+ * 解析失败会让赠品静默不出现,所以每次失败都写一条 warning 日志
+ * (SKU + 原因 + 商品/变体 id),排查时不用再翻库。
  */
  */
 class GiftProductResolver
 class GiftProductResolver
 {
 {
@@ -48,7 +52,8 @@ class GiftProductResolver
     }
     }
 
 
     /**
     /**
-     * 解析失败(赠品被下架/删除、SKU 写错)返回 null,调用方按「本次送不出去」处理。
+     * 解析失败(SKU 写错、商品下架、flexible_variant 没有可用变体)返回 null,
+     * 调用方按「本次送不出去」处理。
      */
      */
     protected function doResolve(string $sku): ?array
     protected function doResolve(string $sku): ?array
     {
     {
@@ -59,7 +64,13 @@ class GiftProductResolver
             if ($product->type === 'flexible_variant') {
             if ($product->type === 'flexible_variant') {
                 $variant = $this->defaultVariant((int) $product->id);
                 $variant = $this->defaultVariant((int) $product->id);
 
 
-                return $variant ? ['product' => $product, 'variant' => $variant] : null;
+                if (! $variant) {
+                    $this->warn($sku, 'flexible_variant 商品没有启用且有货的变体', (int) $product->id);
+
+                    return null;
+                }
+
+                return ['product' => $product, 'variant' => $variant];
             }
             }
 
 
             return ['product' => $product, 'variant' => null];
             return ['product' => $product, 'variant' => null];
@@ -68,7 +79,15 @@ class GiftProductResolver
         // 2) 再当变体 SKU 找(flexible_variant 的行)
         // 2) 再当变体 SKU 找(flexible_variant 的行)
         $variant = $this->variantRepository->findBySku($sku);
         $variant = $this->variantRepository->findBySku($sku);
 
 
-        if (! $variant || ! $variant->product) {
+        if (! $variant) {
+            $this->warn($sku, 'products / product_variants 里都查不到该 SKU');
+
+            return null;
+        }
+
+        if (! $variant->product) {
+            $this->warn($sku, '变体存在但父商品已不存在', null, (int) $variant->id);
+
             return null;
             return null;
         }
         }
 
 
@@ -85,6 +104,29 @@ class GiftProductResolver
             ?: $this->variantRepository->getByProduct($productId, true)->first();
             ?: $this->variantRepository->getByProduct($productId, true)->first();
     }
     }
 
 
+    /**
+     * 批量取「有可售变体」的商品 id(后台赠品选择器用,避免逐行查询)。
+     *
+     * @param  int[]  $productIds
+     * @return array<int, true>  命中集合,用 isset($result[$productId]) 判断
+     */
+    public function productIdsWithSaleableVariant(array $productIds): array
+    {
+        if (! $productIds) {
+            return [];
+        }
+
+        return ProductVariant::query()
+            ->whereIn('product_id', $productIds)
+            ->where('status', true)
+            ->where('quantity', '>', 0)
+            ->whereNull('deleted_at')
+            ->distinct()
+            ->pluck('product_id')
+            ->mapWithKeys(fn ($id) => [(int) $id => true])
+            ->all();
+    }
+
     /**
     /**
      * 拿变体上的最终成交价(含特价/目录促销/客户组价)。
      * 拿变体上的最终成交价(含特价/目录促销/客户组价)。
      */
      */
@@ -100,4 +142,16 @@ class GiftProductResolver
             return (float) ($variant->special_price ?: $variant->price ?: 0);
             return (float) ($variant->special_price ?: $variant->price ?: 0);
         }
         }
     }
     }
+
+    /**
+     * 赠品解析失败:必须留痕,否则线上只表现为「规则命中了但赠品不出现」。
+     */
+    protected function warn(string $sku, string $reason, ?int $productId = null, ?int $variantId = null): void
+    {
+        Log::warning('[FreeGift] 赠品 SKU 无法解析,本次不会加入购物车:'.$reason, [
+            'sku'        => $sku,
+            'product_id' => $productId,
+            'variant_id' => $variantId,
+        ]);
+    }
 }
 }

+ 24 - 10
packages/Longyi/FreeGift/src/Services/GiftProductValidator.php

@@ -28,24 +28,38 @@ class GiftProductValidator
      * @param  \Longyi\Core\Models\ProductVariant|null  $variant  flexible_variant 必传
      * @param  \Longyi\Core\Models\ProductVariant|null  $variant  flexible_variant 必传
      */
      */
     public function isEligible($product, float $qty, $variant = null): bool
     public function isEligible($product, float $qty, $variant = null): bool
+    {
+        return $this->explain($product, $qty, $variant) === null;
+    }
+
+    /**
+     * 不能当赠品的原因;返回 null 表示可以送。
+     *
+     * 返回的是机器可读的短标识,日志、后台选择器提示都用同一套口径,
+     * 避免「哪里能送、哪里不能送」两套判断各自漂移。
+     *
+     * @return string|null product-missing | inactive | type-not-allowed
+     *                     | no-variant | variant-inactive | out-of-stock
+     */
+    public function explain($product, float $qty, $variant = null): ?string
     {
     {
         if (! $product) {
         if (! $product) {
-            return false;
+            return 'product-missing';
         }
         }
 
 
         if (! $product->status) {
         if (! $product->status) {
-            return false;
+            return 'inactive';
         }
         }
 
 
         if (! in_array($product->type, self::ALLOWED_TYPES, true)) {
         if (! in_array($product->type, self::ALLOWED_TYPES, true)) {
-            return false;
+            return 'type-not-allowed';
         }
         }
 
 
         if ($product->type === 'flexible_variant') {
         if ($product->type === 'flexible_variant') {
-            return $this->isVariantInStock($variant, $qty);
+            return $this->explainVariant($variant, $qty);
         }
         }
 
 
-        return $this->isInStock($product, $qty);
+        return $this->isInStock($product, $qty) ? null : 'out-of-stock';
     }
     }
 
 
     /**
     /**
@@ -61,18 +75,18 @@ class GiftProductValidator
     }
     }
 
 
     /**
     /**
-     * 变体库存是否足够(flexible_variant 的库存挂在 product_variants.quantity 上)。
+     * flexible_variant 的资格判定(库存挂在 product_variants.quantity 上)。
      */
      */
-    protected function isVariantInStock($variant, float $qty): bool
+    protected function explainVariant($variant, float $qty): ?string
     {
     {
         if (! $variant) {
         if (! $variant) {
-            return false;
+            return 'no-variant';
         }
         }
 
 
         if (! $variant->status) {
         if (! $variant->status) {
-            return false;
+            return 'variant-inactive';
         }
         }
 
 
-        return (int) $variant->quantity >= (int) ceil($qty);
+        return (int) $variant->quantity >= (int) ceil($qty) ? null : 'out-of-stock';
     }
     }
 }
 }