load('unitOfMeasure'); // Conversión de equivalencia de unidades $inputUnitId = $data['unit_of_measure_id'] ?? $inventory->unit_of_measure_id; $inputQuantity = (float) $data['quantity']; $inputUnitCost = (float) $data['unit_cost']; $usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id; if ($usesEquivalence && $inventory->track_serials) { throw new \Exception('No se pueden usar equivalencias de unidad para productos con rastreo de seriales.'); } $quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity; $unitCost = $usesEquivalence ? $inventory->convertCostToBaseUnit($inputUnitCost, $inputUnitId) : $inputUnitCost; // Solo validar seriales si track_serials Y la unidad NO permite decimales $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; if ($requiresSerials) { if (empty($serialNumbers)) { throw new \Exception('Este producto requiere números de serie. Debe proporcionar '.$quantity.' seriales.'); } // Filtrar seriales vacíos y hacer trim $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { return ! empty($serial); }); // Re-indexar el array $serialNumbers = array_values($serialNumbers); $serialCount = count($serialNumbers); if ($serialCount != $quantity) { throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Seriales recibidos: ".implode(', ', $serialNumbers)); } } // Obtener stock actual para calcular costo promedio ponderado $currentStock = $inventory->stock; $currentCost = $inventory->price?->cost ?? 0.00; // Calcular nuevo costo promedio ponderado $newCost = $this->calculateWeightedAverageCost( $currentStock, $currentCost, $quantity, $unitCost ); // Actualizar costo en prices $this->updateProductCost($inventory, $newCost); // Registrar movimiento PRIMERO $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => null, 'warehouse_to_id' => $warehouse->id, 'movement_type' => 'entry', 'quantity' => $quantity, 'unit_cost' => $unitCost, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'unit_cost_original' => $usesEquivalence ? $inputUnitCost : null, 'supplier_id' => $data['supplier_id'] ?? null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, 'invoice_reference' => $data['invoice_reference'] ?? null, ]); // Crear seriales DESPUÉS del movimiento (para asignar movement_id) if ($requiresSerials && ! empty($serialNumbers)) { $serials = []; foreach ($serialNumbers as $serialNumber) { $serials[] = [ 'inventory_id' => $inventory->id, 'movement_id' => $movement->id, // Ahora tenemos el ID 'warehouse_id' => $warehouse->id, 'serial_number' => trim($serialNumber), 'status' => 'disponible', 'created_at' => now(), 'updated_at' => now(), ]; } InventorySerial::insert($serials); // Activar track_serials si no estaba activo if (! $inventory->track_serials) { $inventory->update(['track_serials' => true]); } // Sincronizar stock desde seriales $inventory->syncStock(); } else { // **SIN SERIALES**: Actualizar stock manualmente (permite decimales) $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); } return $movement; }); } /** * Entrada múltiple de inventario (varios productos) */ public function bulkEntry(array $data): array { return DB::transaction(function () use ($data) { $warehouse = Warehouse::findOrFail($data['warehouse_id']); $movements = []; foreach ($data['products'] as $productData) { $inventory = Inventory::findOrFail($productData['inventory_id']); $inventory->load('unitOfMeasure'); $serialNumbers = $productData['serial_numbers'] ?? null; // Conversión de equivalencia de unidades $inputUnitId = $productData['unit_of_measure_id'] ?? $inventory->unit_of_measure_id; $inputQuantity = (float) $productData['quantity']; $inputUnitCost = (float) $productData['unit_cost']; $usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id; if ($usesEquivalence && $inventory->track_serials) { throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name})."); } $quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity; $unitCost = $usesEquivalence ? $inventory->convertCostToBaseUnit($inputUnitCost, $inputUnitId) : $inputUnitCost; // Validar seriales si el producto los requiere if ($inventory->track_serials) { if (empty($serialNumbers)) { throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales."); } // Filtrar seriales vacíos y hacer trim $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { return ! empty($serial); }); // Re-indexar el array $serialNumbers = array_values($serialNumbers); $serialCount = count($serialNumbers); if ($serialCount != $quantity) { throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Seriales recibidos: ".implode(', ', $serialNumbers)); } // Actualizar el array limpio $productData['serial_numbers'] = $serialNumbers; } // Obtener stock actual para calcular costo promedio ponderado $currentStock = $inventory->stock; $currentCost = $inventory->price?->cost ?? 0.00; // Calcular nuevo costo promedio ponderado $newCost = $this->calculateWeightedAverageCost( $currentStock, $currentCost, $quantity, $unitCost ); // Actualizar costo en prices $this->updateProductCost($inventory, $newCost); // Crear movimiento PRIMERO $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => null, 'warehouse_to_id' => $warehouse->id, 'movement_type' => 'entry', 'quantity' => $quantity, 'unit_cost' => $unitCost, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'unit_cost_original' => $usesEquivalence ? $inputUnitCost : null, 'supplier_id' => $data['supplier_id'] ?? null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, 'invoice_reference' => $data['invoice_reference'], ]); // Solo crear seriales si el producto los requiere (track_serials Y unidad sin decimales) $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; // Crear seriales DESPUÉS (para asignar movement_id) if ($requiresSerials && ! empty($serialNumbers)) { $serials = []; foreach ($serialNumbers as $serialNumber) { $serials[] = [ 'inventory_id' => $inventory->id, 'movement_id' => $movement->id, 'warehouse_id' => $warehouse->id, 'serial_number' => trim($serialNumber), 'status' => 'disponible', 'created_at' => now(), 'updated_at' => now(), ]; } InventorySerial::insert($serials); // Activar track_serials si no estaba activo if (! $inventory->track_serials) { $inventory->update(['track_serials' => true]); } // Sincronizar stock desde seriales $inventory->syncStock(); } else { // Sin seriales, actualizar stock manualmente $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); } $movements[] = $movement->load(['inventory', 'warehouseTo', 'serials']); } return $movements; }); } /** * Salida múltiple de inventario (varios productos) */ public function bulkExit(array $data): array { return DB::transaction(function () use ($data) { $warehouse = Warehouse::findOrFail($data['warehouse_id']); $movements = []; foreach ($data['products'] as $productData) { $inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']); $serialNumbers = $productData['serial_numbers'] ?? null; // Conversión de equivalencia de unidades $inputUnitId = $productData['unit_of_measure_id'] ?? $inventory->unit_of_measure_id; $inputQuantity = (float) $productData['quantity']; $usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id; if ($usesEquivalence && $inventory->track_serials) { throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name})."); } $quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity; // Solo exigir seriales si track_serials Y unidad NO permite decimales $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; // Validar y procesar seriales si el producto los requiere if ($requiresSerials) { if (empty($serialNumbers)) { throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales."); } // Filtrar seriales vacíos y hacer trim $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { return ! empty($serial); }); $serialNumbers = array_values($serialNumbers); $serialCount = count($serialNumbers); if ($serialCount != $quantity) { throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales."); } // Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén correcto foreach ($serialNumbers as $serialNumber) { $serial = InventorySerial::where('serial_number', $serialNumber) ->where('inventory_id', $inventory->id) ->first(); if (! $serial) { throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no pertenece a este producto."); } if ($serial->status != 'disponible') { throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está disponible (estado: {$serial->status})."); } if ($serial->warehouse_id != $warehouse->id) { throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está en el almacén seleccionado."); } } // Registrar movimiento PRIMERO para tener el ID disponible $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouse->id, 'warehouse_to_id' => null, 'movement_type' => 'exit', 'quantity' => $quantity, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); // Marcar seriales como salida (no eliminar, para conservar historial) InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) ->update([ 'status' => 'salida', 'exit_movement_id' => $movement->id, ]); // Sincronizar stock desde seriales $inventory->syncStock(); } else { // Sin seriales, validar y decrementar stock manualmente $this->validateStock($inventory->id, $warehouse->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); // Registrar movimiento $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouse->id, 'warehouse_to_id' => null, 'movement_type' => 'exit', 'quantity' => $quantity, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); } $movements[] = $movement->load(['inventory', 'warehouseFrom', 'exitedSerials']); } return $movements; }); } /** * Traspaso múltiple entre almacenes (varios productos) */ public function bulkTransfer(array $data): array { return DB::transaction(function () use ($data) { $warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']); $warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']); $movements = []; // Validar que no sea el mismo almacén if ($warehouseFrom->id === $warehouseTo->id) { throw new \Exception('No se puede traspasar al mismo almacén.'); } foreach ($data['products'] as $productData) { $inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']); $serialNumbers = (array) ($productData['serial_numbers'] ?? null); // Conversión de equivalencia de unidades $inputUnitId = $productData['unit_of_measure_id'] ?? $inventory->unit_of_measure_id; $inputQuantity = (float) $productData['quantity']; $usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id; if ($usesEquivalence && $inventory->track_serials) { throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name})."); } $quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity; // Validar y procesar seriales si el producto los requiere if ($inventory->track_serials) { if (empty($serialNumbers)) { throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales."); } // Filtrar seriales vacíos y hacer trim $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { return ! empty($serial); }); $serialNumbers = array_values($serialNumbers); $serialCount = count($serialNumbers); if ($serialCount != $quantity) { throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales."); } // Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén origen foreach ($serialNumbers as $serialNumber) { $serial = InventorySerial::where('serial_number', $serialNumber) ->where('inventory_id', $inventory->id) ->first(); if (! $serial) { throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no pertenece a este producto."); } if ($serial->status !== 'disponible') { throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está disponible (estado: {$serial->status})."); } if ($serial->warehouse_id !== $warehouseFrom->id) { throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está en el almacén origen."); } } // Registrar movimiento PRIMERO para tener el ID disponible $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouseFrom->id, 'warehouse_to_id' => $warehouseTo->id, 'movement_type' => 'transfer', 'quantity' => $quantity, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); // Actualizar warehouse_id y transfer_movement_id de los seriales seleccionados InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) ->update([ 'warehouse_id' => $warehouseTo->id, 'transfer_movement_id' => $movement->id, ]); // Sincronizar stock desde seriales (actualiza ambos almacenes) $inventory->syncStock(); } else { // Sin seriales, validar y actualizar stock manualmente $this->validateStock($inventory->id, $warehouseFrom->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity); $this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); // Registrar movimiento $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouseFrom->id, 'warehouse_to_id' => $warehouseTo->id, 'movement_type' => 'transfer', 'quantity' => $quantity, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); } $movements[] = $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'transferredSerials']); } return $movements; }); } /** * Calcular costo promedio ponderado */ protected function calculateWeightedAverageCost( float $currentStock, float $currentCost, float $entryQuantity, float $entryCost ): float { if ($currentStock <= 0) { return $entryCost; } $totalValue = ($currentStock * $currentCost) + ($entryQuantity * $entryCost); $totalQuantity = $currentStock + $entryQuantity; return round($totalValue / $totalQuantity, 2); } protected function updateProductCost(Inventory $inventory, float $newCost): void { $inventory->price()->updateOrCreate( ['inventory_id' => $inventory->id], ['cost' => $newCost] ); } /** * Salida de inventario (merma, ajuste negativo, robo, daño) */ public function exit(array $data): InventoryMovement { return DB::transaction(function () use ($data) { $inventory = Inventory::findOrFail($data['inventory_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']); $serialNumbers = $data['serial_numbers'] ?? null; // Cargar la unidad de medida $inventory->load('unitOfMeasure'); // Conversión de equivalencia de unidades $inputUnitId = $data['unit_of_measure_id'] ?? $inventory->unit_of_measure_id; $inputQuantity = (float) $data['quantity']; $usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id; if ($usesEquivalence && $inventory->track_serials) { throw new \Exception('No se pueden usar equivalencias de unidad para productos con rastreo de seriales.'); } $quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity; // Solo validar seriales si track_serials Y la unidad NO permite decimales $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; // Validar y procesar seriales si el producto los requiere if ($requiresSerials) { if (empty($serialNumbers)) { throw new \Exception('Este producto requiere números de serie. Debe proporcionar '.$quantity.' seriales.'); } // Filtrar seriales vacíos y hacer trim $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { return ! empty($serial); }); $serialNumbers = array_values($serialNumbers); $serialCount = count($serialNumbers); if ($serialCount != $quantity) { throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales."); } // Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén correcto foreach ($serialNumbers as $serialNumber) { $serial = InventorySerial::where('serial_number', $serialNumber) ->where('inventory_id', $inventory->id) ->first(); if (! $serial) { throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto."); } if ($serial->status !== 'disponible') { throw new \Exception("El serial '{$serialNumber}' no está disponible (estado: {$serial->status})."); } if ($serial->warehouse_id !== $warehouse->id) { throw new \Exception("El serial '{$serialNumber}' no está en el almacén seleccionado."); } } // Registrar movimiento PRIMERO para tener el ID disponible $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouse->id, 'warehouse_to_id' => null, 'movement_type' => 'exit', 'quantity' => $quantity, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); // Marcar seriales como salida (no eliminar, para conservar historial) InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) ->update([ 'status' => 'salida', 'exit_movement_id' => $movement->id, ]); // Sincronizar stock desde seriales $inventory->syncStock(); return $movement; } else { // **SIN SERIALES**: Validar y decrementar stock manualmente $this->validateStock($inventory->id, $warehouse->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); } // Registrar movimiento (solo para caso sin seriales) return InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouse->id, 'warehouse_to_id' => null, 'movement_type' => 'exit', 'quantity' => $quantity, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); }); } /** * Traspaso entre almacenes */ public function transfer(array $data): InventoryMovement { return DB::transaction(function () use ($data) { $inventory = Inventory::with('unitOfMeasure')->findOrFail($data['inventory_id']); $warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']); $warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']); $serialNumbers = $data['serial_numbers'] ?? null; // Conversión de equivalencia de unidades $inputUnitId = $data['unit_of_measure_id'] ?? $inventory->unit_of_measure_id; $inputQuantity = (float) $data['quantity']; $usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id; if ($usesEquivalence && $inventory->track_serials) { throw new \Exception('No se pueden usar equivalencias de unidad para productos con rastreo de seriales.'); } $quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity; // Validar que no sea el mismo almacén if ($warehouseFrom->id === $warehouseTo->id) { throw new \Exception('No se puede traspasar al mismo almacén.'); } // Solo exigir seriales si track_serials Y unidad NO permite decimales $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; // Validar y procesar seriales si el producto los requiere if ($requiresSerials) { if (empty($serialNumbers)) { throw new \Exception('Este producto requiere números de serie. Debe proporcionar '.$quantity.' seriales.'); } // Filtrar seriales vacíos y hacer trim $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { return ! empty($serial); }); $serialNumbers = array_values($serialNumbers); $serialCount = count($serialNumbers); if ($serialCount != $quantity) { throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales."); } // Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén origen foreach ($serialNumbers as $serialNumber) { $serial = InventorySerial::where('serial_number', $serialNumber) ->where('inventory_id', $inventory->id) ->first(); if (! $serial) { throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto."); } if ($serial->status !== 'disponible') { throw new \Exception("El serial '{$serialNumber}' no está disponible (estado: {$serial->status})."); } if ($serial->warehouse_id !== $warehouseFrom->id) { throw new \Exception("El serial '{$serialNumber}' no está en el almacén origen."); } } // Registrar movimiento PRIMERO para tener el ID disponible $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouseFrom->id, 'warehouse_to_id' => $warehouseTo->id, 'movement_type' => 'transfer', 'quantity' => $quantity, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); // Actualizar warehouse_id y transfer_movement_id de los seriales seleccionados InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) ->update([ 'warehouse_id' => $warehouseTo->id, 'transfer_movement_id' => $movement->id, ]); // Sincronizar stock desde seriales (actualiza ambos almacenes) $inventory->syncStock(); return $movement; } else { // Sin seriales, validar y actualizar stock manualmente $this->validateStock($inventory->id, $warehouseFrom->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity); $this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); } // Registrar movimiento (solo para caso sin seriales) return InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouseFrom->id, 'warehouse_to_id' => $warehouseTo->id, 'movement_type' => 'transfer', 'quantity' => $quantity, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); }); } /** * Actualizar stock en inventory_warehouse */ public function updateWarehouseStock(int $inventoryId, int $warehouseId, float $quantityChange): void { $record = InventoryWarehouse::firstOrCreate( [ 'inventory_id' => $inventoryId, 'warehouse_id' => $warehouseId, ], ['stock' => 0] ); $newStock = $record->stock + $quantityChange; if ($newStock < 0) { throw new \Exception('Stock insuficiente en el almacén.'); } $record->update(['stock' => $newStock]); } /** * Validar stock disponible */ public function validateStock(int $inventoryId, int $warehouseId, float $requiredQuantity): void { $record = InventoryWarehouse::where('inventory_id', $inventoryId) ->where('warehouse_id', $warehouseId) ->first(); $availableStock = $record?->stock ?? 0; if ($availableStock < $requiredQuantity) { throw new \Exception("Stock insuficiente. Disponible: {$availableStock}, Requerido: {$requiredQuantity}"); } } /** * Registrar movimiento de venta */ public function recordSale(int $inventoryId, int $warehouseId, float $quantity, int $saleId): InventoryMovement { return InventoryMovement::create([ 'inventory_id' => $inventoryId, 'warehouse_from_id' => $warehouseId, 'warehouse_to_id' => null, 'movement_type' => 'sale', 'quantity' => $quantity, 'reference_type' => 'App\Models\Sale', 'reference_id' => $saleId, 'user_id' => auth()->id(), ]); } /** * Registrar movimiento de devolución */ public function recordReturn(int $inventoryId, int $warehouseId, float $quantity, int $returnId): InventoryMovement { return InventoryMovement::create([ 'inventory_id' => $inventoryId, 'warehouse_from_id' => null, 'warehouse_to_id' => $warehouseId, 'movement_type' => 'return', 'quantity' => $quantity, 'reference_type' => 'App\Models\Returns', 'reference_id' => $returnId, 'user_id' => auth()->id(), ]); } /** * Obtener el almacén principal */ public function getMainWarehouseId(): int { $warehouse = Warehouse::where('is_main', true)->first(); if (! $warehouse) { throw new \Exception('No existe un almacén principal configurado.'); } return $warehouse->id; } /** * Actualizar un movimiento existente */ public function updateMovement(int $movementId, array $data): InventoryMovement { return DB::transaction(function () use ($movementId, $data) { $movement = InventoryMovement::findOrFail($movementId); // No permitir editar movimientos de venta o devolución (son automáticos) if (in_array($movement->movement_type, ['sale', 'return'])) { throw new \Exception('No se pueden editar movimientos de venta o devolución.'); } // Preparar datos completos para actualización según tipo $updateData = $this->prepareUpdateData($movement, $data); // Aplicar según el tipo de movimiento if ($movement->movement_type === 'entry') { // Entradas usan lógica delta (no revertir + reaplicar) $this->applyEntryDeltaUpdate($movement, $updateData); } else { // Salidas y traspasos mantienen lógica revert + apply $this->revertMovement($movement); match ($movement->movement_type) { 'exit' => $this->applyExitUpdate($movement, $updateData), 'transfer' => $this->applyTransferUpdate($movement, $updateData), }; } // Actualizar el registro del movimiento $movement->update($updateData); UserEvent::report(model: $movement, event: 'updated', key: 'movement_type'); return $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier', 'serials', 'transferredSerials', 'exitedSerials']); }); } /** * Preparar datos completos para actualización */ protected function prepareUpdateData(InventoryMovement $movement, array $data): array { $inventory = $movement->inventory; // Determinar si se usa equivalencia de unidades $inputUnitId = $data['unit_of_measure_id'] ?? $movement->unit_of_measure_id ?? $inventory->unit_of_measure_id; $usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id; // Convertir cantidad a unidades base si usa equivalencia $inputQuantity = (float) ($data['quantity'] ?? ($movement->unit_quantity ?? $movement->quantity)); $quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity; $updateData = [ 'quantity' => $quantity, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'notes' => $data['notes'] ?? $movement->notes, ]; // Pasar serial_numbers si fueron proporcionados if (! empty($data['serial_numbers'])) { $updateData['serial_numbers'] = $data['serial_numbers']; } // Campos específicos por tipo de movimiento if ($movement->movement_type === 'entry') { $inputUnitCost = (float) ($data['unit_cost'] ?? ($movement->unit_cost_original ?? $movement->unit_cost)); $unitCost = $usesEquivalence ? $inventory->convertCostToBaseUnit($inputUnitCost, $inputUnitId) : $inputUnitCost; $updateData['unit_cost'] = $unitCost; $updateData['unit_cost_original'] = $usesEquivalence ? $inputUnitCost : null; $updateData['invoice_reference'] = $data['invoice_reference'] ?? $movement->invoice_reference; $updateData['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $updateData['supplier_id'] = array_key_exists('supplier_id', $data) ? $data['supplier_id'] : $movement->supplier_id; } elseif ($movement->movement_type === 'exit') { $updateData['warehouse_from_id'] = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; } elseif ($movement->movement_type === 'transfer') { $updateData['warehouse_from_id'] = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; $updateData['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; } return $updateData; } /** * Revertir el impacto de un movimiento en el stock */ protected function revertMovement(InventoryMovement $movement): void { switch ($movement->movement_type) { // Las entradas NO se revierten aquí, usan lógica delta en applyEntryDeltaUpdate() case 'exit': $inventory = $movement->inventory; $inventory->load('unitOfMeasure'); $usesSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; if ($usesSerials) { // Revertir seriales: restaurar a disponible y limpiar exit_movement_id InventorySerial::where('exit_movement_id', $movement->id) ->update([ 'status' => 'disponible', 'exit_movement_id' => null, ]); $inventory->syncStock(); } else { // Revertir salida sin seriales: devolver stock al almacén origen $this->updateWarehouseStock( $movement->inventory_id, $movement->warehouse_from_id, $movement->quantity ); } break; case 'transfer': $inventory = $movement->inventory; if ($inventory->track_serials) { // Revertir seriales: mover de vuelta al almacén origen InventorySerial::where('transfer_movement_id', $movement->id) ->update([ 'warehouse_id' => $movement->warehouse_from_id, 'transfer_movement_id' => null, ]); $inventory->syncStock(); } else { // Revertir traspaso sin seriales: devolver al origen, quitar del destino $this->updateWarehouseStock( $movement->inventory_id, $movement->warehouse_from_id, $movement->quantity ); $this->updateWarehouseStock( $movement->inventory_id, $movement->warehouse_to_id, -$movement->quantity ); } break; } } /** * Aplicar actualización de entrada usando lógica delta * * calcula la diferencia entre la cantidad original y la nueva, * y valida si esa diferencia es posible dado el stock actual. */ protected function applyEntryDeltaUpdate(InventoryMovement $movement, array $data): void { $inventory = $movement->inventory; $oldQuantity = (float) $movement->quantity; $newQuantity = (float) ($data['quantity'] ?? $movement->quantity); $oldUnitCost = (float) $movement->unit_cost; $newUnitCost = (float) ($data['unit_cost'] ?? $movement->unit_cost); $oldWarehouseId = $movement->warehouse_to_id; $newWarehouseId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $warehouseChanged = $newWarehouseId != $oldWarehouseId; // --- Recálculo de costo promedio ponderado (ANTES de ajustar stock) --- $currentStock = (float) $inventory->stock; $currentCost = (float) ($inventory->price?->cost ?? 0.00); // Revertir contribución de la entrada original al costo promedio $stockSinEntrada = $currentStock - $oldQuantity; if ($stockSinEntrada > 0) { $costoRevertido = (($currentStock * $currentCost) - ($oldQuantity * $oldUnitCost)) / $stockSinEntrada; $costoRevertido = max(0, $costoRevertido); } else { $costoRevertido = $newUnitCost; $stockSinEntrada = 0; } $newCost = $this->calculateWeightedAverageCost( $stockSinEntrada, $costoRevertido, $newQuantity, $newUnitCost ); $this->updateProductCost($inventory, $newCost); // --- Ajuste de stock y seriales --- $inventory->load('unitOfMeasure'); $usesSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; if ($usesSerials) { // Productos con seriales: el stock se sincroniza automáticamente desde syncStock() // Solo necesitamos actualizar los seriales, el stock se recalcula al final $this->updateMovementSerials($movement, $data); } else { // Productos sin seriales: ajustar stock manualmente con delta if ($warehouseChanged) { $oldWarehouseStock = $inventory->stockInWarehouse($oldWarehouseId); if ($oldWarehouseStock < $oldQuantity) { throw new \Exception( 'No se puede cambiar el almacén de esta entrada porque el stock actual ' ."del almacén origen ({$oldWarehouseStock}) no es suficiente para retirar " ."las {$oldQuantity} unidades originales. Los productos ya fueron vendidos o movidos." ); } $this->updateWarehouseStock($inventory->id, $oldWarehouseId, -$oldQuantity); $this->updateWarehouseStock($inventory->id, $newWarehouseId, $newQuantity); } else { $delta = $newQuantity - $oldQuantity; if ($delta != 0) { if ($delta < 0) { $currentWarehouseStock = $inventory->stockInWarehouse($newWarehouseId); if ($currentWarehouseStock < abs($delta)) { throw new \Exception( "No se puede reducir esta entrada de {$oldQuantity} a {$newQuantity} " ."porque el stock actual del almacén ({$currentWarehouseStock}) no es suficiente " .'para absorber la reducción de '.abs($delta).' unidades. ' .'Los productos ya fueron vendidos o movidos.' ); } } $this->updateWarehouseStock($inventory->id, $newWarehouseId, $delta); } } } } /** * Aplicar actualización de salida */ protected function applyExitUpdate(InventoryMovement $movement, array $data): void { $inventory = $movement->inventory; $inventory->load('unitOfMeasure'); $usesSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; $warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; $quantity = $data['quantity'] ?? $movement->quantity; if ($usesSerials) { $serialNumbers = $data['serial_numbers'] ?? []; if (empty($serialNumbers)) { throw new \Exception('Debe proporcionar los números de serie para actualizar esta salida.'); } $serialNumbers = array_values(array_filter(array_map('trim', $serialNumbers))); if (count($serialNumbers) != $quantity) { throw new \Exception('La cantidad de seriales no coincide con la cantidad del movimiento.'); } // Validar que los seriales existan, estén disponibles y en el almacén correcto foreach ($serialNumbers as $serialNumber) { $serial = InventorySerial::where('serial_number', $serialNumber) ->where('inventory_id', $inventory->id) ->first(); if (! $serial) { throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto."); } if ($serial->status !== 'disponible') { throw new \Exception("El serial '{$serialNumber}' no está disponible (estado: {$serial->status})."); } if ($serial->warehouse_id !== $warehouseFromId) { throw new \Exception("El serial '{$serialNumber}' no está en el almacén seleccionado."); } } // Marcar los nuevos seriales como salida InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) ->update([ 'status' => 'salida', 'exit_movement_id' => $movement->id, ]); $inventory->syncStock(); } else { // Validar stock disponible $this->validateStock($movement->inventory_id, $warehouseFromId, $quantity); // Aplicar nueva salida $this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity); } } /** * Aplicar actualización de traspaso */ protected function applyTransferUpdate(InventoryMovement $movement, array $data): void { $warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; $warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $quantity = $data['quantity'] ?? $movement->quantity; $inventory = $movement->inventory; // Validar que no sea el mismo almacén if ($warehouseFromId === $warehouseToId) { throw new \Exception('No se puede traspasar al mismo almacén.'); } if ($inventory->track_serials) { $serialNumbers = $data['serial_numbers'] ?? []; if (empty($serialNumbers)) { throw new \Exception("Se requieren los números de serie para editar el traspaso de '{$inventory->name}'."); } if (count($serialNumbers) !== (int) $quantity) { throw new \Exception('La cantidad no coincide con el número de seriales proporcionados.'); } // Validar que cada serial esté disponible en el almacén origen foreach ($serialNumbers as $serialNumber) { $serial = InventorySerial::where('inventory_id', $inventory->id) ->where('serial_number', $serialNumber) ->where('warehouse_id', $warehouseFromId) ->where('status', 'disponible') ->first(); if (! $serial) { throw new \Exception("Serial {$serialNumber} no disponible en el almacén origen."); } } // Mover seriales al almacén destino InventorySerial::where('inventory_id', $inventory->id) ->whereIn('serial_number', $serialNumbers) ->update([ 'warehouse_id' => $warehouseToId, 'transfer_movement_id' => $movement->id, ]); $inventory->syncStock(); } else { // Validar stock disponible en origen $this->validateStock($movement->inventory_id, $warehouseFromId, $quantity); // Aplicar nuevo traspaso $this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity); $this->updateWarehouseStock($movement->inventory_id, $warehouseToId, $quantity); } } /** * Sincronizar stock en inventory_warehouse basado en seriales disponibles * Solo aplica para productos con track_serials = true */ public function syncStockFromSerials(Inventory $inventory): void { if (! $inventory->track_serials) { return; } // Contar seriales disponibles por almacén $stockByWarehouse = $inventory->serials() ->where('status', 'disponible') ->whereNotNull('warehouse_id') ->selectRaw('warehouse_id, COUNT(*) as total') ->groupBy('warehouse_id') ->pluck('total', 'warehouse_id'); // Actualizar stock en cada almacén foreach ($stockByWarehouse as $warehouseId => $count) { InventoryWarehouse::updateOrCreate( [ 'inventory_id' => $inventory->id, 'warehouse_id' => $warehouseId, ], ['stock' => $count] ); } // Poner en 0 los almacenes que ya no tienen seriales disponibles InventoryWarehouse::where('inventory_id', $inventory->id) ->whereNotIn('warehouse_id', $stockByWarehouse->keys()) ->update(['stock' => 0]); } /** * Actualizar seriales de un movimiento usando lógica delta * * - Aumento (delta > 0): solo agrega los nuevos seriales sin tocar los existentes * - Reducción (delta < 0): solo elimina seriales disponibles, bloquea si hay vendidos/devueltos en los que se quieren quitar * - Sin cambio de cantidad: permite reemplazar seriales disponibles por otros */ protected function updateMovementSerials(InventoryMovement $movement, array $data): void { $inventory = $movement->inventory; if (! $inventory->track_serials) { return; } // Solo aplicar a movimientos de entrada if ($movement->movement_type !== 'entry') { return; } $oldQuantity = (int) $movement->quantity; $newQuantity = (int) ($data['quantity'] ?? $movement->quantity); $quantityChanged = $newQuantity != $oldQuantity; $serialsProvided = ! empty($data['serial_numbers']); $warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; // Si NO cambió la cantidad Y NO se proporcionaron seriales, no hacer nada if (! $quantityChanged && ! $serialsProvided) { return; } // Si cambió la cantidad pero NO se proporcionaron seriales, lanzar error if ($quantityChanged && ! $serialsProvided) { throw new \Exception('Debe proporcionar los números de serie cuando cambia la cantidad del movimiento.'); } // Obtener seriales actuales del movimiento $currentSerials = InventorySerial::where('movement_id', $movement->id)->get(); $existingSerialNumbers = $currentSerials->pluck('serial_number')->toArray(); $newSerialNumbers = array_values(array_filter(array_map('trim', $data['serial_numbers']))); // Validar que la cantidad de seriales proporcionados coincida con la nueva cantidad if (count($newSerialNumbers) !== $newQuantity) { throw new \Exception( 'La cantidad de números de serie ('.count($newSerialNumbers).') debe coincidir con la cantidad del movimiento ('.$newQuantity.').' ); } // Determinar qué seriales se conservan, cuáles se agregan y cuáles se eliminan $serialesToKeep = array_intersect($newSerialNumbers, $existingSerialNumbers); $serialesToAdd = array_diff($newSerialNumbers, $existingSerialNumbers); $serialesToRemove = array_diff($existingSerialNumbers, $newSerialNumbers); // Validar que los seriales a eliminar estén disponibles (no vendidos/devueltos) if (! empty($serialesToRemove)) { $unavailable = $currentSerials ->whereIn('serial_number', $serialesToRemove) ->where('status', '!=', 'disponible'); if ($unavailable->isNotEmpty()) { $unavailableNumbers = $unavailable->pluck('serial_number')->implode(', '); throw new \Exception( "No se pueden quitar los seriales [{$unavailableNumbers}] porque ya fueron vendidos o devueltos." ); } // Validar que los seriales a eliminar no hayan sido traspasados a otro almacén $transferred = $currentSerials ->whereIn('serial_number', $serialesToRemove) ->where('warehouse_id', '!=', $movement->warehouse_to_id); if ($transferred->isNotEmpty()) { $transferredNumbers = $transferred->pluck('serial_number')->implode(', '); throw new \Exception( "No se pueden quitar los seriales [{$transferredNumbers}] porque fueron traspasados a otro almacén." ); } } // Validar que los nuevos seriales no existan ya en el sistema foreach ($serialesToAdd as $serialNumber) { $exists = InventorySerial::where('serial_number', $serialNumber) ->where('movement_id', '!=', $movement->id) ->exists(); if ($exists) { throw new \Exception("El número de serie '{$serialNumber}' ya existe en el sistema."); } } // Eliminar seriales que ya no están en la lista if (! empty($serialesToRemove)) { InventorySerial::where('movement_id', $movement->id) ->whereIn('serial_number', $serialesToRemove) ->where('status', 'disponible') ->delete(); } // Actualizar almacén de los seriales que se conservan (por si cambió el almacén) if (! empty($serialesToKeep)) { InventorySerial::where('movement_id', $movement->id) ->whereIn('serial_number', $serialesToKeep) ->update(['warehouse_id' => $warehouseToId]); } // Crear los nuevos seriales if (! empty($serialesToAdd)) { $serials = []; foreach ($serialesToAdd as $serialNumber) { $serials[] = [ 'inventory_id' => $inventory->id, 'movement_id' => $movement->id, 'serial_number' => $serialNumber, 'warehouse_id' => $warehouseToId, 'status' => 'disponible', 'created_at' => now(), 'updated_at' => now(), ]; } InventorySerial::insert($serials); } // Sincronizar stock desde seriales $inventory->syncStock(); } /** * Revertir seriales de un movimiento */ protected function revertSerials(InventoryMovement $movement): void { $inventory = $movement->inventory; if (! $inventory->track_serials) { return; } switch ($movement->movement_type) { case 'entry': // Eliminar seriales creados por este movimiento (solo si están disponibles) InventorySerial::where('movement_id', $movement->id) ->where('status', 'disponible') ->delete(); break; case 'exit': case 'transfer': break; } } }