162 lines
5.3 KiB
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;
|
|
}
|
|
}
|