php-commerce/Models/Product.php
2026-01-27 00:24:22 +00:00

526 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Commerce\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Core\Commerce\Concerns\HasContentOverrides;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Commerce Product - Master catalog entry.
*
* Products are owned exclusively by M1 (Master) entities.
* M2/M3 entities access products through ProductAssignment.
*
* @property int $id
* @property string $sku
* @property int $owner_entity_id
* @property string $name
* @property string|null $description
* @property string|null $short_description
* @property string|null $category
* @property string|null $subcategory
* @property array|null $tags
* @property int $price
* @property int|null $cost_price
* @property int|null $rrp
* @property string $currency
* @property array|null $price_tiers
* @property string $tax_class
* @property bool $tax_inclusive
* @property float|null $weight
* @property float|null $length
* @property float|null $width
* @property float|null $height
* @property bool $track_stock
* @property int $stock_quantity
* @property int $low_stock_threshold
* @property string $stock_status
* @property bool $allow_backorder
* @property string $type
* @property int|null $parent_id
* @property array|null $variant_attributes
* @property string|null $image_url
* @property array|null $gallery_urls
* @property string|null $slug
* @property bool $is_active
* @property bool $is_featured
* @property bool $is_visible
* @property array|null $metadata
*/
class Product extends Model
{
use HasContentOverrides;
use HasFactory;
use LogsActivity;
use SoftDeletes;
// Product types
public const TYPE_SIMPLE = 'simple';
public const TYPE_VARIABLE = 'variable';
public const TYPE_BUNDLE = 'bundle';
public const TYPE_VIRTUAL = 'virtual';
public const TYPE_SUBSCRIPTION = 'subscription';
// Stock statuses
public const STOCK_IN_STOCK = 'in_stock';
public const STOCK_LOW = 'low_stock';
public const STOCK_OUT = 'out_of_stock';
public const STOCK_BACKORDER = 'backorder';
public const STOCK_DISCONTINUED = 'discontinued';
// Tax classes
public const TAX_STANDARD = 'standard';
public const TAX_REDUCED = 'reduced';
public const TAX_ZERO = 'zero';
public const TAX_EXEMPT = 'exempt';
protected $table = 'commerce_products';
protected $fillable = [
'sku',
'owner_entity_id',
'name',
'description',
'short_description',
'category',
'subcategory',
'tags',
'price',
'cost_price',
'rrp',
'currency',
'price_tiers',
'tax_class',
'tax_inclusive',
'weight',
'length',
'width',
'height',
'track_stock',
'stock_quantity',
'low_stock_threshold',
'stock_status',
'allow_backorder',
'type',
'parent_id',
'variant_attributes',
'image_url',
'gallery_urls',
'slug',
'meta_title',
'meta_description',
'is_active',
'is_featured',
'is_visible',
'available_from',
'available_until',
'sort_order',
'metadata',
];
protected $casts = [
'tags' => 'array',
'price' => 'integer',
'cost_price' => 'integer',
'rrp' => 'integer',
'price_tiers' => 'array',
'tax_inclusive' => 'boolean',
'weight' => 'decimal:3',
'length' => 'decimal:2',
'width' => 'decimal:2',
'height' => 'decimal:2',
'track_stock' => 'boolean',
'stock_quantity' => 'integer',
'low_stock_threshold' => 'integer',
'allow_backorder' => 'boolean',
'variant_attributes' => 'array',
'gallery_urls' => 'array',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'is_visible' => 'boolean',
'available_from' => 'datetime',
'available_until' => 'datetime',
'sort_order' => 'integer',
'metadata' => 'array',
];
// Relationships
public function ownerEntity(): BelongsTo
{
return $this->belongsTo(Entity::class, 'owner_entity_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function variants(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
public function assignments(): HasMany
{
return $this->hasMany(ProductAssignment::class, 'product_id');
}
public function prices(): HasMany
{
return $this->hasMany(ProductPrice::class, 'product_id');
}
// Type helpers
public function isSimple(): bool
{
return $this->type === self::TYPE_SIMPLE;
}
public function isVariable(): bool
{
return $this->type === self::TYPE_VARIABLE;
}
public function isBundle(): bool
{
return $this->type === self::TYPE_BUNDLE;
}
public function isVirtual(): bool
{
return $this->type === self::TYPE_VIRTUAL;
}
public function isSubscription(): bool
{
return $this->type === self::TYPE_SUBSCRIPTION;
}
public function isVariant(): bool
{
return $this->parent_id !== null;
}
// Stock helpers
public function isInStock(): bool
{
if (! $this->track_stock) {
return true;
}
return $this->stock_quantity > 0 || $this->allow_backorder;
}
public function isLowStock(): bool
{
return $this->track_stock && $this->stock_quantity <= $this->low_stock_threshold;
}
public function updateStockStatus(): self
{
if (! $this->track_stock) {
$this->stock_status = self::STOCK_IN_STOCK;
} elseif ($this->stock_quantity <= 0) {
$this->stock_status = $this->allow_backorder ? self::STOCK_BACKORDER : self::STOCK_OUT;
} elseif ($this->stock_quantity <= $this->low_stock_threshold) {
$this->stock_status = self::STOCK_LOW;
} else {
$this->stock_status = self::STOCK_IN_STOCK;
}
return $this;
}
public function adjustStock(int $quantity, string $reason = ''): self
{
$this->stock_quantity += $quantity;
$this->updateStockStatus();
$this->save();
return $this;
}
// Price helpers
/**
* Get formatted price.
*/
public function getFormattedPriceAttribute(): string
{
return $this->formatPrice($this->price);
}
/**
* Get price for a specific tier.
*/
public function getTierPrice(string $tier): ?int
{
return $this->price_tiers[$tier] ?? null;
}
/**
* Format a price value.
*/
public function formatPrice(int $amount, ?string $currency = null): string
{
$currency = $currency ?? $this->currency;
$currencyService = app(\Core\Commerce\Services\CurrencyService::class);
return $currencyService->format($amount, $currency, isCents: true);
}
/**
* Get price in a specific currency.
*
* Returns explicit price if set, otherwise auto-converts from base price.
*/
public function getPriceInCurrency(string $currency): ?int
{
$currency = strtoupper($currency);
// Check for explicit price
$price = $this->prices()->where('currency', $currency)->first();
if ($price) {
return $price->amount;
}
// Auto-convert if enabled
if (! config('commerce.currencies.auto_convert', true)) {
return null;
}
// Same currency as base
if ($currency === $this->currency) {
return $this->price;
}
// Convert from base currency
$rate = ExchangeRate::getRate($this->currency, $currency);
if ($rate === null) {
return null;
}
return (int) round($this->price * $rate);
}
/**
* Get formatted price in a specific currency.
*/
public function getFormattedPriceInCurrency(string $currency): ?string
{
$amount = $this->getPriceInCurrency($currency);
if ($amount === null) {
return null;
}
return $this->formatPrice($amount, $currency);
}
/**
* Set an explicit price for a currency.
*/
public function setPriceForCurrency(string $currency, int $amount): ProductPrice
{
return $this->prices()->updateOrCreate(
['currency' => strtoupper($currency)],
[
'amount' => $amount,
'is_manual' => true,
'exchange_rate_used' => null,
]
);
}
/**
* Remove explicit price for a currency (will fall back to conversion).
*/
public function removePriceForCurrency(string $currency): bool
{
return $this->prices()
->where('currency', strtoupper($currency))
->delete() > 0;
}
/**
* Refresh all auto-converted prices from exchange rates.
*/
public function refreshConvertedPrices(): void
{
ProductPrice::refreshAutoConverted($this);
}
/**
* Calculate margin percentage.
*/
public function getMarginPercentAttribute(): ?float
{
if (! $this->cost_price || $this->cost_price === 0) {
return null;
}
return round((($this->price - $this->cost_price) / $this->price) * 100, 2);
}
// SKU helpers
/**
* Build full SKU with entity lineage.
*/
public function buildFullSku(Entity $entity): string
{
return $entity->buildSku($this->sku);
}
/**
* Generate a unique SKU.
*/
public static function generateSku(string $prefix = ''): string
{
$random = strtoupper(Str::random(8));
return $prefix ? "{$prefix}-{$random}" : $random;
}
// Availability helpers
public function isAvailable(): bool
{
if (! $this->is_active || ! $this->is_visible) {
return false;
}
$now = now();
if ($this->available_from && $now->lt($this->available_from)) {
return false;
}
if ($this->available_until && $now->gt($this->available_until)) {
return false;
}
return true;
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeVisible($query)
{
return $query->where('is_visible', true);
}
public function scopeFeatured($query)
{
return $query->where('is_featured', true);
}
public function scopeInStock($query)
{
return $query->where(function ($q) {
$q->where('track_stock', false)
->orWhere('stock_quantity', '>', 0)
->orWhere('allow_backorder', true);
});
}
public function scopeForOwner($query, int $entityId)
{
return $query->where('owner_entity_id', $entityId);
}
public function scopeInCategory($query, string $category)
{
return $query->where('category', $category);
}
public function scopeOfType($query, string $type)
{
return $query->where('type', $type);
}
public function scopeParentsOnly($query)
{
return $query->whereNull('parent_id');
}
// Content override support
/**
* Get the fields that can be overridden by M2/M3 entities.
*/
public function getOverrideableFields(): array
{
return [
'name',
'description',
'short_description',
'image_url',
'gallery_urls',
'meta_title',
'meta_description',
'slug',
];
}
// Boot
protected static function boot(): void
{
parent::boot();
static::creating(function (self $product) {
// Generate slug if not set
if (! $product->slug) {
$product->slug = Str::slug($product->name);
}
// Uppercase SKU
$product->sku = strtoupper($product->sku);
});
static::saving(function (self $product) {
// Update stock status
$product->updateStockStatus();
});
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'sku', 'price', 'is_active', 'stock_status'])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "Product {$eventName}");
}
}