load('unitOfMeasure'); // 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, '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']); $quantity = (float) $productData['quantity']; $unitCost = (float)$productData['unit_cost']; $serialNumbers = $productData['serial_numbers'] ?? null; // 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, 'supplier_id' => $data['supplier_id'] ?? null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, 'invoice_reference' => $data['invoice_reference'], ]); // Crear seriales DESPUÉS (para asignar movement_id) if (!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']); } 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']); $quantity = (float) $productData['quantity']; $serialNumbers = $productData['serial_numbers'] ?? null; // **NUEVO**: 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."); } } // Eliminar los seriales (salida definitiva) InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) ->delete(); // 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, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); $movements[] = $movement->load(['inventory', 'warehouseFrom']); } 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::findOrFail($productData['inventory_id']); $quantity = (float) $productData['quantity']; $serialNumbers = (array) ($productData['serial_numbers'] ?? null); // 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."); } } // Actualizar warehouse_id de los seriales seleccionados InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) ->update(['warehouse_id' => $warehouseTo->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, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); $movements[] = $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']); } 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']); $quantity = (float) $data['quantity']; $serialNumbers = $data['serial_numbers'] ?? null; // **NUEVO**: Cargar la unidad de medida $inventory->load('unitOfMeasure'); // **CAMBIO**: 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."); } } // Eliminar los seriales (salida definitiva) InventorySerial::whereIn('serial_numbers', $serialNumbers) ->where('inventory_id', $inventory->id) ->delete(); // 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 return InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouse->id, 'warehouse_to_id' => null, 'movement_type' => 'exit', 'quantity' => $quantity, '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']); $quantity = (float) $data['quantity']; $serialNumbers = $data['serial_numbers'] ?? null; // Validar que no sea el mismo almacén if ($warehouseFrom->id === $warehouseTo->id) { throw new \Exception('No se puede traspasar al mismo almacén.'); } // **NUEVO**: 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."); } } // Actualizar warehouse_id de los seriales seleccionados InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) ->update(['warehouse_id' => $warehouseTo->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 return InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouseFrom->id, 'warehouse_to_id' => $warehouseTo->id, 'movement_type' => 'transfer', 'quantity' => $quantity, '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.'); } // 1. Revertir el movimiento original $this->revertMovement($movement); // 2. Preparar datos completos para actualización según tipo $updateData = $this->prepareUpdateData($movement, $data); // 3. Aplicar el nuevo movimiento según el tipo switch ($movement->movement_type) { case 'entry': $this->applyEntryUpdate($movement, $updateData); break; case 'exit': $this->applyExitUpdate($movement, $updateData); break; case 'transfer': $this->applyTransferUpdate($movement, $updateData); break; } // 4. Actualizar el registro del movimiento $movement->update($updateData); return $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'serials']); }); } /** * Preparar datos completos para actualización */ protected function prepareUpdateData(InventoryMovement $movement, array $data): array { $updateData = [ 'quantity' => $data['quantity'] ?? $movement->quantity, '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') { $updateData['unit_cost'] = $data['unit_cost'] ?? $movement->unit_cost; $updateData['invoice_reference'] = $data['invoice_reference'] ?? $movement->invoice_reference; $updateData['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_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) { case 'entry': // Revertir entrada: restar del almacén destino // NOTA: Los seriales se manejan en updateMovementSerials(), no aquí $this->updateWarehouseStock( $movement->inventory_id, $movement->warehouse_to_id, -$movement->quantity ); break; case 'exit': // Revertir salida: devolver al almacén origen $this->updateWarehouseStock( $movement->inventory_id, $movement->warehouse_from_id, $movement->quantity ); break; case 'transfer': // Revertir traspaso: 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 */ protected function applyEntryUpdate(InventoryMovement $movement, array $data): void { $inventory = $movement->inventory; $warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $quantity = $data['quantity'] ?? $movement->quantity; $unitCost = $data['unit_cost'] ?? $movement->unit_cost; // Recalcular costo promedio ponderado $currentStock = $inventory->stock; $currentCost = $inventory->price?->cost ?? 0.00; $newCost = $this->calculateWeightedAverageCost( $currentStock, $currentCost, $quantity, $unitCost ); // Actualizar costo $this->updateProductCost($inventory, $newCost); // Aplicar nuevo stock $this->updateWarehouseStock($inventory->id, $warehouseToId, $quantity); // Revertir y aplicar nuevos seriales (solo si se proporcionaron o cambió la cantidad) $this->updateMovementSerials($movement, $data); } /** * Aplicar actualización de salida */ protected function applyExitUpdate(InventoryMovement $movement, array $data): void { $warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; $quantity = $data['quantity'] ?? $movement->quantity; // 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; // Validar que no sea el mismo almacén if ($warehouseFromId === $warehouseToId) { throw new \Exception('No se puede traspasar al mismo almacén.'); } // 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 (revertir antiguos y aplicar nuevos) */ 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; } // Verificar si la cantidad cambió $quantityChanged = isset($data['quantity']) && $data['quantity'] != $movement->quantity; $serialsProvided = !empty($data['serial_numbers']); // 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.'); } // Si llegamos aquí, hay que actualizar los seriales // Validar que TODOS los seriales actuales estén disponibles $totalSerials = InventorySerial::where('movement_id', $movement->id)->count(); $availableSerials = InventorySerial::where('movement_id', $movement->id) ->where('status', 'disponible') ->count(); if ($totalSerials > $availableSerials) { throw new \Exception( 'No se puede editar este movimiento porque algunos seriales ya fueron vendidos o devueltos.' ); } // Validar que la cantidad de nuevos seriales coincida con la cantidad $quantity = $data['quantity'] ?? $movement->quantity; if (count($data['serial_numbers']) !== (int)$quantity) { throw new \Exception('La cantidad de números de serie debe coincidir con la cantidad del movimiento.'); } // Validar que no existan seriales duplicados en los nuevos foreach ($data['serial_numbers'] as $serialNumber) { $exists = InventorySerial::where('inventory_id', $inventory->id) ->where('serial_number', $serialNumber) ->where('movement_id', '!=', $movement->id) // Excluir los del movimiento actual ->exists(); if ($exists) { throw new \Exception("El número de serie '{$serialNumber}' ya existe para este producto."); } } // Eliminar seriales antiguos InventorySerial::where('movement_id', $movement->id) ->where('status', 'disponible') ->delete(); // Crear nuevos seriales $warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $serials = []; foreach ($data['serial_numbers'] 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); } /** * 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; } } }