1066 lines
44 KiB
PHP
1066 lines
44 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.');
|
|
}
|
|
|
|
// Preparar datos completos para actualización según tipo
|
|
$updateData = $this->prepareUpdateData($movement, $data);
|
|
|
|
// 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),
|
|
};
|
|
}
|
|
|
|
// 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) {
|
|
// Las entradas NO se revierten aquí, usan lógica delta en applyEntryDeltaUpdate()
|
|
|
|
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 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 applyEntryDeltaUpdate(InventoryMovement $movement, array $data): void
|
|
{
|
|
$inventory = $movement->inventory;
|
|
$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;
|
|
|
|
// --- 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(
|
|
$stockSinEntrada,
|
|
$costoRevertido,
|
|
$newQuantity,
|
|
$newUnitCost
|
|
);
|
|
|
|
$this->updateProductCost($inventory, $newCost);
|
|
|
|
// --- Ajuste de stock y seriales ---
|
|
$inventory->load('unitOfMeasure');
|
|
$usesSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals;
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 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
|
|
{
|
|
$inventory = $movement->inventory;
|
|
|
|
if (!$inventory->track_serials) {
|
|
return;
|
|
}
|
|
|
|
// Solo aplicar a movimientos de entrada
|
|
if ($movement->movement_type !== 'entry') {
|
|
return;
|
|
}
|
|
|
|
$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) {
|
|
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.');
|
|
}
|
|
|
|
// 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'])));
|
|
|
|
// Validar que la cantidad de seriales proporcionados coincida con la nueva cantidad
|
|
if (count($newSerialNumbers) !== $newQuantity) {
|
|
throw new \Exception(
|
|
'La cantidad de números de serie (' . count($newSerialNumbers) . ') debe coincidir con la cantidad del movimiento (' . $newQuantity . ').'
|
|
);
|
|
}
|
|
|
|
// 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 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 en el sistema.");
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
}
|