pdv.backend/app/Services/InventoryMovementService.php

991 lines
40 KiB
PHP

<?php
namespace App\Services;
use App\Models\Inventory;
use App\Models\InventoryMovement;
use App\Models\InventorySerial;
use App\Models\InventoryWarehouse;
use App\Models\UserEvent;
use App\Models\Warehouse;
use Illuminate\Support\Facades\DB;
/**
* Servicio para gestión de movimientos de inventario
*
* El stock vive en inventory_warehouse, no en inventories
*/
class InventoryMovementService
{
/**
* Entrada de inventario
*/
public function entry(array $data): InventoryMovement
{
return DB::transaction(function () use ($data) {
$inventory = Inventory::findOrFail($data['inventory_id']);
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
$quantity = (float) $data['quantity'];
$unitCost = (float) $data['unit_cost'];
$serialNumbers = $data['serial_numbers'] ?? null;
// cargar la unidad de medida
$inventory->load('unitOfMeasure');
// Solo validar seriales si track_serials Y la unidad NO permite decimales
$requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals;
if ($requiresSerials) {
if (empty($serialNumbers)) {
throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.');
}
// Filtrar seriales vacíos y hacer trim
$serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
// Re-indexar el array
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount != $quantity) {
throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Seriales recibidos: " . implode(', ', $serialNumbers));
}
}
// Obtener stock actual para calcular costo promedio ponderado
$currentStock = $inventory->stock;
$currentCost = $inventory->price?->cost ?? 0.00;
// Calcular nuevo costo promedio ponderado
$newCost = $this->calculateWeightedAverageCost(
$currentStock,
$currentCost,
$quantity,
$unitCost
);
// Actualizar costo en prices
$this->updateProductCost($inventory, $newCost);
// Registrar movimiento PRIMERO
$movement = InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => null,
'warehouse_to_id' => $warehouse->id,
'movement_type' => 'entry',
'quantity' => $quantity,
'unit_cost' => $unitCost,
'supplier_id' => $data['supplier_id'] ?? null,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
'invoice_reference' => $data['invoice_reference'] ?? null,
]);
// Crear seriales DESPUÉS del movimiento (para asignar movement_id)
if ($requiresSerials && !empty($serialNumbers)) {
$serials = [];
foreach ($serialNumbers as $serialNumber) {
$serials[] = [
'inventory_id' => $inventory->id,
'movement_id' => $movement->id, // Ahora tenemos el ID
'warehouse_id' => $warehouse->id,
'serial_number' => trim($serialNumber),
'status' => 'disponible',
'created_at' => now(),
'updated_at' => now(),
];
}
InventorySerial::insert($serials);
// Activar track_serials si no estaba activo
if (!$inventory->track_serials) {
$inventory->update(['track_serials' => true]);
}
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// **SIN SERIALES**: Actualizar stock manualmente (permite decimales)
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
}
return $movement;
});
}
/**
* Entrada múltiple de inventario (varios productos)
*/
public function bulkEntry(array $data): array
{
return DB::transaction(function () use ($data) {
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
$movements = [];
foreach ($data['products'] as $productData) {
$inventory = Inventory::findOrFail($productData['inventory_id']);
$quantity = (float) $productData['quantity'];
$unitCost = (float)$productData['unit_cost'];
$serialNumbers = $productData['serial_numbers'] ?? null;
// Validar seriales si el producto los requiere
if ($inventory->track_serials) {
if (empty($serialNumbers)) {
throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales.");
}
// Filtrar seriales vacíos y hacer trim
$serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
// Re-indexar el array
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount != $quantity) {
throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Seriales recibidos: " . implode(', ', $serialNumbers));
}
// Actualizar el array limpio
$productData['serial_numbers'] = $serialNumbers;
}
// Obtener stock actual para calcular costo promedio ponderado
$currentStock = $inventory->stock;
$currentCost = $inventory->price?->cost ?? 0.00;
// Calcular nuevo costo promedio ponderado
$newCost = $this->calculateWeightedAverageCost(
$currentStock,
$currentCost,
$quantity,
$unitCost
);
// Actualizar costo en prices
$this->updateProductCost($inventory, $newCost);
// Crear movimiento PRIMERO
$movement = InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => null,
'warehouse_to_id' => $warehouse->id,
'movement_type' => 'entry',
'quantity' => $quantity,
'unit_cost' => $unitCost,
'supplier_id' => $data['supplier_id'] ?? null,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
'invoice_reference' => $data['invoice_reference'],
]);
// Crear seriales DESPUÉS (para asignar movement_id)
if (!empty($serialNumbers)) {
$serials = [];
foreach ($serialNumbers as $serialNumber) {
$serials[] = [
'inventory_id' => $inventory->id,
'movement_id' => $movement->id,
'warehouse_id' => $warehouse->id,
'serial_number' => trim($serialNumber),
'status' => 'disponible',
'created_at' => now(),
'updated_at' => now(),
];
}
InventorySerial::insert($serials);
// Activar track_serials si no estaba activo
if (!$inventory->track_serials) {
$inventory->update(['track_serials' => true]);
}
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// Sin seriales, actualizar stock manualmente
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
}
$movements[] = $movement->load(['inventory', 'warehouseTo']);
}
return $movements;
});
}
/**
* Salida múltiple de inventario (varios productos)
*/
public function bulkExit(array $data): array
{
return DB::transaction(function () use ($data) {
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
$movements = [];
foreach ($data['products'] as $productData) {
$inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']);
$quantity = (float) $productData['quantity'];
$serialNumbers = $productData['serial_numbers'] ?? null;
// Solo exigir seriales si track_serials Y unidad NO permite decimales
$requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals;
// Validar y procesar seriales si el producto los requiere
if ($requiresSerials) {
if (empty($serialNumbers)) {
throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales.");
}
// Filtrar seriales vacíos y hacer trim
$serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount != $quantity) {
throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales.");
}
// Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén correcto
foreach ($serialNumbers as $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('inventory_id', $inventory->id)
->first();
if (!$serial) {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no pertenece a este producto.");
}
if ($serial->status != 'disponible') {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está disponible (estado: {$serial->status}).");
}
if ($serial->warehouse_id != $warehouse->id) {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está en el almacén seleccionado.");
}
}
// Eliminar los seriales (salida definitiva)
InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id)
->delete();
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// Sin seriales, validar y decrementar stock manualmente
$this->validateStock($inventory->id, $warehouse->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
}
// Registrar movimiento
$movement = InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouse->id,
'warehouse_to_id' => null,
'movement_type' => 'exit',
'quantity' => $quantity,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
]);
$movements[] = $movement->load(['inventory', 'warehouseFrom']);
}
return $movements;
});
}
/**
* Traspaso múltiple entre almacenes (varios productos)
*/
public function bulkTransfer(array $data): array
{
return DB::transaction(function () use ($data) {
$warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']);
$warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']);
$movements = [];
// Validar que no sea el mismo almacén
if ($warehouseFrom->id === $warehouseTo->id) {
throw new \Exception('No se puede traspasar al mismo almacén.');
}
foreach ($data['products'] as $productData) {
$inventory = Inventory::findOrFail($productData['inventory_id']);
$quantity = (float) $productData['quantity'];
$serialNumbers = (array) ($productData['serial_numbers'] ?? null);
// Validar y procesar seriales si el producto los requiere
if ($inventory->track_serials) {
if (empty($serialNumbers)) {
throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales.");
}
// Filtrar seriales vacíos y hacer trim
$serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount != $quantity) {
throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales.");
}
// Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén origen
foreach ($serialNumbers as $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('inventory_id', $inventory->id)
->first();
if (!$serial) {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no pertenece a este producto.");
}
if ($serial->status !== 'disponible') {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está disponible (estado: {$serial->status}).");
}
if ($serial->warehouse_id !== $warehouseFrom->id) {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está en el almacén origen.");
}
}
// Actualizar warehouse_id de los seriales seleccionados
InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id)
->update(['warehouse_id' => $warehouseTo->id]);
// Sincronizar stock desde seriales (actualiza ambos almacenes)
$inventory->syncStock();
} else {
// Sin seriales, validar y actualizar stock manualmente
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity);
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
}
// Registrar movimiento
$movement = InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouseFrom->id,
'warehouse_to_id' => $warehouseTo->id,
'movement_type' => 'transfer',
'quantity' => $quantity,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
]);
$movements[] = $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']);
}
return $movements;
});
}
/**
* Calcular costo promedio ponderado
*/
protected function calculateWeightedAverageCost(
float $currentStock,
float $currentCost,
float $entryQuantity,
float $entryCost
): float {
if ($currentStock <= 0) {
return $entryCost;
}
$totalValue = ($currentStock * $currentCost) + ($entryQuantity * $entryCost);
$totalQuantity = $currentStock + $entryQuantity;
return round($totalValue / $totalQuantity, 2);
}
protected function updateProductCost(Inventory $inventory, float $newCost): void
{
$inventory->price()->updateOrCreate(
['inventory_id' => $inventory->id],
['cost' => $newCost]
);
}
/**
* Salida de inventario (merma, ajuste negativo, robo, daño)
*/
public function exit(array $data): InventoryMovement
{
return DB::transaction(function () use ($data) {
$inventory = Inventory::findOrFail($data['inventory_id']);
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
$quantity = (float) $data['quantity'];
$serialNumbers = $data['serial_numbers'] ?? null;
// Cargar la unidad de medida
$inventory->load('unitOfMeasure');
// Solo validar seriales si track_serials Y la unidad NO permite decimales
$requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals;
// Validar y procesar seriales si el producto los requiere
if ($requiresSerials) {
if (empty($serialNumbers)) {
throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.');
}
// Filtrar seriales vacíos y hacer trim
$serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount != $quantity) {
throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales.");
}
// Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén correcto
foreach ($serialNumbers as $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('inventory_id', $inventory->id)
->first();
if (!$serial) {
throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto.");
}
if ($serial->status !== 'disponible') {
throw new \Exception("El serial '{$serialNumber}' no está disponible (estado: {$serial->status}).");
}
if ($serial->warehouse_id !== $warehouse->id) {
throw new \Exception("El serial '{$serialNumber}' no está en el almacén seleccionado.");
}
}
// Eliminar los seriales (salida definitiva)
InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id)
->delete();
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// **SIN SERIALES**: Validar y decrementar stock manualmente
$this->validateStock($inventory->id, $warehouse->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
}
// Registrar movimiento
return InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouse->id,
'warehouse_to_id' => null,
'movement_type' => 'exit',
'quantity' => $quantity,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
]);
});
}
/**
* Traspaso entre almacenes
*/
public function transfer(array $data): InventoryMovement
{
return DB::transaction(function () use ($data) {
$inventory = Inventory::with('unitOfMeasure')->findOrFail($data['inventory_id']);
$warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']);
$warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']);
$quantity = (float) $data['quantity'];
$serialNumbers = $data['serial_numbers'] ?? null;
// Validar que no sea el mismo almacén
if ($warehouseFrom->id === $warehouseTo->id) {
throw new \Exception('No se puede traspasar al mismo almacén.');
}
// Solo exigir seriales si track_serials Y unidad NO permite decimales
$requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals;
// Validar y procesar seriales si el producto los requiere
if ($requiresSerials) {
if (empty($serialNumbers)) {
throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.');
}
// Filtrar seriales vacíos y hacer trim
$serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount != $quantity) {
throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales.");
}
// Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén origen
foreach ($serialNumbers as $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('inventory_id', $inventory->id)
->first();
if (!$serial) {
throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto.");
}
if ($serial->status !== 'disponible') {
throw new \Exception("El serial '{$serialNumber}' no está disponible (estado: {$serial->status}).");
}
if ($serial->warehouse_id !== $warehouseFrom->id) {
throw new \Exception("El serial '{$serialNumber}' no está en el almacén origen.");
}
}
// Actualizar warehouse_id de los seriales seleccionados
InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id)
->update(['warehouse_id' => $warehouseTo->id]);
// Sincronizar stock desde seriales (actualiza ambos almacenes)
$inventory->syncStock();
} else {
// Sin seriales, validar y actualizar stock manualmente
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity);
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
}
// Registrar movimiento
return InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouseFrom->id,
'warehouse_to_id' => $warehouseTo->id,
'movement_type' => 'transfer',
'quantity' => $quantity,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
]);
});
}
/**
* Actualizar stock en inventory_warehouse
*/
public function updateWarehouseStock(int $inventoryId, int $warehouseId, float $quantityChange): void
{
$record = InventoryWarehouse::firstOrCreate(
[
'inventory_id' => $inventoryId,
'warehouse_id' => $warehouseId,
],
['stock' => 0]
);
$newStock = $record->stock + $quantityChange;
if ($newStock < 0) {
throw new \Exception('Stock insuficiente en el almacén.');
}
$record->update(['stock' => $newStock]);
}
/**
* Validar stock disponible
*/
public function validateStock(int $inventoryId, int $warehouseId, float $requiredQuantity): void
{
$record = InventoryWarehouse::where('inventory_id', $inventoryId)
->where('warehouse_id', $warehouseId)
->first();
$availableStock = $record?->stock ?? 0;
if ($availableStock < $requiredQuantity) {
throw new \Exception("Stock insuficiente. Disponible: {$availableStock}, Requerido: {$requiredQuantity}");
}
}
/**
* Registrar movimiento de venta
*/
public function recordSale(int $inventoryId, int $warehouseId, float $quantity, int $saleId): InventoryMovement
{
return InventoryMovement::create([
'inventory_id' => $inventoryId,
'warehouse_from_id' => $warehouseId,
'warehouse_to_id' => null,
'movement_type' => 'sale',
'quantity' => $quantity,
'reference_type' => 'App\Models\Sale',
'reference_id' => $saleId,
'user_id' => auth()->id(),
]);
}
/**
* Registrar movimiento de devolución
*/
public function recordReturn(int $inventoryId, int $warehouseId, float $quantity, int $returnId): InventoryMovement
{
return InventoryMovement::create([
'inventory_id' => $inventoryId,
'warehouse_from_id' => null,
'warehouse_to_id' => $warehouseId,
'movement_type' => 'return',
'quantity' => $quantity,
'reference_type' => 'App\Models\Returns',
'reference_id' => $returnId,
'user_id' => auth()->id(),
]);
}
/**
* Obtener el almacén principal
*/
public function getMainWarehouseId(): int
{
$warehouse = Warehouse::where('is_main', true)->first();
if (!$warehouse) {
throw new \Exception('No existe un almacén principal configurado.');
}
return $warehouse->id;
}
/**
* Actualizar un movimiento existente
*/
public function updateMovement(int $movementId, array $data): InventoryMovement
{
return DB::transaction(function () use ($movementId, $data) {
$movement = InventoryMovement::findOrFail($movementId);
// No permitir editar movimientos de venta o devolución (son automáticos)
if (in_array($movement->movement_type, ['sale', 'return'])) {
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
$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;
}
// 4. Actualizar el registro del movimiento
$movement->update($updateData);
UserEvent::report(model: $movement, event: 'updated', key: 'movement_type');
return $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'serials']);
});
}
/**
* Preparar datos completos para actualización
*/
protected function prepareUpdateData(InventoryMovement $movement, array $data): array
{
$updateData = [
'quantity' => $data['quantity'] ?? $movement->quantity,
'notes' => $data['notes'] ?? $movement->notes,
];
// Pasar serial_numbers si fueron proporcionados
if (!empty($data['serial_numbers'])) {
$updateData['serial_numbers'] = $data['serial_numbers'];
}
// Campos específicos por tipo de movimiento
if ($movement->movement_type === 'entry') {
$updateData['unit_cost'] = $data['unit_cost'] ?? $movement->unit_cost;
$updateData['invoice_reference'] = $data['invoice_reference'] ?? $movement->invoice_reference;
$updateData['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_id;
} elseif ($movement->movement_type === 'exit') {
$updateData['warehouse_from_id'] = $data['warehouse_from_id'] ?? $movement->warehouse_from_id;
} elseif ($movement->movement_type === 'transfer') {
$updateData['warehouse_from_id'] = $data['warehouse_from_id'] ?? $movement->warehouse_from_id;
$updateData['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_id;
}
return $updateData;
}
/**
* Revertir el impacto de un movimiento en el stock
*/
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;
case 'exit':
// Revertir salida: devolver al almacén origen
$this->updateWarehouseStock(
$movement->inventory_id,
$movement->warehouse_from_id,
$movement->quantity
);
break;
case 'transfer':
// Revertir traspaso: devolver al origen, quitar del destino
$this->updateWarehouseStock(
$movement->inventory_id,
$movement->warehouse_from_id,
$movement->quantity
);
$this->updateWarehouseStock(
$movement->inventory_id,
$movement->warehouse_to_id,
-$movement->quantity
);
break;
}
}
/**
* Aplicar actualización de entrada
*/
protected function applyEntryUpdate(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;
// Recalcular costo promedio ponderado
$currentStock = $inventory->stock;
$currentCost = $inventory->price?->cost ?? 0.00;
$newCost = $this->calculateWeightedAverageCost(
$currentStock,
$currentCost,
$quantity,
$unitCost
);
// Actualizar costo
$this->updateProductCost($inventory, $newCost);
// Aplicar nuevo stock
$this->updateWarehouseStock($inventory->id, $warehouseToId, $quantity);
// Revertir y aplicar nuevos seriales (solo si se proporcionaron o cambió la cantidad)
$this->updateMovementSerials($movement, $data);
}
/**
* Aplicar actualización de salida
*/
protected function applyExitUpdate(InventoryMovement $movement, array $data): void
{
$warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id;
$quantity = $data['quantity'] ?? $movement->quantity;
// Validar stock disponible
$this->validateStock($movement->inventory_id, $warehouseFromId, $quantity);
// Aplicar nueva salida
$this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity);
}
/**
* Aplicar actualización de traspaso
*/
protected function applyTransferUpdate(InventoryMovement $movement, array $data): void
{
$warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id;
$warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id;
$quantity = $data['quantity'] ?? $movement->quantity;
// Validar que no sea el mismo almacén
if ($warehouseFromId === $warehouseToId) {
throw new \Exception('No se puede traspasar al mismo almacén.');
}
// Validar stock disponible en origen
$this->validateStock($movement->inventory_id, $warehouseFromId, $quantity);
// Aplicar nuevo traspaso
$this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity);
$this->updateWarehouseStock($movement->inventory_id, $warehouseToId, $quantity);
}
/**
* Sincronizar stock en inventory_warehouse basado en seriales disponibles
* Solo aplica para productos con track_serials = true
*/
public function syncStockFromSerials(Inventory $inventory): void
{
if (!$inventory->track_serials) {
return;
}
// Contar seriales disponibles por almacén
$stockByWarehouse = $inventory->serials()
->where('status', 'disponible')
->whereNotNull('warehouse_id')
->selectRaw('warehouse_id, COUNT(*) as total')
->groupBy('warehouse_id')
->pluck('total', 'warehouse_id');
// Actualizar stock en cada almacén
foreach ($stockByWarehouse as $warehouseId => $count) {
InventoryWarehouse::updateOrCreate(
[
'inventory_id' => $inventory->id,
'warehouse_id' => $warehouseId,
],
['stock' => $count]
);
}
// Poner en 0 los almacenes que ya no tienen seriales disponibles
InventoryWarehouse::where('inventory_id', $inventory->id)
->whereNotIn('warehouse_id', $stockByWarehouse->keys())
->update(['stock' => 0]);
}
/**
* Actualizar seriales de un movimiento (revertir antiguos y aplicar nuevos)
*/
protected function updateMovementSerials(InventoryMovement $movement, array $data): void
{
$inventory = $movement->inventory;
if (!$inventory->track_serials) {
return;
}
// Solo aplicar a movimientos de entrada
if ($movement->movement_type !== 'entry') {
return;
}
// Verificar si la cantidad cambió
$quantityChanged = isset($data['quantity']) && $data['quantity'] != $movement->quantity;
$serialsProvided = !empty($data['serial_numbers']);
// Si NO cambió la cantidad Y NO se proporcionaron seriales, no hacer nada
if (!$quantityChanged && !$serialsProvided) {
return;
}
// Si cambió la cantidad pero NO se proporcionaron seriales, lanzar error
if ($quantityChanged && !$serialsProvided) {
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();
if ($totalSerials > $availableSerials) {
throw new \Exception(
'No se puede editar este movimiento porque algunos seriales ya fueron vendidos o devueltos.'
);
}
// 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.');
}
// 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();
if ($exists) {
throw new \Exception("El número de serie '{$serialNumber}' ya existe para este producto.");
}
}
// Eliminar seriales antiguos
InventorySerial::where('movement_id', $movement->id)
->where('status', 'disponible')
->delete();
// 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(),
];
}
InventorySerial::insert($serials);
}
/**
* Revertir seriales de un movimiento
*/
protected function revertSerials(InventoryMovement $movement): void
{
$inventory = $movement->inventory;
if (!$inventory->track_serials) {
return;
}
switch ($movement->movement_type) {
case 'entry':
// Eliminar seriales creados por este movimiento (solo si están disponibles)
InventorySerial::where('movement_id', $movement->id)
->where('status', 'disponible')
->delete();
break;
case 'exit':
case 'transfer':
break;
}
}
}