feat: mejorar validación de stock en la creación de ventas, considerando seriales y agrupación por producto
This commit is contained in:
parent
115a033510
commit
c2929d0b2f
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user