2025-11-21 10:49:41 +00:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace Blax\Shop\Models;
|
|
|
|
|
|
|
2025-11-23 14:07:12 +00:00
|
|
|
|
use Blax\Shop\Contracts\Cartable;
|
2025-11-21 10:49:41 +00:00
|
|
|
|
use Blax\Workkit\Traits\HasMetaTranslation;
|
|
|
|
|
|
use Blax\Shop\Events\ProductCreated;
|
|
|
|
|
|
use Blax\Shop\Events\ProductUpdated;
|
|
|
|
|
|
use Blax\Shop\Contracts\Purchasable;
|
2025-12-03 12:59:01 +00:00
|
|
|
|
use Blax\Shop\Enums\ProductStatus;
|
|
|
|
|
|
use Blax\Shop\Enums\ProductType;
|
2025-12-15 13:10:59 +00:00
|
|
|
|
use Blax\Shop\Enums\ProductRelationType;
|
2025-12-03 12:59:01 +00:00
|
|
|
|
use Blax\Shop\Enums\StockStatus;
|
|
|
|
|
|
use Blax\Shop\Enums\StockType;
|
2025-12-15 10:32:31 +00:00
|
|
|
|
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
|
|
|
|
|
|
use Blax\Shop\Exceptions\HasNoPriceException;
|
|
|
|
|
|
use Blax\Shop\Exceptions\InvalidBookingConfigurationException;
|
|
|
|
|
|
use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
|
2025-12-17 08:24:42 +00:00
|
|
|
|
use Blax\Shop\Services\CartService;
|
2025-12-18 15:54:33 +00:00
|
|
|
|
use Blax\Shop\Traits\ChecksIfBooking;
|
2025-12-04 11:35:39 +00:00
|
|
|
|
use Blax\Shop\Traits\HasCategories;
|
2025-12-03 12:21:23 +00:00
|
|
|
|
use Blax\Shop\Traits\HasPrices;
|
2025-12-16 12:58:03 +00:00
|
|
|
|
use Blax\Shop\Traits\HasPricingStrategy;
|
2025-12-05 08:21:07 +00:00
|
|
|
|
use Blax\Shop\Traits\HasProductRelations;
|
2025-12-03 12:21:23 +00:00
|
|
|
|
use Blax\Shop\Traits\HasStocks;
|
2025-12-15 13:10:59 +00:00
|
|
|
|
use Blax\Shop\Traits\MayBePoolProduct;
|
2025-11-21 10:49:41 +00:00
|
|
|
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
2025-11-23 14:07:12 +00:00
|
|
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
2025-11-21 10:49:41 +00:00
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
|
|
|
2025-11-23 14:07:12 +00:00
|
|
|
|
class Product extends Model implements Purchasable, Cartable
|
2025-11-21 10:49:41 +00:00
|
|
|
|
{
|
2025-12-18 15:54:33 +00:00
|
|
|
|
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, ChecksIfBooking;
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
|
|
protected $fillable = [
|
|
|
|
|
|
'slug',
|
2025-11-25 16:25:20 +00:00
|
|
|
|
'sku',
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'type',
|
|
|
|
|
|
'stripe_product_id',
|
|
|
|
|
|
'sale_start',
|
|
|
|
|
|
'sale_end',
|
|
|
|
|
|
'manage_stock',
|
|
|
|
|
|
'low_stock_threshold',
|
|
|
|
|
|
'weight',
|
|
|
|
|
|
'length',
|
|
|
|
|
|
'width',
|
|
|
|
|
|
'height',
|
|
|
|
|
|
'virtual',
|
|
|
|
|
|
'downloadable',
|
|
|
|
|
|
'parent_id',
|
|
|
|
|
|
'featured',
|
2025-11-22 08:55:58 +00:00
|
|
|
|
'is_visible',
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'status',
|
|
|
|
|
|
'published_at',
|
|
|
|
|
|
'meta',
|
|
|
|
|
|
'tax_class',
|
|
|
|
|
|
'sort_order',
|
2025-12-02 09:58:43 +00:00
|
|
|
|
'name',
|
|
|
|
|
|
'description',
|
|
|
|
|
|
'short_description',
|
2025-11-21 10:49:41 +00:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
protected $casts = [
|
|
|
|
|
|
'manage_stock' => 'boolean',
|
|
|
|
|
|
'virtual' => 'boolean',
|
|
|
|
|
|
'downloadable' => 'boolean',
|
2025-12-03 12:59:01 +00:00
|
|
|
|
'type' => ProductType::class,
|
|
|
|
|
|
'status' => ProductStatus::class,
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'meta' => 'object',
|
|
|
|
|
|
'sale_start' => 'datetime',
|
|
|
|
|
|
'sale_end' => 'datetime',
|
|
|
|
|
|
'published_at' => 'datetime',
|
|
|
|
|
|
'featured' => 'boolean',
|
2025-11-22 08:55:58 +00:00
|
|
|
|
'is_visible' => 'boolean',
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'low_stock_threshold' => 'integer',
|
|
|
|
|
|
'sort_order' => 'integer',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
protected $dispatchesEvents = [
|
|
|
|
|
|
'created' => ProductCreated::class,
|
|
|
|
|
|
'updated' => ProductUpdated::class,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
protected $hidden = [
|
|
|
|
|
|
'stripe_product_id',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
public function __construct(array $attributes = [])
|
|
|
|
|
|
{
|
|
|
|
|
|
// Initialize meta BEFORE parent constructor to avoid trait errors
|
|
|
|
|
|
if (!isset($attributes['meta'])) {
|
|
|
|
|
|
$attributes['meta'] = '{}';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
parent::__construct($attributes);
|
|
|
|
|
|
$this->setTable(config('shop.tables.products', 'products'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Initialize the HasMetaTranslation trait for the model.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return void
|
|
|
|
|
|
*/
|
|
|
|
|
|
protected function initializeHasMetaTranslation()
|
|
|
|
|
|
{
|
|
|
|
|
|
// Ensure meta is never null
|
|
|
|
|
|
if (!isset($this->attributes['meta'])) {
|
|
|
|
|
|
$this->attributes['meta'] = '{}';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected static function booted()
|
|
|
|
|
|
{
|
|
|
|
|
|
parent::booted();
|
|
|
|
|
|
|
|
|
|
|
|
static::creating(function ($model) {
|
|
|
|
|
|
if (! $model->slug) {
|
|
|
|
|
|
$model->slug = 'new-product-' . str()->random(8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$model->slug = str()->slug($model->slug);
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure meta is initialized before creation
|
|
|
|
|
|
if (is_null($model->getAttributes()['meta'] ?? null)) {
|
|
|
|
|
|
$model->setAttribute('meta', json_encode(new \stdClass()));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
static::updated(function ($model) {
|
|
|
|
|
|
if (config('shop.cache.enabled')) {
|
|
|
|
|
|
Cache::forget(config('shop.cache.prefix') . 'product:' . $model->id);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-11-22 17:09:45 +00:00
|
|
|
|
|
|
|
|
|
|
static::deleted(function ($model) {
|
|
|
|
|
|
$model->actions()->delete();
|
2025-11-25 16:14:00 +00:00
|
|
|
|
$model->attributes()->delete();
|
2025-11-22 17:09:45 +00:00
|
|
|
|
});
|
2025-11-21 10:49:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function parent()
|
|
|
|
|
|
{
|
2025-12-09 08:09:23 +00:00
|
|
|
|
return $this->belongsTo(static::class, 'parent_id');
|
2025-11-21 10:49:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function children(): HasMany
|
|
|
|
|
|
{
|
2025-12-09 08:09:23 +00:00
|
|
|
|
return $this->hasMany(static::class, 'parent_id');
|
2025-11-21 10:49:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function attributes(): HasMany
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->hasMany(config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function actions(): HasMany
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->hasMany(config('shop.models.product_action', ProductAction::class));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-23 14:07:12 +00:00
|
|
|
|
public function purchases(): MorphMany
|
2025-11-21 10:49:41 +00:00
|
|
|
|
{
|
2025-11-23 14:07:12 +00:00
|
|
|
|
return $this->morphMany(
|
|
|
|
|
|
config('shop.models.product_purchase', ProductPurchase::class),
|
|
|
|
|
|
'purchasable'
|
|
|
|
|
|
);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function scopePublished($query)
|
|
|
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
|
return $query->where('status', ProductStatus::PUBLISHED->value);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function scopeFeatured($query)
|
|
|
|
|
|
{
|
|
|
|
|
|
return $query->where('featured', true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function isOnSale(): bool
|
|
|
|
|
|
{
|
2025-11-24 13:32:11 +00:00
|
|
|
|
if (!$this->sale_start) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
|
$now = now();
|
|
|
|
|
|
|
2025-11-24 13:32:11 +00:00
|
|
|
|
if ($now->lt($this->sale_start)) {
|
2025-11-21 10:49:41 +00:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($this->sale_end && $now->gt($this->sale_end)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 09:26:51 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Duplicate/clone this product with all related data.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Creates a copy of the product including:
|
|
|
|
|
|
* - All basic attributes (with modified slug/sku)
|
|
|
|
|
|
* - All prices
|
|
|
|
|
|
* - All categories
|
|
|
|
|
|
* - All product attributes
|
|
|
|
|
|
* - All product relations (related, upsell, cross-sell)
|
|
|
|
|
|
* - All children (variants) if includeChildren is true
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array $overrides Attributes to override in the duplicated product
|
|
|
|
|
|
* @param bool $includeChildren Whether to duplicate child products (variants)
|
|
|
|
|
|
* @param bool $includePrices Whether to duplicate prices
|
|
|
|
|
|
* @param bool $includeCategories Whether to duplicate category associations
|
|
|
|
|
|
* @param bool $includeAttributes Whether to duplicate product attributes
|
|
|
|
|
|
* @param bool $includeRelations Whether to duplicate product relations
|
|
|
|
|
|
* @return static The duplicated product
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function duplicate(
|
|
|
|
|
|
array $overrides = [],
|
|
|
|
|
|
bool $includeChildren = true,
|
|
|
|
|
|
bool $includePrices = true,
|
|
|
|
|
|
bool $includeCategories = true,
|
|
|
|
|
|
bool $includeAttributes = true,
|
|
|
|
|
|
bool $includeRelations = true
|
|
|
|
|
|
): static {
|
|
|
|
|
|
// Get attributes to duplicate
|
|
|
|
|
|
$attributes = $this->attributesToArray();
|
|
|
|
|
|
|
|
|
|
|
|
// Remove fields that shouldn't be copied
|
|
|
|
|
|
unset(
|
|
|
|
|
|
$attributes['id'],
|
|
|
|
|
|
$attributes['created_at'],
|
|
|
|
|
|
$attributes['updated_at'],
|
|
|
|
|
|
$attributes['deleted_at'],
|
|
|
|
|
|
$attributes['stripe_product_id'], // Stripe ID should be unique
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Generate unique slug and SKU
|
|
|
|
|
|
$baseSlug = preg_replace('/-copy(-\d+)?$/', '', $this->slug);
|
|
|
|
|
|
$suffix = '-copy';
|
|
|
|
|
|
$counter = 1;
|
|
|
|
|
|
|
|
|
|
|
|
while (static::where('slug', $baseSlug . $suffix)->exists()) {
|
|
|
|
|
|
$suffix = '-copy-' . ++$counter;
|
|
|
|
|
|
}
|
|
|
|
|
|
$attributes['slug'] = $baseSlug . $suffix;
|
|
|
|
|
|
|
|
|
|
|
|
// Handle SKU uniqueness
|
|
|
|
|
|
if ($this->sku) {
|
|
|
|
|
|
$baseSku = preg_replace('/-COPY(-\d+)?$/i', '', $this->sku);
|
|
|
|
|
|
$skuSuffix = '-COPY';
|
|
|
|
|
|
$skuCounter = 1;
|
|
|
|
|
|
|
|
|
|
|
|
while (static::where('sku', $baseSku . $skuSuffix)->exists()) {
|
|
|
|
|
|
$skuSuffix = '-COPY-' . ++$skuCounter;
|
|
|
|
|
|
}
|
|
|
|
|
|
$attributes['sku'] = $baseSku . $skuSuffix;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set as draft by default
|
|
|
|
|
|
$attributes['status'] = ProductStatus::DRAFT->value;
|
|
|
|
|
|
$attributes['published_at'] = null;
|
|
|
|
|
|
|
|
|
|
|
|
// Apply overrides
|
|
|
|
|
|
$attributes = array_merge($attributes, $overrides);
|
|
|
|
|
|
|
|
|
|
|
|
// Create the duplicate product
|
|
|
|
|
|
$duplicate = static::create($attributes);
|
|
|
|
|
|
|
|
|
|
|
|
// Duplicate prices
|
|
|
|
|
|
if ($includePrices && method_exists($this, 'prices')) {
|
|
|
|
|
|
foreach ($this->prices as $price) {
|
|
|
|
|
|
$priceData = $price->attributesToArray();
|
|
|
|
|
|
unset(
|
|
|
|
|
|
$priceData['id'],
|
|
|
|
|
|
$priceData['purchasable_id'],
|
|
|
|
|
|
$priceData['purchasable_type'],
|
|
|
|
|
|
$priceData['stripe_price_id'],
|
|
|
|
|
|
$priceData['created_at'],
|
|
|
|
|
|
$priceData['updated_at']
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
$duplicate->prices()->create($priceData);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Duplicate categories
|
|
|
|
|
|
if ($includeCategories && method_exists($this, 'categories')) {
|
|
|
|
|
|
$categoryIds = $this->categories->pluck('id')->toArray();
|
|
|
|
|
|
if (!empty($categoryIds)) {
|
|
|
|
|
|
$duplicate->categories()->sync($categoryIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Duplicate attributes (product attributes, not model attributes)
|
|
|
|
|
|
if ($includeAttributes) {
|
|
|
|
|
|
foreach ($this->attributes()->get() as $attribute) {
|
|
|
|
|
|
$attrData = $attribute->attributesToArray();
|
|
|
|
|
|
unset(
|
|
|
|
|
|
$attrData['id'],
|
|
|
|
|
|
$attrData['product_id'],
|
|
|
|
|
|
$attrData['created_at'],
|
|
|
|
|
|
$attrData['updated_at']
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
$duplicate->attributes()->create($attrData);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Duplicate product relations
|
|
|
|
|
|
if ($includeRelations && method_exists($this, 'relatedProducts')) {
|
|
|
|
|
|
foreach ($this->relatedProducts as $related) {
|
|
|
|
|
|
$duplicate->relatedProducts()->attach($related->id, [
|
|
|
|
|
|
'type' => $related->pivot->type ?? 'related',
|
|
|
|
|
|
'sort_order' => $related->pivot->sort_order ?? 0,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Duplicate children (variants)
|
|
|
|
|
|
if ($includeChildren) {
|
|
|
|
|
|
foreach ($this->children as $child) {
|
|
|
|
|
|
$child->duplicate(
|
|
|
|
|
|
['parent_id' => $duplicate->id],
|
|
|
|
|
|
false, // Don't recurse into children's children
|
|
|
|
|
|
$includePrices,
|
|
|
|
|
|
$includeCategories,
|
|
|
|
|
|
$includeAttributes,
|
|
|
|
|
|
$includeRelations
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $duplicate->fresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
|
public static function getAvailableActions(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return ProductAction::getAvailableActions();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-29 19:09:19 +00:00
|
|
|
|
public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = [])
|
2025-11-21 10:49:41 +00:00
|
|
|
|
{
|
2025-11-29 19:09:19 +00:00
|
|
|
|
return ProductAction::callForProduct(
|
|
|
|
|
|
$this,
|
|
|
|
|
|
$event,
|
|
|
|
|
|
$productPurchase,
|
|
|
|
|
|
$additionalData
|
|
|
|
|
|
);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function scopeVisible($query)
|
|
|
|
|
|
{
|
2025-11-22 08:55:58 +00:00
|
|
|
|
return $query->where('is_visible', true)
|
2025-12-03 12:59:01 +00:00
|
|
|
|
->where('status', ProductStatus::PUBLISHED->value)
|
2025-11-21 10:49:41 +00:00
|
|
|
|
->where(function ($q) {
|
|
|
|
|
|
$q->whereNull('published_at')
|
|
|
|
|
|
->orWhere('published_at', '<=', now());
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function scopeSearch($query, string $search)
|
|
|
|
|
|
{
|
|
|
|
|
|
return $query->where(function ($q) use ($search) {
|
|
|
|
|
|
$q->where('slug', 'like', "%{$search}%")
|
|
|
|
|
|
->orWhere('sku', 'like', "%{$search}%")
|
2025-11-25 16:14:00 +00:00
|
|
|
|
->orWhere('name', 'like', "%{$search}%");
|
2025-11-21 10:49:41 +00:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function isVisible(): bool
|
|
|
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
|
if (!$this->is_visible || $this->status !== ProductStatus::PUBLISHED) {
|
2025-11-21 10:49:41 +00:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($this->published_at && now()->lt($this->published_at)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public function toApiArray(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return [
|
|
|
|
|
|
'id' => $this->id,
|
|
|
|
|
|
'slug' => $this->slug,
|
|
|
|
|
|
'sku' => $this->sku,
|
|
|
|
|
|
'name' => $this->getLocalized('name'),
|
|
|
|
|
|
'description' => $this->getLocalized('description'),
|
|
|
|
|
|
'short_description' => $this->getLocalized('short_description'),
|
|
|
|
|
|
'type' => $this->type,
|
|
|
|
|
|
'price' => $this->getCurrentPrice(),
|
|
|
|
|
|
'sale_price' => $this->sale_price,
|
|
|
|
|
|
'is_on_sale' => $this->isOnSale(),
|
|
|
|
|
|
'low_stock' => $this->isLowStock(),
|
|
|
|
|
|
'featured' => $this->featured,
|
|
|
|
|
|
'virtual' => $this->virtual,
|
|
|
|
|
|
'downloadable' => $this->downloadable,
|
|
|
|
|
|
'weight' => $this->weight,
|
|
|
|
|
|
'dimensions' => [
|
|
|
|
|
|
'length' => $this->length,
|
|
|
|
|
|
'width' => $this->width,
|
|
|
|
|
|
'height' => $this->height,
|
|
|
|
|
|
],
|
|
|
|
|
|
'categories' => $this->categories,
|
|
|
|
|
|
'attributes' => $this->attributes,
|
|
|
|
|
|
'variants' => $this->children,
|
|
|
|
|
|
'parent' => $this->parent,
|
|
|
|
|
|
'created_at' => $this->created_at,
|
|
|
|
|
|
'updated_at' => $this->updated_at,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get an attribute from the model.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $key
|
|
|
|
|
|
* @return mixed
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function getAttribute($key)
|
|
|
|
|
|
{
|
|
|
|
|
|
$value = parent::getAttribute($key);
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure meta is never null for HasMetaTranslation trait
|
|
|
|
|
|
if ($key === 'meta' && is_null($value)) {
|
|
|
|
|
|
$this->attributes['meta'] = '{}';
|
|
|
|
|
|
return json_decode('{}');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Create a new instance of the given model.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array $attributes
|
|
|
|
|
|
* @param bool $exists
|
|
|
|
|
|
* @return static
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function newInstance($attributes = [], $exists = false)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Ensure meta is initialized
|
|
|
|
|
|
if (!isset($attributes['meta'])) {
|
|
|
|
|
|
$attributes['meta'] = '{}';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return parent::newInstance($attributes, $exists);
|
|
|
|
|
|
}
|
2025-12-03 12:59:01 +00:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Check if this is a booking product
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function isBooking(): bool
|
|
|
|
|
|
{
|
2025-12-18 15:54:33 +00:00
|
|
|
|
return $this->checkProductIsBooking($this);
|
2025-12-03 12:59:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Check stock availability for a booking period
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function isAvailableForBooking(\DateTimeInterface $from, \DateTimeInterface $until, int $quantity = 1): bool
|
|
|
|
|
|
{
|
2025-12-19 11:25:59 +00:00
|
|
|
|
// For pool products, delegate to pool-specific availability checking
|
|
|
|
|
|
if ($this->isPool()) {
|
|
|
|
|
|
$available = $this->getPoolMaxQuantity($from, $until);
|
|
|
|
|
|
return $available === PHP_INT_MAX || $available >= $quantity;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-03 12:59:01 +00:00
|
|
|
|
if (!$this->manage_stock) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 13:26:57 +00:00
|
|
|
|
// Get stock claims (CLAIMED entries) that overlap with the requested period
|
2025-12-04 10:06:09 +00:00
|
|
|
|
$overlappingClaims = $this->stocks()
|
|
|
|
|
|
->where('type', StockType::CLAIMED->value)
|
2025-12-03 12:59:01 +00:00
|
|
|
|
->where('status', StockStatus::PENDING->value)
|
|
|
|
|
|
->where(function ($query) use ($from, $until) {
|
|
|
|
|
|
$query->where(function ($q) use ($from, $until) {
|
2025-12-26 07:42:59 +00:00
|
|
|
|
// Claim starts during the requested period (exclusive end for hotel-style bookings)
|
|
|
|
|
|
$q->where('claimed_from', '>=', $from)
|
|
|
|
|
|
->where('claimed_from', '<', $until);
|
2025-12-03 12:59:01 +00:00
|
|
|
|
})->orWhere(function ($q) use ($from, $until) {
|
2025-12-26 07:42:59 +00:00
|
|
|
|
// Claim ends during the requested period (exclusive start - checkout day = checkin day is OK)
|
|
|
|
|
|
$q->where('expires_at', '>', $from)
|
|
|
|
|
|
->where('expires_at', '<=', $until);
|
2025-12-03 12:59:01 +00:00
|
|
|
|
})->orWhere(function ($q) use ($from, $until) {
|
2025-12-04 10:06:09 +00:00
|
|
|
|
// Claim encompasses the entire requested period
|
|
|
|
|
|
$q->where('claimed_from', '<=', $from)
|
2025-12-26 07:42:59 +00:00
|
|
|
|
->where('expires_at', '>', $until);
|
2025-12-04 10:06:09 +00:00
|
|
|
|
})->orWhere(function ($q) use ($from, $until) {
|
|
|
|
|
|
// Claim without claimed_from (immediately claimed)
|
|
|
|
|
|
$q->whereNull('claimed_from')
|
|
|
|
|
|
->where(function ($subQ) use ($from, $until) {
|
|
|
|
|
|
$subQ->whereNull('expires_at')
|
2025-12-26 07:42:59 +00:00
|
|
|
|
->orWhere('expires_at', '>', $from);
|
2025-12-04 10:06:09 +00:00
|
|
|
|
});
|
2025-12-03 12:59:01 +00:00
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
->sum('quantity');
|
|
|
|
|
|
|
2025-12-19 13:26:57 +00:00
|
|
|
|
// Also get DECREASE entries with expires_at that overlap (from completed bookings)
|
|
|
|
|
|
// These are booking purchases that reduce stock during the booking period
|
|
|
|
|
|
$overlappingBookings = $this->stocks()
|
|
|
|
|
|
->where('type', StockType::DECREASE->value)
|
|
|
|
|
|
->where('status', StockStatus::COMPLETED->value)
|
|
|
|
|
|
->whereNotNull('expires_at')
|
|
|
|
|
|
->where('expires_at', '>', $from) // Booking hasn't ended before our period starts
|
|
|
|
|
|
->sum('quantity');
|
|
|
|
|
|
|
2025-12-20 10:22:04 +00:00
|
|
|
|
// Use base stock at the START of the booking period and subtract all overlapping reservations
|
|
|
|
|
|
// We check availability at $from because claims that expire before then should not affect availability
|
2025-12-19 13:26:57 +00:00
|
|
|
|
// Note: overlappingBookings is already negative (DECREASE entries), so we add it
|
2025-12-20 10:22:04 +00:00
|
|
|
|
$availableStock = $this->getAvailableStock($from) - abs($overlappingClaims) + $overlappingBookings;
|
2025-12-03 12:59:01 +00:00
|
|
|
|
|
|
|
|
|
|
return $availableStock >= $quantity;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Scope for booking products
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function scopeBookings($query)
|
|
|
|
|
|
{
|
|
|
|
|
|
return $query->where('type', ProductType::BOOKING->value);
|
|
|
|
|
|
}
|
2025-12-15 10:32:31 +00:00
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-18 08:57:33 +00:00
|
|
|
|
* Get the current price with pool product inheritance support and cart-aware pricing.
|
|
|
|
|
|
*
|
|
|
|
|
|
* IMPORTANT: This method handles cart-aware pricing automatically!
|
|
|
|
|
|
*
|
|
|
|
|
|
* For pool products, this method:
|
|
|
|
|
|
* - Automatically retrieves the cart from session or authenticated user if not provided
|
|
|
|
|
|
* - Considers which price tiers are already used in the cart
|
|
|
|
|
|
* - Returns the next available price based on the pricing strategy (LOWEST, HIGHEST, AVERAGE)
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ COMMON MISTAKE: Do NOT call getNextAvailablePoolPriceConsideringCart() directly!
|
|
|
|
|
|
* Always use this method instead, as it handles cart resolution and edge cases properly.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Example usage:
|
|
|
|
|
|
* ```php
|
|
|
|
|
|
* ✅ CORRECT: Let getCurrentPrice handle cart resolution
|
|
|
|
|
|
* $price = $product->getCurrentPrice();
|
|
|
|
|
|
*
|
|
|
|
|
|
* ✅ CORRECT: Pass cart explicitly if you have it
|
|
|
|
|
|
* $price = $product->getCurrentPrice(null, $cart);
|
|
|
|
|
|
*
|
|
|
|
|
|
* ✅ CORRECT: Pass dates for booking calculations
|
|
|
|
|
|
* $price = $product->getCurrentPrice(null, $cart, $fromDate, $untilDate);
|
|
|
|
|
|
*
|
|
|
|
|
|
* ❌ WRONG: Bypasses cart resolution and session handling
|
|
|
|
|
|
* $price = $product->getNextAvailablePoolPriceConsideringCart($cart, null);
|
|
|
|
|
|
* ```
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param bool|null $sales_price Whether to get sale price (null = auto-detect)
|
|
|
|
|
|
* @param mixed $cart Optional cart instance (auto-resolved from session/user if not provided)
|
|
|
|
|
|
* @param \DateTimeInterface|null $from Optional start date for booking calculations
|
|
|
|
|
|
* @param \DateTimeInterface|null $until Optional end date for booking calculations
|
2025-12-19 09:57:26 +00:00
|
|
|
|
* @param string|int|null $excludeCartItemId Cart item ID to exclude from usage calculation (for date updates)
|
2025-12-18 08:57:33 +00:00
|
|
|
|
* @return float|null The current price, or null if unavailable
|
2025-12-15 10:32:31 +00:00
|
|
|
|
*/
|
2025-12-18 08:57:33 +00:00
|
|
|
|
public function getCurrentPrice(
|
|
|
|
|
|
bool|null $sales_price = null,
|
|
|
|
|
|
mixed $cart = null,
|
|
|
|
|
|
?\DateTimeInterface $from = null,
|
2025-12-19 09:57:26 +00:00
|
|
|
|
?\DateTimeInterface $until = null,
|
|
|
|
|
|
string|int|null $excludeCartItemId = null
|
2025-12-18 08:57:33 +00:00
|
|
|
|
): ?float {
|
2025-12-16 12:58:03 +00:00
|
|
|
|
// If this is a pool product, use cart-aware pricing if cart is provided
|
2025-12-15 13:10:59 +00:00
|
|
|
|
if ($this->isPool()) {
|
2025-12-17 08:24:42 +00:00
|
|
|
|
// If no cart provided, try to get the cart from session first, then user's cart
|
|
|
|
|
|
if (!$cart) {
|
|
|
|
|
|
// Try session first
|
|
|
|
|
|
$cartId = session(CartService::CART_SESSION_KEY);
|
|
|
|
|
|
if ($cartId) {
|
|
|
|
|
|
$cart = \Blax\Shop\Models\Cart::find($cartId);
|
|
|
|
|
|
// Make sure the cart is valid (not expired/converted)
|
|
|
|
|
|
if ($cart && ($cart->isExpired() || $cart->isConverted())) {
|
|
|
|
|
|
$cart = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fall back to authenticated user's cart if no valid session cart
|
|
|
|
|
|
if (!$cart && auth()->check()) {
|
|
|
|
|
|
$cart = auth()->user()->currentCart();
|
|
|
|
|
|
}
|
2025-12-16 12:58:03 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($cart) {
|
2025-12-17 09:41:52 +00:00
|
|
|
|
// Cart-aware: Use smarter pricing that considers which price tiers are used
|
2025-12-17 15:43:22 +00:00
|
|
|
|
// This returns null if no items are available (all sold out)
|
2025-12-19 09:57:26 +00:00
|
|
|
|
return $this->getNextAvailablePoolPriceConsideringCart($cart, $sales_price, $from, $until, $excludeCartItemId);
|
2025-12-16 12:58:03 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:43:22 +00:00
|
|
|
|
// No cart: Get inherited price from single items
|
|
|
|
|
|
// This returns null if no items are available OR if items exist but have no prices
|
2025-12-16 12:58:03 +00:00
|
|
|
|
return $this->getInheritedPoolPrice($sales_price);
|
2025-12-15 10:32:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For non-pool products, use the trait's default behavior
|
|
|
|
|
|
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Validate booking product configuration and provide helpful error messages
|
|
|
|
|
|
*
|
|
|
|
|
|
* @throws InvalidBookingConfigurationException
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function validateBookingConfiguration(bool $throwOnWarnings = false): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$errors = [];
|
|
|
|
|
|
$warnings = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (!$this->isBooking()) {
|
|
|
|
|
|
throw InvalidBookingConfigurationException::notABookingProduct($this->name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Critical: Stock management must be enabled
|
|
|
|
|
|
if (!$this->manage_stock) {
|
|
|
|
|
|
throw InvalidBookingConfigurationException::stockManagementNotEnabled($this->name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for available stock
|
|
|
|
|
|
if ($this->getAvailableStock() <= 0) {
|
|
|
|
|
|
$warnings[] = "No stock available for booking";
|
|
|
|
|
|
if ($throwOnWarnings) {
|
|
|
|
|
|
throw InvalidBookingConfigurationException::noStockAvailable($this->name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for pricing
|
|
|
|
|
|
if (!$this->hasPrice()) {
|
|
|
|
|
|
$warnings[] = "No pricing configured";
|
|
|
|
|
|
if ($throwOnWarnings) {
|
|
|
|
|
|
throw InvalidBookingConfigurationException::noPricingConfigured($this->name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'valid' => empty($errors),
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
'warnings' => $warnings,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Validate product pricing configuration
|
|
|
|
|
|
*
|
|
|
|
|
|
* @throws HasNoPriceException
|
|
|
|
|
|
* @throws HasNoDefaultPriceException
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function validatePricing(bool $throwExceptions = true): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$errors = [];
|
|
|
|
|
|
$warnings = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Special handling for pool products
|
|
|
|
|
|
if ($this->isPool()) {
|
|
|
|
|
|
$hasDirectPrice = $this->prices()->exists();
|
|
|
|
|
|
$singleItems = $this->singleProducts;
|
|
|
|
|
|
|
|
|
|
|
|
if (!$hasDirectPrice) {
|
|
|
|
|
|
// Check if single items have prices to inherit
|
|
|
|
|
|
$singleItemsWithPrices = $singleItems->filter(function ($item) {
|
|
|
|
|
|
return $item->prices()->exists();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if ($singleItemsWithPrices->isEmpty()) {
|
2025-12-17 15:43:22 +00:00
|
|
|
|
// Pool has no direct price AND no single items with prices
|
|
|
|
|
|
// This is only an error if we're actually trying to use the price
|
|
|
|
|
|
// So we don't throw here - let the actual usage point handle it
|
|
|
|
|
|
$warnings[] = "Pool product has no pricing (direct or inherited). Price will be needed when adding to cart.";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Pool has single items with prices - this is valid
|
|
|
|
|
|
$warnings[] = "Pool product uses inherited pricing from single items";
|
2025-12-15 10:32:31 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If pool has direct prices, validate them
|
|
|
|
|
|
if ($hasDirectPrice) {
|
|
|
|
|
|
return $this->validateDirectPricing($throwExceptions);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:43:22 +00:00
|
|
|
|
// Pool without direct pricing is valid as long as it has single items with prices
|
2025-12-15 10:32:31 +00:00
|
|
|
|
return [
|
|
|
|
|
|
'valid' => empty($errors),
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
'warnings' => $warnings,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For all other product types, validate direct pricing
|
|
|
|
|
|
return $this->validateDirectPricing($throwExceptions);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Validate direct pricing on the product
|
|
|
|
|
|
*
|
|
|
|
|
|
* @throws HasNoPriceException
|
|
|
|
|
|
* @throws HasNoDefaultPriceException
|
|
|
|
|
|
*/
|
|
|
|
|
|
protected function validateDirectPricing(bool $throwExceptions = true): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$errors = [];
|
|
|
|
|
|
$warnings = [];
|
|
|
|
|
|
|
|
|
|
|
|
$allPrices = $this->prices;
|
|
|
|
|
|
$priceCount = $allPrices->count();
|
|
|
|
|
|
|
|
|
|
|
|
// No prices at all
|
|
|
|
|
|
if ($priceCount === 0) {
|
|
|
|
|
|
$errors[] = "Product has no prices configured";
|
|
|
|
|
|
if ($throwExceptions) {
|
|
|
|
|
|
throw HasNoPriceException::noPricesConfigured($this->name, $this->id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'valid' => false,
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
'warnings' => $warnings,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$defaultPrices = $allPrices->where('is_default', true);
|
|
|
|
|
|
$defaultCount = $defaultPrices->count();
|
|
|
|
|
|
|
|
|
|
|
|
// Multiple default prices
|
|
|
|
|
|
if ($defaultCount > 1) {
|
|
|
|
|
|
$errors[] = "Product has {$defaultCount} default prices (should have exactly 1)";
|
|
|
|
|
|
if ($throwExceptions) {
|
|
|
|
|
|
throw HasNoDefaultPriceException::multipleDefaultPrices($this->name, $defaultCount);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'valid' => false,
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
'warnings' => $warnings,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// No default price
|
|
|
|
|
|
if ($defaultCount === 0) {
|
|
|
|
|
|
if ($priceCount === 1) {
|
|
|
|
|
|
// Single price but not marked as default
|
|
|
|
|
|
$errors[] = "Product has one price but it's not marked as default";
|
|
|
|
|
|
if ($throwExceptions) {
|
|
|
|
|
|
throw HasNoDefaultPriceException::onlyNonDefaultPriceExists($this->name);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Multiple prices but none are default
|
|
|
|
|
|
$errors[] = "Product has {$priceCount} prices but none are marked as default";
|
|
|
|
|
|
if ($throwExceptions) {
|
|
|
|
|
|
throw HasNoDefaultPriceException::multiplePricesNoDefault($this->name, $priceCount);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'valid' => false,
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
'warnings' => $warnings,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Valid: Exactly one default price
|
|
|
|
|
|
return [
|
|
|
|
|
|
'valid' => true,
|
|
|
|
|
|
'errors' => $errors,
|
|
|
|
|
|
'warnings' => $warnings,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get helpful setup instructions for pool products
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function getPoolSetupInstructions(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return <<<'INSTRUCTIONS'
|
|
|
|
|
|
# Pool Product Setup Guide
|
|
|
|
|
|
|
|
|
|
|
|
Pool products aggregate multiple individual items (e.g., parking spots, hotel rooms)
|
|
|
|
|
|
into a single purchasable product where customers don't need to select specific items.
|
|
|
|
|
|
|
|
|
|
|
|
## Step 1: Create the Pool Product
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
use Blax\Shop\Models\Product;
|
|
|
|
|
|
use Blax\Shop\Enums\ProductType;
|
|
|
|
|
|
|
|
|
|
|
|
$pool = Product::create([
|
|
|
|
|
|
'type' => ProductType::POOL,
|
|
|
|
|
|
'name' => 'Parking Lot',
|
|
|
|
|
|
'slug' => 'parking-lot',
|
|
|
|
|
|
]);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Step 2: Create Single Items (Booking Products)
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
$spot1 = Product::create([
|
|
|
|
|
|
'type' => ProductType::BOOKING,
|
|
|
|
|
|
'name' => 'Parking Spot #1',
|
|
|
|
|
|
'manage_stock' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
$spot1->increaseStock(1);
|
|
|
|
|
|
|
|
|
|
|
|
$spot2 = Product::create([
|
|
|
|
|
|
'type' => ProductType::BOOKING,
|
|
|
|
|
|
'name' => 'Parking Spot #2',
|
|
|
|
|
|
'manage_stock' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
$spot2->increaseStock(1);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Step 3: Link Single Items to Pool
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
use Blax\Shop\Enums\ProductRelationType;
|
|
|
|
|
|
|
|
|
|
|
|
$pool->productRelations()->attach([
|
|
|
|
|
|
$spot1->id => ['type' => ProductRelationType::SINGLE],
|
|
|
|
|
|
$spot2->id => ['type' => ProductRelationType::SINGLE],
|
|
|
|
|
|
]);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Step 4: Set Pricing (Optional)
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
use Blax\Shop\Models\ProductPrice;
|
|
|
|
|
|
|
|
|
|
|
|
// Option A: Set price on pool (takes precedence)
|
|
|
|
|
|
ProductPrice::create([
|
|
|
|
|
|
'purchasable_id' => $pool->id,
|
|
|
|
|
|
'purchasable_type' => Product::class,
|
|
|
|
|
|
'unit_amount' => 5000, // 50.00 per day
|
|
|
|
|
|
'currency' => 'USD',
|
|
|
|
|
|
'is_default' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Option B: Set prices on single items (pool inherits)
|
|
|
|
|
|
ProductPrice::create([
|
|
|
|
|
|
'purchasable_id' => $spot1->id,
|
|
|
|
|
|
'purchasable_type' => Product::class,
|
|
|
|
|
|
'unit_amount' => 5000,
|
|
|
|
|
|
'currency' => 'USD',
|
|
|
|
|
|
'is_default' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Set pricing strategy (if using inheritance)
|
|
|
|
|
|
$pool->setPoolPricingStrategy('average'); // or 'lowest' or 'highest'
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Step 5: Add to Cart with Timespan
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
use Blax\Shop\Facades\Cart;
|
|
|
|
|
|
use Carbon\Carbon;
|
|
|
|
|
|
|
|
|
|
|
|
Cart::addBooking(
|
|
|
|
|
|
$pool,
|
|
|
|
|
|
2, // quantity
|
|
|
|
|
|
Carbon::parse('2025-01-15 09:00'), // from
|
|
|
|
|
|
Carbon::parse('2025-01-17 17:00'), // until
|
|
|
|
|
|
);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Validation
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
// Validate configuration before use
|
|
|
|
|
|
$validation = $pool->validatePoolConfiguration();
|
|
|
|
|
|
if (!$validation['valid']) {
|
|
|
|
|
|
foreach ($validation['errors'] as $error) {
|
|
|
|
|
|
echo "Error: $error\n";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
INSTRUCTIONS;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get helpful setup instructions for booking products
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function getBookingSetupInstructions(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return <<<'INSTRUCTIONS'
|
|
|
|
|
|
# Booking Product Setup Guide
|
|
|
|
|
|
|
|
|
|
|
|
Booking products represent time-based reservations (conference rooms, equipment, etc.)
|
|
|
|
|
|
|
|
|
|
|
|
## Step 1: Create the Booking Product
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
use Blax\Shop\Models\Product;
|
|
|
|
|
|
use Blax\Shop\Enums\ProductType;
|
|
|
|
|
|
|
|
|
|
|
|
$product = Product::create([
|
|
|
|
|
|
'type' => ProductType::BOOKING,
|
|
|
|
|
|
'name' => 'Conference Room A',
|
|
|
|
|
|
'manage_stock' => true, // REQUIRED for bookings
|
|
|
|
|
|
]);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Step 2: Set Initial Stock
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
// For single-unit bookings (1 room, 1 equipment piece, etc.)
|
|
|
|
|
|
$product->increaseStock(1);
|
|
|
|
|
|
|
|
|
|
|
|
// For multiple units (e.g., 5 identical meeting rooms)
|
|
|
|
|
|
$product->increaseStock(5);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Step 3: Configure Pricing
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
use Blax\Shop\Models\ProductPrice;
|
|
|
|
|
|
|
|
|
|
|
|
ProductPrice::create([
|
|
|
|
|
|
'purchasable_id' => $product->id,
|
|
|
|
|
|
'purchasable_type' => Product::class,
|
|
|
|
|
|
'unit_amount' => 10000, // Price per day in cents (100.00 USD)
|
|
|
|
|
|
'currency' => 'USD',
|
|
|
|
|
|
'is_default' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Step 4: Add to Cart with Timespan
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
use Blax\Shop\Facades\Cart;
|
|
|
|
|
|
use Carbon\Carbon;
|
|
|
|
|
|
|
|
|
|
|
|
Cart::addBooking(
|
|
|
|
|
|
$product,
|
|
|
|
|
|
1, // quantity
|
|
|
|
|
|
Carbon::parse('2025-01-15 09:00'), // from
|
|
|
|
|
|
Carbon::parse('2025-01-17 17:00'), // until
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Price will be: 100.00/day × 3 days = 300.00
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Check Availability
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
$from = Carbon::parse('2025-01-15 09:00');
|
|
|
|
|
|
$until = Carbon::parse('2025-01-17 17:00');
|
|
|
|
|
|
|
|
|
|
|
|
if ($product->isAvailableForBooking($from, $until, 1)) {
|
|
|
|
|
|
// Product is available for this period
|
|
|
|
|
|
Cart::addBooking($product, 1, $from, $until);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## Validation
|
|
|
|
|
|
|
|
|
|
|
|
```php
|
|
|
|
|
|
// Validate configuration
|
|
|
|
|
|
$validation = $product->validateBookingConfiguration();
|
|
|
|
|
|
if (!$validation['valid']) {
|
|
|
|
|
|
foreach ($validation['errors'] as $error) {
|
|
|
|
|
|
echo "Error: $error\n";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
INSTRUCTIONS;
|
|
|
|
|
|
}
|
2025-11-21 10:49:41 +00:00
|
|
|
|
}
|