feat: permitir categorías nulas en inventarios y actualizar validaciones de subcategorías en solicitudes

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-26 22:06:02 -06:00
parent f184e4d444
commit 48fe26899a
8 changed files with 174 additions and 46 deletions

View File

@ -50,6 +50,12 @@ public function update(CategoryUpdateRequest $request, Category $categoria)
public function destroy(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(); $categoria->delete();
return ApiResponse::OK->response(); return ApiResponse::OK->response();

View File

@ -24,8 +24,8 @@ public function rules(): array
'key_sat' => ['nullable', 'string', 'max:20'], 'key_sat' => ['nullable', 'string', 'max:20'],
'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'], 'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'], 'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
'category_id' => ['required', 'exists:categories,id'], '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' => ['required', 'exists:units_of_measurement,id'], 'unit_of_measure_id' => ['required', 'exists:units_of_measurement,id'],
'track_serials' => ['nullable', 'boolean'], 'track_serials' => ['nullable', 'boolean'],
@ -48,8 +48,9 @@ public function messages(): array
'sku.unique' => 'El SKU ya está en uso.', 'sku.unique' => 'El SKU ya está en uso.',
'barcode.string' => 'El código de barras debe ser una cadena de texto.', '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.', '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 clasificación seleccionada no es válida.',
'category_id.exists' => 'La categoría 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 // Mensajes de Price
'retail_price.required' => 'El precio de venta es obligatorio.', 'retail_price.required' => 'El precio de venta es obligatorio.',
'retail_price.numeric' => 'El precio de venta debe ser un número.', 'retail_price.numeric' => 'El precio de venta debe ser un número.',
@ -68,6 +69,20 @@ public function messages(): array
public function withValidator($validator) public function withValidator($validator)
{ {
$validator->after(function ($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'); $cost = $this->input('cost');
$retailPrice = $this->input('retail_price'); $retailPrice = $this->input('retail_price');

View File

@ -27,7 +27,7 @@ public function rules(): array
'sku' => ['nullable', 'string', 'max:50'], 'sku' => ['nullable', 'string', 'max:50'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId], 'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
'category_id' => ['nullable', 'exists:categories,id'], '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'], 'unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
'track_serials' => ['nullable', 'boolean'], 'track_serials' => ['nullable', 'boolean'],
@ -49,7 +49,9 @@ public function messages(): array
'sku.unique' => 'El SKU ya está en uso.', 'sku.unique' => 'El SKU ya está en uso.',
'barcode.string' => 'El código de barras debe ser una cadena de texto.', '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.', '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 // Mensajes de Price
'cost.numeric' => 'El costo debe ser un número.', 'cost.numeric' => 'El costo debe ser un número.',
'cost.min' => 'El costo no puede ser negativo.', 'cost.min' => 'El costo no puede ser negativo.',
@ -69,6 +71,39 @@ public function messages(): array
public function withValidator($validator) public function withValidator($validator)
{ {
$validator->after(function ($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'); $cost = $this->input('cost');
$retailPrice = $this->input('retail_price'); $retailPrice = $this->input('retail_price');
@ -80,8 +115,8 @@ public function withValidator($validator)
} }
// Validar incompatibilidad track_serials + unidades decimales // Validar incompatibilidad track_serials + unidades decimales
$trackSerials = $this->input('track_serials', $this->route('inventario')?->track_serials); $trackSerials = $this->input('track_serials', $inventory?->track_serials);
$unitId = $this->input('unit_of_measure_id', $this->route('inventario')?->unit_of_measure_id); $unitId = $this->input('unit_of_measure_id', $inventory?->unit_of_measure_id);
if ($trackSerials && $unitId) { if ($trackSerials && $unitId) {
$unit = \App\Models\UnitOfMeasurement::find($unitId); $unit = \App\Models\UnitOfMeasurement::find($unitId);

View File

@ -46,7 +46,7 @@ public function rules(): array
'items.*.subtotal' => ['required_if:items.*.type,product', 'numeric', 'min:0'], 'items.*.subtotal' => ['required_if:items.*.type,product', 'numeric', 'min:0'],
// Comunes a ambos // Comunes a ambos
'items.*.quantity' => ['required', 'integer', 'min:1'], 'items.*.quantity' => ['required', 'numeric', 'min:0.001'],
'items.*.warehouse_id' => ['nullable', 'exists:warehouses,id'], 'items.*.warehouse_id' => ['nullable', 'exists:warehouses,id'],
// Seriales // Seriales
@ -93,8 +93,8 @@ public function messages(): array
'items.*.inventory_id.exists' => 'El producto seleccionado no existe.', 'items.*.inventory_id.exists' => 'El producto seleccionado no existe.',
'items.*.product_name.required' => 'El nombre del producto es obligatorio.', 'items.*.product_name.required' => 'El nombre del producto es obligatorio.',
'items.*.quantity.required' => 'La cantidad es obligatoria.', 'items.*.quantity.required' => 'La cantidad es obligatoria.',
'items.*.quantity.integer' => 'La cantidad debe ser un número entero.', 'items.*.quantity.numeric' => 'La cantidad debe ser un número.',
'items.*.quantity.min' => 'La cantidad debe ser al menos 1.', 'items.*.quantity.min' => 'La cantidad debe ser mayor a 0.',
'items.*.unit_price.required' => 'El precio unitario es obligatorio.', 'items.*.unit_price.required' => 'El precio unitario es obligatorio.',
'items.*.unit_price.numeric' => 'El precio unitario debe ser un número.', 'items.*.unit_price.numeric' => 'El precio unitario debe ser un número.',
'items.*.unit_price.min' => 'El precio unitario no puede ser negativo.', 'items.*.unit_price.min' => 'El precio unitario no puede ser negativo.',

View File

@ -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 public function getAvailableStockAttribute(): int
{ {
if ($this->items->isEmpty()) { $mainWarehouseId = Warehouse::where('is_main', true)->value('id');
if (! $mainWarehouseId) {
return 0; return 0;
} }
$minStock = PHP_INT_MAX; return $this->stockInWarehouse($mainWarehouseId);
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;
} }
/** /**

View File

@ -926,7 +926,18 @@ protected function revertMovement(InventoryMovement $movement): void
break; break;
case 'transfer': case 'transfer':
// Revertir traspaso: devolver al origen, quitar del destino $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( $this->updateWarehouseStock(
$movement->inventory_id, $movement->inventory_id,
$movement->warehouse_from_id, $movement->warehouse_from_id,
@ -937,6 +948,7 @@ protected function revertMovement(InventoryMovement $movement): void
$movement->warehouse_to_id, $movement->warehouse_to_id,
-$movement->quantity -$movement->quantity
); );
}
break; break;
} }
} }
@ -1095,12 +1107,47 @@ protected function applyTransferUpdate(InventoryMovement $movement, array $data)
$warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; $warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id;
$warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id;
$quantity = $data['quantity'] ?? $movement->quantity; $quantity = $data['quantity'] ?? $movement->quantity;
$inventory = $movement->inventory;
// Validar que no sea el mismo almacén // Validar que no sea el mismo almacén
if ($warehouseFromId === $warehouseToId) { if ($warehouseFromId === $warehouseToId) {
throw new \Exception('No se puede traspasar al mismo almacén.'); throw new \Exception('No se puede traspasar al mismo almacén.');
} }
if ($inventory->track_serials) {
$serialNumbers = $data['serial_numbers'] ?? [];
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 // Validar stock disponible en origen
$this->validateStock($movement->inventory_id, $warehouseFromId, $quantity); $this->validateStock($movement->inventory_id, $warehouseFromId, $quantity);
@ -1108,6 +1155,7 @@ protected function applyTransferUpdate(InventoryMovement $movement, array $data)
$this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity); $this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity);
$this->updateWarehouseStock($movement->inventory_id, $warehouseToId, $quantity); $this->updateWarehouseStock($movement->inventory_id, $warehouseToId, $quantity);
} }
}
/** /**
* Sincronizar stock en inventory_warehouse basado en seriales disponibles * Sincronizar stock en inventory_warehouse basado en seriales disponibles

View File

@ -122,23 +122,27 @@ public function createSale(array $data)
]); ]);
if ($inventory->track_serials) { if ($inventory->track_serials) {
$serialWarehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
// Si se proporcionaron números de serie específicos // Si se proporcionaron números de serie específicos
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
foreach ($item['serial_numbers'] as $serialNumber) { foreach ($item['serial_numbers'] as $serialNumber) {
$serial = InventorySerial::where('inventory_id', $inventory->id) $serial = InventorySerial::where('inventory_id', $inventory->id)
->where('serial_number', $serialNumber) ->where('serial_number', $serialNumber)
->where('warehouse_id', $serialWarehouseId)
->where('status', 'disponible') ->where('status', 'disponible')
->first(); ->first();
if ($serial) { if ($serial) {
$serial->markAsSold($saleDetail->id); $serial->markAsSold($saleDetail->id);
} else { } else {
throw new \Exception("Serial {$serialNumber} no disponible"); throw new \Exception("Serial {$serialNumber} no disponible en el almacén");
} }
} }
} else { } 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) $serials = InventorySerial::where('inventory_id', $inventory->id)
->where('warehouse_id', $serialWarehouseId)
->where('status', 'disponible') ->where('status', 'disponible')
->limit($baseQuantity) ->limit($baseQuantity)
->get(); ->get();
@ -369,22 +373,24 @@ private function validateStockForAllItems(array $items): void
if ($inventory->track_serials) { if ($inventory->track_serials) {
if (! empty($serialsByProduct[$entry['inventory_id']])) { 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) { foreach ($serialsByProduct[$entry['inventory_id']] as $serialNumber) {
$serial = InventorySerial::where('inventory_id', $inventory->id) $serial = InventorySerial::where('inventory_id', $inventory->id)
->where('serial_number', $serialNumber) ->where('serial_number', $serialNumber)
->where('warehouse_id', $entry['warehouse_id'])
->where('status', 'disponible') ->where('status', 'disponible')
->first(); ->first();
if (! $serial) { if (! $serial) {
throw new \Exception( throw new \Exception(
"Serial {$serialNumber} no disponible para {$inventory->name}" "Serial {$serialNumber} no disponible en el almacén para {$inventory->name}"
); );
} }
} }
} else { } 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) $availableSerials = InventorySerial::where('inventory_id', $inventory->id)
->where('warehouse_id', $entry['warehouse_id'])
->where('status', 'disponible') ->where('status', 'disponible')
->count(); ->count();

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('inventories', function (Blueprint $table) {
$table->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');
});
}
};