diff --git a/docs/collections.md b/docs/collections.md index 6b15831..affba4f 100644 --- a/docs/collections.md +++ b/docs/collections.md @@ -21,6 +21,8 @@ nowhere near enough for a storefront category page or a nav tree. | `collection_group_id` | `$model->collection_group_id` | Filterable. Mirrors `Collection::scopeInGroup()`. | | `slugs` | `$model->urls->pluck('slug')` | Filterable. Every locale's `Url::slug`, so `getBySlug()` resolves purely from the index. | | `thumbnail` | `$model->getThumbnailImage()` | Display only. `null` if the collection has no thumbnail image. | +| `ancestors` | `$model->ancestors` | Display only. Array of `{id, name}`, ordered root-first — a breadcrumb (`Home > Apparel > Keychains`) can render directly from a single `getById()`/`getBySlug()` call, no extra queries. Empty array for a top-level collection. | +| `product_count` | Queried from the *product* Meilisearch index at collection-index time | Display only. How many products are in this collection **or any of its descendants** — matches what `ProductService::list(ProductFilters(collectionId: ...))` would return, not just direct assignment. Computed via `Product::search('')->options(['filter' => "collection_ids = \"{id}\""])`, so it depends on the product index already being current — reindex products *before* collections (see "Gotchas" below). | `name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale by Lunar's base indexer and resolved by `CollectionService` exactly like @@ -87,6 +89,19 @@ lunar:meilisearch:setup` re-syncs index settings, and existing documents need the queue worker also needs restarting after deploying changes to the indexer class — see `docs/lunar.md` "Gotchas". +**`product_count` needs the product index reindexed first.** `config/lunar/search.php`'s +`indexers` array is typically ordered `Collection` before `Product`, so a plain +`lunar:search:index --refresh` computes `product_count` against whatever the product +index held *before* this run — stale if products changed too. `lunar:search:index` +takes an explicit model list as its argument (`--ignore` restricts it to only those), +so reindex products first, then collections, when both need a fresh `--refresh` in the +same deploy: + +``` +php artisan lunar:search:index "Lunar\Models\Product" --ignore --refresh +php artisan lunar:search:index "Lunar\Models\Collection" --ignore --refresh +``` + --- ## When to still use Eloquent directly diff --git a/docs/product-listing.md b/docs/product-listing.md index 2332acc..d422d69 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -33,9 +33,9 @@ $service = app(ProductService::class); // "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator. $products = $service->list(perPage: 24, page: 1); -// Filter by collection, brand, and/or price range +// Filter by collection, brand, price range, and/or stock $products = $service->list( - filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0), + filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true), perPage: 24, page: 1, ); @@ -56,10 +56,41 @@ $product = $service->getById(367); // array, or null if not found // Single product, by URL slug (any locale — slugs are indexed across all languages) $product = $service->getBySlug('erotika-mprelok'); // array, or null if not found + +// Facet counts for a sidebar — value => matching product count, scoped to whatever +// $filters is passed. Does NOT exclude the faceted field itself from $filters — see +// facets()'s docblock for why, and how to build a standard "every option's count, +// unaffected by that option's own currently-selected value" sidebar. +$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17)); +// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135] + +// Min/max price across matching products, for sizing a price-range slider. +// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike +// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink +// to whatever range is currently selected on it. Other filters (collectionId, +// brand, inStockOnly) still apply normally. +$range = $service->priceRange(new ProductFilters(collectionId: 17)); +// ['min' => 0.0, 'max' => 120.0] ``` All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query. +`facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric +field like `price` would return one "facet" per exact price, not a usable range bucket. Use +`priceRange()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different +feature from `facetDistribution`. + +--- + +## Stock goes stale between orders + +`in_stock` reflects `ProductVariant::stock`/`purchasable` as of the **last reindex**, not live +inventory. Nothing in this codebase currently reindexes a product when an order decrements its +stock — that's a cart/checkout concern, not something `ProductIndexer` can solve on its own (see +`Modules\Core\Catalog\Observers\ProductOptionReindexObserver` for the equivalent pattern once an +order → stock → reindex pipeline exists to hook into). Until then, `in_stock`/`product_count` can +drift from the database the same way every other indexed field already can between writes. + --- ## Fields this depends on: `Modules\Core\Catalog\Services\ProductIndexer` @@ -81,6 +112,7 @@ needs, listing and detail alike: | `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. | | `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). | | `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. | +| `in_stock` | `$model->variants` | Filterable boolean. `true` if ANY variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` — Lunar's own purchasability rule (`purchasable === 'always'` ignores stock entirely; `in_stock` checks `stock` alone; anything else checks `stock + backorder`). Only as fresh as the last reindex — see "Stock goes stale" below. | `name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see "Locale resolution" below for how `ProductService` resolves them down to one value per request. diff --git a/src/Catalog/DTOs/ProductFilters.php b/src/Catalog/DTOs/ProductFilters.php index 902aa1a..6bdcaf0 100644 --- a/src/Catalog/DTOs/ProductFilters.php +++ b/src/Catalog/DTOs/ProductFilters.php @@ -23,5 +23,6 @@ class ProductFilters public readonly ?string $brand = null, public readonly ?float $minPrice = null, public readonly ?float $maxPrice = null, + public readonly bool $inStockOnly = false, ) {} } diff --git a/src/Catalog/Services/CollectionIndexer.php b/src/Catalog/Services/CollectionIndexer.php index 117a63e..29eb4ef 100644 --- a/src/Catalog/Services/CollectionIndexer.php +++ b/src/Catalog/Services/CollectionIndexer.php @@ -5,6 +5,7 @@ namespace Modules\Core\Catalog\Services; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Lunar\Models\Collection; +use Lunar\Models\Product; use Lunar\Search\CollectionIndexer as BaseCollectionIndexer; /** @@ -20,6 +21,15 @@ use Lunar\Search\CollectionIndexer as BaseCollectionIndexer; * - slugs (filterable) — every locale's Url::slug, so getBySlug() resolves from the * index directly, no database read * - thumbnail (display) — the collection's thumbnail image URL + * - ancestors (display) — [{id, name}, ...] ordered root-first, so a breadcrumb can + * render directly from a single indexed document with zero extra queries + * - product_count (display) — how many products are in this collection or any of + * its descendants, read from the *product* Meilisearch index at collection-index + * time (via `collection_ids`, see Modules\Core\Catalog\Services\ProductIndexer) — + * matches what ProductService::list(ProductFilters(collectionId: ...)) would + * return, not just direct assignment. Reflects the product index's state as of + * the last collection reindex, so re-run `lunar:search:index --refresh` after a + * product reindex if this needs to be current. * * New fields aren't filterable/sortable in Meilisearch until `php artisan * lunar:meilisearch:setup` re-syncs index settings, and existing documents need @@ -49,7 +59,7 @@ class CollectionIndexer extends BaseCollectionIndexer public function makeAllSearchableUsing(Builder $query): Builder { - return parent::makeAllSearchableUsing($query)->with(['urls', 'media']); + return parent::makeAllSearchableUsing($query)->with(['urls', 'media', 'ancestors']); } public function toSearchableArray(Model $model): array @@ -63,6 +73,18 @@ class CollectionIndexer extends BaseCollectionIndexer $data['collection_group_id'] = $model->collection_group_id; $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); $data['thumbnail'] = $model->getThumbnailImage() ?: null; + $data['ancestors'] = $model->ancestors + ->sortBy('_lft') + ->map(fn ($ancestor) => [ + 'id' => $ancestor->id, + 'name' => $ancestor->translateAttribute('name'), + ]) + ->values() + ->all(); + $data['product_count'] = Product::search('') + ->options(['filter' => "collection_ids = \"{$model->id}\""]) + ->paginateRaw(perPage: 1, page: 1) + ->total(); return $data; } diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index 79f6ed3..d4b1011 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -37,6 +37,12 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * - channel_ids (filterable) — Lunar's base indexer only indexes "status" as * filterable, not channel assignment, so search results can't otherwise be * scoped to products actually assigned+enabled on the current sales channel + * - in_stock (filterable) — true if ANY variant can currently be purchased at + * quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own + * purchasability rule: `purchasable === 'always'` is always true regardless of + * stock, `in_stock` checks stock alone, anything else checks stock+backorder). + * Reflects stock as of the last reindex only — nothing currently reindexes a + * product when an order decrements its stock (see docs/product-listing.md). * * A review is created/edited independently of its product (Modules\Core\Providers\ * ReviewServiceProvider re-indexes the product on review create/update/delete), so @@ -60,6 +66,7 @@ class ProductIndexer extends BaseProductIndexer 'price', 'slugs', 'channel_ids', + 'in_stock', ]; } @@ -116,6 +123,9 @@ class ProductIndexer extends BaseProductIndexer ->wherePivot('enabled', true) ->pluck('lunar_channels.id') ->toArray(); + $data['in_stock'] = $model->variants->contains( + fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1) + ); return $data; } diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php index b820b07..616426e 100644 --- a/src/Catalog/Services/ProductService.php +++ b/src/Catalog/Services/ProductService.php @@ -60,6 +60,60 @@ class ProductService ); } + /** + * Facet value counts for the given filter/field, scoped to the SAME filters + * `list()` would apply. Note this does NOT exclude `$field` itself from + * `$filters` — e.g. `facets('brand', new ProductFilters(brand: 'Acme'))` would + * scope the counts to only "Acme" already, collapsing every other brand's count + * to whatever remains under that filter. For a standard "faceted sidebar" (every + * brand's count reflecting collection/price/stock filters but NOT the brand + * filter itself), build a `$filters` that omits the field being faceted on and + * apply that field's own filter separately in the UI/query layer. + * + * `$field` must be one of ProductIndexer's filterable fields; only discrete-value + * fields make sense here (`brand`, `in_stock`) — a numeric field like `price` + * would return one "facet" per exact price, not a usable range bucket. Use + * `priceRange()` for `price` instead. + * + * @return array facet value => matching product count + */ + public function facets(string $field, ?ProductFilters $filters = null): array + { + return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? []; + } + + /** + * The min/max `price` across products matching the given filters (minus + * `minPrice`/`maxPrice` themselves, same "scoped but not self-collapsing" + * reasoning as `facets()` — a price slider's own bounds shouldn't shrink to + * whatever range is currently selected). Backed by Meilisearch's `facetStats`, + * not `facetDistribution` — the right feature for a numeric field's range, + * where `facets('price')` would otherwise return one entry per exact price. + * + * @return array{min: ?float, max: ?float} null/null if no product matches + */ + public function priceRange(?ProductFilters $filters = null): array + { + $filter = $this->buildFilter($filters, exclude: ['price']); + $stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null; + + return [ + 'min' => $stats['min'] ?? null, + 'max' => $stats['max'] ?? null, + ]; + } + + private function rawFacets(string $field, ?string $filter): array + { + return Product::search('') + ->options([ + 'filter' => $filter, + 'facets' => [$field], + 'hitsPerPage' => 0, + ]) + ->raw(); + } + /** * Look up a single product by its URL slug (any locale - slugs are indexed across * all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full @@ -150,18 +204,27 @@ class ProductService return collect($rawResponse['hits'] ?? [])->values()->all(); } - private function buildFilter(?ProductFilters $filters): ?string + /** + * @param array $exclude filter + * fields to leave out even if set on $filters — e.g. priceRange() excludes + * 'price' so a price slider's own bounds don't shrink to whatever range is + * already selected on it. + */ + private function buildFilter(?ProductFilters $filters, array $exclude = []): ?string { if ($filters === null) { return null; } $clauses = Collection::make([ - $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, - $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, - $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, - $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, - ])->filter(); + 'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, + 'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, + 'price' => Collection::make([ + $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, + $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, + ])->filter()->join(' AND ') ?: null, + 'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null, + ])->except($exclude)->filter(); return $clauses->isEmpty() ? null : $clauses->join(' AND '); }