pdv.backend/app/Services/InventoryMovementService.php
Juan Felipe Zapata Moreno 9a78d92dbf feat(inventory): movimientos masivos y costeo unitario
- Habilita entradas, salidas y traspasos masivos con validación.
- Implementa cálculo de costo promedio ponderado y campo de costo unitario.
- Agrega filtro por almacén y ajusta manejo de costos nulos.
2026-02-06 16:03:09 -06:00

410 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\Inventory;
use App\Models\InventoryMovement;
use App\Models\InventoryWarehouse;
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 = $data['quantity'];
$unitCost = $data['unit_cost'];
//Obtener stock actual para calcular costo promedio ponderado
$curentStock = $inventory->stock;
$currentCost = $inventory->price?->cost ?? 0.00;
// Calcular nuevo costo promedio ponderado
$newCost = $this->calculateWeightedAverageCost(
$curentStock,
$currentCost,
$quantity,
$unitCost
);
// Actualizar costo en prices
$this->updateProductCost($inventory, $newCost);
// Actualizar stock en inventory_warehouse
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
// Registrar movimiento
return InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => null,
'warehouse_to_id' => $warehouse->id,
'movement_type' => 'entry',
'quantity' => $quantity,
'unit_cost' => $unitCost,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
'invoice_reference' => $data['invoice_reference'] ?? null,
]);
});
}
/**
* 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 = $productData['quantity'];
$unitCost = $productData['unit_cost'];
// 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);
// Actualizar stock en inventory_warehouse
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
// Registrar movimiento
$movement = InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => null,
'warehouse_to_id' => $warehouse->id,
'movement_type' => 'entry',
'quantity' => $quantity,
'unit_cost' => $unitCost,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
'invoice_reference' => $data['invoice_reference'],
]);
$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::findOrFail($productData['inventory_id']);
$quantity = $productData['quantity'];
// Validar stock disponible
$this->validateStock($inventory->id, $warehouse->id, $quantity);
// Decrementar stock en inventory_warehouse
$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 = $productData['quantity'];
// Validar stock disponible en almacén origen
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity);
// Decrementar en origen
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity);
// Incrementar en destino
$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(
int $currentStock,
float $currentCost,
int $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 = $data['quantity'];
// Validar stock disponible
$this->validateStock($inventory->id, $warehouse->id, $quantity);
// Decrementar stock en inventory_warehouse
$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::findOrFail($data['inventory_id']);
$warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']);
$warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']);
$quantity = $data['quantity'];
// Validar que no sea el mismo almacén
if ($warehouseFrom->id === $warehouseTo->id) {
throw new \Exception('No se puede traspasar al mismo almacén.');
}
// Validar stock disponible en almacén origen
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity);
// Decrementar en origen
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity);
// Incrementar en destino
$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, int $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, int $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, int $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, int $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;
}
/**
* 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]);
}
}