Kaynağa Gözat

修改赠品的商品验证

llp 1 hafta önce
ebeveyn
işleme
a40d8db98f

+ 32 - 5
packages/Longyi/FreeGift/src/Services/GiftCartSummary.php

@@ -27,7 +27,8 @@ class GiftCartSummary
         protected FreeGiftItemRepository $giftItemRepository,
         protected FreeGiftRuleRepository $giftRuleRepository,
         protected ProductRepository $productRepository,
-        protected ProductImage $productImage
+        protected ProductImage $productImage,
+        protected GiftProductResolver $productResolver
     ) {}
 
     /**
@@ -75,12 +76,15 @@ class GiftCartSummary
                 continue;
             }
 
-            $product = $row->product_id ? $this->productRepository->find($row->product_id) : null;
+            $resolved = $this->resolveRowProduct($row);
 
-            if (! $product) {
+            if (! $resolved) {
                 continue;
             }
 
+            $product = $resolved['product'];
+            $variant = $resolved['variant'];
+
             $ruleId = (int) $row->rule_id;
             $ruleName = $giftRule->cart_rule?->name;
 
@@ -114,10 +118,12 @@ class GiftCartSummary
             $gift->rule_id = $ruleId;
             $gift->rule_name = $ruleName;
             $gift->product_id = (int) $product->id;
-            $gift->name = $product->name;
+            $gift->name = $variant->name ?? $product->name;
             $gift->url = route('shop.product_or_category.index', $product->url_key);
             $gift->image = $image['medium_image_url'] ?? null;
-            $gift->price = core()->formatPrice((float) $product->price);
+            $gift->price = core()->formatPrice(
+                $variant ? $this->productResolver->variantPrice($variant) : (float) $product->price
+            );
             $gift->allowed_qty = (float) $row->allowed_qty;
             $gift->reserved_qty = (float) $row->reserved_qty;
             $gift->remaining_qty = max(0, (float) $row->allowed_qty - (float) $row->reserved_qty);
@@ -142,6 +148,27 @@ class GiftCartSummary
         return [array_values($groups), $gifts];
     }
 
+    /**
+     * 把 free_gift_items 的一行解析成「商品 + 变体」。
+     *
+     * 优先按 SKU 解析(flexible_variant 能拿到具体变体与变体价),
+     * 解析不到再退回 product_id(历史数据 / 规则里 SKU 写错的情况)。
+     *
+     * @return array{product: object, variant: ?\Longyi\Core\Models\ProductVariant}|null
+     */
+    protected function resolveRowProduct($row): ?array
+    {
+        $resolved = $this->productResolver->resolve((string) $row->sku);
+
+        if ($resolved) {
+            return $resolved;
+        }
+
+        $product = $row->product_id ? $this->productRepository->find((int) $row->product_id) : null;
+
+        return $product ? ['product' => $product, 'variant' => null] : null;
+    }
+
     /**
      * 需要展示 Banner 的规则(受总开关 + 规则自身 show_banner 控制)。
      *

+ 47 - 2
packages/Longyi/FreeGift/src/Services/GiftItem.php

@@ -15,6 +15,11 @@ use Longyi\FreeGift\Config\Gift;
  *  - storedQty   上一轮持久化到 free_gift_items 的 allowed_qty,用来判断"是变多还是变少"
  *  - reservedQty 已经真实躺在 cart_items 里的数量
  *  - qtyToProcess = allowedQty - reservedQty(还差几个要补)
+ *
+ * 变体(flexible_variant)说明:
+ *  - sku 永远是规则里配置的 SKU(free_gift_items 的唯一键之一,不能变)
+ *  - variantId 是解析出来的具体变体;配置父商品 SKU 时,cart_items 行的 sku 是
+ *    variant sku,所以「认领购物车行」必须用 ruleId + variantId 而不是 ruleId + sku
  */
 class GiftItem
 {
@@ -28,6 +33,11 @@ class GiftItem
      */
     protected ?int $productId = null;
 
+    /**
+     * flexible_variant 赠品解析出的 product_variants.id。
+     */
+    protected ?int $variantId = null;
+
     /**
      * 本轮注册的增量。
      */
@@ -159,6 +169,18 @@ class GiftItem
         return $this;
     }
 
