Bladeren bron

变体加购

chengwl 4 weken geleden
bovenliggende
commit
cb6a920730

+ 15 - 1
packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php

@@ -3,8 +3,10 @@
 namespace Longyi\Core\Providers;
 namespace Longyi\Core\Providers;
 
 
 use Illuminate\Support\Facades\Blade;
 use Illuminate\Support\Facades\Blade;
-use Illuminate\Support\ServiceProvider;
+use Illuminate\Support\Facades\Event;
 use Illuminate\Support\Facades\Route;
 use Illuminate\Support\Facades\Route;
+use Illuminate\Support\ServiceProvider;
+use Longyi\Core\Listeners\VariantInventoryListener;
 
 
 class LongyiCoreServiceProvider extends ServiceProvider
 class LongyiCoreServiceProvider extends ServiceProvider
 {
 {
@@ -34,6 +36,18 @@ class LongyiCoreServiceProvider extends ServiceProvider
         $this->publishes([
         $this->publishes([
             __DIR__ . '/../Resources/lang' => lang_path('vendor/longyi'),
             __DIR__ . '/../Resources/lang' => lang_path('vendor/longyi'),
         ], 'longyi-lang');
         ], 'longyi-lang');
+
+        $this->registerEventListeners();
+    }
+
+    /**
+     * Hook variant inventory adjustments into Bagisto's order lifecycle.
+     */
+    protected function registerEventListeners(): void
+    {
+        Event::listen('checkout.order.save.after', [VariantInventoryListener::class, 'decrementOnOrderSave']);
+
+        Event::listen('sales.order.cancel.after', [VariantInventoryListener::class, 'restoreOnOrderCancel']);
     }
     }
 
 
     /**
     /**

+ 245 - 2
packages/Longyi/Core/src/Type/FlexibleVariant.php

@@ -3,24 +3,35 @@
 namespace Longyi\Core\Type;
 namespace Longyi\Core\Type;
 
 
 use Longyi\Core\Helpers\Indexers\Price\FlexibleVariant as FlexibleVariantIndexer;
 use Longyi\Core\Helpers\Indexers\Price\FlexibleVariant as FlexibleVariantIndexer;
+use Longyi\Core\Models\ProductVariant;
+use Longyi\Core\Repositories\ProductVariantRepository;
+use Webkul\Checkout\Models\CartItem;
+use Webkul\Product\DataTypes\CartItemValidationResult;
+use Webkul\Product\Exceptions\InsufficientProductInventoryException;
 use Webkul\Product\Type\AbstractType;
 use Webkul\Product\Type\AbstractType;
 
 
 class FlexibleVariant extends AbstractType
 class FlexibleVariant extends AbstractType
 {
 {
     /**
     /**
      * Is a composite product type.
      * Is a composite product type.
+     *
+     * Kept true so OrderItemRepository::manageInventory iterates `children` (empty for
+     * flexible_variant cart rows) and skips the simple-product branch. Variant stock is
+     * adjusted by Longyi\Core\Listeners\VariantInventoryListener.
      */
      */
     protected $isComposite = true;
     protected $isComposite = true;
 
 
     /**
     /**
      * Is a stockable product type.
      * Is a stockable product type.
+     *
+     * Variants ship physical goods, so checkout requires shipping address + method.
      */
      */
-    protected $isStockable = false;
+    protected $isStockable = true;
 
 
     /**
     /**
      * Show quantity box.
      * Show quantity box.
      */
      */
