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.
This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-06 16:03:09 -06:00
parent 5a646d84d5
commit 9a78d92dbf
18 changed files with 606 additions and 77 deletions

View File

@ -25,7 +25,7 @@ public function __construct(
public function index(Request $request)
{
$products = Inventory::with(['category', 'price'])->withCount('serials')
->where('is_active', true);
->where('is_active', true);
// Filtro por búsqueda de texto (nombre, SKU, código de barras)
@ -90,6 +90,50 @@ public function destroy(Inventory $inventario)
return ApiResponse::OK->response();
}
/**
* Obtener productos disponibles en un almacén específico
*/
public function getProductsByWarehouse(Request $request, int $warehouseId)
{
$query = Inventory::query()
->with(['category', 'price'])
->where('is_active', true)
->whereHas('warehouses', function ($q) use ($warehouseId) {
$q->where('warehouse_id', $warehouseId)
->where('stock', '>', 0);
});
// Filtro por búsqueda de texto
if ($request->has('q') && $request->q) {
$query->where(function($q) use ($request) {
$q->where('name', 'like', "%{$request->q}%")
->orWhere('sku', 'like', "%{$request->q}%")
->orWhere('barcode', $request->q);
});
}
// Filtro por categoría
if ($request->has('category_id') && $request->category_id) {
$query->where('category_id', $request->category_id);
}
$products = $query->orderBy('name')->get();
// Agregar el stock específico de este almacén a cada producto
$products->each(function ($product) use ($warehouseId) {
$warehouseStock = $product->warehouses()
->where('warehouse_id', $warehouseId)
->first();
$product->warehouse_stock = $warehouseStock ? $warehouseStock->pivot->stock : 0;
});
return ApiResponse::OK->response([
'products' => $products,
'warehouse_id' => $warehouseId,
]);
}
/**
* Importar productos desde Excel
*/

View File

@ -29,6 +29,14 @@ public function index(Request $request)
$query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user'])
->orderBy('created_at', 'desc');
if ($request->has('q') && $request->q){
$query->whereHas('inventory', function($qy) use ($request){
$qy->where('name', 'like', "%{$request->q}%")
->orWhere('sku', 'like', "%{$request->q}%")
->orWhere('barcode', $request->q);
});
}
if ($request->has('movement_type')) {
$query->where('movement_type', $request->movement_type);
}
@ -82,12 +90,25 @@ public function show(int $id)
public function entry(InventoryEntryRequest $request)
{
try {
$movement = $this->movementService->entry($request->validated());
$validated = $request->validated();
if(isset($validated['products'])){
$movements = $this->movementService->bulkEntry($validated);
return ApiResponse::CREATED->response([
'message' => 'Entrada registrada correctamente',
'movement' => $movements,
'total_products' => count($movements),
]);
} else {
$movement = $this->movementService->entry($validated);
return ApiResponse::CREATED->response([
'message' => 'Entrada registrada correctamente',
'movement' => $movement->load(['inventory', 'warehouseTo']),
]);
}
return ApiResponse::CREATED->response([
'message' => 'Entrada registrada correctamente',
'movement' => $movement->load(['inventory', 'warehouseTo']),
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
@ -101,12 +122,24 @@ public function entry(InventoryEntryRequest $request)
public function exit(InventoryExitRequest $request)
{
try {
$movement = $this->movementService->exit($request->validated());
$validated = $request->validated();
return ApiResponse::CREATED->response([
'message' => 'Salida registrada correctamente',
'movement' => $movement->load(['inventory', 'warehouseFrom']),
]);
if(isset($validated['products'])){
$movements = $this->movementService->bulkExit($validated);
return ApiResponse::CREATED->response([
'message' => 'Salidas registradas correctamente',
'movements' => $movements,
'total_products' => count($movements),
]);
} else {
$movement = $this->movementService->exit($validated);
return ApiResponse::CREATED->response([
'message' => 'Salida registrada correctamente',
'movement' => $movement->load(['inventory', 'warehouseFrom']),
]);
}
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
@ -120,12 +153,24 @@ public function exit(InventoryExitRequest $request)
public function transfer(InventoryTransferRequest $request)
{
try {
$movement = $this->movementService->transfer($request->validated());
$validated = $request->validated();
return ApiResponse::CREATED->response([
'message' => 'Traspaso registrado correctamente',
'movement' => $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']),
]);
if(isset($validated['products'])){
$movements = $this->movementService->bulkTransfer($validated);
return ApiResponse::CREATED->response([
'message' => 'Traspasos registrados correctamente',
'movements' => $movements,
'total_products' => count($movements),
]);
} else {
$movement = $this->movementService->transfer($validated);
return ApiResponse::CREATED->response([
'message' => 'Traspaso registrado correctamente',
'movement' => $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']),
]);
}
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()

View File

@ -28,6 +28,10 @@ public function index(Request $request)
'details.inventory',
])->orderBy('created_at', 'desc');
if (!auth()->user()->hasRole('admin')) {
$query->where('user_id', auth()->id());
}
// Filtros
if ($request->has('q') && $request->q) {
$query->where('return_number', 'like', "%{$request->q}%")
@ -72,6 +76,12 @@ public function show(Returns $return)
'cashRegister',
]);
if (!auth()->user()->hasRole('admin') && $return->user_id !== auth()->id()) {
return ApiResponse::FORBIDDEN->response([
'message' => 'No tienes permiso para ver esta venta.'
]);
}
return ApiResponse::OK->response([
'model' => $return,
]);
@ -101,6 +111,12 @@ public function store(ReturnStoreRequest $request)
*/
public function cancel(Returns $return)
{
if (!auth()->user()->hasRole('admin') && $return->user_id !== auth()->id()) {
return ApiResponse::FORBIDDEN->response([
'message' => 'No tienes permiso para cancelar esta venta.'
]);
}
try {
$cancelledReturn = $this->returnService->cancelReturn($return);

View File

@ -20,11 +20,18 @@ public function index(Request $request)
$sales = Sale::with(['details.inventory', 'details.serials', 'user', 'client'])
->orderBy('created_at', 'desc');
// Filtrar por usuario: solo admin puede ver todas las ventas
if (!auth()->user()->hasRole('admin')) {
$sales->where('user_id', auth()->id());
}
if ($request->has('q') && $request->q) {
$sales->where('invoice_number', 'like', "%{$request->q}%")
->orWhereHas('user', fn($query) =>
$query->where('name', 'like', "%{$request->q}%")
);
$sales->where(function ($query) use ($request) {
$query->where('invoice_number', 'like', "%{$request->q}%")
->orWhereHas('user', fn($q) =>
$q->where('name', 'like', "%{$request->q}%")
);
});
}
if ($request->has('cash_register_id')) {
@ -42,6 +49,13 @@ public function index(Request $request)
public function show(Sale $sale)
{
// Solo admin puede ver ventas de otros usuarios
if (!auth()->user()->hasRole('admin') && $sale->user_id !== auth()->id()) {
return ApiResponse::FORBIDDEN->response([
'message' => 'No tienes permiso para ver esta venta.'
]);
}
return ApiResponse::OK->response([
'model' => $sale->load(['details.inventory', 'user', 'client'])
]);
@ -58,6 +72,13 @@ public function store(SaleStoreRequest $request)
public function cancel(Sale $sale)
{
// Solo admin puede cancelar ventas de otros usuarios
if (!auth()->user()->hasRole('admin') && $sale->user_id !== auth()->id()) {
return ApiResponse::FORBIDDEN->response([
'message' => 'No tienes permiso para cancelar esta venta.'
]);
}
try {
$cancelledSale = $this->saleService->cancelSale($sale);

View File

@ -13,11 +13,26 @@ public function authorize(): bool
public function rules(): array
{
if ($this->has('products')) {
return [
'warehouse_id' => 'required|exists:warehouses,id',
'invoice_reference' => 'required|string|max:255',
'notes' => 'nullable|string|max:1000',
// Validación del array de productos
'products' => 'required|array|min:1',
'products.*.inventory_id' => 'required|exists:inventories,id',
'products.*.quantity' => 'required|integer|min:1',
'products.*.unit_cost' => 'required|numeric|min:0',
];
}
return [
'inventory_id' => 'required|exists:inventories,id',
'warehouse_id' => 'required|exists:warehouses,id',
'quantity' => 'required|integer|min:1',
'invoice_reference' => 'nullable|string|max:255',
'unit_cost' => 'required|numeric|min:0',
'invoice_reference' => 'required|string|max:255',
'notes' => 'nullable|string|max:1000',
];
}
@ -25,12 +40,27 @@ public function rules(): array
public function messages(): array
{
return [
// Mensajes para entrada única
'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe',
// Mensajes para entrada múltiple
'products.required' => 'Debe incluir al menos un producto',
'products.*.inventory_id.required' => 'El producto es requerido',
'products.*.inventory_id.exists' => 'El producto no existe',
'products.*.quantity.required' => 'La cantidad es requerida',
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
'products.*.unit_cost.required' => 'El costo unitario es requerido',
'products.*.unit_cost.numeric' => 'El costo unitario debe ser un número',
'products.*.unit_cost.min' => 'El costo unitario no puede ser negativo',
// Mensajes comunes
'warehouse_id.required' => 'El almacén es requerido',
'warehouse_id.exists' => 'El almacén no existe',
'quantity.required' => 'La cantidad es requerida',
'quantity.min' => 'La cantidad debe ser al menos 1',
'unit_cost.required' => 'El costo unitario es requerido',
'invoice_reference.required' => 'La referencia de la factura es requerida',
];
}
}

View File

@ -13,6 +13,20 @@ public function authorize(): bool
public function rules(): array
{
// Si tiene "products" array, es salida múltiple
if ($this->has('products')) {
return [
'warehouse_id' => 'required|exists:warehouses,id',
'notes' => 'nullable|string|max:1000',
// Validación del array de productos
'products' => 'required|array|min:1',
'products.*.inventory_id' => 'required|exists:inventories,id',
'products.*.quantity' => 'required|integer|min:1',
];
}
// Salida única (formato original)
return [
'inventory_id' => 'required|exists:inventories,id',
'warehouse_id' => 'required|exists:warehouses,id',
@ -24,8 +38,18 @@ public function rules(): array
public function messages(): array
{
return [
// Mensajes para salida única
'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe',
// Mensajes para salida múltiple
'products.required' => 'Debe incluir al menos un producto',
'products.*.inventory_id.required' => 'El producto es requerido',
'products.*.inventory_id.exists' => 'El producto no existe',
'products.*.quantity.required' => 'La cantidad es requerida',
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
// Mensajes comunes
'warehouse_id.required' => 'El almacén es requerido',
'warehouse_id.exists' => 'El almacén no existe',
'quantity.required' => 'La cantidad es requerida',

View File

@ -55,7 +55,7 @@ public static function rowRules(): array
'codigo_barras' => ['nullable', 'string', 'max:100'],
'categoria' => ['nullable', 'string', 'max:100'],
'stock' => ['required', 'integer', 'min:0'],
'costo' => ['required', 'numeric', 'min:0'],
'costo' => ['nullable', 'numeric', 'min:0'],
'precio_venta' => ['required', 'numeric', 'min:0'],
'impuesto' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
@ -73,7 +73,6 @@ public static function rowMessages(): array
'stock.required' => 'El stock es requerido.',
'stock.integer' => 'El stock debe ser un número entero.',
'stock.min' => 'El stock no puede ser negativo.',
'costo.required' => 'El costo es requerido.',
'costo.numeric' => 'El costo debe ser un número.',
'costo.min' => 'El costo no puede ser negativo.',
'precio_venta.required' => 'El precio de venta es requerido.',

View File

@ -27,8 +27,8 @@ public function rules(): array
'track_serials' => ['nullable', 'boolean'],
// Campos de Price
'cost' => ['required', 'numeric', 'min:0'],
'retail_price' => ['required', 'numeric', 'min:0', 'gt:cost'],
'cost' => ['nullable', 'numeric', 'min:0'],
'retail_price' => ['required', 'numeric', 'min:0'],
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
@ -48,9 +48,6 @@ public function messages(): array
'category_id.required' => 'La categoría es obligatoria.',
'category_id.exists' => 'La categoría seleccionada no es válida.',
// Mensajes de Price
'cost.required' => 'El costo es obligatorio.',
'cost.numeric' => 'El costo debe ser un número.',
'cost.min' => 'El costo no puede ser negativo.',
'retail_price.required' => 'El precio de venta es obligatorio.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
@ -60,4 +57,22 @@ public function messages(): array
'tax.max' => 'El impuesto no puede exceder el 100%.',
];
}
/**
* Validación condicional: retail_price > cost solo si cost > 0
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
$cost = $this->input('cost');
$retailPrice = $this->input('retail_price');
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
$validator->errors()->add(
'retail_price',
'El precio de venta debe ser mayor que el costo.'
);
}
});
}
}

View File

@ -13,6 +13,21 @@ public function authorize(): bool
public function rules(): array
{
// Si tiene "products" array, es traspaso múltiple
if ($this->has('products')) {
return [
'warehouse_from_id' => 'required|exists:warehouses,id',
'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_id',
'notes' => 'nullable|string|max:1000',
// Validación del array de productos
'products' => 'required|array|min:1',
'products.*.inventory_id' => 'required|exists:inventories,id',
'products.*.quantity' => 'required|integer|min:1',
];
}
// Traspaso único (formato original)
return [
'inventory_id' => 'required|exists:inventories,id',
'warehouse_from_id' => 'required|exists:warehouses,id',
@ -25,8 +40,18 @@ public function rules(): array
public function messages(): array
{
return [
// Mensajes para traspaso único
'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe',
// Mensajes para traspaso múltiple
'products.required' => 'Debe incluir al menos un producto',
'products.*.inventory_id.required' => 'El producto es requerido',
'products.*.inventory_id.exists' => 'El producto no existe',
'products.*.quantity.required' => 'La cantidad es requerida',
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
// Mensajes comunes
'warehouse_from_id.required' => 'El almacén origen es requerido',
'warehouse_from_id.exists' => 'El almacén origen no existe',
'warehouse_to_id.required' => 'El almacén destino es requerido',

View File

@ -30,7 +30,7 @@ public function rules(): array
// Campos de Price
'cost' => ['nullable', 'numeric', 'min:0'],
'retail_price' => ['nullable', 'numeric', 'min:0', 'gt:cost'],
'retail_price' => ['nullable', 'numeric', 'min:0'],
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
@ -58,4 +58,22 @@ public function messages(): array
'tax.max' => 'El impuesto no puede exceder el 100%.',
];
}
/**
* Validación condicional: retail_price > cost solo si cost > 0
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
$cost = $this->input('cost');
$retailPrice = $this->input('retail_price');
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
$validator->errors()->add(
'retail_price',
'El precio de venta debe ser mayor que el costo.'
);
}
});
}
}

View File

@ -20,7 +20,7 @@ public function rules(): array
{
return [
'cost' => ['nullable', 'numeric', 'min:0'],
'retail_price' => ['nullable', 'numeric', 'min:0', 'gt:cost'],
'retail_price' => ['nullable', 'numeric', 'min:0'],
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
@ -38,4 +38,22 @@ public function messages(): array
'tax.max' => 'El impuesto no puede exceder el 100%.',
];
}
/**
* Validación condicional: retail_price > cost solo si cost > 0
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
$cost = $this->input('cost');
$retailPrice = $this->input('retail_price');
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
$validator->errors()->add(
'retail_price',
'El precio de venta debe ser mayor que el costo.'
);
}
});
}
}

View File

@ -81,16 +81,17 @@ public function model(array $row)
$existingInventory = Inventory::where('barcode', trim($row['codigo_barras']))->first();
}
// Si el producto ya existe, solo agregar stock y seriales
// Si el producto ya existe, solo agregar stock y costo
if ($existingInventory) {
return $this->updateExistingProduct($existingInventory, $row);
}
// Producto nuevo: validar precios
$costo = (float) $row['costo'];
// Producto nuevo: obtener valores
$costo = isset($row['costo']) && $row['costo'] !== '' ? (float) $row['costo'] : 0;
$precioVenta = (float) $row['precio_venta'];
if ($precioVenta <= $costo) {
// Validar precio > costo solo si costo > 0
if ($costo > 0 && $precioVenta <= $costo) {
$this->skipped++;
$this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta ($precioVenta) debe ser mayor que el costo ($costo)";
return null;
@ -123,8 +124,16 @@ public function model(array $row)
'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0,
]);
// Crear números de serie si se proporcionan
$this->addSerials($inventory, $row['numeros_serie'] ?? null, $row['stock'] ?? 0);
// Agregar stock inicial si existe
$stockFromExcel = (int) ($row['stock'] ?? 0);
if ($stockFromExcel > 0) {
$this->addStockWithCost(
$inventory,
$stockFromExcel,
$costo,
$row['numeros_serie'] ?? null
);
}
$this->imported++;
@ -137,64 +146,79 @@ public function model(array $row)
}
/**
* Actualiza un producto existente: suma stock y agrega seriales nuevos
* Actualiza un producto existente: suma stock y actualiza costo
*/
private function updateExistingProduct(Inventory $inventory, array $row)
{
$serialsAdded = 0;
$serialsSkipped = 0;
$mainWarehouseId = $this->movementService->getMainWarehouseId();
$stockToAdd = (int) ($row['stock'] ?? 0);
$costo = isset($row['costo']) && $row['costo'] !== '' ? (float) $row['costo'] : null;
// Agregar seriales nuevos (ignorar duplicados)
if (!empty($row['numeros_serie'])) {
$serials = explode(',', $row['numeros_serie']);
// Si hay stock para agregar
if ($stockToAdd > 0) {
// Si tiene números de serie
if (!empty($row['numeros_serie'])) {
$serials = explode(',', $row['numeros_serie']);
$serialsAdded = 0;
$serialsSkipped = 0;
foreach ($serials as $serial) {
$serial = trim($serial);
if (empty($serial)) continue;
foreach ($serials as $serial) {
$serial = trim($serial);
if (empty($serial)) continue;
// Verificar si el serial ya existe (global, no solo en este producto)
$exists = InventorySerial::where('serial_number', $serial)->exists();
// Verificar si el serial ya existe
$exists = InventorySerial::where('serial_number', $serial)->exists();
if (!$exists) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'serial_number' => $serial,
'status' => 'disponible',
]);
$serialsAdded++;
} else {
$serialsSkipped++;
if (!$exists) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'serial_number' => $serial,
'status' => 'disponible',
]);
$serialsAdded++;
} else {
$serialsSkipped++;
}
}
// Sincronizar stock basado en seriales
$inventory->syncStock();
if ($serialsSkipped > 0) {
$this->errors[] = "Producto '{$inventory->name}': {$serialsSkipped} seriales ya existían y fueron ignorados";
}
}
// Sincronizar stock basado en seriales disponibles
$inventory->syncStock();
} else {
// Producto sin seriales: sumar stock en almacén principal
$stockToAdd = (int) ($row['stock'] ?? 0);
if ($stockToAdd > 0) {
// Registrar movimiento de entrada con costo si existe
if ($costo !== null && $costo > 0) {
$this->movementService->entry([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'quantity' => $stockToAdd,
'unit_cost' => $costo,
'invoice_reference' => 'IMP-' . date('YmdHis'),
'notes' => 'Importación desde Excel - actualización',
]);
} else {
// Sin costo, solo agregar stock sin movimiento de entrada
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockToAdd);
}
}
$this->updated++;
if ($serialsSkipped > 0) {
$this->errors[] = "Producto '{$inventory->name}': {$serialsSkipped} seriales ya existían y fueron ignorados";
}
return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo
}
/**
* Agrega seriales a un producto nuevo
* Agrega stock inicial a un producto nuevo con registro de movimiento
*/
private function addSerials(Inventory $inventory, ?string $serialsString, int $stockFromExcel)
private function addStockWithCost(Inventory $inventory, int $quantity, float $cost, ?string $serialsString): void
{
$mainWarehouseId = $this->movementService->getMainWarehouseId();
// Si tiene números de serie
if (!empty($serialsString)) {
$serials = explode(',', $serialsString);
@ -209,12 +233,24 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
]);
}
}
// Sincronizar stock basado en seriales
$inventory->syncStock();
}
// Registrar movimiento de entrada con costo si existe
if ($cost > 0) {
$this->movementService->entry([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'quantity' => $quantity,
'unit_cost' => $cost,
'invoice_reference' => 'IMP-' . date('YmdHis'),
'notes' => 'Importación desde Excel - stock inicial',
]);
} else {
// Producto sin seriales: registrar stock en almacén principal
if ($stockFromExcel > 0) {
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockFromExcel);
}
// Sin costo, solo agregar stock
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $quantity);
}
}

View File

@ -12,6 +12,7 @@ class InventoryMovement extends Model
'warehouse_to_id',
'movement_type',
'quantity',
'unit_cost',
'reference_type',
'reference_id',
'user_id',
@ -21,6 +22,7 @@ class InventoryMovement extends Model
protected $casts = [
'quantity' => 'integer',
'unit_cost' => 'decimal:2',
'created_at' => 'datetime',
];

View File

@ -1,4 +1,6 @@
<?php namespace App\Services;
<?php
namespace App\Services;
use App\Models\Inventory;
use App\Models\InventoryMovement;
@ -14,7 +16,7 @@
class InventoryMovementService
{
/**
* Entrada de inventario (compra, ajuste positivo)
* Entrada de inventario
*/
public function entry(array $data): InventoryMovement
{
@ -22,6 +24,22 @@ public function entry(array $data): InventoryMovement
$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);
@ -33,6 +51,7 @@ public function entry(array $data): InventoryMovement
'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,
@ -40,6 +59,166 @@ public function entry(array $data): InventoryMovement
});
}
/**
* 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)
*/

View File

@ -19,7 +19,7 @@ public function createProduct(array $data)
Price::create([
'inventory_id' => $inventory->id,
'cost' => $data['cost'],
'cost' => $data['cost'] ?? 0,
'retail_price' => $data['retail_price'],
'tax' => $data['tax'] ?? 16.00,
]);

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('inventory_movements', function (Blueprint $table) {
$table->decimal('unit_cost', 15, 2)->nullable()->after('quantity');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_movements', function (Blueprint $table) {
$table->dropColumn('unit_cost');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('prices', function (Blueprint $table) {
$table->decimal('cost', 15, 2)->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('prices', function (Blueprint $table) {
$table->decimal('cost', 15, 2)->nullable(false)->change();
});
}
};

View File

@ -36,9 +36,10 @@
// Tus rutas protegidas
//INVENTARIO
Route::resource('inventario', InventoryController::class);
Route::get('inventario/almacen/{warehouse}', [InventoryController::class, 'getProductsByWarehouse']);
Route::post('inventario/import', [InventoryController::class, 'import']);
Route::get('inventario/template/download', [InventoryController::class, 'downloadTemplate']);
Route::resource('inventario', InventoryController::class);
// NÚMEROS DE SERIE DE INVENTARIO
Route::resource('inventario.serials', InventorySerialController::class);