|
@@ -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);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|