'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\Mod\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}"); } }