TA/web_JMW/app/Services/CarRecommendationService.php

162 lines
5.3 KiB
PHP

<?php
namespace App\Services;
use App\Models\Category;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Throwable;
class CarRecommendationService
{
private const PRICE_TOLERANCE_PERCENT = 10;
public function recommendForBudget(float $budget, int $limit = 50, array $criteria = []): array
{
$year = $this->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;
}
}