* * @version 1.0.0 */ class Inventory extends Model { protected $fillable = [ 'category_id', 'unit_of_measure_id', 'name', 'key_sat', 'sku', 'barcode', 'track_serials', 'is_active', ]; protected $casts = [ 'is_active' => 'boolean', 'track_serials' => 'boolean', ]; protected $appends = ['has_serials', 'inventory_value', 'stock']; public function warehouses() { return $this->belongsToMany(Warehouse::class, 'inventory_warehouse') ->withPivot('stock', 'min_stock', 'max_stock') ->withTimestamps(); } /** * Stock total en todos los almacenes (ahora soporta decimales) */ public function getStockAttribute(): float { return (float) $this->warehouses()->sum('inventory_warehouse.stock'); } /** * Alias para compatibilidad */ public function getTotalStockAttribute(): float { return $this->stock; } /** * Stock en un almacén específico (ahora soporta decimales) */ public function stockInWarehouse(int $warehouseId): float { return (float) ($this->warehouses() ->where('warehouse_id', $warehouseId) ->value('inventory_warehouse.stock') ?? 0); } public function category() { return $this->belongsTo(Category::class); } public function unitOfMeasure() { return $this->belongsTo(UnitOfMeasurement::class); } public function price() { return $this->hasOne(Price::class); } public function serials() { return $this->hasMany(InventorySerial::class); } /** * Obtener seriales disponibles */ public function availableSerials() { return $this->hasMany(InventorySerial::class) ->where('status', 'disponible'); } public function unitEquivalences() { return $this->hasMany(UnitEquivalence::class); } /** * Obtener factor de conversión para una unidad dada. * Retorna 1.0 si es la unidad base del producto. */ public function getConversionFactor(int $unitOfMeasureId): float { if ($this->unit_of_measure_id === $unitOfMeasureId) { return 1.0; } $equivalence = $this->unitEquivalences() ->where('unit_of_measure_id', $unitOfMeasureId) ->where('is_active', true) ->first(); if (! $equivalence) { throw new \Exception( "No existe equivalencia activa para la unidad seleccionada en el producto '{$this->name}'." ); } return (float) $equivalence->conversion_factor; } /** * Convertir cantidad de cualquier unidad a unidades base */ public function convertToBaseUnits(float $quantity, int $unitOfMeasureId): float { return round($quantity * $this->getConversionFactor($unitOfMeasureId), 3); } /** * Convertir costo por unidad de entrada a costo por unidad base */ public function convertCostToBaseUnit(float $costPerUnit, int $unitOfMeasureId): float { $factor = $this->getConversionFactor($unitOfMeasureId); if ($factor <= 0) { throw new \Exception('Factor de conversión inválido.'); } return round($costPerUnit / $factor, 2); } /** * Stock basado en seriales disponibles (para productos con track_serials) */ public function getAvailableStockAttribute(): int { return $this->availableSerials()->count(); } public function getHasSerialsAttribute(): bool { return isset($this->attributes['serials_count']) && $this->attributes['serials_count'] > 0; } /** * Valor total del inventario (stock * costo) */ public function getInventoryValueAttribute(): float { return $this->stock * ($this->price?->cost ?? 0); } /** * Sincronizar stock basado en seriales disponibles * Delega al servicio para mantener la lógica centralizada */ public function syncStock(): void { app(InventoryMovementService::class)->syncStockFromSerials($this); } }