addMediaCollection('image'); } public function imageUrl(): ?string { $m = $this->getFirstMedia('image'); if (! $m) return null; if (! @file_exists($m->getPath())) return null; return $m->getUrl(); } /** @return list All published image URLs (excluding any whose file is missing). */ public function imageUrls(): array { return $this->getMedia('image') ->filter(fn ($m) => @file_exists($m->getPath())) ->map(fn ($m) => $m->getUrl()) ->values()->all(); } public const CATEGORIES = [ 'Ulei', 'Filtre', 'Frâne', 'Suspensie', 'Lichide', 'Distribuție', 'Anvelope', 'Electrică', 'Caroserie', 'Altele', ]; protected $fillable = [ 'company_id', 'name', 'article', 'brand', 'category', 'qty', 'qty_reserved', 'unit', 'min_qty', 'buy_price', 'sell_price', 'hidden_markup_pct', 'location', 'barcode', 'preferred_supplier_id', 'is_active', 'is_published', 'notes', ]; protected $casts = [ 'qty' => 'decimal:2', 'qty_reserved' => 'decimal:3', 'min_qty' => 'decimal:2', 'buy_price' => 'decimal:2', 'sell_price' => 'decimal:2', 'hidden_markup_pct' => 'decimal:2', 'is_active' => 'boolean', 'is_published' => 'boolean', ]; /** Internal cost+hidden markup (NOT shown to customer). Used for margin analytics + B2B contract pricing. */ public function internalCostWithHiddenMarkup(): float { $base = (float) $this->buy_price; $pct = (float) ($this->hidden_markup_pct ?: 0); return round($base * (1 + $pct / 100), 2); } public function preferredSupplier(): BelongsTo { return $this->belongsTo(Supplier::class, 'preferred_supplier_id'); } public function batches(): HasMany { return $this->hasMany(PartBatch::class); } public function reservations(): HasMany { return $this->hasMany(PartReservation::class); } public function events(): HasMany { return $this->hasMany(WarehouseEvent::class); } public function priceHistory(): HasMany { return $this->hasMany(SupplierPartPrice::class); } public function crossRefs(): HasMany { return $this->hasMany(PartCrossRef::class); } public function scopePublished($q) { return $q->where('is_active', true)->where('is_published', true); } /** * Search published parts by free text against name / article / brand and * any registered cross-reference article. Returns a query builder. */ public static function searchPublished(?string $term) { $q = static::published(); if ($term = trim((string) $term)) { $like = '%' . $term . '%'; $q->where(function ($w) use ($like, $term) { $w->where('name', 'like', $like) ->orWhere('article', 'like', $like) ->orWhere('brand', 'like', $like) ->orWhereHas('crossRefs', fn ($c) => $c->where('cross_article', 'like', $like)); }); } return $q; } /** Live total across all batches of all warehouses (source of truth). */ public function qtyOnHand(?int $warehouseId = null): float { $q = $this->batches()->newQuery()->where('part_id', $this->id); if ($warehouseId) $q->where('warehouse_id', $warehouseId); return (float) $q->sum('qty_remaining'); } /** Available for new reservations = on hand − already reserved. */ public function qtyAvailable(?int $warehouseId = null): float { return max(0.0, $this->qtyOnHand($warehouseId) - (float) $this->qty_reserved); } public function isLow(): bool { return (float) $this->qty <= (float) $this->min_qty; } public function isOut(): bool { return (float) $this->qty <= 0; } /** * Legacy direct-stock adjustment. * NOTE: this only moves the cached `qty` column. Real stock changes * should go through WarehouseService so batches + events stay in sync. */ public function adjustStock(float $delta, ?string $reason = null): void { $this->qty = max(0, (float) $this->qty + $delta); $this->save(); } }