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.');
|
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) {
|
||||||
$this->updateMovementSerials($movement, $data);
|
// 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
|
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,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.');
|
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 no existan seriales duplicados en los nuevos
|
// Validar que los seriales a eliminar estén disponibles (no vendidos/devueltos)
|
||||||
foreach ($data['serial_numbers'] as $serialNumber) {
|
if (!empty($serialesToRemove)) {
|
||||||
$exists = InventorySerial::where('inventory_id', $inventory->id)
|
$unavailable = $currentSerials
|
||||||
->where('serial_number', $serialNumber)
|
->whereIn('serial_number', $serialesToRemove)
|
||||||
->where('movement_id', '!=', $movement->id) // Excluir los del movimiento actual
|
->where('status', '!=', 'disponible');
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($exists) {
|
if ($unavailable->isNotEmpty()) {
|
||||||
throw new \Exception("El número de serie '{$serialNumber}' ya existe para este producto.");
|
$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
|
// Validar que los nuevos seriales no existan ya en el sistema
|
||||||
InventorySerial::where('movement_id', $movement->id)
|
foreach ($serialesToAdd as $serialNumber) {
|
||||||
->where('status', 'disponible')
|
$exists = InventorySerial::where('serial_number', $serialNumber)
|
||||||
->delete();
|
->where('movement_id', '!=', $movement->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
// Crear nuevos seriales
|
if ($exists) {
|
||||||
$warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id;
|
throw new \Exception("El número de serie '{$serialNumber}' ya existe en el sistema.");
|
||||||
$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);
|
// 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
|
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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user