first(); } // Calcular descuento si el cliente tiene tier $discountPercentage = 0; $discountAmount = 0; $clientTierName = null; if ($client && $client->tier) { $discountPercentage = $this->clientTierService->getApplicableDiscount($client); $clientTierName = $client->tier->tier_name; // Calcular descuento solo sobre el subtotal (sin IVA) $discountAmount = round($data['subtotal'] * ($discountPercentage / 100), 2); // Recalcular total: subtotal - descuento + IVA $data['total'] = ($data['subtotal'] - $discountAmount) + $data['tax']; } // Calcular el cambio si es pago en efectivo $cashReceived = null; $change = null; if ($data['payment_method'] === 'cash' && isset($data['cash_received'])) { $cashReceived = $data['cash_received']; $change = $cashReceived - $data['total']; } // 1. Crear la venta principal $sale = Sale::create([ 'user_id' => $data['user_id'], 'client_id' => $client?->id ?? $data['client_id'] ?? null, 'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']), 'invoice_number' => $data['invoice_number'] ?? $this->generateInvoiceNumber(), 'subtotal' => $data['subtotal'], 'tax' => $data['tax'], 'total' => $data['total'], 'discount_percentage' => $discountPercentage, 'discount_amount' => $discountAmount, 'client_tier_name' => $clientTierName, 'cash_received' => $cashReceived, 'change' => $change, 'payment_method' => $data['payment_method'], 'status' => $data['status'] ?? 'completed', ]); // 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) { $itemDiscountAmount = round($item['subtotal'] * ($discountPercentage / 100), 2); } // Crear detalle de venta $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'], 'subtotal' => $item['subtotal'], 'discount_percentage' => $discountPercentage, 'discount_amount' => $itemDiscountAmount, ]); // Obtener el inventario $inventory = Inventory::find($item['inventory_id']); if ($inventory->track_serials) { // 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('status', 'disponible') ->first(); if ($serial) { $serial->markAsSold($saleDetail->id); } else { throw new \Exception("Serial {$serialNumber} no disponible"); } } } else { // Asignar automáticamente los primeros N seriales disponibles $serials = InventorySerial::where('inventory_id', $inventory->id) ->where('status', 'disponible') ->limit($item['quantity']) ->get(); if ($serials->count() < $item['quantity']) { throw new \Exception("Stock insuficiente de seriales para {$item['product_name']}"); } foreach ($serials as $serial) { $serial->markAsSold($saleDetail->id); } } // Sincronizar el stock $inventory->syncStock(); } else { // Obtener almacén (del item o el principal) $warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId(); $this->movementService->validateStock($inventory->id, $warehouseId, $item['quantity']); $this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$item['quantity']); } } // 3. Actualizar estadísticas del cliente si existe if ($client && $sale->status === 'completed') { $this->clientTierService->updateClientPurchaseStats($client, $sale->total); } // 4. Actualizar cash register con descuentos if ($sale->cash_register_id && $discountAmount > 0) { $cashRegister = CashRegister::find($sale->cash_register_id); if ($cashRegister) { $cashRegister->increment('total_discounts', $discountAmount); } } // 5. Retornar la venta con sus relaciones cargadas return $sale->load(['details.inventory', 'details.serials', 'user', 'client.tier']); }); } /** * Cancelar una venta y restaurar el stock * */ public function cancelSale(Sale $sale) { return DB::transaction(function () use ($sale) { // Verificar que la venta esté completada if ($sale->status !== 'completed') { throw new \Exception('Solo se pueden cancelar ventas completadas.'); } // Verificar que la venta no tenga devoluciones procesadas if ($sale->returns()->exists()) { throw new \Exception('No se puede cancelar una venta que tiene devoluciones procesadas.'); } // Restaurar seriales a disponible foreach ($sale->details as $detail) { if ($detail->inventory->track_serials) { // Restaurar seriales $serials = InventorySerial::where('sale_detail_id', $detail->id)->get(); foreach ($serials as $serial) { $serial->markAsAvailable(); } $detail->inventory->syncStock(); } else { // Restaurar stock en el almacén $warehouseId = $detail->warehouse_id ?? $this->movementService->getMainWarehouseId(); $this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, $detail->quantity); } } // Revertir estadísticas del cliente si existe if ($sale->client_id) { $client = Client::find($sale->client_id); if ($client) { // Restar de total_purchases y decrementar transacciones $client->decrement('total_purchases', $sale->total); $client->decrement('total_transactions', 1); // Recalcular tier después de cancelación $this->clientTierService->recalculateClientTier($client); } } // Revertir descuentos del cash register if ($sale->cash_register_id && $sale->discount_amount > 0) { $cashRegister = CashRegister::find($sale->cash_register_id); if ($cashRegister) { $cashRegister->decrement('total_discounts', $sale->discount_amount); } } // Marcar venta como cancelada $sale->update(['status' => 'cancelled']); return $sale->fresh(['details.inventory', 'details.serials', 'user', 'client.tier']); }); } /** * Generar número de factura único * Formato: INV-YYYYMMDD-0001 */ private function generateInvoiceNumber(): string { $prefix = 'INV-'; $date = now()->format('Ymd'); // Obtener la última venta del día $lastSale = Sale::whereDate('created_at', today()) ->orderBy('id', 'desc') ->first(); // Incrementar secuencial $sequential = $lastSale ? (intval(substr($lastSale->invoice_number, -4)) + 1) : 1; return $prefix . $date . '-' . str_pad($sequential, 4, '0', STR_PAD_LEFT); } private function getCurrentCashRegister($userId) { $register = CashRegister::where('user_id', $userId) ->where('status', 'open') ->first(); 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 { // Agrupar cantidad total por producto+almacén para evitar que un bundle y un producto // suelto pasen la validación individualmente pero no haya stock suficiente en conjunto $aggregated = []; $serialsByProduct = []; foreach ($items as $item) { $inventoryId = $item['inventory_id']; $warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId(); $key = "{$inventoryId}_{$warehouseId}"; if (!isset($aggregated[$key])) { $aggregated[$key] = [ 'inventory_id' => $inventoryId, 'warehouse_id' => $warehouseId, 'quantity' => 0, ]; } $aggregated[$key]['quantity'] += $item['quantity']; // Acumular seriales específicos por producto if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { if (!isset($serialsByProduct[$inventoryId])) { $serialsByProduct[$inventoryId] = []; } $serialsByProduct[$inventoryId] = array_merge( $serialsByProduct[$inventoryId], $item['serial_numbers'] ); } } // Validar stock total agrupado foreach ($aggregated as $entry) { $inventory = Inventory::find($entry['inventory_id']); if ($inventory->track_serials) { if (!empty($serialsByProduct[$entry['inventory_id']])) { // Validar que los seriales específicos existan y estén disponibles foreach ($serialsByProduct[$entry['inventory_id']] 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 para la cantidad TOTAL $availableSerials = InventorySerial::where('inventory_id', $inventory->id) ->where('status', 'disponible') ->count(); if ($availableSerials < $entry['quantity']) { throw new \Exception( "Stock insuficiente de seriales para {$inventory->name}. " . "Disponibles: {$availableSerials}, Requeridos: {$entry['quantity']}" ); } } } else { // Validar stock total en almacén $this->movementService->validateStock($inventory->id, $entry['warehouse_id'], $entry['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); } }