positiveInt($criteria['year'] ?? null); $mesinCc = $this->positiveInt($criteria['mesin_cc'] ?? null); try { $filters = [ 'toleransi_harga_percent' => self::PRICE_TOLERANCE_PERCENT, 'jumlah_cluster' => 4, ]; if ($year) { $filters['tahun_min'] = $year; } $preferences = []; if ($mesinCc) { $preferences['mesin_cc'] = $mesinCc; } $response = Http::timeout(config('services.car_recommendation.timeout', 3)) ->post(rtrim(config('services.car_recommendation.base_url'), '/') . '/recommend', [ 'budget' => $budget, 'limit' => $limit, 'strategi' => 'harga_pas', 'preferences' => $preferences, 'filter' => $filters, ]); if ($response->failed()) { return $this->fallback($budget, $criteria); } $payload = $response->json(); $cars = $this->mapRecommendationsToCategories($payload['recommendations'] ?? []); if ($cars->isEmpty()) { return $this->fallback($budget, $criteria); } return [ 'cars' => $cars, 'source' => 'python_kmeans', 'meta' => $payload['model'] ?? null, 'has_exact_budget_match' => (bool) data_get($payload, 'model.exact_budget_match', false), ]; } catch (Throwable $exception) { return $this->fallback($budget, $criteria); } } public function fallback(float $budget, array $criteria = []): array { $year = $this->positiveInt($criteria['year'] ?? null); $mesinCc = $this->positiveInt($criteria['mesin_cc'] ?? null); $baseQuery = Category::where('status', 'available'); if ($year) { $baseQuery->where('year', '>=', $year); } if ($mesinCc) { $engineTolerance = 250; $baseQuery->whereBetween('mesin_cc', [$mesinCc - $engineTolerance, $mesinCc + $engineTolerance]); } $exactMatches = (clone $baseQuery) ->where('price', $budget) ->orderBy('year', 'desc') ->get(); if ($exactMatches->isNotEmpty()) { return [ 'cars' => $exactMatches, 'source' => 'laravel_fallback', 'meta' => null, 'has_exact_budget_match' => true, ]; } $minimumPrice = $budget * (1 - (self::PRICE_TOLERANCE_PERCENT / 100)); return [ 'cars' => (clone $baseQuery) ->where('price', '>', 0) ->where('price', '>=', $minimumPrice) ->where('price', '<=', $budget) ->orderByRaw('ABS(price - ?) ASC', [$budget]) ->when($mesinCc, fn ($query) => $query->orderByRaw('ABS(mesin_cc - ?) ASC', [$mesinCc])) ->orderBy('year', 'desc') ->get(), 'source' => 'laravel_fallback', 'meta' => null, 'has_exact_budget_match' => false, ]; } private function mapRecommendationsToCategories(array $recommendations): Collection { $categories = Category::where('status', 'available')->get(); $matched = collect(); foreach ($recommendations as $recommendation) { $category = $this->findMatchingCategory($categories, $recommendation, $matched->pluck('id')->all()); if ($category) { $matched->push($category); } } return $matched; } private function findMatchingCategory(Collection $categories, array $recommendation, array $usedIds): ?Category { $name = $this->normalize($recommendation['nama'] ?? ''); $year = (int) ($recommendation['tahun'] ?? 0); $price = (float) ($recommendation['harga'] ?? 0); return $categories->first(function (Category $category) use ($name, $year, $price, $usedIds) { return ! in_array($category->id, $usedIds, true) && $this->normalize($category->name) === $name && (int) $category->year === $year && (float) $category->price === $price; }) ?? $categories->first(function (Category $category) use ($name, $year, $usedIds) { return ! in_array($category->id, $usedIds, true) && $this->normalize($category->name) === $name && (int) $category->year === $year; }); } private function normalize(string $value): string { return preg_replace('/\s+/', ' ', strtolower(trim($value))) ?? ''; } private function positiveInt(mixed $value): ?int { if (! is_numeric($value)) { return null; } $value = (int) $value; return $value > 0 ? $value : null; } }