WebSAW-LOCAL/app/Services/ToyRecommendationService.php

307 lines
11 KiB
PHP

<?php
namespace App\Services;
use App\Models\Criteria;
use App\Models\Product;
use Illuminate\Support\Collection;
class ToyRecommendationService
{
/**
* Hitung rekomendasi SAW.
*
* @param array $input ['age_min', 'age_max', 'budget_min', 'budget_max', 'priorities' => [criteria_id=>1-5, ...]]
* @return Collection<int, array{rank: int, product: Product, score: float, explanation: string}>
*/
public function getRecommendations(array $input): Collection
{
$ageMin = (int) ($input['age_min'] ?? 0);
$ageMax = (int) ($input['age_max'] ?? 99);
$budgetMin = (float) ($input['budget_min'] ?? 0);
$budgetMax = (float) ($input['budget_max'] ?? 999999999);
$priorities = $input['priorities'] ?? [];
$products = $this->getFilteredProducts($ageMin, $ageMax, $budgetMin, $budgetMax);
if ($products->isEmpty()) {
return collect();
}
$criterias = Criteria::orderBy('weight_order')->get();
if ($criterias->isEmpty()) {
return $products->take(5)->map(fn ($p, $i) => [
'rank' => $i + 1,
'product' => $p,
'score' => 0.0,
'explanation' => 'Produk ini tersedia dalam rentang usia dan budget Anda.',
]);
}
$weights = $this->buildWeightsFromPriorities($priorities, $criterias);
$matrix = $this->buildMatrix($products, $criterias);
$normalized = $this->normalizeMatrix($matrix, $criterias);
$scores = $this->calculatePreferenceScores($normalized, $weights, $criterias);
$ranked = $scores->sortByDesc('score')->take(5)->values();
$criteriaNames = $criterias->keyBy('id')->map->name;
return $ranked->map(function ($item, $index) use ($products, $criteriaNames, $priorities) {
$product = $products->firstWhere('id', $item['product_id']);
if (! $product) {
return null;
}
$explanation = $this->buildExplanation($product, $item['score'], $priorities, $criteriaNames);
return [
'rank' => $index + 1,
'product' => $product,
'score' => round($item['score'], 4),
'explanation' => $explanation,
];
})->filter()->values();
}
/**
* Hitung rekomendasi SAW beserta data matriks untuk tampilan (skripsi).
*
* @return array{recommendations: Collection, criterias: array, decision_matrix: array, normalized_matrix: array, weights: array, preference_scores: array}
*/
public function getRecommendationsWithMatrix(array $input): array
{
$ageMin = (int) ($input['age_min'] ?? 0);
$ageMax = (int) ($input['age_max'] ?? 99);
$budgetMin = (float) ($input['budget_min'] ?? 0);
$budgetMax = (float) ($input['budget_max'] ?? 999999999);
$priorities = $input['priorities'] ?? [];
$products = $this->getFilteredProducts($ageMin, $ageMax, $budgetMin, $budgetMax);
if ($products->isEmpty()) {
return [
'recommendations' => collect(),
'criterias' => [],
'decision_matrix' => [],
'normalized_matrix' => [],
'weights' => [],
'preference_scores' => [],
];
}
$criterias = Criteria::orderBy('weight_order')->get();
if ($criterias->isEmpty()) {
$recommendations = $products->take(5)->map(fn ($p, $i) => [
'rank' => $i + 1,
'product' => $p,
'score' => 0.0,
'explanation' => 'Produk ini tersedia dalam rentang usia dan budget Anda.',
]);
return [
'recommendations' => $recommendations,
'criterias' => [],
'decision_matrix' => [],
'normalized_matrix' => [],
'weights' => [],
'preference_scores' => [],
];
}
$weights = $this->buildWeightsFromPriorities($priorities, $criterias);
$matrix = $this->buildMatrix($products, $criterias);
$normalized = $this->normalizeMatrix($matrix, $criterias);
$scores = $this->calculatePreferenceScores($normalized, $weights, $criterias);
$ranked = $scores->sortByDesc('score')->take(5)->values();
$topProductIds = $ranked->pluck('product_id')->all();
$rankedScoresByProductId = $ranked
->mapWithKeys(fn ($item) => [$item['product_id'] => round($item['score'], 4)])
->all();
$criteriasWithWeight = [];
foreach ($criterias as $c) {
$criteriasWithWeight[] = [
'name' => $c->name,
'type' => $c->type,
'weight' => $weights[$c->name] ?? 0,
];
}
$decisionMatrix = [];
foreach ($topProductIds as $productId) {
$row = $matrix[$productId] ?? null;
if (! $row) {
continue;
}
$product = $products->firstWhere('id', $productId);
$r = ['product_id' => $productId, 'product_name' => $product ? $product->name : '-'];
foreach ($criterias as $c) {
$r[$c->name] = $row[$c->name] ?? 0;
}
$decisionMatrix[] = $r;
}
$normalizedMatrix = [];
foreach ($topProductIds as $productId) {
$row = $normalized[$productId] ?? null;
if (! $row) {
continue;
}
$product = $products->firstWhere('id', $productId);
$r = ['product_id' => $productId, 'product_name' => $product ? $product->name : '-'];
foreach ($criterias as $c) {
$val = $row[$c->name] ?? 0;
$r[$c->name] = round((float) $val, 4);
}
$normalizedMatrix[] = $r;
}
$preferenceScores = $rankedScoresByProductId;
$criteriaNames = $criterias->keyBy('id')->map->name;
$recommendations = $ranked->map(function ($item, $index) use ($products, $criteriaNames, $priorities) {
$product = $products->firstWhere('id', $item['product_id']);
if (! $product) {
return null;
}
$explanation = $this->buildExplanation($product, $item['score'], $priorities, $criteriaNames);
return [
'rank' => $index + 1,
'product' => $product,
'score' => round($item['score'], 4),
'explanation' => $explanation,
];
})->filter()->values();
return [
'recommendations' => $recommendations,
'criterias' => $criteriasWithWeight,
'decision_matrix' => $decisionMatrix,
'normalized_matrix' => $normalizedMatrix,
'weights' => $weights,
'preference_scores' => $preferenceScores,
];
}
protected function getFilteredProducts(int $ageMin, int $ageMax, float $budgetMin, float $budgetMax): Collection
{
return Product::with('criterias')
->where('stock', '>', 0)
->whereBetween('price', [$budgetMin, $budgetMax])
->get()
->filter(function ($product) use ($ageMin, $ageMax) {
if (empty($product->age_range)) {
return true;
}
return $this->ageRangeOverlaps($product->age_range, $ageMin, $ageMax);
})
->values();
}
protected function ageRangeOverlaps(string $ageRange, int $childMin, int $childMax): bool
{
if (preg_match('/^(\d+)\s*-\s*(\d+)$/', trim($ageRange), $m)) {
$pMin = (int) $m[1];
$pMax = (int) $m[2];
return $childMax >= $pMin && $childMin <= $pMax;
}
return true;
}
protected function buildWeightsFromPriorities(array $priorities, Collection $criterias): array
{
// priorities: [criteria_id => 1..5]
// return weights by criteria name: ['Harga' => 0.2, 'Kualitas' => 0.2, ...]
$raw = [];
$sum = 0.0;
foreach ($criterias as $c) {
$w = (float) ($priorities[(string) $c->id] ?? $priorities[$c->id] ?? 1);
if ($w < 1) $w = 1;
if ($w > 5) $w = 5;
$raw[$c->name] = $w;
$sum += $w;
}
$count = max(1, $criterias->count());
$weights = [];
foreach ($raw as $name => $v) {
$weights[$name] = $sum > 0 ? $v / $sum : 1 / $count;
}
return $weights;
}
protected function buildMatrix(Collection $products, Collection $criterias): array
{
$matrix = [];
foreach ($products as $product) {
$row = ['product_id' => $product->id];
foreach ($product->criterias as $pc) {
$criteria = $criterias->firstWhere('id', $pc->id);
if ($criteria) {
$row[$criteria->name] = (float) $pc->pivot->value;
}
}
foreach ($criterias as $c) {
if (! isset($row[$c->name])) {
$row[$c->name] = $c->name === 'Harga' ? (float) $product->price : 0;
}
}
$matrix[$product->id] = $row;
}
return $matrix;
}
protected function normalizeMatrix(array $matrix, Collection $criterias): array
{
if (empty($matrix)) {
return [];
}
$normalized = [];
foreach (array_keys($matrix) as $pid) {
$normalized[$pid] = ['product_id' => $pid];
}
foreach ($criterias as $criteria) {
$key = $criteria->name;
$values = array_column($matrix, $key);
$max = max($values);
$min = min($values);
$isCost = $criteria->type === 'cost';
if ($isCost) {
foreach ($matrix as $pid => $row) {
$val = (float) ($row[$key] ?? 0);
$normalized[$pid][$key] = $min > 0 && $val > 0 ? $min / $val : 0;
}
} else {
foreach ($matrix as $pid => $row) {
$val = (float) ($row[$key] ?? 0);
$normalized[$pid][$key] = $max > 0 ? $val / $max : 0;
}
}
}
return $normalized;
}
protected function calculatePreferenceScores(array $normalized, array $weights, Collection $criterias): Collection
{
$result = collect();
foreach ($normalized as $productId => $row) {
$vi = 0;
foreach ($criterias as $c) {
$w = (float) ($weights[$c->name] ?? (1 / max(1, $criterias->count())));
$r = (float) ($row[$c->name] ?? 0);
$vi += $w * $r;
}
$result->push(['product_id' => $productId, 'score' => $vi]);
}
return $result;
}
protected function buildExplanation($product, float $score, array $priorities, $criteriaNames): string
{
return 'Produk ini direkomendasikan karena memiliki nilai tinggi pada kriteria yang Anda prioritaskan.';
}
}