-    protected $showQuantityBox = false;
+    protected $showQuantityBox = true;
 
 
     /**
     /**
      * Has child products i.e. variants.
      * Has child products i.e. variants.
@@ -200,4 +211,236 @@ class FlexibleVariant extends AbstractType
     {
     {
         return $this->getSaleableVariants()->isNotEmpty();
         return $this->getSaleableVariants()->isNotEmpty();
     }
     }
+
+    /**
+     * Resolve the selected variant from cart input data.
+     *
+     * Priority: explicit `variant_id` -> `super_attribute` map (option_id => option_value_id).
+     */
+    protected function resolveVariant(array $data): ?ProductVariant
+    {
+        $repository = app(ProductVariantRepository::class);
+
+        $variantId = $data['variant_id'] ?? null;
+
+        if ($variantId) {
+            $variant = $repository->find((int) $variantId);
+
+            return ($variant && (int) $variant->product_id === (int) $this->product->id) ? $variant : null;
+        }
+
+        $superAttribute = $data['super_attribute'] ?? null;
+
+        if (is_array($superAttribute) && ! empty($superAttribute)) {
+            $valueIds = array_values(array_map('intval', $superAttribute));
+
+            return $repository->findByOptionValues((int) $this->product->id, $valueIds);
+        }
+
+        return null;
+    }
+
+    /**
+     * Build the cart row for the selected variant.
+     *
+     * Returns a single cart_item array; variant identity is preserved in `additional.variant_id`
+     * so that compareOptions/getItemByProduct can keep different variants of the same parent
+     * product as separate cart rows while merging duplicates.
+     */
+    public function prepareForCart($data)
+    {
+        $variant = $this->resolveVariant(is_array($data) ? $data : []);
+
+        if (! $variant) {
+            return trans('shop::app.checkout.cart.missing-options');
+        }
+
+        if (! $variant->status) {
+            return trans('shop::app.checkout.cart.inactive-add');
+        }
+
+        $data['variant_id'] = (int) $variant->id;
+        $data['quantity'] = $this->handleQuantity((int) ($data['quantity'] ?? 1));
+
+        $data = $this->getQtyRequest($data);
+
+        if ($variant->quantity < (int) $data['quantity']) {
+            throw new InsufficientProductInventoryException(trans('product::app.checkout.cart.inventory-warning'));
+        }
+
+        $price = $this->getVariantPrice($variant);
+        $convertedPrice = core()->convertPrice($price);
+        $weight = (float) ($variant->weight ?? $this->product->weight ?? 0);
+
+        return [
+            [
+                'product_id'          => $this->product->id,
+                'sku'                 => $variant->sku ?: $this->product->sku,
+                'name'                => $variant->name ?: $this->product->name,
+                'type'                => $this->product->type,
+                'quantity'            => $data['quantity'],
+                'price'               => $convertedPrice,
+                'price_incl_tax'      => $convertedPrice,
+                'base_price'          => $price,
+                'base_price_incl_tax' => $price,
+                'total'               => $convertedPrice * $data['quantity'],
+                'total_incl_tax'      => $convertedPrice * $data['quantity'],
+                'base_total'          => $price * $data['quantity'],
+                'base_total_incl_tax' => $price * $data['quantity'],
+                'weight'              => $weight,
+                'total_weight'        => $weight * $data['quantity'],
+                'base_total_weight'   => $weight * $data['quantity'],
+                'additional'          => $this->getAdditionalOptions($data),
+            ],
+        ];
+    }
+
+    /**
+     * Compose `additional` payload persisted on the cart item.
+     *
+     * `product_id` is required so Cart::getItemByProduct triggers compareOptions.
+     * Selected option labels are denormalised here for display in cart/order views.
+     */
+    public function getAdditionalOptions($data)
+    {
+        $variantId = $data['variant_id'] ?? null;
+
+        if (! $variantId) {
+            return $data;
+        }
+
+        $variant = ProductVariant::with('values.option')->find((int) $variantId);
+
+        $data['product_id'] = (int) $this->product->id;
+        $data['variant_id'] = (int) $variantId;
+
+        if ($variant) {
+            $data['variant_sku'] = $variant->sku;
+            $data['variant_name'] = $variant->name;
+
+            $attributes = [];
+
+            foreach ($variant->values as $value) {
+                $option = $value->option;
+
+                if (! $option) {
+                    continue;
+                }
+
+                $attributes[$option->code] = [
+                    'option_id'      => (int) $option->id,
+                    'option_code'    => $option->code,
+                    'option_label'   => $option->label,
+                    'value_id'       => (int) $value->id,
+                    'value_code'     => $value->code,
+                    'value_label'    => $value->label,
+                    'attribute_name' => $option->label,
+                ];
+            }
+
+            $data['attributes'] = $attributes;
+        }
+
+        return $data;
+    }
+
+    /**
+     * Two cart rows are mergeable iff they reference the same parent product AND
+     * the same variant.
+     */
+    public function compareOptions($options1, $options2)
+    {
+        if ((int) $this->product->id !== (int) ($options2['product_id'] ?? 0)) {
+            return false;
+        }
+
+        $variant1 = $options1['variant_id'] ?? null;
+        $variant2 = $options2['variant_id'] ?? null;
+
+        if (! $variant1 || ! $variant2) {
+            return false;
+        }
+
+        return (int) $variant1 === (int) $variant2;
+    }
+
+    /**
+     * Per-variant inventory check (overrides the parent's product-level aggregate).
+     */
+    public function isItemHaveQuantity($cartItem)
+    {
+        $variantId = $cartItem->additional['variant_id'] ?? null;
+
+        if (! $variantId) {
+            return false;
+        }
+
+        $variant = ProductVariant::find((int) $variantId);
+
+        if (! $variant || ! $variant->status) {
+            return false;
+        }
+
+        return $variant->quantity >= (int) $cartItem->quantity;
+    }
+
+    /**
+     * Cart-level validation: re-resolve variant, ensure it is still active and the
+     * persisted price matches the current effective price.
+     */
+    public function validateCartItem(CartItem $item): CartItemValidationResult
+    {
+        $validation = new CartItemValidationResult;
+
+        if ($this->isCartItemInactive($item)) {
+            $validation->itemIsInactive();
+
+            return $validation;
+        }
+
+        $variantId = $item->additional['variant_id'] ?? null;
+        $variant = $variantId ? ProductVariant::find((int) $variantId) : null;
+
+        if (! $variant || ! $variant->status) {
+            $validation->itemIsInactive();
+
+            return $validation;
+        }
+
+        $basePrice = round($this->getVariantPrice($variant), 4);
+
+        if ($basePrice == $item->base_price_incl_tax) {
+            return $validation;
+        }
+
+        $item->base_price = $basePrice;
+        $item->base_price_incl_tax = $basePrice;
+
+        $price = core()->convertPrice($basePrice);
+        $item->price = $price;
+        $item->price_incl_tax = $price;
+
+        $item->base_total = $basePrice * $item->quantity;
+        $item->base_total_incl_tax = $basePrice * $item->quantity;
+
+        $total = core()->convertPrice($basePrice * $item->quantity);
+        $item->total = $total;
+        $item->total_incl_tax = $total;
+
+        $item->save();
+
+        return $validation;
+    }
+
+    /**
+     * Effective price for a single variant, channel/customer-group aware.
+     */
+    protected function getVariantPrice(ProductVariant $variant): float
+    {
+        $customerGroupId = app(\Webkul\Customer\Repositories\CustomerRepository::class)
+            ->getCurrentGroup()->id;
+        $channelId = core()->getCurrentChannel()->id;
+
+        return (float) $variant->getEffectivePrice($customerGroupId, $channelId);
+    }
 }
 }

