WebSAW-LOCAL/app/Services/ToyRecommendationService.php

430 lines
14 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();
// }
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'] ?? [];
$category = $input['category'] ?? null;
$products = $this->getFilteredProducts(
$ageMin,
$ageMax,
$budgetMin,
$budgetMax,
$category
);
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'] ?? [];
$category = $input['category'] ?? null;
$products = $this->getFilteredProducts(
$ageMin,
$ageMax,
$budgetMin,
$budgetMax,
$category
);
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,
?string $category = null
): Collection
{
$query = Product::with('criterias')
->where('stock', '>', 0)
->whereBetween('price', [$budgetMin, $budgetMax]);
// Filter kategori jika dipilih
if (!empty($category)) {
$query->where('category', $category);
}
return $query->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
{
$raw = [];
$sum = 0.0;
foreach ($criterias as $c) {
$input = (int) ($priorities[(string) $c->id] ?? $priorities[$c->id] ?? 3);
if ($input < 1)
$input = 1;
if ($input > 5)
$input = 5;
// Jika Cost, dibalik
if ($c->type === 'cost') {
$w = 6 - $input;
} else {
$w = $input;
}
$raw[$c->name] = $w;
$sum += $w;
}
$weights = [];
foreach ($raw as $name => $value) {
$weights[$name] = $value / $sum;
}
return $weights;
}
// 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.';
}
}