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.');
}
// 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)
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,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.');
}
// 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 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
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
// 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 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)
->whereIn('serial_number', $serialesToRemove)
->where('status', 'disponible')
->delete();
}
// Crear nuevos seriales
$warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id;
// 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 ($data['serial_numbers'] as $serialNumber) {
foreach ($serialesToAdd as $serialNumber) {
$serials[] = [
'inventory_id' => $inventory->id,
'movement_id' => $movement->id,
@ -958,10 +1030,13 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat
'updated_at' => now(),
];
}
InventorySerial::insert($serials);
}
// Sincronizar stock desde seriales
$inventory->syncStock();
}
/**
* Revertir seriales de un movimiento
*/

View File

@ -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']);
}
}
}