From 7a68c458b8a563971aa628e7d667ceb31b2629e8 Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Mon, 16 Feb 2026 17:17:05 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20implementar=20gesti=C3=B3n=20de=20bundl?= =?UTF-8?q?es,=20incluyendo=20creaci=C3=B3n,=20actualizaci=C3=B3n=20y=20el?= =?UTF-8?q?iminaci=C3=B3n,=20as=C3=AD=20como=20validaci=C3=B3n=20de=20stoc?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/App/BundleController.php | 140 ++++++++++++++ app/Http/Requests/App/BundleStoreRequest.php | 73 ++++++++ app/Http/Requests/App/BundleUpdateRequest.php | 73 ++++++++ app/Http/Requests/App/SaleStoreRequest.php | 21 ++- app/Models/Bundle.php | 115 ++++++++++++ app/Models/BundleItem.php | 32 ++++ app/Models/BundlePrice.php | 27 +++ app/Models/SaleDetail.php | 33 ++++ app/Services/BundleService.php | 175 ++++++++++++++++++ app/Services/SaleService.php | 124 ++++++++++++- app/Services/WhatsappService.php | 20 -- ...2026_02_16_125620_create_bundles_table.php | 31 ++++ ...02_16_135929_create_bundle_items_table.php | 33 ++++ ...2_16_140044_create_bundle_prices_table.php | 31 ++++ ...40253_add_bundle_to_sale_details_table.php | 35 ++++ routes/api.php | 11 ++ 16 files changed, 947 insertions(+), 27 deletions(-) create mode 100644 app/Http/Controllers/App/BundleController.php create mode 100644 app/Http/Requests/App/BundleStoreRequest.php create mode 100644 app/Http/Requests/App/BundleUpdateRequest.php create mode 100644 app/Models/Bundle.php create mode 100644 app/Models/BundleItem.php create mode 100644 app/Models/BundlePrice.php create mode 100644 app/Services/BundleService.php create mode 100644 database/migrations/2026_02_16_125620_create_bundles_table.php create mode 100644 database/migrations/2026_02_16_135929_create_bundle_items_table.php create mode 100644 database/migrations/2026_02_16_140044_create_bundle_prices_table.php create mode 100644 database/migrations/2026_02_16_140253_add_bundle_to_sale_details_table.php diff --git a/app/Http/Controllers/App/BundleController.php b/app/Http/Controllers/App/BundleController.php new file mode 100644 index 0000000..759c02e --- /dev/null +++ b/app/Http/Controllers/App/BundleController.php @@ -0,0 +1,140 @@ +when($request->has('q'), function ($query) use ($request) { + $query->where(function ($q) use ($request) { + $q->where('name', 'like', "%{$request->q}%") + ->orWhere('sku', 'like', "%{$request->q}%") + ->orWhere('barcode', $request->q); + }); + }) + ->orderBy('name') + ->paginate(config('app.pagination', 15)); + + return ApiResponse::OK->response([ + 'bundles' => $bundles, + ]); + } + + /** + * Ver detalle de un bundle + */ + public function show(Bundle $bundle) + { + $bundle->load(['items.inventory.price', 'price']); + + return ApiResponse::OK->response([ + 'model' => $bundle, + ]); + } + + /** + * Crear un nuevo bundle + */ + public function store(BundleStoreRequest $request) + { + try { + $bundle = $this->bundleService->createBundle($request->validated()); + + return ApiResponse::CREATED->response([ + 'model' => $bundle, + 'message' => 'Bundle creado exitosamente', + ]); + } catch (\Exception $e) { + return ApiResponse::INTERNAL_ERROR->response([ + 'message' => 'Error al crear el bundle: ' . $e->getMessage(), + ]); + } + } + + /** + * Actualizar un bundle existente + */ + public function update(BundleUpdateRequest $request, Bundle $bundle) + { + try { + $updatedBundle = $this->bundleService->updateBundle($bundle, $request->validated()); + + return ApiResponse::OK->response([ + 'model' => $updatedBundle, + 'message' => 'Bundle actualizado exitosamente', + ]); + } catch (\Exception $e) { + return ApiResponse::INTERNAL_ERROR->response([ + 'message' => 'Error al actualizar el bundle: ' . $e->getMessage(), + ]); + } + } + + /** + * Eliminar (soft delete) un bundle + */ + public function destroy(Bundle $bundle) + { + try { + $this->bundleService->deleteBundle($bundle); + + return ApiResponse::OK->response([ + 'message' => 'Bundle eliminado exitosamente', + ]); + } catch (\Exception $e) { + return ApiResponse::INTERNAL_ERROR->response([ + 'message' => 'Error al eliminar el bundle: ' . $e->getMessage(), + ]); + } + } + + /** + * Verificar stock disponible de un bundle + */ + public function checkStock(Request $request, Bundle $bundle) + { + $quantity = $request->input('quantity', 1); + $warehouseId = $request->input('warehouse_id'); + + $bundle->load(['items.inventory']); + + $availableStock = $warehouseId + ? $bundle->stockInWarehouse($warehouseId) + : $bundle->available_stock; + + $hasStock = $bundle->hasStock($quantity, $warehouseId); + + return ApiResponse::OK->response([ + 'bundle_id' => $bundle->id, + 'bundle_name' => $bundle->name, + 'quantity_requested' => $quantity, + 'available_stock' => $availableStock, + 'has_stock' => $hasStock, + 'components_stock' => $bundle->items->map(function ($item) use ($warehouseId) { + return [ + 'inventory_id' => $item->inventory_id, + 'product_name' => $item->inventory->name, + 'required_quantity' => $item->quantity, + 'available_stock' => $warehouseId + ? $item->inventory->stockInWarehouse($warehouseId) + : $item->inventory->stock, + ]; + }), + ]); + } +} diff --git a/app/Http/Requests/App/BundleStoreRequest.php b/app/Http/Requests/App/BundleStoreRequest.php new file mode 100644 index 0000000..d7ecb09 --- /dev/null +++ b/app/Http/Requests/App/BundleStoreRequest.php @@ -0,0 +1,73 @@ + ['required', 'string', 'max:255'], + 'sku' => ['required', 'string', 'max:50', 'unique:bundles,sku'], + 'barcode' => ['nullable', 'string', 'max:50'], + + // Componentes del kit (mínimo 2 productos) + 'items' => ['required', 'array', 'min:2'], + 'items.*.inventory_id' => ['required', 'exists:inventories,id'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + + // Precio (opcional, se calcula automáticamente si no se provee) + 'retail_price' => ['nullable', 'numeric', 'min:0'], + 'tax' => ['nullable', 'numeric', 'min:0'], + ]; + } + + /** + * Custom validation messages + */ + public function messages(): array + { + return [ + 'name.required' => 'El nombre del bundle es obligatorio.', + 'sku.required' => 'El SKU es obligatorio.', + 'sku.unique' => 'Este SKU ya está en uso.', + 'items.required' => 'Debes agregar productos al bundle.', + 'items.min' => 'Un bundle debe tener al menos 2 productos.', + 'items.*.inventory_id.required' => 'Cada producto debe tener un ID válido.', + 'items.*.inventory_id.exists' => 'Uno de los productos no existe.', + 'items.*.quantity.required' => 'La cantidad es obligatoria.', + 'items.*.quantity.min' => 'La cantidad debe ser al menos 1.', + ]; + } + + /** + * Validación adicional + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + // Validar que no haya productos duplicados + $inventoryIds = collect($this->items)->pluck('inventory_id')->toArray(); + + if (count($inventoryIds) !== count(array_unique($inventoryIds))) { + $validator->errors()->add( + 'items', + 'No se pueden agregar productos duplicados al bundle.' + ); + } + }); + } +} diff --git a/app/Http/Requests/App/BundleUpdateRequest.php b/app/Http/Requests/App/BundleUpdateRequest.php new file mode 100644 index 0000000..5794d49 --- /dev/null +++ b/app/Http/Requests/App/BundleUpdateRequest.php @@ -0,0 +1,73 @@ +route('bundle')?->id; + + return [ + 'name' => ['nullable', 'string', 'max:255'], + 'sku' => ['nullable', 'string', 'max:50', 'unique:bundles,sku,' . $bundleId], + 'barcode' => ['nullable', 'string', 'max:50'], + + // Componentes del kit (opcional en update) + 'items' => ['nullable', 'array', 'min:2'], + 'items.*.inventory_id' => ['required_with:items', 'exists:inventories,id'], + 'items.*.quantity' => ['required_with:items', 'integer', 'min:1'], + + // Precio + 'retail_price' => ['nullable', 'numeric', 'min:0'], + 'tax' => ['nullable', 'numeric', 'min:0'], + 'recalculate_price' => ['nullable', 'boolean'], + ]; + } + + /** + * Custom validation messages + */ + public function messages(): array + { + return [ + 'sku.unique' => 'Este SKU ya está en uso.', + 'items.min' => 'Un bundle debe tener al menos 2 productos.', + 'items.*.inventory_id.exists' => 'Uno de los productos no existe.', + 'items.*.quantity.min' => 'La cantidad debe ser al menos 1.', + ]; + } + + /** + * Validación adicional + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + // Validar que no haya productos duplicados (si se proporcionan items) + if ($this->has('items')) { + $inventoryIds = collect($this->items)->pluck('inventory_id')->toArray(); + + if (count($inventoryIds) !== count(array_unique($inventoryIds))) { + $validator->errors()->add( + 'items', + 'No se pueden agregar productos duplicados al bundle.' + ); + } + } + }); + } +} diff --git a/app/Http/Requests/App/SaleStoreRequest.php b/app/Http/Requests/App/SaleStoreRequest.php index e544085..1fae81b 100644 --- a/app/Http/Requests/App/SaleStoreRequest.php +++ b/app/Http/Requests/App/SaleStoreRequest.php @@ -34,13 +34,24 @@ public function rules(): array // Items del carrito 'items' => ['required', 'array', 'min:1'], - 'items.*.inventory_id' => ['required', 'exists:inventories,id'], - 'items.*.product_name' => ['required', 'string', 'max:255'], + + // Items pueden ser productos O bundles + 'items.*.type' => ['nullable', 'in:product,bundle'], + 'items.*.bundle_id' => ['required_if:items.*.type,bundle', 'exists:bundles,id'], + + // Para productos normales + 'items.*.inventory_id' => ['required_if:items.*.type,product', 'exists:inventories,id'], + 'items.*.product_name' => ['required_if:items.*.type,product', 'string', 'max:255'], + 'items.*.unit_price' => ['required_if:items.*.type,product', 'numeric', 'min:0'], + 'items.*.subtotal' => ['required_if:items.*.type,product', 'numeric', 'min:0'], + + // Comunes a ambos 'items.*.quantity' => ['required', 'integer', 'min:1'], - 'items.*.unit_price' => ['required', 'numeric', 'min:0'], - 'items.*.subtotal' => ['required', 'numeric', 'min:0'], + 'items.*.warehouse_id' => ['nullable', 'exists:warehouses,id'], + + // Seriales (para productos normales o componentes del bundle) 'items.*.serial_numbers' => ['nullable', 'array'], - 'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'], + 'items.*.serial_numbers.*' => ['nullable', 'array'], // Para bundles: {inventory_id: [serials]} ]; } diff --git a/app/Models/Bundle.php b/app/Models/Bundle.php new file mode 100644 index 0000000..03baeb1 --- /dev/null +++ b/app/Models/Bundle.php @@ -0,0 +1,115 @@ +hasMany(BundleItem::class); + } + + /** + * Productos que componen el kit (relación many-to-many) + */ + public function inventories() + { + return $this->belongsToMany(Inventory::class, 'bundle_items') + ->withPivot('quantity') + ->withTimestamps(); + } + + /** + * Precio del kit + */ + public function price() + { + return $this->hasOne(BundlePrice::class); + } + + /** + * Stock disponible del kit = mínimo(stock_componente / cantidad_requerida) + */ + public function getAvailableStockAttribute(): int + { + if ($this->items->isEmpty()) { + 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; + } + + /** + * Stock disponible en un almacén específico + */ + public function stockInWarehouse(int $warehouseId): int + { + if ($this->items->isEmpty()) { + return 0; + } + + $minStock = PHP_INT_MAX; + + foreach ($this->items as $item) { + $inventory = $item->inventory; + $warehouseStock = $inventory->stockInWarehouse($warehouseId); + + $possibleKits = $warehouseStock > 0 ? floor($warehouseStock / $item->quantity) : 0; + $minStock = min($minStock, $possibleKits); + } + + return $minStock === PHP_INT_MAX ? 0 : (int) $minStock; + } + + /** + * Costo total del kit (suma de costos de componentes) + */ + public function getTotalCostAttribute(): float + { + $total = 0; + + foreach ($this->items as $item) { + $componentCost = $item->inventory->price->cost ?? 0; + $total += $componentCost * $item->quantity; + } + + return round($total, 2); + } + + /** + * Validar si el kit tiene stock disponible + */ + public function hasStock(int $quantity = 1, ?int $warehouseId = null): bool + { + if ($warehouseId) { + return $this->stockInWarehouse($warehouseId) >= $quantity; + } + + return $this->available_stock >= $quantity; + } +} diff --git a/app/Models/BundleItem.php b/app/Models/BundleItem.php new file mode 100644 index 0000000..9c3c4cf --- /dev/null +++ b/app/Models/BundleItem.php @@ -0,0 +1,32 @@ + 'integer', + ]; + + /** + * Bundle al que pertenece este item + */ + public function bundle() + { + return $this->belongsTo(Bundle::class); + } + + /** + * Producto componente + */ + public function inventory() + { + return $this->belongsTo(Inventory::class); + } +} diff --git a/app/Models/BundlePrice.php b/app/Models/BundlePrice.php new file mode 100644 index 0000000..5aae17d --- /dev/null +++ b/app/Models/BundlePrice.php @@ -0,0 +1,27 @@ + 'decimal:2', + 'retail_price' => 'decimal:2', + 'tax' => 'decimal:2', + ]; + + /** + * Bundle al que pertenece este precio + */ + public function bundle() + { + return $this->belongsTo(Bundle::class); + } +} diff --git a/app/Models/SaleDetail.php b/app/Models/SaleDetail.php index 07b6821..6a0cb9f 100644 --- a/app/Models/SaleDetail.php +++ b/app/Models/SaleDetail.php @@ -18,6 +18,8 @@ class SaleDetail extends Model protected $fillable = [ 'sale_id', 'inventory_id', + 'bundle_id', + 'bundle_sale_group', 'warehouse_id', 'product_name', 'quantity', @@ -101,4 +103,35 @@ public function getMaxReturnableQuantityAttribute(): int { return $this->getQuantityRemainingAttribute(); } + + /** + * Bundle al que pertenece este sale_detail (si es componente de un kit) + */ + public function bundle() + { + return $this->belongsTo(Bundle::class); + } + + /** + * Verificar si este sale_detail es parte de un kit + */ + public function isPartOfBundle(): bool + { + return !is_null($this->bundle_id); + } + + /** + * Obtener todos los sale_details del mismo kit vendido + * (todos los componentes con el mismo bundle_sale_group) + */ + public function bundleComponents() + { + if (!$this->isPartOfBundle()) { + return collect([]); + } + + return SaleDetail::where('sale_id', $this->sale_id) + ->where('bundle_sale_group', $this->bundle_sale_group) + ->get(); + } } diff --git a/app/Services/BundleService.php b/app/Services/BundleService.php new file mode 100644 index 0000000..6b50533 --- /dev/null +++ b/app/Services/BundleService.php @@ -0,0 +1,175 @@ + $data['name'], + 'sku' => $data['sku'], + 'barcode' => $data['barcode'] ?? null, + ]); + + // 2. Agregar componentes al kit + foreach ($data['items'] as $item) { + BundleItem::create([ + 'bundle_id' => $bundle->id, + 'inventory_id' => $item['inventory_id'], + 'quantity' => $item['quantity'], + ]); + } + + // 3. Calcular costos y crear precio + $bundle->load('items.inventory.price'); + + $totalCost = 0; + $totalRetailPrice = 0; + + foreach ($bundle->items as $item) { + $totalCost += ($item->inventory->price->cost ?? 0) * $item->quantity; + $totalRetailPrice += ($item->inventory->price->retail_price ?? 0) * $item->quantity; + } + + // Permitir override de precio (para promociones) + $finalRetailPrice = $data['retail_price'] ?? $totalRetailPrice; + $tax = $data['tax'] ?? ($finalRetailPrice * 0.16); // 16% por defecto + + BundlePrice::create([ + 'bundle_id' => $bundle->id, + 'cost' => $totalCost, + 'retail_price' => $finalRetailPrice, + 'tax' => $tax, + ]); + + return $bundle->fresh(['items.inventory.price', 'price']); + }); + } + + /** + * Actualizar un kit existente + */ + public function updateBundle(Bundle $bundle, array $data): Bundle + { + return DB::transaction(function () use ($bundle, $data) { + // 1. Actualizar datos básicos del bundle + $bundle->update([ + 'name' => $data['name'] ?? $bundle->name, + 'sku' => $data['sku'] ?? $bundle->sku, + 'barcode' => $data['barcode'] ?? $bundle->barcode, + ]); + + // 2. Actualizar componentes si se proporcionan + if (isset($data['items'])) { + // Eliminar componentes actuales + $bundle->items()->delete(); + + // Crear nuevos componentes + foreach ($data['items'] as $item) { + BundleItem::create([ + 'bundle_id' => $bundle->id, + 'inventory_id' => $item['inventory_id'], + 'quantity' => $item['quantity'], + ]); + } + } + + // 3. Recalcular o actualizar precio + if (isset($data['recalculate_price']) && $data['recalculate_price']) { + // Recalcular precio basado en componentes actuales + $bundle->load('items.inventory.price'); + + $totalCost = 0; + $totalRetailPrice = 0; + + foreach ($bundle->items as $item) { + $totalCost += ($item->inventory->price->cost ?? 0) * $item->quantity; + $totalRetailPrice += ($item->inventory->price->retail_price ?? 0) * $item->quantity; + } + + $finalRetailPrice = $data['retail_price'] ?? $totalRetailPrice; + $tax = $data['tax'] ?? ($finalRetailPrice * 0.16); + + $bundle->price->update([ + 'cost' => $totalCost, + 'retail_price' => $finalRetailPrice, + 'tax' => $tax, + ]); + } elseif (isset($data['retail_price'])) { + // Solo actualizar precio sin recalcular componentes + $bundle->price->update([ + 'retail_price' => $data['retail_price'], + 'tax' => $data['tax'] ?? ($data['retail_price'] * 0.16), + ]); + } + + return $bundle->fresh(['items.inventory.price', 'price']); + }); + } + + /** + * Validar disponibilidad de stock del kit + */ + public function validateBundleAvailability(Bundle $bundle, int $quantity, ?int $warehouseId = null): void + { + if (!$bundle->hasStock($quantity, $warehouseId)) { + $availableStock = $warehouseId + ? $bundle->stockInWarehouse($warehouseId) + : $bundle->available_stock; + + throw new \Exception( + "Stock insuficiente del kit '{$bundle->name}'. " . + "Disponibles: {$availableStock}, Requeridos: {$quantity}" + ); + } + } + + /** + * Obtener precio total de componentes (sin promoción) + */ + public function getComponentsValue(Bundle $bundle): float + { + $total = 0; + + foreach ($bundle->items as $item) { + $componentPrice = $item->inventory->price->retail_price ?? 0; + $total += $componentPrice * $item->quantity; + } + + return round($total, 2); + } + + /** + * Eliminar (soft delete) un bundle + */ + public function deleteBundle(Bundle $bundle): bool + { + return $bundle->delete(); + } + + /** + * Restaurar un bundle eliminado + */ + public function restoreBundle(int $bundleId): ?Bundle + { + $bundle = Bundle::withTrashed()->find($bundleId); + + if ($bundle && $bundle->trashed()) { + $bundle->restore(); + return $bundle->fresh(['items.inventory.price', 'price']); + } + + return null; + } +} diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index 6d1dbe3..1f37b92 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -1,5 +1,6 @@ $data['status'] ?? 'completed', ]); - // 2. Crear los detalles de la venta y asignar seriales - foreach ($data['items'] as $item) { + // 2. Expandir bundles en componentes individuales + $expandedItems = $this->expandBundlesIntoComponents($data['items']); + + // 2.1. Validar stock de TODOS los items (componentes + productos normales) + $this->validateStockForAllItems($expandedItems); + + // 3. Crear los detalles de la venta y asignar seriales + foreach ($expandedItems as $item) { // Calcular descuento por detalle si aplica $itemDiscountAmount = 0; if ($discountPercentage > 0) { @@ -84,6 +92,9 @@ public function createSale(array $data) $saleDetail = SaleDetail::create([ 'sale_id' => $sale->id, 'inventory_id' => $item['inventory_id'], + 'bundle_id' => $item['bundle_id'] ?? null, + 'bundle_sale_group' => $item['bundle_sale_group'] ?? null, + 'warehouse_id' => $item['warehouse_id'] ?? null, 'product_name' => $item['product_name'], 'quantity' => $item['quantity'], 'unit_price' => $item['unit_price'], @@ -244,4 +255,113 @@ private function getCurrentCashRegister($userId) return $register ? $register->id : null; } + + /** + * Expandir bundles en componentes individuales + */ + private function expandBundlesIntoComponents(array $items): array + { + $expanded = []; + + foreach ($items as $item) { + // Detectar si es un bundle + if (isset($item['type']) && $item['type'] === 'bundle') { + // Es un kit, expandir en componentes + $bundle = Bundle::with(['items.inventory.price', 'price'])->findOrFail($item['bundle_id']); + $bundleQuantity = $item['quantity']; + $bundleSaleGroup = Str::uuid()->toString(); + + // Calcular precio por unidad de kit (para distribuir) + $bundleTotalPrice = $bundle->price->retail_price; + $bundleComponentsValue = $this->calculateBundleComponentsValue($bundle); + + foreach ($bundle->items as $bundleItem) { + $componentInventory = $bundleItem->inventory; + $componentQuantity = $bundleItem->quantity * $bundleQuantity; + + // Calcular precio proporcional del componente + $componentValue = ($componentInventory->price->retail_price ?? 0) * $bundleItem->quantity; + $priceRatio = $bundleComponentsValue > 0 ? $componentValue / $bundleComponentsValue : 0; + $componentUnitPrice = round($bundleTotalPrice * $priceRatio / $bundleItem->quantity, 2); + + $expanded[] = [ + 'inventory_id' => $componentInventory->id, + 'bundle_id' => $bundle->id, + 'bundle_sale_group' => $bundleSaleGroup, + 'warehouse_id' => $item['warehouse_id'] ?? null, + 'product_name' => $componentInventory->name, + 'quantity' => $componentQuantity, + 'unit_price' => $componentUnitPrice, + 'subtotal' => $componentUnitPrice * $componentQuantity, + 'serial_numbers' => $item['serial_numbers'][$componentInventory->id] ?? null, + ]; + } + } else { + // Producto normal, agregar tal cual + $expanded[] = $item; + } + } + + return $expanded; + } + + /** + * Validar stock de todos los items (antes de crear sale_details) + */ + private function validateStockForAllItems(array $items): void + { + foreach ($items as $item) { + $inventory = Inventory::find($item['inventory_id']); + $warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId(); + + if ($inventory->track_serials) { + // Validar seriales disponibles + if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { + // Validar que los seriales específicos existan y estén disponibles + foreach ($item['serial_numbers'] as $serialNumber) { + $serial = InventorySerial::where('inventory_id', $inventory->id) + ->where('serial_number', $serialNumber) + ->where('status', 'disponible') + ->first(); + + if (!$serial) { + throw new \Exception( + "Serial {$serialNumber} no disponible para {$inventory->name}" + ); + } + } + } else { + // Validar que haya suficientes seriales disponibles + $availableSerials = InventorySerial::where('inventory_id', $inventory->id) + ->where('status', 'disponible') + ->count(); + + if ($availableSerials < $item['quantity']) { + throw new \Exception( + "Stock insuficiente de seriales para {$inventory->name}. " . + "Disponibles: {$availableSerials}, Requeridos: {$item['quantity']}" + ); + } + } + } else { + // Validar stock en almacén + $this->movementService->validateStock($inventory->id, $warehouseId, $item['quantity']); + } + } + } + + /** + * Obtener precio total de componentes de un bundle + */ + private function calculateBundleComponentsValue(Bundle $bundle): float + { + $total = 0; + + foreach ($bundle->items as $item) { + $componentPrice = $item->inventory->price->retail_price ?? 0; + $total += $componentPrice * $item->quantity; + } + + return round($total, 2); + } } diff --git a/app/Services/WhatsappService.php b/app/Services/WhatsappService.php index cc3622d..a8cf80b 100644 --- a/app/Services/WhatsappService.php +++ b/app/Services/WhatsappService.php @@ -158,26 +158,6 @@ public function sendInvoice( return $pdfResult; } - /** - * Enviar ticket de venta por WhatsApp - */ - public function sendSaleTicket( - string $phoneNumber, - string $ticketUrl, - string $saleNumber, - string $customerName - ): array { - return $this->sendDocument( - phoneNumber: $phoneNumber, - documentUrl: $ticketUrl, - caption: "Ticket de venta {$saleNumber} - {$customerName}. Gracias por su compra.", - filename: "Ticket_{$saleNumber}.pdf", - userEmail: $this->email, - ticket: $saleNumber, - customerName: $customerName - ); - } - /** * Limpiar número de teléfono */ diff --git a/database/migrations/2026_02_16_125620_create_bundles_table.php b/database/migrations/2026_02_16_125620_create_bundles_table.php new file mode 100644 index 0000000..a019e2f --- /dev/null +++ b/database/migrations/2026_02_16_125620_create_bundles_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('sku')->unique(); + $table->string('barcode')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bundles'); + } +}; diff --git a/database/migrations/2026_02_16_135929_create_bundle_items_table.php b/database/migrations/2026_02_16_135929_create_bundle_items_table.php new file mode 100644 index 0000000..2842087 --- /dev/null +++ b/database/migrations/2026_02_16_135929_create_bundle_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('bundle_id')->constrained('bundles')->onDelete('cascade'); + $table->foreignId('inventory_id')->constrained('inventories')->onDelete('restrict'); + $table->integer('quantity')->default(1); + $table->timestamps(); + + // Un producto solo puede aparecer una vez por kit + $table->unique(['bundle_id', 'inventory_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bundle_items'); + } +}; diff --git a/database/migrations/2026_02_16_140044_create_bundle_prices_table.php b/database/migrations/2026_02_16_140044_create_bundle_prices_table.php new file mode 100644 index 0000000..f363f9f --- /dev/null +++ b/database/migrations/2026_02_16_140044_create_bundle_prices_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('bundle_id')->unique()->constrained('bundles')->onDelete('cascade'); + $table->decimal('cost', 10, 2); + $table->decimal('retail_price', 10, 2); + $table->decimal('tax', 10, 2); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bundle_prices'); + } +}; diff --git a/database/migrations/2026_02_16_140253_add_bundle_to_sale_details_table.php b/database/migrations/2026_02_16_140253_add_bundle_to_sale_details_table.php new file mode 100644 index 0000000..bdcb6ad --- /dev/null +++ b/database/migrations/2026_02_16_140253_add_bundle_to_sale_details_table.php @@ -0,0 +1,35 @@ +foreignId('bundle_id')->nullable()->after('inventory_id')->constrained('bundles')->onDelete('restrict'); + $table->string('bundle_sale_group', 36)->nullable()->after('bundle_id'); + $table->index('bundle_id'); + $table->index('bundle_sale_group'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sale_details', function (Blueprint $table) { + $table->dropIndex(['bundle_id']); + $table->dropIndex(['bundle_sale_group']); + $table->dropForeign(['bundle_id']); + $table->dropColumn(['bundle_id', 'bundle_sale_group']); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index a12e623..d4664c1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,6 @@ group(function () { + Route::get('/', [BundleController::class, 'index']); + Route::get('/{bundle}', [BundleController::class, 'show']); + Route::post('/', [BundleController::class, 'store']); + Route::put('/{bundle}', [BundleController::class, 'update']); + Route::delete('/{bundle}', [BundleController::class, 'destroy']); + Route::get('/{bundle}/check-stock', [BundleController::class, 'checkStock']); + }); + //PRECIOS Route::resource('precios', PriceController::class);