+    public function getVariantId(): ?int
+    {
+        return $this->variantId;
+    }
+
+    public function setVariantId(?int $variantId): self
+    {
+        $this->variantId = $variantId;
+
+        return $this;
+    }
+
     public function getQty(): float
     {
         return $this->qty;
@@ -263,7 +285,9 @@ class GiftItem
     public function isDeleted(): bool
     {
         return $this->deleted;
-    }    public function setDeleted(bool $deleted): self
+    }
+
+    public function setDeleted(bool $deleted): self
     {
         $this->deleted = $deleted;
 
@@ -325,10 +349,31 @@ class GiftItem
     }
 
     /**
-     * 唯一键。
+     * free_gift_items 的唯一键(cart_id + rule_id + sku)。
      */
     public function getKey(): string
     {
         return $this->ruleId.':'.$this->sku;
     }
+
+    /**
+     * 认领 cart_items 行用的键:有变体时用 variant_id,否则退回 sku。
+     *
+     * flexible_variant 的购物车行 sku 存的是 variant sku,而配置里可能是父商品
+     * sku,两者对不上,只能靠 variant_id 认领。
+     */
+    public function getCartKey(): string
+    {
+        return static::cartKey($this->ruleId, $this->variantId, $this->sku);
+    }
+
+    /**
+     * @param  int  $ruleId
+     * @param  int|null  $variantId
+     * @param  string  $sku  cart_items.sku(变体商品是 variant sku)
+     */
+    public static function cartKey(int $ruleId, ?int $variantId, string $sku): string
+    {
+        return $ruleId.':'.($variantId ? 'v'.$variantId : $sku);
+    }
 }

+ 107 - 37
packages/Longyi/FreeGift/src/Services/GiftProcessor.php

@@ -41,6 +41,7 @@ class GiftProcessor
         protected FreeGiftItemRepository $giftItemRepository,
         protected CartItemRepository $cartItemRepository,
         protected ProductRepository $productRepository,
+        protected GiftProductResolver $productResolver,
         protected Validator $validator
     ) {}
 
@@ -78,22 +79,29 @@ class GiftProcessor
                 return;
             }
 
-            // 3. 以购物车真实数据校准「已占用数量」
+            // 3. 解析赠品商品:flexible_variant 必须落到具体变体,
+            //    购物车行的 sku 是 variant sku,认领行要靠 variant_id
+            $this->resolveGiftProducts($group);
+
+            // 4. 以购物车真实数据校准「已占用数量」
             $this->registry->syncReservedQty($cart, $group);
 
-            // 4. 阶段 A
+            // 5. 阶段 A
             $this->registerGiftItems($cart, $group);
 
-            // 5. 阶段 B1
+            // 阶段 A 会新增候选,补一次解析
+            $this->resolveGiftProducts($group);
+
+            // 6. 阶段 B1
             $this->syncGiftItems($cart, $group);
 
-            // 6. 阶段 B2
+            // 7. 阶段 B2
             $this->autoAddGiftItems($cart, $group);
 
-            // 7. 清理孤儿赠品行(历史残留的重复行、规则下线后的残留)
+            // 8. 清理孤儿赠品行(历史残留的重复行、规则下线后的残留)
             $this->removeOrphanGiftRows($cart, $group);
 
-            // 8. 落库
+            // 9. 落库
             $this->registry->persist($cartId);
         } finally {
             $this->running = false;
@@ -292,15 +300,18 @@ class GiftProcessor
                 continue;
             }
 
-            $product = $this->productRepository->findOneWhere(['sku' => $gift->getSku()]);
+            $resolved = $this->productResolver->resolve($gift->getSku());
+
+            $product = $resolved['product'] ?? null;
+            $variant = $resolved['variant'] ?? null;
 
