diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index 78ea9ab..91fb7a5 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -675,26 +675,24 @@ public function updateMovement(int $movementId, array $data): InventoryMovement 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 + // 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; + // 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), + }; } - // 4. Actualizar el registro del movimiento + // Actualizar el registro del movimiento $movement->update($updateData); UserEvent::report(model: $movement, event: 'updated', key: 'movement_type'); @@ -739,15 +737,7 @@ protected function prepareUpdateData(InventoryMovement $movement, array $data): 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; + // Las entradas NO se revierten aquí, usan lógica delta en applyEntryDeltaUpdate() case 'exit': // Revertir salida: devolver al almacén origen @@ -775,34 +765,90 @@ protected function revertMovement(InventoryMovement $movement): void } /** - * Aplicar actualización de entrada + * 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 applyEntryUpdate(InventoryMovement $movement, array $data): void + protected function applyEntryDeltaUpdate(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; + $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; - // Recalcular costo promedio ponderado - $currentStock = $inventory->stock; - $currentCost = $inventory->price?->cost ?? 0.00; + // --- 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( - $currentStock, - $currentCost, - $quantity, - $unitCost + $stockSinEntrada, + $costoRevertido, + $newQuantity, + $newUnitCost ); - // Actualizar costo $this->updateProductCost($inventory, $newCost); - // Aplicar nuevo stock - $this->updateWarehouseStock($inventory->id, $warehouseToId, $quantity); + // --- Ajuste de stock y seriales --- + $inventory->load('unitOfMeasure'); + $usesSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals; - // Revertir y aplicar nuevos seriales (solo si se proporcionaron o cambió la cantidad) - $this->updateMovementSerials($movement, $data); + 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); + } + } + } } /** @@ -878,7 +924,11 @@ public function syncStockFromSerials(Inventory $inventory): void } /** - * Actualizar seriales de un movimiento (revertir antiguos y aplicar nuevos) + * 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 { @@ -893,9 +943,11 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat return; } - // Verificar si la cantidad cambió - $quantityChanged = isset($data['quantity']) && $data['quantity'] != $movement->quantity; + $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) { @@ -907,59 +959,82 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat 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(); + // 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']))); - if ($totalSerials > $availableSerials) { + // Validar que la cantidad de seriales proporcionados coincida con la nueva cantidad + if (count($newSerialNumbers) !== $newQuantity) { throw new \Exception( - 'No se puede editar este movimiento porque algunos seriales ya fueron vendidos o devueltos.' + 'La cantidad de números de serie (' . count($newSerialNumbers) . ') debe coincidir con la cantidad del movimiento (' . $newQuantity . ').' ); } - // 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.'); - } + // 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 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(); + // 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 ($exists) { - throw new \Exception("El número de serie '{$serialNumber}' ya existe para este producto."); + 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." + ); } } - // Eliminar seriales antiguos - InventorySerial::where('movement_id', $movement->id) - ->where('status', 'disponible') - ->delete(); + // 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(); - // 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(), - ]; + if ($exists) { + throw new \Exception("El número de serie '{$serialNumber}' ya existe en el sistema."); + } } - InventorySerial::insert($serials); + // 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(); } /** diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index 1f37b92..e4a56a4 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -310,15 +310,46 @@ private function expandBundlesIntoComponents(array $items): array */ private function validateStockForAllItems(array $items): void { + // Agrupar cantidad total por producto+almacén para evitar que un bundle y un producto + // suelto pasen la validación individualmente pero no haya stock suficiente en conjunto + $aggregated = []; + $serialsByProduct = []; + foreach ($items as $item) { - $inventory = Inventory::find($item['inventory_id']); + $inventoryId = $item['inventory_id']; $warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId(); + $key = "{$inventoryId}_{$warehouseId}"; + + if (!isset($aggregated[$key])) { + $aggregated[$key] = [ + 'inventory_id' => $inventoryId, + 'warehouse_id' => $warehouseId, + 'quantity' => 0, + ]; + } + + $aggregated[$key]['quantity'] += $item['quantity']; + + // Acumular seriales específicos por producto + if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { + if (!isset($serialsByProduct[$inventoryId])) { + $serialsByProduct[$inventoryId] = []; + } + $serialsByProduct[$inventoryId] = array_merge( + $serialsByProduct[$inventoryId], + $item['serial_numbers'] + ); + } + } + + // Validar stock total agrupado + foreach ($aggregated as $entry) { + $inventory = Inventory::find($entry['inventory_id']); if ($inventory->track_serials) { - // Validar seriales disponibles - if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { + if (!empty($serialsByProduct[$entry['inventory_id']])) { // Validar que los seriales específicos existan y estén disponibles - foreach ($item['serial_numbers'] as $serialNumber) { + foreach ($serialsByProduct[$entry['inventory_id']] as $serialNumber) { $serial = InventorySerial::where('inventory_id', $inventory->id) ->where('serial_number', $serialNumber) ->where('status', 'disponible') @@ -331,21 +362,21 @@ private function validateStockForAllItems(array $items): void } } } else { - // Validar que haya suficientes seriales disponibles + // Validar que haya suficientes seriales disponibles para la cantidad TOTAL $availableSerials = InventorySerial::where('inventory_id', $inventory->id) ->where('status', 'disponible') ->count(); - if ($availableSerials < $item['quantity']) { + if ($availableSerials < $entry['quantity']) { throw new \Exception( "Stock insuficiente de seriales para {$inventory->name}. " . - "Disponibles: {$availableSerials}, Requeridos: {$item['quantity']}" + "Disponibles: {$availableSerials}, Requeridos: {$entry['quantity']}" ); } } } else { - // Validar stock en almacén - $this->movementService->validateStock($inventory->id, $warehouseId, $item['quantity']); + // Validar stock total en almacén + $this->movementService->validateStock($inventory->id, $entry['warehouse_id'], $entry['quantity']); } } }