feat: mejorar validación de stock en la creación de ventas, considerando seriales y agrupación por producto

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-19 21:37:26 -06:00
parent 115a033510
commit c2929d0b2f
2 changed files with 200 additions and 94 deletions

View File

@ -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.'); throw new \Exception('No se pueden editar movimientos de venta o devolución.');
} }
// 1. Revertir el movimiento original // Preparar datos completos para actualización según tipo
$this->revertMovement($movement);
// 2. Preparar datos completos para actualización según tipo
$updateData = $this->prepareUpdateData($movement, $data); $updateData = $this->prepareUpdateData($movement, $data);
// 3. Aplicar el nuevo movimiento según el tipo // Aplicar según el tipo de movimiento
switch ($movement->movement_type) { if ($movement->movement_type === 'entry') {
case 'entry': // Entradas usan lógica delta (no revertir + reaplicar)
$this->applyEntryUpdate($movement, $updateData); $this->applyEntryDeltaUpdate($movement, $updateData);
break; } else {
case 'exit': // Salidas y traspasos mantienen lógica revert + apply
$this->applyExitUpdate($movement, $updateData); $this->revertMovement($movement);
break;
case 'transfer': match ($movement->movement_type) {
$this->applyTransferUpdate($movement, $updateData); 'exit' => $this->applyExitUpdate($movement, $updateData),
break; 'transfer' => $this->applyTransferUpdate($movement, $updateData),
};
} }
// 4. Actualizar el registro del movimiento // Actualizar el registro del movimiento
$movement->update($updateData); $movement->update($updateData);
UserEvent::report(model: $movement, event: 'updated', key: 'movement_type'); 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 protected function revertMovement(InventoryMovement $movement): void
{ {
switch ($movement->movement_type) { switch ($movement->movement_type) {
case 'entry': // Las entradas NO se revierten aquí, usan lógica delta en applyEntryDeltaUpdate()
// 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': case 'exit':
// Revertir salida: devolver al almacén origen // 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; $inventory = $movement->inventory;
$warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $oldQuantity = (float) $movement->quantity;
$quantity = $data['quantity'] ?? $movement->quantity; $newQuantity = (float) ($data['quantity'] ?? $movement->quantity);
$unitCost = $data['unit_cost'] ?? $movement->unit_cost; $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 // --- Recálculo de costo promedio ponderado (ANTES de ajustar stock) ---
$currentStock = $inventory->stock; $currentStock = (float) $inventory->stock;
$currentCost = $inventory->price?->cost ?? 0.00; $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( $newCost = $this->calculateWeightedAverageCost(
$currentStock, $stockSinEntrada,
$currentCost, $costoRevertido,
$quantity, $newQuantity,
$unitCost $newUnitCost
); );
// Actualizar costo
$this->updateProductCost($inventory, $newCost); $this->updateProductCost($inventory, $newCost);
// Aplicar nuevo stock // --- Ajuste de stock y seriales ---
$this->updateWarehouseStock($inventory->id, $warehouseToId, $quantity); $inventory->load('unitOfMeasure');
$usesSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals;
// Revertir y aplicar nuevos seriales (solo si se proporcionaron o cambió la cantidad) 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); $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 protected function updateMovementSerials(InventoryMovement $movement, array $data): void
{ {
@ -893,9 +943,11 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat
return; return;
} }
// Verificar si la cantidad cambió $oldQuantity = (int) $movement->quantity;
$quantityChanged = isset($data['quantity']) && $data['quantity'] != $movement->quantity; $newQuantity = (int) ($data['quantity'] ?? $movement->quantity);
$quantityChanged = $newQuantity != $oldQuantity;
$serialsProvided = !empty($data['serial_numbers']); $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 // Si NO cambió la cantidad Y NO se proporcionaron seriales, no hacer nada
if (!$quantityChanged && !$serialsProvided) { if (!$quantityChanged && !$serialsProvided) {
@ -907,47 +959,67 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat
throw new \Exception('Debe proporcionar los números de serie cuando cambia la cantidad del movimiento.'); throw new \Exception('Debe proporcionar los números de serie cuando cambia la cantidad del movimiento.');
} }
// Si llegamos aquí, hay que actualizar los seriales // Obtener seriales actuales del movimiento
// Validar que TODOS los seriales actuales estén disponibles $currentSerials = InventorySerial::where('movement_id', $movement->id)->get();
$totalSerials = InventorySerial::where('movement_id', $movement->id)->count(); $existingSerialNumbers = $currentSerials->pluck('serial_number')->toArray();
$availableSerials = InventorySerial::where('movement_id', $movement->id) $newSerialNumbers = array_values(array_filter(array_map('trim', $data['serial_numbers'])));
->where('status', 'disponible')
->count();
if ($totalSerials > $availableSerials) { // Validar que la cantidad de seriales proporcionados coincida con la nueva cantidad
if (count($newSerialNumbers) !== $newQuantity) {
throw new \Exception( 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 // Determinar qué seriales se conservan, cuáles se agregan y cuáles se eliminan
$quantity = $data['quantity'] ?? $movement->quantity; $serialesToKeep = array_intersect($newSerialNumbers, $existingSerialNumbers);
if (count($data['serial_numbers']) !== (int)$quantity) { $serialesToAdd = array_diff($newSerialNumbers, $existingSerialNumbers);
throw new \Exception('La cantidad de números de serie debe coincidir con la cantidad del movimiento.'); $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 no existan seriales duplicados en los nuevos // Validar que los nuevos seriales no existan ya en el sistema
foreach ($data['serial_numbers'] as $serialNumber) { foreach ($serialesToAdd as $serialNumber) {
$exists = InventorySerial::where('inventory_id', $inventory->id) $exists = InventorySerial::where('serial_number', $serialNumber)
->where('serial_number', $serialNumber) ->where('movement_id', '!=', $movement->id)
->where('movement_id', '!=', $movement->id) // Excluir los del movimiento actual
->exists(); ->exists();
if ($exists) { if ($exists) {
throw new \Exception("El número de serie '{$serialNumber}' ya existe para este producto."); throw new \Exception("El número de serie '{$serialNumber}' ya existe en el sistema.");
} }
} }
// Eliminar seriales antiguos // Eliminar seriales que ya no están en la lista
if (!empty($serialesToRemove)) {
InventorySerial::where('movement_id', $movement->id) InventorySerial::where('movement_id', $movement->id)
->whereIn('serial_number', $serialesToRemove)
->where('status', 'disponible') ->where('status', 'disponible')
->delete(); ->delete();
}
// Crear nuevos seriales // Actualizar almacén de los seriales que se conservan (por si cambió el almacén)
$warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; 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 = []; $serials = [];
foreach ($serialesToAdd as $serialNumber) {
foreach ($data['serial_numbers'] as $serialNumber) {
$serials[] = [ $serials[] = [
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'movement_id' => $movement->id, 'movement_id' => $movement->id,
@ -958,10 +1030,13 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat
'updated_at' => now(), 'updated_at' => now(),
]; ];
} }
InventorySerial::insert($serials); InventorySerial::insert($serials);
} }
// Sincronizar stock desde seriales
$inventory->syncStock();
}
/** /**
* Revertir seriales de un movimiento * Revertir seriales de un movimiento
*/ */

View File

@ -310,15 +310,46 @@ private function expandBundlesIntoComponents(array $items): array
*/ */
private function validateStockForAllItems(array $items): void 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) { foreach ($items as $item) {
$inventory = Inventory::find($item['inventory_id']); $inventoryId = $item['inventory_id'];
$warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId(); $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) { if ($inventory->track_serials) {
// Validar seriales disponibles if (!empty($serialsByProduct[$entry['inventory_id']])) {
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
// Validar que los seriales específicos existan y estén disponibles // 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) $serial = InventorySerial::where('inventory_id', $inventory->id)
->where('serial_number', $serialNumber) ->where('serial_number', $serialNumber)
->where('status', 'disponible') ->where('status', 'disponible')
@ -331,21 +362,21 @@ private function validateStockForAllItems(array $items): void
} }
} }
} else { } else {
// Validar que haya suficientes seriales disponibles // Validar que haya suficientes seriales disponibles para la cantidad TOTAL
$availableSerials = InventorySerial::where('inventory_id', $inventory->id) $availableSerials = InventorySerial::where('inventory_id', $inventory->id)
->where('status', 'disponible') ->where('status', 'disponible')
->count(); ->count();
if ($availableSerials < $item['quantity']) { if ($availableSerials < $entry['quantity']) {
throw new \Exception( throw new \Exception(
"Stock insuficiente de seriales para {$inventory->name}. " . "Stock insuficiente de seriales para {$inventory->name}. " .
"Disponibles: {$availableSerials}, Requeridos: {$item['quantity']}" "Disponibles: {$availableSerials}, Requeridos: {$entry['quantity']}"
); );
} }
} }
} else { } else {
// Validar stock en almacén // Validar stock total en almacén
$this->movementService->validateStock($inventory->id, $warehouseId, $item['quantity']); $this->movementService->validateStock($inventory->id, $entry['warehouse_id'], $entry['quantity']);
} }
} }
} }