laravel-shop/src/Models/Product.php

1077 lines
30 KiB
PHP
Raw Normal View History

2025-11-21 10:49:41 +00:00
<?php
namespace Blax\Shop\Models;
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;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
use Blax\Shop\Exceptions\HasNoPriceException;
use Blax\Shop\Exceptions\InvalidBookingConfigurationException;
use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
use Blax\Shop\Traits\HasCategories;
2025-12-03 12:21:23 +00:00
use Blax\Shop\Traits\HasPrices;
use Blax\Shop\Traits\HasProductRelations;
2025-12-03 12:21:23 +00:00
use Blax\Shop\Traits\HasStocks;
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;
use Illuminate\Database\Eloquent\Relations\MorphMany;
2025-11-21 10:49:41 +00:00
use Illuminate\Support\Facades\Cache;
class Product extends Model implements Purchasable, Cartable
2025-11-21 10:49:41 +00:00
{
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories, HasProductRelations;
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',
'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()
{
return $this->belongsTo(static::class, 'parent_id');
2025-11-21 10:49:41 +00:00
}
public function children(): HasMany
{
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));
}
public function purchases(): MorphMany
2025-11-21 10:49:41 +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)
{
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;
}
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)
->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
{
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);
}
/**
* Check if this is a booking product
*/
public function isBooking(): bool
{
return $this->type === ProductType::BOOKING;
}
/**
* Check if this is a pool product
*/
public function isPool(): bool
{
return $this->type === ProductType::POOL;
}
/**
* Get the maximum available quantity for a pool product based on single items
*/
public function getPoolMaxQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int
{
if (!$this->isPool()) {
return $this->getAvailableStock();
}
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
return 0;
}
// If no dates provided, return the count of single items
if (!$from || !$until) {
return $singleItems->count();
}
// Check availability for each single item during the timespan
$availableCount = 0;
foreach ($singleItems as $item) {
if ($item->isAvailableForBooking($from, $until, 1)) {
$availableCount++;
}
}
return $availableCount;
}
/**
* Claim stock for a pool product
* This will claim stock from the available single items
*
* @param int $quantity Number of pool items to claim
* @param mixed $reference Reference model
* @param \DateTimeInterface|null $from Start date
* @param \DateTimeInterface|null $until End date
* @param string|null $note Optional note
* @return array Array of claimed single item products
* @throws \Exception
*/
public function claimPoolStock(
int $quantity,
$reference = null,
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null,
?string $note = null
): array {
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
throw new \Exception('Pool product has no single items to claim');
}
// Get available single items for the period
$availableItems = [];
foreach ($singleItems as $item) {
if ($item->isAvailableForBooking($from, $until, 1)) {
$availableItems[] = $item;
}
if (count($availableItems) >= $quantity) {
break;
}
}
if (count($availableItems) < $quantity) {
throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested");
}
// Claim stock from each selected single item
$claimedItems = [];
foreach (array_slice($availableItems, 0, $quantity) as $item) {
$item->claimStock(1, $reference, $from, $until, $note);
$claimedItems[] = $item;
}
return $claimedItems;
}
/**
* Release pool stock claims
*
* @param mixed $reference Reference model used when claiming
* @return int Number of claims released
*/
public function releasePoolStock($reference): int
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
$singleItems = $this->singleProducts;
$released = 0;
foreach ($singleItems as $item) {
$referenceType = is_object($reference) ? get_class($reference) : null;
$referenceId = is_object($reference) ? $reference->id : null;
// Find and delete claims for this reference
$claims = $item->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->where('reference_type', $referenceType)
->where('reference_id', $referenceId)
->get();
foreach ($claims as $claim) {
$claim->release();
$released++;
}
}
return $released;
}
/**
* Check if any single item in pool is a booking product
*/
public function hasBookingSingleItems(): bool
{
if (!$this->isPool()) {
return false;
}
return $this->singleProducts()->where('products.type', ProductType::BOOKING->value)->exists();
}
/**
* Check stock availability for a booking period
*/
public function isAvailableForBooking(\DateTimeInterface $from, \DateTimeInterface $until, int $quantity = 1): bool
{
if (!$this->manage_stock) {
return true;
}
2025-12-04 10:06:09 +00:00
// Get stock claims that overlap with the requested period
$overlappingClaims = $this->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->where(function ($query) use ($from, $until) {
$query->where(function ($q) use ($from, $until) {
2025-12-04 10:06:09 +00:00
// Claim starts during the requested period
$q->whereBetween('claimed_from', [$from, $until]);
})->orWhere(function ($q) use ($from, $until) {
2025-12-04 10:06:09 +00:00
// Claim ends during the requested period
$q->whereBetween('expires_at', [$from, $until]);
})->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)
->where('expires_at', '>=', $until);
})->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')
->orWhere('expires_at', '>=', $from);
});
});
})
->sum('quantity');
2025-12-04 10:06:09 +00:00
$availableStock = $this->getAvailableStock() - abs($overlappingClaims);
return $availableStock >= $quantity;
}
/**
* Scope for booking products
*/
public function scopeBookings($query)
{
return $query->where('type', ProductType::BOOKING->value);
}
/**
* Get the current price with pool product inheritance support
*/
public function getCurrentPrice(bool|null $sales_price = null): ?float
{
// If this is a pool product and it has no direct price, inherit from single items
if ($this->isPool() && !$this->hasPrice()) {
return $this->getInheritedPoolPrice($sales_price);
}
// If pool has a direct price, use it
if ($this->isPool() && $this->hasPrice()) {
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
}
// For non-pool products, use the trait's default behavior
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
}
/**
* Get inherited price from single items based on pricing strategy
*/
protected function getInheritedPoolPrice(bool|null $sales_price = null): ?float
{
if (!$this->isPool()) {
return null;
}
$strategy = $this->getPoolPricingStrategy();
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
return null;
}
$prices = $singleItems->map(function ($item) use ($sales_price) {
return $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale());
})->filter()->values();
if ($prices->isEmpty()) {
return null;
}
return match ($strategy) {
'lowest' => $prices->min(),
'highest' => $prices->max(),
'average' => round($prices->avg()),
default => round($prices->avg()), // Default to average
};
}
/**
* Get the pool pricing strategy from metadata
*/
public function getPoolPricingStrategy(): string
{
if (!$this->isPool()) {
return 'average';
}
$meta = $this->getMeta();
return $meta->pricing_strategy ?? 'average';
}
/**
* Set the pool pricing strategy
*/
public function setPoolPricingStrategy(string $strategy): void
{
if (!$this->isPool()) {
throw new \Exception('This method is only for pool products');
}
if (!in_array($strategy, ['average', 'lowest', 'highest'])) {
throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}");
}
$this->updateMetaKey('pricing_strategy', $strategy);
$this->save();
}
/**
* Get the lowest price from single items
*/
public function getLowestPoolPrice(): ?float
{
if (!$this->isPool()) {
return null;
}
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
return null;
}
$prices = $singleItems->map(function ($item) {
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
})->filter()->values();
return $prices->isEmpty() ? null : $prices->min();
}
/**
* Get the highest price from single items
*/
public function getHighestPoolPrice(): ?float
{
if (!$this->isPool()) {
return null;
}
$singleItems = $this->singleProducts;
if ($singleItems->isEmpty()) {
return null;
}
$prices = $singleItems->map(function ($item) {
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
})->filter()->values();
return $prices->isEmpty() ? null : $prices->max();
}
/**
* Get the price range for pool products
*/
public function getPoolPriceRange(): ?array
{
if (!$this->isPool()) {
return null;
}
$lowest = $this->getLowestPoolPrice();
$highest = $this->getHighestPoolPrice();
if ($lowest === null || $highest === null) {
return null;
}
return [
'min' => $lowest,
'max' => $highest,
];
}
/**
* Validate pool product configuration and provide helpful error messages
*
* @throws InvalidPoolConfigurationException
*/
public function validatePoolConfiguration(bool $throwOnWarnings = false): array
{
$errors = [];
$warnings = [];
if (!$this->isPool()) {
throw InvalidPoolConfigurationException::notAPoolProduct($this->name);
}
$singleItems = $this->singleProducts;
// Critical: No single items
if ($singleItems->isEmpty()) {
throw InvalidPoolConfigurationException::noSingleItems($this->name);
}
// Check for mixed product types
$types = $singleItems->pluck('type')->unique();
if ($types->count() > 1) {
$warning = "Mixed single item types detected. This may cause unexpected behavior.";
$warnings[] = $warning;
if ($throwOnWarnings) {
throw InvalidPoolConfigurationException::mixedSingleItemTypes($this->name);
}
}
// Check stock management on single items
$itemsWithoutStock = $singleItems->filter(fn($item) => !$item->manage_stock);
if ($itemsWithoutStock->isNotEmpty()) {
$itemNames = $itemsWithoutStock->pluck('name')->toArray();
$errors[] = "Single items without stock management: " . implode(', ', $itemNames);
throw InvalidPoolConfigurationException::singleItemsWithoutStock($this->name, $itemNames);
}
// Check for items with zero stock
$itemsWithZeroStock = $singleItems->filter(fn($item) => $item->getAvailableStock() <= 0);
if ($itemsWithZeroStock->isNotEmpty()) {
$itemNames = $itemsWithZeroStock->pluck('name')->toArray();
$warnings[] = "Single items with zero stock: " . implode(', ', $itemNames);
if ($throwOnWarnings) {
throw InvalidPoolConfigurationException::singleItemsWithZeroStock($this->name, $itemNames);
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings,
];
}
/**
* 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()) {
$errors[] = "Pool product has no pricing (direct or inherited)";
if ($throwExceptions) {
throw HasNoPriceException::poolProductNoPriceAndNoSingleItemPrices($this->name);
}
}
}
// If pool has direct prices, validate them
if ($hasDirectPrice) {
return $this->validateDirectPricing($throwExceptions);
}
// Pool with inherited pricing is valid
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
}