+ 10 - 0
packages/Webkul/BagistoApi/src/Dto/CartInput.php

@@ -190,6 +190,16 @@ class CartInput
     #[Groups(['mutation'])]
     #[Groups(['mutation'])]
     public ?int $selectedConfigurableOption = null;
     public ?int $selectedConfigurableOption = null;
 
 
+    /**
+     * Selected flexible-variant id (Longyi product_variants.id).
+     *
+     * Used for products of type `flexible_variant`. Either this or `superAttribute`
+     * must be provided so the type can resolve which variant to add to cart.
+     */
+    #[ApiProperty(required: false, description: 'Selected flexible variant ID (product_variants.id)')]
+    #[Groups(['mutation'])]
+    public ?int $variantId = null;
+
     /**
     /**
      * Super attribute values for configurable products
      * Super attribute values for configurable products
      * Format: [attribute_id => option_value]
      * Format: [attribute_id => option_value]

+ 31 - 0
packages/Webkul/BagistoApi/src/Models/AddProductInCart.php

@@ -89,6 +89,16 @@ use Webkul\BagistoApi\State\CartTokenProcessor;
                                         'example'     => 'This is a special note',
                                         'example'     => 'This is a special note',
                                         'description' => 'Special request / note (optional; merged into booking.note)',
                                         'description' => 'Special request / note (optional; merged into booking.note)',
                                     ],
                                     ],
+                                    'variantId' => [
+                                        'type'        => 'integer',
+                                        'example'     => 1001,
+                                        'description' => 'Selected flexible variant ID (required for flexible_variant products unless superAttribute is provided)',
+                                    ],
+                                    'superAttribute' => [
+                                        'type'        => 'object',
+                                        'example'     => ['12' => 34, '13' => 56],
+                                        'description' => 'Map of optionId => optionValueId; used to resolve flexible variant when variantId is omitted',
+                                    ],
                                 ],
                                 ],
                             ],
                             ],
                             'examples' => [
                             'examples' => [
@@ -146,6 +156,27 @@ use Webkul\BagistoApi\State\CartTokenProcessor;
                                         'booking'   => '{"type":"event","qty":{"501":1,"502":2}}',
                                         'booking'   => '{"type":"event","qty":{"501":1,"502":2}}',
                                     ],
                                     ],
                                 ],
                                 ],
+                                'flexible_variant_product' => [
+                                    'summary'     => 'Add Flexible Variant Product',
+                                    'description' => 'Add a Longyi flexible_variant product by passing the selected variantId',
+                                    'value'       => [
+                                        'productId' => 7,
+                                        'variantId' => 1001,
+                                        'quantity'  => 1,
+                                    ],
+                                ],
+                                'flexible_variant_by_super_attribute' => [
+                                    'summary'     => 'Add Flexible Variant via super_attribute',
+                                    'description' => 'Resolve a flexible variant from a {optionId: valueId} map when variantId is unknown',
+                                    'value'       => [
+                                        'productId'      => 7,
+                                        'quantity'       => 1,
+                                        'superAttribute' => [
+                                            '12' => 34,
+                                            '13' => 56,
+                                        ],
+                                    ],
+                                ],
                                 'table_booking_product_with_note' => [
                                 'table_booking_product_with_note' => [
                                     'summary'     => 'Add Table Booking (with note)',
                                     'summary'     => 'Add Table Booking (with note)',
                                     'description' => 'Add a table booking product and send special request/note separately',
                                     'description' => 'Add a table booking product and send special request/note separately',

+ 1 - 0
packages/Webkul/BagistoApi/src/State/CartTokenProcessor.php

@@ -472,6 +472,7 @@ class CartTokenProcessor implements ProcessorInterface
                 ...(is_array($bundleOptions) ? ['bundle_options' => $bundleOptions] : []),
                 ...(is_array($bundleOptions) ? ['bundle_options' => $bundleOptions] : []),
                 ...(is_array($bundleOptionQty) ? ['bundle_option_qty' => $bundleOptionQty] : []),
                 ...(is_array($bundleOptionQty) ? ['bundle_option_qty' => $bundleOptionQty] : []),
                 ...(isset($data->selectedConfigurableOption) ? ['selected_configurable_option' => $data->selectedConfigurableOption] : []),
                 ...(isset($data->selectedConfigurableOption) ? ['selected_configurable_option' => $data->selectedConfigurableOption] : []),
+                ...(isset($data->variantId) ? ['variant_id' => (int) $data->variantId] : []),
                 ...(is_array($data->superAttribute) ? ['super_attribute' => $data->superAttribute] : []),
                 ...(is_array($data->superAttribute) ? ['super_attribute' => $data->superAttribute] : []),
                 ...(is_array($groupedQty) ? ['qty' => $groupedQty] : []),
                 ...(is_array($groupedQty) ? ['qty' => $groupedQty] : []),
                 ...(is_array($data->links) ? ['links' => $data->links] : []),
                 ...(is_array($data->links) ? ['links' => $data->links] : []),