diff --git a/app/Http/Controllers/App/InventoryController.php b/app/Http/Controllers/App/InventoryController.php index ce07967..97eda9f 100644 --- a/app/Http/Controllers/App/InventoryController.php +++ b/app/Http/Controllers/App/InventoryController.php @@ -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 */ diff --git a/app/Http/Controllers/App/InventoryMovementController.php b/app/Http/Controllers/App/InventoryMovementController.php index 757140d..8baa690 100644 --- a/app/Http/Controllers/App/InventoryMovementController.php +++ b/app/Http/Controllers/App/InventoryMovementController.php @@ -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() diff --git a/app/Http/Controllers/App/ReturnController.php b/app/Http/Controllers/App/ReturnController.php index 5dfca5c..20265e5 100644 --- a/app/Http/Controllers/App/ReturnController.php +++ b/app/Http/Controllers/App/ReturnController.php @@ -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); diff --git a/app/Http/Controllers/App/SaleController.php b/app/Http/Controllers/App/SaleController.php index 8d92b24..fdce925 100644 --- a/app/Http/Controllers/App/SaleController.php +++ b/app/Http/Controllers/App/SaleController.php @@ -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); diff --git a/app/Http/Requests/App/InventoryEntryRequest.php b/app/Http/Requests/App/InventoryEntryRequest.php index 7826ffb..f6dc9a7 100644 --- a/app/Http/Requests/App/InventoryEntryRequest.php +++ b/app/Http/Requests/App/InventoryEntryRequest.php @@ -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', ]; } } diff --git a/app/Http/Requests/App/InventoryExitRequest.php b/app/Http/Requests/App/InventoryExitRequest.php index a62a463..b544e3c 100644 --- a/app/Http/Requests/App/InventoryExitRequest.php +++ b/app/Http/Requests/App/InventoryExitRequest.php @@ -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', diff --git a/app/Http/Requests/App/InventoryImportRequest.php b/app/Http/Requests/App/InventoryImportRequest.php index 7c9b2d2..2395951 100644 --- a/app/Http/Requests/App/InventoryImportRequest.php +++ b/app/Http/Requests/App/InventoryImportRequest.php @@ -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.', diff --git a/app/Http/Requests/App/InventoryStoreRequest.php b/app/Http/Requests/App/InventoryStoreRequest.php index 8220098..e58aed7 100644 --- a/app/Http/Requests/App/InventoryStoreRequest.php +++ b/app/Http/Requests/App/InventoryStoreRequest.php @@ -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.' + ); + } + }); + } } diff --git a/app/Http/Requests/App/InventoryTransferRequest.php b/app/Http/Requests/App/InventoryTransferRequest.php index ea46b52..a7c1f0b 100644 --- a/app/Http/Requests/App/InventoryTransferRequest.php +++ b/app/Http/Requests/App/InventoryTransferRequest.php @@ -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', diff --git a/app/Http/Requests/App/InventoryUpdateRequest.php b/app/Http/Requests/App/InventoryUpdateRequest.php index b053748..c47cf0c 100644 --- a/app/Http/Requests/App/InventoryUpdateRequest.php +++ b/app/Http/Requests/App/InventoryUpdateRequest.php @@ -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.' + ); + } + }); + } } diff --git a/app/Http/Requests/App/PriceUpdateRequest.php b/app/Http/Requests/App/PriceUpdateRequest.php index 0bb3885..e8a116a 100644 --- a/app/Http/Requests/App/PriceUpdateRequest.php +++ b/app/Http/Requests/App/PriceUpdateRequest.php @@ -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.' + ); + } + }); + } } diff --git a/app/Imports/ProductsImport.php b/app/Imports/ProductsImport.php index de148e3..07b4992 100644 --- a/app/Imports/ProductsImport.php +++ b/app/Imports/ProductsImport.php @@ -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); } } diff --git a/app/Models/InventoryMovement.php b/app/Models/InventoryMovement.php index ff84638..d62dad3 100644 --- a/app/Models/InventoryMovement.php +++ b/app/Models/InventoryMovement.php @@ -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', ]; diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index 3ba41be..15fae7e 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -1,4 +1,6 @@ -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) */ diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index 4ac362c..2de721d 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -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, ]); diff --git a/database/migrations/2026_02_06_101139_add_unit_cost_to_inventory_movements_table.php b/database/migrations/2026_02_06_101139_add_unit_cost_to_inventory_movements_table.php new file mode 100644 index 0000000..6881f8e --- /dev/null +++ b/database/migrations/2026_02_06_101139_add_unit_cost_to_inventory_movements_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_02_06_103956_make_cost_nullable_in_prices_table.php b/database/migrations/2026_02_06_103956_make_cost_nullable_in_prices_table.php new file mode 100644 index 0000000..2e48f64 --- /dev/null +++ b/database/migrations/2026_02_06_103956_make_cost_nullable_in_prices_table.php @@ -0,0 +1,28 @@ +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(); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 48b0e2c..8ecec5f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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);