Feature: Recommendation Service

This commit introduces a RecommendationService that is used when indexing products.

The service is utilizing a recommendation rules interface, so many rules can be created and applied during indexing the products
This commit is contained in:
2026-09-01 12:06:29 +03:00
parent 53d8a5aefe
commit 0f07751559
11 changed files with 507 additions and 0 deletions
+26
View File
@@ -44,6 +44,21 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* Reflects stock as of the last reindex only — nothing currently reindexes a
* product when an order decrements its stock (see docs/product-listing.md).
*
* - recommendations (recommendations.id filterable): [{id, name, price, image}, ...]
* up to 4 other products to show alongside this one (a "related products"
* section), sourced from Modules\Core\Catalog\Services\RecommendationService's
* configured rule chain (config('catalog.recommendation_rules')). Embedded
* card data, not just ids, same reasoning as `collections`: renders
* directly with zero extra Meilisearch calls. `name` is resolved via
* translateAttribute() at index time (not through ProductService's
* per-request locale resolution, since indexing has no "current locale"
* the way a storefront request does) — same known index-time-locale
* tradeoff `collections` already has. `recommendations.id` is filterable
* specifically so Modules\Core\Catalog\Listeners\
* ReindexProductsRecommendingProduct can find every product currently
* recommending a given one, when that one changes — there's no Postgres
* relation for this, a recommendation only exists inside the index.
*
* A review is created/edited independently of its product (Modules\Core\Providers\
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
* this data doesn't go stale between full reindexes.
@@ -67,6 +82,7 @@ class ProductIndexer extends BaseProductIndexer
'slugs',
'channel_ids',
'in_stock',
'recommendations.id',
];
}
@@ -126,6 +142,16 @@ class ProductIndexer extends BaseProductIndexer
$data['in_stock'] = $model->variants->contains(
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
);
$data['recommendations'] = app(RecommendationService::class)
->recommend($model)
->load(['media', 'variants.prices'])
->map(fn (Product $recommendation) => [
'id' => $recommendation->id,
'name' => $recommendation->translateAttribute('name'),
'price' => $this->cheapestPrice($recommendation, $currency),
'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null,
])
->all();
return $data;
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* Runs each rule in config('catalog.recommendation_rules'), in order,
* topping up from each successive rule until $limit distinct products are
* collected or every rule is exhausted — e.g. 3 from SameCategoryRule
* (the product's category only has 3 other products) + 1 from RandomRule.
* No rule is special-cased as "the fallback" here; a store gets fallback
* behaviour purely by how it orders its own config (e.g. SameCategoryRule
* before RandomRule). Never returns the same product twice even if two
* rules would both suggest it (see RecommendationRule's $exclude), and
* never returns fewer than $limit unless the store genuinely doesn't have
* that many other products at all.
*/
class RecommendationService
{
/**
* @return Collection<int, Product>
*/
public function recommend(Product $product, int $limit = 4): Collection
{
$recommendations = collect();
foreach (config('catalog.recommendation_rules', []) as $ruleClass) {
if ($recommendations->count() >= $limit) {
break;
}
$exclude = [$product->id, ...$recommendations->pluck('id')];
$remaining = $limit - $recommendations->count();
/** @var RecommendationRule $rule */
$rule = app($ruleClass);
$recommendations = $recommendations->merge(
$rule->recommend($product, $remaining, $exclude)
);
}
return $recommendations->take($limit)->values();
}
}