diff --git a/config/catalog.php b/config/catalog.php new file mode 100644 index 0000000..14af9e4 --- /dev/null +++ b/config/catalog.php @@ -0,0 +1,25 @@ + [ + SameCategoryRule::class, + RandomRule::class, + ], +]; diff --git a/docs/product-recommendations.md b/docs/product-recommendations.md new file mode 100644 index 0000000..2a391ed --- /dev/null +++ b/docs/product-recommendations.md @@ -0,0 +1,155 @@ +# Product Recommendations + +`Modules\Core\Catalog\Services\RecommendationService` computes "related products" for a given +product — a same-category pick today, with a random fallback, but built as a configurable chain of +strategies rather than one hardcoded rule. `Modules\Core\Catalog\Services\ProductIndexer` embeds +the result directly into each product's own Meilisearch document, so a product detail page renders +its recommendations with zero extra queries — same reasoning as `collections` (see +`docs/product-listing.md`). + +--- + +## The rule chain + +```php +use Modules\Core\Catalog\Services\RecommendationService; + +$recommendations = app(RecommendationService::class)->recommend($product, limit: 4); +// Illuminate\Support\Collection +``` + +`recommend()` walks `config('catalog.recommendation_rules')` in order, **topping up** from each +successive rule until `$limit` distinct products are collected or every rule is exhausted — it does +not stop at the first rule that returns *something*. If a product's category only has 3 other +products, `SameCategoryRule` contributes those 3 and `RandomRule` fills the last slot. A rule is +handed the ids already collected (`$exclude`, always including the source product's own id) so it +never wastes its own `$limit` budget re-suggesting something already picked, and the same product +is never returned twice even if two rules would both suggest it. + +Default chain (`config/catalog.php`): + +```php +'recommendation_rules' => [ + SameCategoryRule::class, // other products sharing $product's first collection + RandomRule::class, // universal fallback — always returns something as + // long as the store has more than one product +], +``` + +A consuming app publishes and edits this config to reorder, add, or remove rules — nothing about +the chain shape is hardcoded in `RecommendationService` itself. A new rule (same tag, best sellers, +"frequently bought together", ...) is a class implementing `Modules\Core\Catalog\Contracts\ +RecommendationRule`, added to the array: + +```php +interface RecommendationRule +{ + /** + * @param array $exclude ids to never return — the source product's own + * id, plus every id an earlier rule in the chain already picked + * @return Collection at most $limit products + */ + public function recommend(Product $product, int $limit, array $exclude): Collection; +} +``` + +Rules query Eloquent directly (`$product->collections->first()->products()`, `Product::query()`), +not `Modules\Core\Catalog\Services\ProductService` — see "Why not `ProductService`" below. + +--- + +## Why not `ProductService` + +Every other read path in `Modules\Core\Catalog` goes through `ProductService`, which reads +Meilisearch and resolves translated fields to whatever locale the *current request* is in (see +`docs/product-listing.md`, "Locale resolution"). Recommendation rules deliberately don't use it: +they run inside `ProductIndexer::toSearchableArray()`, at **index time** — there is no request, no +meaningful "current locale" to resolve against, and Meilisearch itself may be mid-write for the very +product being indexed. Rules return raw `Lunar\Models\Product` models instead; `ProductIndexer` +resolves what it embeds (`name` via `translateAttribute()`, `price` via the indexer's own +`cheapestPrice()`, `image` via its own `mapMedia()`) the same way it already does for the embedded +`collections` field — including that field's same accepted index-time-locale tradeoff (a +recommendation's embedded `name` reflects whatever locale was active when *that* product was last +indexed, not the viewer's current locale). + +--- + +## What's embedded, and why not just an id + +`ProductIndexer` embeds full card data per recommendation, not just an id: + +```php +$data['recommendations'] = [ + ['id' => 42, 'name' => 'Espresso Cup', 'price' => 12.5, 'image' => 'https://.../thumb.jpg'], + // ... +]; +``` + +This shape is deliberately exactly what `x-ui.product-card`/`x-product-grid` (3dealer's storefront +components) need — `name`, `price`, `image`, and an `id` the view resolves to a URL itself via +`route('product.show', ['id' => $rec['id']])`. A resolved `href` is **not** embedded: `product.show` +is locale-prefixed (`{locale}/products/{id}`), so a URL baked in at index time would be correct only +for whichever locale happened to be active during that index run — wrong for every other locale. +Building the URL is left to the view, which knows the current request's locale. + +`recommendations.id` is marked **filterable** — not for the storefront, but for the reverse-lookup +reindexing below. + +--- + +## Keeping it fresh: `ProductSaved` / `ProductDeleted` + +A recommendation is computed once, at index time, and embedded — it does not update itself when the +recommended product later changes name, price, or image, or is deleted. Unlike `Modules\Core\Catalog\ +Observers\ProductOptionReindexObserver`'s equivalent problem (which product option value is used by), +there is no Postgres relation for "which products currently recommend product X" — a recommendation +only exists inside Meilisearch. The fix is a reverse Meilisearch filter query, not a database join, +wired through a real event → listener pair (`Modules\Core\Providers\CatalogServiceProvider`): + +- `Product::saved()` dispatches `Modules\Core\Catalog\Events\ProductSaved`. +- `Product::deleted()` dispatches `Modules\Core\Catalog\Events\ProductDeleted` — fires for both a + soft delete and a force delete (`Lunar\Models\Product` uses `SoftDeletes`), the same model event + Laravel Scout's own `ModelObserver` hooks to make a deleted product `unsearchable()`. +- `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct` handles both: it searches the + product index for `recommendations.id = "{id}"`, finds every referencing product, and calls + `->searchable()` on each — which recomputes their `recommendations` field fresh, picking up the + changed name/price/image, or (for a delete) dropping the now-gone product and topping back up to + the configured limit via the rule chain, same as any other reindex. + +`->searchable()` dispatches Scout's own reindex job, queued if `SCOUT_QUEUE` is configured — this +listener does no synchronous Meilisearch writing itself. + +**Product creation is deliberately not hooked into this.** A brand-new product has no +`recommendations` of its own until Scout's existing create-triggered indexing runs (already correct +— nothing to add). What's *not* immediate is other products picking the new one up as a fresh +recommendation candidate — that happens on their own next natural reindex (a save, or the nightly +full reindex below), the same accepted staleness window `docs/product-listing.md` already documents +for `in_stock`/`price`. A full proactive "who could now recommend this new product" pass was +considered and rejected as unnecessary cost for a cosmetic delay. + +--- + +## Nightly full reindex + +`Modules\Core\Providers\CatalogServiceProvider` schedules `lunar:search:index "Lunar\Models\Product" +--refresh` daily at 03:00 — a safety net on top of the event-driven reindexing above, not a +replacement for it. Catches what event-driven reindexing deliberately doesn't cover: a newly-created +product not yet appearing as a recommendation elsewhere, and any other drift already accepted +between reindexes (see `docs/product-listing.md`, "Stock goes stale between orders"). `--refresh` +also re-syncs filterable/sortable index *settings*, not just documents, so a deploy that changed +`ProductIndexer`'s field list self-heals overnight even if `lunar:meilisearch:setup` wasn't run +manually right after that deploy. + +--- + +## Re-syncing after this change + +Same as any other `ProductIndexer` field change (see `docs/product-listing.md`): + +```bash +php artisan lunar:meilisearch:setup +php artisan lunar:search:index "Lunar\Models\Product" --refresh +``` + +Restart the queue worker if `SCOUT_QUEUE=true` — see `docs/product-listing.md`'s "Re-syncing after +this change" for why a running worker won't otherwise pick up the new indexer code. diff --git a/src/Catalog/Contracts/RecommendationRule.php b/src/Catalog/Contracts/RecommendationRule.php new file mode 100644 index 0000000..c512531 --- /dev/null +++ b/src/Catalog/Contracts/RecommendationRule.php @@ -0,0 +1,39 @@ + $exclude + * @return Collection at most $limit products + */ + public function recommend(Product $product, int $limit, array $exclude): Collection; +} diff --git a/src/Catalog/Events/ProductDeleted.php b/src/Catalog/Events/ProductDeleted.php new file mode 100644 index 0000000..136fcb4 --- /dev/null +++ b/src/Catalog/Events/ProductDeleted.php @@ -0,0 +1,23 @@ +searchable() dispatches Scout's own (queued, if + * SCOUT_QUEUE is configured) reindex job per matched product — this + * listener itself does no synchronous Meilisearch writing. + */ +class ReindexProductsRecommendingProduct +{ + public function handleSaved(ProductSaved $event): void + { + $this->reindexReferencingProducts($event->product->id); + } + + public function handleDeleted(ProductDeleted $event): void + { + $this->reindexReferencingProducts($event->productId); + } + + private function reindexReferencingProducts(int $productId): void + { + $hits = Product::search('') + ->options([ + 'filter' => "recommendations.id = \"{$productId}\"", + 'attributesToRetrieve' => ['id'], + // Meilisearch's own hitsPerPage default (20) would silently + // drop referencing products past that count — this is a + // reverse lookup, not a paginated storefront result, so it + // needs every match, up to Meilisearch's hard limit. + 'hitsPerPage' => 1000, + ]) + ->raw()['hits'] ?? []; + + $ids = collect($hits)->pluck('id')->unique()->values(); + + if ($ids->isEmpty()) { + return; + } + + Product::whereIn('id', $ids)->get()->each->searchable(); + } +} diff --git a/src/Catalog/Recommendations/RandomRule.php b/src/Catalog/Recommendations/RandomRule.php new file mode 100644 index 0000000..c55621f --- /dev/null +++ b/src/Catalog/Recommendations/RandomRule.php @@ -0,0 +1,27 @@ +whereKeyNot($exclude) + ->inRandomOrder() + ->limit($limit) + ->get(); + } +} diff --git a/src/Catalog/Recommendations/SameCategoryRule.php b/src/Catalog/Recommendations/SameCategoryRule.php new file mode 100644 index 0000000..b07c09e --- /dev/null +++ b/src/Catalog/Recommendations/SameCategoryRule.php @@ -0,0 +1,39 @@ +collections->first(); + + if ($collection === null) { + return collect(); + } + + return $collection->products() + ->whereKeyNot($exclude) + ->inRandomOrder() + ->limit($limit) + ->get(); + } +} diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index d4b1011..261bbfa 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -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; } diff --git a/src/Catalog/Services/RecommendationService.php b/src/Catalog/Services/RecommendationService.php new file mode 100644 index 0000000..3e107f4 --- /dev/null +++ b/src/Catalog/Services/RecommendationService.php @@ -0,0 +1,47 @@ + + */ + 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(); + } +} diff --git a/src/Providers/CatalogServiceProvider.php b/src/Providers/CatalogServiceProvider.php index 7354e13..428dffc 100644 --- a/src/Providers/CatalogServiceProvider.php +++ b/src/Providers/CatalogServiceProvider.php @@ -2,17 +2,32 @@ namespace Modules\Core\Providers; +use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Lunar\Models\Product; use Lunar\Models\ProductOption; use Lunar\Models\ProductOptionValue; +use Modules\Core\Catalog\Events\ProductDeleted; +use Modules\Core\Catalog\Events\ProductSaved; +use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct; use Modules\Core\Catalog\Observers\ProductOptionReindexObserver; use Modules\Core\Catalog\OptionTypes\ColorOptionType; use Modules\Core\Catalog\Services\ProductOptionTypeManager; class CatalogServiceProvider extends ServiceProvider { + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../../config/catalog.php', 'catalog'); + } + public function boot(): void { + $this->publishes([ + __DIR__ . '/../../config/catalog.php' => config_path('catalog.php'), + ], 'core-config'); + ProductOptionTypeManager::get()->register([ ColorOptionType::class, ]); @@ -24,5 +39,28 @@ class CatalogServiceProvider extends ServiceProvider ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); + + Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product))); + Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id))); + + Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']); + Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']); + + $this->app->booted(function () { + // A full nightly reindex, on top of the per-event reindexing + // above — catches everything event-driven reindexing + // deliberately doesn't cover: a newly-created product not yet + // appearing as a recommendation elsewhere, in_stock/price + // drifting from an order decrementing stock outside a product + // save, and any other staleness ProductIndexer's own docblock + // already documents as accepted between reindexes. --refresh + // re-syncs filterable/sortable field settings too, not just + // documents, so a deploy that changed ProductIndexer's field + // list self-heals here even if `lunar:meilisearch:setup` + // wasn't run manually after that deploy. + $this->app->make(Schedule::class) + ->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh']) + ->dailyAt('03:00'); + }); } }