From 48fe26899a73cec7e13ed64066c591f07fc88d13 Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Thu, 26 Feb 2026 22:06:02 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20permitir=20categor=C3=ADas=20nulas=20en?= =?UTF-8?q?=20inventarios=20y=20actualizar=20validaciones=20de=20subcatego?= =?UTF-8?q?r=C3=ADas=20en=20solicitudes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/App/CategoryController.php | 6 ++ .../Requests/App/InventoryStoreRequest.php | 23 +++++- .../Requests/App/InventoryUpdateRequest.php | 43 +++++++++- app/Http/Requests/App/SaleStoreRequest.php | 6 +- app/Models/Bundle.php | 20 ++--- app/Services/InventoryMovementService.php | 80 +++++++++++++++---- app/Services/SaleService.php | 16 ++-- ...egory_id_nullable_in_inventories_table.php | 26 ++++++ 8 files changed, 174 insertions(+), 46 deletions(-) create mode 100644 database/migrations/2026_02_26_000001_make_category_id_nullable_in_inventories_table.php diff --git a/app/Http/Controllers/App/CategoryController.php b/app/Http/Controllers/App/CategoryController.php index 31f897d..04d013b 100644 --- a/app/Http/Controllers/App/CategoryController.php +++ b/app/Http/Controllers/App/CategoryController.php @@ -50,6 +50,12 @@ public function update(CategoryUpdateRequest $request, Category $categoria) public function destroy(Category $categoria) { + if ($categoria->inventories()->exists()) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'No se puede eliminar la clasificación porque tiene productos asociados.' + ]); + } + $categoria->delete(); return ApiResponse::OK->response(); diff --git a/app/Http/Requests/App/InventoryStoreRequest.php b/app/Http/Requests/App/InventoryStoreRequest.php index 222fd83..4877ea2 100644 --- a/app/Http/Requests/App/InventoryStoreRequest.php +++ b/app/Http/Requests/App/InventoryStoreRequest.php @@ -24,8 +24,8 @@ public function rules(): array 'key_sat' => ['nullable', 'string', 'max:20'], 'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'], 'barcode' => ['nullable', 'string', 'unique:inventories,barcode'], - 'category_id' => ['required', 'exists:categories,id'], - 'subcategory_id' => ['nullable', 'exists:subcategories,id'], + 'category_id' => ['nullable', 'exists:categories,id'], + 'subcategory_id' => ['nullable', 'required_with:category_id', 'exists:subcategories,id'], 'unit_of_measure_id' => ['required', 'exists:units_of_measurement,id'], 'track_serials' => ['nullable', 'boolean'], @@ -48,8 +48,9 @@ public function messages(): array 'sku.unique' => 'El SKU ya está en uso.', 'barcode.string' => 'El código de barras debe ser una cadena de texto.', 'barcode.unique' => 'El código de barras ya está registrado en otro producto.', - 'category_id.required' => 'La categoría es obligatoria.', - 'category_id.exists' => 'La categoría seleccionada no es válida.', + 'category_id.exists' => 'La clasificación seleccionada no es válida.', + 'subcategory_id.required_with' => 'La subclasificación es obligatoria cuando se asigna una clasificación.', + 'subcategory_id.exists' => 'La subclasificación seleccionada no es válida.', // Mensajes de Price 'retail_price.required' => 'El precio de venta es obligatorio.', 'retail_price.numeric' => 'El precio de venta debe ser un número.', @@ -68,6 +69,20 @@ public function messages(): array public function withValidator($validator) { $validator->after(function ($validator) { + // Validar que la subcategoría pertenezca a la categoría seleccionada + $categoryId = $this->input('category_id'); + $subcategoryId = $this->input('subcategory_id'); + + if ($categoryId && $subcategoryId) { + $subcategory = \App\Models\Subcategory::find($subcategoryId); + if ($subcategory && (int) $subcategory->category_id !== (int) $categoryId) { + $validator->errors()->add( + 'subcategory_id', + 'La subclasificación no pertenece a la clasificación seleccionada.' + ); + } + } + $cost = $this->input('cost'); $retailPrice = $this->input('retail_price'); diff --git a/app/Http/Requests/App/InventoryUpdateRequest.php b/app/Http/Requests/App/InventoryUpdateRequest.php index ff23a4c..6f51c8b 100644 --- a/app/Http/Requests/App/InventoryUpdateRequest.php +++ b/app/Http/Requests/App/InventoryUpdateRequest.php @@ -27,7 +27,7 @@ public function rules(): array 'sku' => ['nullable', 'string', 'max:50'], 'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId], 'category_id' => ['nullable', 'exists:categories,id'], - 'subcategory_id' => ['nullable', 'exists:subcategories,id'], + 'subcategory_id' => ['nullable', 'required_with:category_id', 'exists:subcategories,id'], 'unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'], 'track_serials' => ['nullable', 'boolean'], @@ -49,7 +49,9 @@ public function messages(): array 'sku.unique' => 'El SKU ya está en uso.', 'barcode.string' => 'El código de barras debe ser una cadena de texto.', 'barcode.unique' => 'El código de barras ya está registrado en otro producto.', - 'category_id.exists' => 'La categoría seleccionada no es válida.', + 'category_id.exists' => 'La clasificación seleccionada no es válida.', + 'subcategory_id.required_with' => 'La subclasificación es obligatoria cuando se asigna una clasificación.', + 'subcategory_id.exists' => 'La subclasificación seleccionada no es válida.', // Mensajes de Price 'cost.numeric' => 'El costo debe ser un número.', 'cost.min' => 'El costo no puede ser negativo.', @@ -69,6 +71,39 @@ public function messages(): array public function withValidator($validator) { $validator->after(function ($validator) { + /** @var \App\Models\Inventory $inventory */ + $inventory = $this->route('inventario'); + + // Validar que la subcategoría pertenezca a la categoría seleccionada + $categoryId = $this->input('category_id', $inventory?->category_id); + $subcategoryId = $this->input('subcategory_id'); + + if ($subcategoryId && $categoryId) { + $subcategory = \App\Models\Subcategory::find($subcategoryId); + if ($subcategory && (int) $subcategory->category_id !== (int) $categoryId) { + $validator->errors()->add( + 'subcategory_id', + 'La subclasificación no pertenece a la clasificación seleccionada.' + ); + } + } + + // Bloquear cambio de unidad de medida si el producto ya tiene movimientos de inventario + if ($this->has('unit_of_measure_id') && $inventory) { + $newUnitId = $this->input('unit_of_measure_id'); + $currentUnitId = $inventory->unit_of_measure_id; + + if ((int) $newUnitId !== (int) $currentUnitId) { + $hasMovements = \App\Models\InventoryMovement::where('inventory_id', $inventory->id)->exists(); + if ($hasMovements) { + $validator->errors()->add( + 'unit_of_measure_id', + 'No se puede cambiar la unidad de medida porque el producto ya tiene existencias registradas.' + ); + } + } + } + $cost = $this->input('cost'); $retailPrice = $this->input('retail_price'); @@ -80,8 +115,8 @@ public function withValidator($validator) } // Validar incompatibilidad track_serials + unidades decimales - $trackSerials = $this->input('track_serials', $this->route('inventario')?->track_serials); - $unitId = $this->input('unit_of_measure_id', $this->route('inventario')?->unit_of_measure_id); + $trackSerials = $this->input('track_serials', $inventory?->track_serials); + $unitId = $this->input('unit_of_measure_id', $inventory?->unit_of_measure_id); if ($trackSerials && $unitId) { $unit = \App\Models\UnitOfMeasurement::find($unitId); diff --git a/app/Http/Requests/App/SaleStoreRequest.php b/app/Http/Requests/App/SaleStoreRequest.php index 1d2d9da..3ac300a 100644 --- a/app/Http/Requests/App/SaleStoreRequest.php +++ b/app/Http/Requests/App/SaleStoreRequest.php @@ -46,7 +46,7 @@ public function rules(): array 'items.*.subtotal' => ['required_if:items.*.type,product', 'numeric', 'min:0'], // Comunes a ambos - 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'items.*.quantity' => ['required', 'numeric', 'min:0.001'], 'items.*.warehouse_id' => ['nullable', 'exists:warehouses,id'], // Seriales @@ -93,8 +93,8 @@ public function messages(): array 'items.*.inventory_id.exists' => 'El producto seleccionado no existe.', 'items.*.product_name.required' => 'El nombre del producto es obligatorio.', 'items.*.quantity.required' => 'La cantidad es obligatoria.', - 'items.*.quantity.integer' => 'La cantidad debe ser un número entero.', - 'items.*.quantity.min' => 'La cantidad debe ser al menos 1.', + 'items.*.quantity.numeric' => 'La cantidad debe ser un número.', + 'items.*.quantity.min' => 'La cantidad debe ser mayor a 0.', 'items.*.unit_price.required' => 'El precio unitario es obligatorio.', 'items.*.unit_price.numeric' => 'El precio unitario debe ser un número.', 'items.*.unit_price.min' => 'El precio unitario no puede ser negativo.', diff --git a/app/Models/Bundle.php b/app/Models/Bundle.php index 03baeb1..5cefef0 100644 --- a/app/Models/Bundle.php +++ b/app/Models/Bundle.php @@ -42,26 +42,18 @@ public function price() } /** - * Stock disponible del kit = mínimo(stock_componente / cantidad_requerida) + * Stock disponible del kit en el almacén principal (único para venta) + * = mínimo(stock_almacén_principal_componente / cantidad_requerida) */ public function getAvailableStockAttribute(): int { - if ($this->items->isEmpty()) { + $mainWarehouseId = Warehouse::where('is_main', true)->value('id'); + + if (! $mainWarehouseId) { return 0; } - $minStock = PHP_INT_MAX; - - foreach ($this->items as $item) { - $inventory = $item->inventory; - $availableStock = $inventory->stock; - - // Cuántos kits puedo hacer con este componente - $possibleKits = $availableStock > 0 ? floor($availableStock / $item->quantity) : 0; - $minStock = min($minStock, $possibleKits); - } - - return $minStock === PHP_INT_MAX ? 0 : (int) $minStock; + return $this->stockInWarehouse($mainWarehouseId); } /** diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index 0d95461..9edf9fa 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -926,17 +926,29 @@ protected function revertMovement(InventoryMovement $movement): void 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 - ); + $inventory = $movement->inventory; + + if ($inventory->track_serials) { + // Revertir seriales: mover de vuelta al almacén origen + InventorySerial::where('transfer_movement_id', $movement->id) + ->update([ + 'warehouse_id' => $movement->warehouse_from_id, + 'transfer_movement_id' => null, + ]); + $inventory->syncStock(); + } else { + // Revertir traspaso sin seriales: 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; } } @@ -1095,18 +1107,54 @@ protected function applyTransferUpdate(InventoryMovement $movement, array $data) $warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; $warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $quantity = $data['quantity'] ?? $movement->quantity; + $inventory = $movement->inventory; // 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); + if ($inventory->track_serials) { + $serialNumbers = $data['serial_numbers'] ?? []; - // Aplicar nuevo traspaso - $this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity); - $this->updateWarehouseStock($movement->inventory_id, $warehouseToId, $quantity); + if (empty($serialNumbers)) { + throw new \Exception("Se requieren los números de serie para editar el traspaso de '{$inventory->name}'."); + } + + if (count($serialNumbers) !== (int) $quantity) { + throw new \Exception('La cantidad no coincide con el número de seriales proporcionados.'); + } + + // Validar que cada serial esté disponible en el almacén origen + foreach ($serialNumbers as $serialNumber) { + $serial = InventorySerial::where('inventory_id', $inventory->id) + ->where('serial_number', $serialNumber) + ->where('warehouse_id', $warehouseFromId) + ->where('status', 'disponible') + ->first(); + + if (! $serial) { + throw new \Exception("Serial {$serialNumber} no disponible en el almacén origen."); + } + } + + // Mover seriales al almacén destino + InventorySerial::where('inventory_id', $inventory->id) + ->whereIn('serial_number', $serialNumbers) + ->update([ + 'warehouse_id' => $warehouseToId, + 'transfer_movement_id' => $movement->id, + ]); + + $inventory->syncStock(); + } else { + // 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); + } } /** diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index deff6be..86a836d 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -122,23 +122,27 @@ public function createSale(array $data) ]); if ($inventory->track_serials) { + $serialWarehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId(); + // Si se proporcionaron números de serie específicos if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { foreach ($item['serial_numbers'] as $serialNumber) { $serial = InventorySerial::where('inventory_id', $inventory->id) ->where('serial_number', $serialNumber) + ->where('warehouse_id', $serialWarehouseId) ->where('status', 'disponible') ->first(); if ($serial) { $serial->markAsSold($saleDetail->id); } else { - throw new \Exception("Serial {$serialNumber} no disponible"); + throw new \Exception("Serial {$serialNumber} no disponible en el almacén"); } } } else { - // Asignar automáticamente los primeros N seriales disponibles + // Asignar automáticamente los primeros N seriales disponibles en el almacén $serials = InventorySerial::where('inventory_id', $inventory->id) + ->where('warehouse_id', $serialWarehouseId) ->where('status', 'disponible') ->limit($baseQuantity) ->get(); @@ -369,22 +373,24 @@ private function validateStockForAllItems(array $items): void if ($inventory->track_serials) { if (! empty($serialsByProduct[$entry['inventory_id']])) { - // Validar que los seriales específicos existan y estén disponibles + // Validar que los seriales específicos existan, estén disponibles y en el almacén correcto foreach ($serialsByProduct[$entry['inventory_id']] as $serialNumber) { $serial = InventorySerial::where('inventory_id', $inventory->id) ->where('serial_number', $serialNumber) + ->where('warehouse_id', $entry['warehouse_id']) ->where('status', 'disponible') ->first(); if (! $serial) { throw new \Exception( - "Serial {$serialNumber} no disponible para {$inventory->name}" + "Serial {$serialNumber} no disponible en el almacén para {$inventory->name}" ); } } } else { - // Validar que haya suficientes seriales disponibles para la cantidad TOTAL + // Validar que haya suficientes seriales disponibles en el almacén $availableSerials = InventorySerial::where('inventory_id', $inventory->id) + ->where('warehouse_id', $entry['warehouse_id']) ->where('status', 'disponible') ->count(); diff --git a/database/migrations/2026_02_26_000001_make_category_id_nullable_in_inventories_table.php b/database/migrations/2026_02_26_000001_make_category_id_nullable_in_inventories_table.php new file mode 100644 index 0000000..c595a76 --- /dev/null +++ b/database/migrations/2026_02_26_000001_make_category_id_nullable_in_inventories_table.php @@ -0,0 +1,26 @@ +dropForeign(['category_id']); + $table->foreignId('category_id')->nullable()->change(); + $table->foreign('category_id')->references('id')->on('categories')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('inventories', function (Blueprint $table) { + $table->dropForeign(['category_id']); + $table->foreignId('category_id')->nullable(false)->change(); + $table->foreign('category_id')->references('id')->on('categories')->onDelete('cascade'); + }); + } +};