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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user