-            if (! $this->productValidator->isEligible($product, $qty)) {
+            if (! $this->productValidator->isEligible($product, $qty, $variant)) {
                 continue;
             }
 
-            $gift->setProductId((int) $product->id);
+            $gift->setProductId((int) $product->id)->setVariantId($variant?->id);
 
-            $cartItem = $this->writeGiftCartItem($cart, $product, $gift, $qty, $gift->getCartItemId());
+            $cartItem = $this->writeGiftCartItem($cart, $product, $gift, $qty, $gift->getCartItemId(), $variant);
 
             // reserved 必须以行上的真实总量为准:writeGiftCartItem 传进去的是差额,
             // 加完之后行数量才是「已经占用」的量
@@ -350,9 +361,12 @@ class GiftProcessor
 
         $qty = min($qty, $remaining);
 
-        $product = $this->productRepository->findOneWhere(['sku' => $sku]);
+        $resolved = $this->productResolver->resolve($sku);
 
-        if (! $this->productValidator->isEligible($product, $qty)) {
+        $product = $resolved['product'] ?? null;
+        $variant = $resolved['variant'] ?? null;
+
+        if (! $this->productValidator->isEligible($product, $qty, $variant)) {
             return ['success' => false, 'message' => trans('free_gift::app.cart.gift-unavailable'), 'cart_item_id' => null];
         }
 
@@ -367,11 +381,12 @@ class GiftProcessor
         $giftRule = $this->giftRuleRepository->findByCartRuleId($ruleId);
 
         $gift->setProductId((int) $product->id)
+            ->setVariantId($variant?->id)
             ->setMinimalPrice($giftRule?->minimal_items_price);
 
         $existingId = $this->findGiftCartItem($cart, $gift)?->id ?: $row->cart_item_id;
 
-        $cartItem = $this->writeGiftCartItem($cart, $product, $gift, $qty, $existingId);
+        $cartItem = $this->writeGiftCartItem($cart, $product, $gift, $qty, $existingId, $variant);
 
         $this->giftItemRepository->update([
             'cart_item_id'    => $cartItem->id,
@@ -395,7 +410,7 @@ class GiftProcessor
      * @param  \Webkul\Checkout\Contracts\Cart  $cart
      * @param  \Webkul\Product\Contracts\Product  $product
      */
-    protected function writeGiftCartItem($cart, $product, GiftItem $gift, float $qty, $cartItemId = null)
+    protected function writeGiftCartItem($cart, $product, GiftItem $gift, float $qty, $cartItemId = null, $variant = null)
     {
         /*
          * $qty 的语义是「本次新增的数量」,不是「这一行应有的总数量」。
@@ -415,12 +430,12 @@ class GiftProcessor
             }
         }
 
-        $originalPrice = $this->getOriginalPrice($product, $qty);
+        $originalPrice = $this->getOriginalPrice($product, $qty, $variant);
 
         // 赠品实付单价:原价 - 折扣,并以 minimal_items_price 兜底
         $price = $this->calculator->priceFor($gift->getDiscount(), $originalPrice, $gift->getMinimalPrice());
 
-        $weight = (float) ($product->weight ?? 0);
+        $weight = (float) ($variant->weight ?? $product->weight ?? 0);
 
         $payload = [
             'quantity'            => $qty,
@@ -441,12 +456,7 @@ class GiftProcessor
             'weight'              => $weight,
             'total_weight'        => $weight * $qty,
             'base_total_weight'   => $weight * $qty,
-            'additional'          => [
-                Gift::CART_ITEM_FLAG       => $gift->getRuleId(),
-                'free_gift'                => 1,
-                'free_gift_original_price' => $originalPrice,
-                'quantity'                 => $qty,
-            ],
+            'additional'          => $this->giftAdditional($product, $variant, $gift, $originalPrice, $qty),
         ];
 
         if ($cartItemId) {
@@ -456,12 +466,40 @@ class GiftProcessor
         return $this->cartItemRepository->create(array_merge($payload, [
             'cart_id'    => $cart->id,
             'product_id' => $product->id,
-            'sku'        => $product->sku,
+            'sku'        => $variant->sku ?? $product->sku,
             'type'       => $product->type,
-            'name'       => $product->name,
+            'name'       => $variant->name ?? $product->name,
         ]));
     }
 
+    /**
+     * 赠品行的 additional 载荷。
+     *
+     * flexible_variant 必须带 product_id / variant_id / variant_sku / variant_name / attributes:
+     * FlexibleVariant::validateCartItem() 靠 additional.variant_id 重新解析变体,
+     * 库存扣减(VariantInventoryListener)也按它找变体 —— 缺了这两个键,赠品行会被判失效。
+     * 变体信息直接复用类型自己的 getAdditionalOptions(),与前台加购保持同一份结构。
+     */
+    protected function giftAdditional($product, $variant, GiftItem $gift, float $originalPrice, float $qty): array
+    {
+        $additional = [
+            Gift::CART_ITEM_FLAG       => $gift->getRuleId(),
+            'free_gift'                => 1,
+            'free_gift_original_price' => $originalPrice,
+            'quantity'                 => $qty,
+        ];
+
+        if (! $variant) {
+            return $additional;
+        }
+
+        $variantOptions = $product->getTypeInstance()->getAdditionalOptions(['variant_id' => (int) $variant->id]);
+
+        unset($variantOptions['variant_id']);
+
+        return array_merge($additional, $variantOptions, ['variant_id' => (int) $variant->id]);
+    }
+
     /**
      * 在购物车里找到某条赠品候选对应的购物车行。
      *
@@ -477,11 +515,13 @@ class GiftProcessor
                 continue;
             }
 
-            if ($item->sku !== $gift->getSku()) {
-                continue;
-            }
+            // flexible_variant 的行 sku 是 variant sku,而规则里配的可能是父商品 sku,
+            // 两者对不上,统一按「ruleId + variant_id」认领(无变体时退回 sku)
+            $variantId = (int) ($item->additional['variant_id'] ?? 0) ?: null;
 
-            return $item;
+            if (GiftItem::cartKey($gift->getRuleId(), $variantId, (string) $item->sku) === $gift->getCartKey()) {
+                return $item;
+            }
         }
 
         return null;
@@ -493,8 +533,17 @@ class GiftProcessor
      * 与 Webkul 的 validateCartItem 用同一口径:取含特价、目录促销的最终价,
      * 而不是 catalog 里的标价,保证「赠品价 = 现价 × 折扣」。
      */
-    protected function getOriginalPrice($product, float $qty): float
+    protected function getOriginalPrice($product, float $qty, $variant = null): float
     {
+        // flexible_variant 的成交价挂在变体上,先取变体价
+        if ($variant) {
+            $variantPrice = $this->productResolver->variantPrice($variant);
+
+            if ($variantPrice > 0) {
+                return $variantPrice;
+            }
+        }
+
         try {
             $finalPrice = (float) $product->getTypeInstance()->getFinalPrice($qty);
         } catch (\Throwable) {
@@ -514,13 +563,13 @@ class GiftProcessor
      */
     protected function applyGiftPrice($cartItem, GiftItem $gift): void
     {
-        $product = $this->productRepository->findOneWhere(['sku' => $gift->getSku()]);
+        $resolved = $this->productResolver->resolve($gift->getSku());
 
-        if (! $product) {
+        if (! $resolved) {
             return;
         }
 
-        $originalPrice = $this->getOriginalPrice($product, (float) $cartItem->quantity);
+        $originalPrice = $this->getOriginalPrice($resolved['product'], (float) $cartItem->quantity, $resolved['variant']);
 
         $price = $this->calculator->priceFor($gift->getDiscount(), $originalPrice, $gift->getMinimalPrice());
 
@@ -555,18 +604,18 @@ class GiftProcessor
 
         foreach ($group->getItems() as $gift) {
             if ($gift->getCartItemId()) {
-                $claimed[$gift->getKey()] = (int) $gift->getCartItemId();
+                $claimed[(int) $gift->getCartItemId()] = true;
             }
         }
 
         foreach ($cart->all_items as $cartItem) {
-            $ruleId = $cartItem->additional[Gift::CART_ITEM_FLAG] ?? null;
-
-            if (! $ruleId) {
+            if (! ($cartItem->additional[Gift::CART_ITEM_FLAG] ?? null)) {
                 continue;
             }
 
-            if (($claimed[((int) $ruleId).':'.$cartItem->sku] ?? null) === (int) $cartItem->id) {
+            // 按 cart_item_id 认领:flexible_variant 配置的是父商品 sku、
+            // 行上是 variant sku,用 sku 拼 key 会把好行误判成孤儿
+            if (isset($claimed[(int) $cartItem->id])) {
                 continue;
             }
 
@@ -574,6 +623,27 @@ class GiftProcessor
         }
     }
 
+    /**
+     * 给分组里的每个候选解析「商品 + 变体」。
+     *
+     * resolver 内部按 SKU 缓存,重复调用安全。必须在 syncGiftItems /
+     * autoAddGiftItems 之前跑:否则 findGiftCartItem 拿不到 variant_id,
+     * flexible_variant 的赠品行会被当成"不存在"而被重复加购。
+     */
+    protected function resolveGiftProducts(GiftItemsGroup $group): void
+    {
+        foreach ($group->getItems() as $gift) {
+            $resolved = $this->productResolver->resolve($gift->getSku());
+
+            if (! $resolved) {
+                continue;
+            }
+
+            $gift->setProductId((int) $resolved['product']->id)
+                ->setVariantId($resolved['variant']?->id);
+        }
+    }
+
     /**
      * 自动加购总开关。
      *

+ 30 - 4
packages/Longyi/FreeGift/src/Services/GiftProductValidator.php

@@ -8,20 +8,26 @@ use Webkul\Product\Contracts\Product as ProductContract;
  * 赠品资格校验(对应 Amasty 的 Model\Product + InStockFilterApplier)。
  *
  * 注意:赠品行是绕过 prepareForCart() 直接写 cart_items 的,
- * 所以这里只放行 simple / virtual,避免 configurable / bundle / grouped
- * 在渲染、税费、下单链路里炸掉。
+ * 所以这里只放行能在购物车/结算链路里安全渲染的类型:
+ *  - simple / virtual        直接写行即可
+ *  - flexible_variant        Longyi 的「多变体」类型,必须落到具体变体,
+ *                            行上要带 additional.variant_id(否则校验与扣库存都会失败)
  */
 class GiftProductValidator
 {
     /**
      * 允许作为赠品的商品类型。
      */
-    public const ALLOWED_TYPES = ['simple', 'virtual'];
+    public const ALLOWED_TYPES = ['simple', 'virtual', 'flexible_variant'];
 
     /**
      * 商品能否作为赠品送出去。
+     *
+     * @param  \Webkul\Product\Contracts\Product|null  $product
+     * @param  float  $qty
+     * @param  \Longyi\Core\Models\ProductVariant|null  $variant  flexible_variant 必传
      */
-    public function isEligible($product, float $qty): bool
+    public function isEligible($product, float $qty, $variant = null): bool
     {
         if (! $product) {
             return false;
@@ -35,6 +41,10 @@ class GiftProductValidator
             return false;
         }
 
+        if ($product->type === 'flexible_variant') {
+            return $this->isVariantInStock($variant, $qty);
+        }
+
         return $this->isInStock($product, $qty);
     }
 
@@ -49,4 +59,20 @@ class GiftProductValidator
 
         return $product->haveSufficientQuantity((int) ceil($qty));
     }
+
+    /**
+     * 变体库存是否足够(flexible_variant 的库存挂在 product_variants.quantity 上)。
+     */
+    protected function isVariantInStock($variant, float $qty): bool
+    {
+        if (! $variant) {
+            return false;
+        }
+
+        if (! $variant->status) {
+            return false;
+        }
+
+        return (int) $variant->quantity >= (int) ceil($qty);
+    }
 }

+ 9 - 3
packages/Longyi/FreeGift/src/Services/GiftRegistry.php

@@ -64,15 +64,21 @@ class GiftRegistry
                 continue;
             }
 
-            $key = ((int) $ruleId).':'.$cartItem->sku;
+            // flexible_variant 的行 sku 是 variant sku,与规则里配置的 SKU 可能不同,
+            // 所以和 GiftItem::getCartKey() 用同一套 key(ruleId + variant_id)
+            $variantId = (int) ($cartItem->additional['variant_id'] ?? 0) ?: null;
+
+            $key = GiftItem::cartKey((int) $ruleId, $variantId, (string) $cartItem->sku);
 
             $reserved[$key] = ($reserved[$key] ?? 0) + (float) $cartItem->quantity;
         }
 
         foreach ($group->getItems() as $item) {
-            $item->setReservedQty($reserved[$item->getKey()] ?? 0);
+            $key = $item->getCartKey();
+
+            $item->setReservedQty($reserved[$key] ?? 0);
 
-            unset($reserved[$item->getKey()]);
+            unset($reserved[$key]);
         }
 
         // 购物车里存在但注册表里没有的赠品行(规则被删掉等)交给 syncGiftItems 清理