diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4107d..a6b4599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.12.1] - 2026-09-03 + +### Fixed +- `Modules\Core\MigrateImport\Shopify\ShopifyExportImporter` now attaches a variant's `Variant Image` CSV column to that `ProductVariant`'s own `images()` media pivot (`media_product_variant`, `primary`/`position`). Previously the variant image was never read at all — every image from the CSV, including ones the export clearly scopes to one specific variant, went only into the product's own top-level gallery, so a variant swatch/option change had no way to show its own photo. +- `Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver::resolveOption()` now sets `label` (same value as `name`) when creating a `Lunar\Models\ProductOption`, not just `name`. A `ProductOption` with a null `label` crashes Lunar's own `ProductOptionIndexer::toSearchableArray()` (`foreach()` on `null`) the moment that option gets reindexed — every option created by the importer before this fix has a null `label` and needs a wipe-and-reimport (see `docs/shopify-reimport.md`, new in this release) to pick up the fix, since `firstOrCreate()` never revisits an already-existing row. +- `product_reviews.product_id`'s foreign key had no `ON DELETE` clause, so deleting a reviewed `Product` threw a constraint violation instead of the review going with it, unlike every other product-dependent table. New migration adds `cascadeOnDelete()`. + +### Added +- `Modules\Core\Catalog\Services\ProductIndexer::mapVariant()` now embeds `gtin`, `mpn`, `ean`, `backorder`, `unit_quantity`, `shippable`, `tax_ref`, and `dimensions` (length/width/height/weight/volume, each with `value`+`unit`) on every indexed variant — previously only `id`/`sku`/`stock`/`purchasable`/`options`/`prices`/`media` were embedded, so a search result or filter needing any of these had no way to get at them without a separate Postgres query per variant. +- `ProductIndexer::toSearchableArray()` adds a top-level, filterable `skus` field (every variant's SKU, deduplicated) — filtering/matching by SKU no longer requires reaching into the nested `variants` array. +- `docs/shopify-reimport.md` — runbook for wiping every imported product (cascading through Lunar so Meilisearch documents go too) and re-running the importer from scratch, needed whenever a fix like the two above only takes effect on newly-created rows. + +## [0.12.0] - 2026-09-03 + +### Changed +- **Breaking:** `Modules\Core\Catalog\Services\ProductService::list()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` (`->products`: the same `Illuminate\Pagination\LengthAwarePaginator` as before, `->priceBounds`: a new `Modules\Core\Catalog\DTOs\PriceSliderBounds`) instead of returning the paginator directly. A caller doing `$service->list(...)->items()`/`->through(...)` must update to `$service->list(...)->products->items()`/`->through(...)`. This collapses what used to be two separate calls a controller had to orchestrate itself (`list()` for products, `priceRange()` + manual floor/ceil/"is this actually filtered" math for the slider) into one. +- **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()`'s signature changed from `search(string $query, ?string $locale = null)` to `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null)` — the `$locale` parameter is gone (see "every configured language, always" below); `$filters`/`$sort` apply the same `Modules\Core\Catalog\Support\ProductFilterBuilder`/`ProductSort::toMeilisearchSort()` semantics `ProductService::list()` already used, so a text search can now be narrowed by price/brand/stock and sorted the same way a category listing can. +- `ProductSearchService` now targets every configured store language's fields on every search (`Lunar\Models\Language::all()`), not just the current request locale plus the store's default language. The old `{current, default}` pairing silently stopped catching anything outside those two locales whenever they were equal (a single-language store, or a shopper browsing in the default language) — always searching every configured language closes that gap in both directions. See `docs/product-search.md`. +- Extracted `Modules\Core\Catalog\Services\ProductService`'s private `buildFilter()` into a new standalone `Modules\Core\Catalog\Support\ProductFilterBuilder`, so `ProductSearchService` can apply the exact same Meilisearch filter-clause semantics to a text query, instead of reimplementing filter-building a second time. + +### Added +- `Modules\Core\Catalog\Services\ProductService::priceSliderBounds()` — `priceRange()` rounded to whole currency units (floor/ceil) plus whether the given selected min/max actually narrows it, returned as a `PriceSliderBounds` DTO. Used internally by `list()` now; also callable directly for a caller (e.g. a text-search results page) that needs slider bounds without a full `list()` call. +- `Modules\Core\Catalog\Services\ProductService::priceRange()` gained an optional `string $query = ''` parameter, so a caller can scope the price range to a text search's own matches (pass the shopper's search text) instead of always spanning the whole catalog. +- `Modules\Core\Catalog\Services\ProductService::random(int $limit)` — random products still scoped to the Meilisearch index's own channel/status visibility, unlike a raw `Product::inRandomOrder()` (which has no notion of that filtering). Meilisearch has no `ORDER BY RANDOM()` equivalent, so this fetches every matching id only (`attributesToRetrieve: ['id']`), shuffles in PHP, then fetches the full localized documents for just the ids picked, restoring the shuffled order afterward (Meilisearch's `id IN [...]` filter doesn't preserve list order on its own). +- `Modules\Core\Catalog\Services\ProductService::variantSummaries(array $product)` — the id/price/image of every variant on a product document, for a variant picker/swatch list, without a caller reaching into `$product['variants'][n]['prices'][0]`/`['media'][0]` itself. +- `Modules\Core\Catalog\Services\ProductSearchService::search()` now also targets `variants.options.value` — a variant's own option value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is matchable by search even when that text never appears in the product's own name or description. +- `php artisan lunar:meilisearch:tune-product-search` (`Modules\Core\Command\TuneProductSearchCommand`) — tightens `minWordSizeForTypos` (1 typo only at 8+ characters, 2 typos only at 12+) and disables Meilisearch's `prefixSearch` on the product index. Meilisearch's defaults for both were loose enough to produce bad matches on short Greek words (confirmed the specific case was `prefixSearch`'s default `indexingTime` behavior on a shared word-start, not typo tolerance, via `showMatchesPosition`). Consuming apps should run this after `lunar:meilisearch:setup` whenever the product index needs (re)provisioning — **requires Meilisearch v1.12+** (`prefixSearch` didn't exist as a configurable setting before then). + ## [0.11.1] - 2026-09-01 ### Fixed diff --git a/composer.json b/composer.json index 4e06f6b..1b7e4cd 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.11.1", + "version": "0.12.1", "autoload": { "psr-4": { "Modules\\Core\\": "src/" diff --git a/database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php b/database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php new file mode 100644 index 0000000..38cc54a --- /dev/null +++ b/database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php @@ -0,0 +1,43 @@ +dropForeign(['product_id']); + }); + + Schema::table('product_reviews', function (Blueprint $table) { + $table->foreign('product_id') + ->references('id') + ->on(config('lunar.database.table_prefix').'products') + ->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::table('product_reviews', function (Blueprint $table) { + $table->dropForeign(['product_id']); + }); + + Schema::table('product_reviews', function (Blueprint $table) { + $table->foreign('product_id') + ->references('id') + ->on(config('lunar.database.table_prefix').'products'); + }); + } +}; diff --git a/docs/product-listing.md b/docs/product-listing.md index d422d69..02b4934 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -1,8 +1,9 @@ # Product Listing `Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product -lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the -Meilisearch index rather than the database. One data source for everything this service does. +lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` — +all reading directly from the Meilisearch index rather than the database. One data source for +everything this service does. This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which handles free-text query search. `ProductService` is for browsing/lookup without a search term. @@ -28,13 +29,14 @@ use Modules\Core\Catalog\Enums\ProductSort; $service = app(ProductService::class); -// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator, -// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see -// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator. -$products = $service->list(perPage: 24, page: 1); +// One call for everything a listing page needs — products AND the price slider's +// bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller +// used to have to call list() and priceSliderBounds() (or the older priceRange()) +// separately and glue the results together itself; that's now list()'s own job. +$listing = $service->list(perPage: 24, page: 1); // Filter by collection, brand, price range, and/or stock -$products = $service->list( +$listing = $service->list( filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true), perPage: 24, page: 1, @@ -42,8 +44,13 @@ $products = $service->list( // Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default // relevance ordering (irrelevant here since the query is always empty). -$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); +$listing = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); +$products = $listing->products; // a real Illuminate\Pagination\LengthAwarePaginator, + // built from the localized Meilisearch hits (not Scout's + // own paginateRaw() result — see "Meilisearch driver + // quirk" below), so it behaves like any other Laravel + // paginator. $products->items(); // array of Meilisearch documents (plain arrays, not models) $products->total(); $products->perPage(); @@ -51,12 +58,32 @@ $products->currentPage(); $products->lastPage(); $products->links(); // in a Blade view — renders pagination links as usual +$bounds = $listing->priceBounds; // Modules\Core\Catalog\DTOs\PriceSliderBounds +$bounds->floor; // ?int — floor() of the matching range's minimum, in whole currency units +$bounds->ceil; // ?int — ceil() of the matching range's maximum +$bounds->filtered; // bool — whether the applied filters' minPrice/maxPrice actually + // narrow the slider below/above these bounds (drives whether a + // "clear filter" control should show) + // Single product, by primary key $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 +// $limit random products — still scoped to the index's own default channel/status +// visibility, unlike Eloquent's Product::inRandomOrder() (which has no notion of +// that filtering at all). Meilisearch has no ORDER BY RANDOM() equivalent, so this +// pulls every matching id only, shuffles in PHP, then fetches the full localized +// documents for just the ids picked — see random()'s own docblock. +$randomProducts = $service->random(13); // array of documents, same shape as list()'s items + +// The id/price/image of every variant on a product document — the base price and +// thumbnail a variant picker/swatch list needs, without reaching into +// $product['variants'][n]['prices'][0]/['media'][0] yourself. +$variants = $service->variantSummaries($product); +// [['id' => 1204, 'price' => 19.99, 'image' => 'https://.../thumb.jpg'], ...] + // 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, @@ -64,11 +91,13 @@ $product = $service->getBySlug('erotika-mprelok'); // array, or null if not fo $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. +// Min/max price across matching products — the raw, unrounded values list() itself +// uses to build priceBounds above. 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. Pass $query too +// to scope the range to a text search's own matches (see product-search.md) rather +// than the whole catalog. $range = $service->priceRange(new ProductFilters(collectionId: 17)); // ['min' => 0.0, 'max' => 120.0] ``` @@ -77,8 +106,8 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei `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`. +`priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's +`facetStats` (min/max), a different feature from `facetDistribution`. --- @@ -106,11 +135,12 @@ needs, listing and detail alike: | `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. | | `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. | | `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. | +| `skus` | `$product->variants->pluck('sku')` | Filterable. Every variant's `sku`, deduplicated, empty ones dropped. Same "resolve from the index alone" reasoning as `slugs`, for a future SKU-based lookup/filter. | | `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. | | `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. | | `tags` | `$product->tags->pluck('value')` | Display only. | | `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). | +| `variants` | `$product->variants` | Per variant: `id`, `sku`, `gtin`, `mpn`, `ean`, `stock`, `backorder`, `unit_quantity`, `purchasable`, `shippable`, `tax_ref`, `dimensions` (`length`/`width`/`height`/`weight`/`volume`, each `{value, unit}`), `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (the variant's own images — `ProductVariant::images()`, a separate pivot from the product's own gallery above, populated by `ShopifyExportImporter` from Shopify's `Variant Image` CSV column). | | `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. | diff --git a/docs/product-search.md b/docs/product-search.md index 7445197..aceb157 100644 --- a/docs/product-search.md +++ b/docs/product-search.md @@ -24,36 +24,62 @@ merges `$builder->options` directly into the search request). ## Usage ```php +use Modules\Core\Catalog\DTOs\ProductFilters; +use Modules\Core\Catalog\Enums\ProductSort; use Modules\Core\Catalog\Services\ProductSearchService; $results = app(ProductSearchService::class)->search('running shoes'); -// or an explicit locale, bypassing App::getLocale(): -$results = app(ProductSearchService::class)->search('running shoes', 'el'); + +// Filters/sort apply the exact same semantics ProductService::list() uses for +// collection browsing (same ProductFilterBuilder, same ProductSort) — a shopper +// narrowing a text search by price/brand/stock gets identical filter behavior +// to narrowing a category listing. +$results = app(ProductSearchService::class)->search( + 'running shoes', + filters: new ProductFilters(brand: 'Acme', minPrice: 20.0, inStockOnly: true), + sort: ProductSort::PriceAsc, +); ``` Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's `->get()` hydrates real models from the database after the Meilisearch query, so relations (`variants`, `brand`, `media`, etc.) are available on the results as normal. -`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by -`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers -don't need to pass it explicitly. +There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why +every configured store language is always searched, regardless of the current request locale. --- -## Missing-translation fallback +## Missing-translation fallback, in both directions If a product was only ever given an English name, `name_el` doesn't exist on that document at all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly -against `name_el` would make that product invisible to Greek-locale search, even though it's a -real catalog item. +against the current request's locale field would make that product invisible whenever a shopper's +locale doesn't match the language it happens to be translated into. -To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both** -the resolved locale's fields **and** the default language's fields -(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`, -`name_en`, `description_el`, `description_en` together (assuming `en` is the default language). -A product missing an `el` translation still matches via its `en` fields. +`ProductSearchService` avoids this by targeting **every configured store language's fields** +(`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the +store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`, +`description_el`, `description_en` together, regardless of which locale the shopper is browsing +in. This is deliberately not scoped to "current locale + default locale": if the current locale +already equals the default (a single-language store, or a shopper browsing in the default +language), that pairing collapses to one locale and stops catching anything else — always +searching every configured language avoids that gap in both directions, at the cost of a larger +`attributesToSearchOn` list as the store's language count grows. + +--- + +## Variant option values are searched too + +Alongside the locale-suffixed attribute fields, every search also targets +`variants.options.value` directly — e.g. a variant named "Κάπτεν Γαμέρικα" on a "Name" option +matches a search for that text, even though it never appears in the product's own name or +description. This isn't one of Lunar's own attributes (`AttributeManifest` has no entry for it), +so it can't be discovered the way `name`/`description` are — it's a structural field of +`Modules\Core\Catalog\Services\ProductIndexer`'s own document shape (see `ProductIndexer::mapVariant()`), +added here directly. Not locale-suffixed — each option value is stored as one already-resolved +string per variant. --- diff --git a/docs/shopify-import.md b/docs/shopify-import.md index 8534085..5a477db 100644 --- a/docs/shopify-import.md +++ b/docs/shopify-import.md @@ -2,6 +2,9 @@ Findings from comparing a real Shopify product export CSV against Lunar's schema (`vendor/lunarphp/core`), plus the resulting implementation plan for `MigrateImport\Shopify\ShopifyExportImporter`. +Need to discard everything and re-import from scratch (e.g. after a schema/indexer change that +only applies to newly-created rows)? See `docs/shopify-reimport.md`. + ## Idempotency problem Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run. diff --git a/docs/shopify-reimport.md b/docs/shopify-reimport.md new file mode 100644 index 0000000..a5de9b3 --- /dev/null +++ b/docs/shopify-reimport.md @@ -0,0 +1,149 @@ +# Wiping products before a clean Shopify re-import + +A runbook for discarding every imported product (and everything that hangs off one — +variants, prices, media, reviews, options/values, the Meilisearch documents) and re-running +`ShopifyExportImporter` from scratch. Useful after a schema/indexer change that only applies to +newly-created rows (see "Why a wipe, not an update" below), or when the export CSV itself changed +enough that stale products need to go, not just be updated in place. + +Every command below is a `tinker --execute=` one-liner run inside the app container — adjust the +exec prefix (`./bin/dc-core.sh exec app ...`, `docker compose exec app ...`, etc.) for your setup. + +--- + +## Why a wipe, not an update + +`ShopifyExportImporter`'s resolvers are mostly `firstOrCreate` — re-running the importer against +an *existing* database updates matched rows but leaves already-created ones exactly as they were. +That's the right behavior for routine re-imports (an updated price, a new variant), but it means a +change to what gets set **at creation time only** — e.g. `ProductOptionResolver` now also setting +`label`, not just `name`, on a `ProductOption` — never reaches a `ProductOption` row that already +exists. A wipe forces every row to go through creation again, picking up such fixes. + +--- + +## 1. Delete every product + +Cascades to `ProductVariant`, prices, and Spatie media rows — verified live (see +`shopify-import.md`'s own history/commit log for context). Also removes each product's Meilisearch +document automatically, via Scout's own delete hook fired on `forceDelete()` — no separate +`scout:flush` needed. + +```php +\Lunar\Models\Product::withTrashed()->get()->each->forceDelete(); +``` + +**Let this run to completion.** Interrupting it mid-loop (e.g. Ctrl+C on the tinker session) stops +after whichever product it was on, leaving the rest undeleted — safe to just re-run the same +command again afterward, since already-deleted products are simply skipped. + +Verify: + +```php +\Lunar\Models\Product::withTrashed()->count(); // 0 +``` + +### Requires: `product_reviews.product_id` cascades on delete + +`product_reviews` (boboko-core's own table, not Lunar's) originally had no `ON DELETE` clause on +its `product_id` foreign key — deleting a reviewed product threw a constraint violation instead of +the review going with it. Fixed by +`database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php`. Make +sure this migration has actually run (`php artisan migrate`) before step 1, or a product with +reviews will fail to delete. + +--- + +## 2. Delete product options and values + +Not touched by step 1 (`ProductOption`/`ProductOptionValue` aren't scoped to one product — they're +shared across the catalog, per `ProductOptionResolver::resolveOption()`'s `shared: true`). Safe to +delete in full once every product (and therefore every variant referencing an option value via the +`product_option_value_product_variant` pivot) is gone — deleting values while variants still +reference them throws the same kind of FK violation step 1 guards against. + +```php +\Lunar\Models\ProductOptionValue::query()->delete(); +\Lunar\Models\ProductOption::query()->delete(); +``` + +Verify: + +```php +\Lunar\Models\ProductOption::count(); // 0 +\Lunar\Models\ProductOptionValue::count(); // 0 +``` + +--- + +## 3. Clear the import mappings + +Without this, the importer's `ImportMapping::resolve(...)` calls still find the (now-deleted) +mappings' rows absent, so this step is really about not leaving stale mapping rows pointing at +nothing — `ImportMapping` rows aren't foreign-keyed to the models they map (`morphTo`, no +constraint), so leaving them wouldn't break the re-import, but a stale mapping for a product that +no longer exists is dead weight. + +```php +\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->delete(); +``` + +Verify: + +```php +\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->count(); // 0 +``` + +--- + +## 4. Re-run the importer + +`boboko:migrate:import` dispatches `RunMigrateImportJob` onto the queue — **not synchronous** — +so a queue worker must actually be running (`php artisan queue:work`, or your dev queue container) +or the job just sits queued. + +```bash +php artisan boboko:migrate:import --source=shopify --type=export --file= +``` + +The `--file` value must be an **absolute path** inside the container (e.g. +`/var/www/html/storage/app/private/imports/shopify/products_export.csv`) when running +non-interactively — a path relative to `storage/app/private/imports` only resolves correctly when +the command can fall back to its interactive prompt, which isn't available in a scripted/non-TTY +run. + +Watch the queue worker's own log output for `FAIL` entries (see `docs/lunar.md` or your compose +setup for how logs are routed to `docker compose logs`) — a clean run shows every +`Laravel\Scout\Jobs\MakeSearchable` / `Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob` +line ending `DONE`, never `FAIL`. + +--- + +## 5. Re-sync Meilisearch and reindex + +```bash +php artisan lunar:meilisearch:setup +php artisan lunar:meilisearch:tune-product-search +php artisan lunar:search:index "Lunar\Models\Product" --refresh +``` + +`--refresh` re-syncs filterable/sortable index settings *and* reindexes every document — it does +not reset `typoTolerance`/`prefixSearch` (confirmed live: both survived a `--refresh` run +unchanged), so `tune-product-search` only needs re-running here for completeness/if it hadn't +already been applied, not because `--refresh` would have clobbered it. + +--- + +## Verifying the result + +```php +// Product count should match the CSV's actual unique `Handle` count, not +// whatever the database held before the wipe — those aren't the same number +// if stale/manually-added products existed alongside the CSV-sourced ones. +\Lunar\Models\Product::count(); + +// Spot-check that at least one variant picked up its own image (see +// shopify-import.md's "Images" section) — 0 is only correct if the CSV +// genuinely has no `Variant Image` values populated. +\Lunar\Models\ProductVariant::has('images')->count(); +``` diff --git a/src/Catalog/DTOs/PriceSliderBounds.php b/src/Catalog/DTOs/PriceSliderBounds.php new file mode 100644 index 0000000..e6ab419 --- /dev/null +++ b/src/Catalog/DTOs/PriceSliderBounds.php @@ -0,0 +1,19 @@ + $availableTags every distinct tag value + * present on at least one product matching the listing's OTHER + * filters (collection/price/stock — never the tag filter itself, so + * selecting a tag doesn't collapse the list down to just that tag). + * Sorted alphabetically. Empty if no product in scope has any tag. + */ + public function __construct( + public readonly LengthAwarePaginator $products, + public readonly PriceSliderBounds $priceBounds, + public readonly array $availableTags = [], + ) {} +} diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index 261bbfa..bf97f38 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -27,8 +27,14 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media; * - slugs (every locale's Url::slug for the product, filterable) — lets * ProductService::getBySlug() resolve a product from the index directly, with * no database read at all + * - skus (every variant's sku, deduplicated, filterable) — same "resolve from the + * index alone" reasoning as slugs, for a future SKU-based lookup/filter * - price (cheapest variant, filterable) and full per-variant pricing - * - variants: sku, stock, purchasable, option values, prices, media + * - variants: sku, gtin, mpn, ean, stock, backorder, unit_quantity, purchasable, + * shippable, tax_ref, dimensions (length/width/height/weight/volume, each + * {value, unit}), option values, prices, media — the variant's own images + * (ProductVariant::images(), separate from the product's gallery below), not + * the product's own media repeated per variant * - the full media gallery (not just the single thumbnail Lunar's base indexer sends) * - tags * - reviews: {items: [...], count, average_rating} — items are public-safe fields @@ -83,6 +89,8 @@ class ProductIndexer extends BaseProductIndexer 'channel_ids', 'in_stock', 'recommendations.id', + 'skus', + 'tags', ]; } @@ -126,6 +134,7 @@ class ProductIndexer extends BaseProductIndexer ->values() ->all(); $data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all(); + $data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all(); $data['tags'] = $model->tags->pluck('value')->all(); $data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all(); $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); @@ -161,8 +170,22 @@ class ProductIndexer extends BaseProductIndexer return [ 'id' => $variant->id, 'sku' => $variant->sku, + 'gtin' => $variant->gtin, + 'mpn' => $variant->mpn, + 'ean' => $variant->ean, 'stock' => $variant->stock, + 'backorder' => $variant->backorder, + 'unit_quantity' => $variant->unit_quantity, 'purchasable' => $variant->purchasable, + 'shippable' => $variant->shippable, + 'tax_ref' => $variant->tax_ref, + 'dimensions' => [ + 'length' => ['value' => $variant->length_value, 'unit' => $variant->length_unit], + 'width' => ['value' => $variant->width_value, 'unit' => $variant->width_unit], + 'height' => ['value' => $variant->height_value, 'unit' => $variant->height_unit], + 'weight' => ['value' => $variant->weight_value, 'unit' => $variant->weight_unit], + 'volume' => ['value' => $variant->volume_value, 'unit' => $variant->volume_unit], + ], 'options' => $variant->values->map(fn ($value) => [ 'option' => $this->translatedName($value->option->name), 'handle' => $value->option->handle, diff --git a/src/Catalog/Services/ProductSearchService.php b/src/Catalog/Services/ProductSearchService.php index 0ae8e9e..8686699 100644 --- a/src/Catalog/Services/ProductSearchService.php +++ b/src/Catalog/Services/ProductSearchService.php @@ -3,10 +3,12 @@ namespace Modules\Core\Catalog\Services; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\App; use Lunar\Facades\AttributeManifest; use Lunar\Models\Language; use Lunar\Models\Product; +use Modules\Core\Catalog\DTOs\ProductFilters; +use Modules\Core\Catalog\Enums\ProductSort; +use Modules\Core\Catalog\Support\ProductFilterBuilder; /** * Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed @@ -17,39 +19,69 @@ use Lunar\Models\Product; */ class ProductSearchService { + public function __construct( + private readonly ProductFilterBuilder $filterBuilder, + ) {} + /** + * $filters/$sort apply the exact same semantics ProductService::list() + * uses for collection browsing (same ProductFilterBuilder, same + * ProductSort::toMeilisearchSort()) — a shopper narrowing a text search + * by price/brand/stock gets identical filter behavior to narrowing a + * category listing, since both go through the same Meilisearch `filter` + * clause underneath. + * * @return Collection */ - public function search(string $query, ?string $locale = null): Collection + public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection { - $locale ??= App::getLocale(); - $defaultLocale = Language::getDefault()->code; + $options = [ + 'attributesToSearchOn' => $this->searchableFields(), + 'filter' => $this->filterBuilder->build($filters), + ]; + + if ($sort !== null) { + $options['sort'] = [$sort->toMeilisearchSort()]; + } return Product::search($query) - ->options([ - 'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale), - ]) + ->options($options) ->get(); } /** - * Target the resolved locale's fields plus the default locale's fields, so a - * product that's only ever been translated into the default language still - * surfaces when searched in another locale, instead of becoming invisible - * until every product is fully translated. + * Targets every configured store language's fields, not just the current + * request locale plus the store default — a shopper browsing in Greek + * typing an English word (or vice versa) should still match a product + * whose only translation for that text happens to be in a third + * language. There's no per-request "current locale" concept in this + * method any more: which fields exist to search on is a property of the + * store's configured languages, not of who's asking. + * + * Also targets variants.options.value directly — a variant's option + * value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is how ProductIndexer + * already indexes it (see mapVariant()), but it isn't one of Lunar's own + * attributes, so it can't come from AttributeManifest the way name/ + * description do; it's a structural field of the document, added here + * directly instead. Not locale-suffixed like the attribute-manifest + * fields — option values are stored as one already-resolved string per + * variant (see ProductIndexer::translatedName()), not per-locale. * * @return array */ - private function searchableFields(string $locale, string $defaultLocale): array + private function searchableFields(): array { $handles = AttributeManifest::getSearchableAttributes(Product::morphName()) ->pluck('handle'); - $locales = array_unique([$locale, $defaultLocale]); + $locales = Language::all()->pluck('code'); - return $handles + $attributeFields = $handles ->crossJoin($locales) - ->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}") + ->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}"); + + return $attributeFields + ->push('variants.options.value') ->values() ->all(); } diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php index 616426e..326837d 100644 --- a/src/Catalog/Services/ProductService.php +++ b/src/Catalog/Services/ProductService.php @@ -4,14 +4,16 @@ namespace Modules\Core\Catalog\Services; use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; use Lunar\Base\AttributeManifest; use Lunar\FieldTypes\TranslatedText; use Lunar\Models\Product; use Modules\Core\Localization\Services\LanguageCache; +use Modules\Core\Catalog\DTOs\PriceSliderBounds; use Modules\Core\Catalog\DTOs\ProductFilters; +use Modules\Core\Catalog\DTOs\ProductListingResult; use Modules\Core\Catalog\Enums\ProductSort; +use Modules\Core\Catalog\Support\ProductFilterBuilder; /** * Storefront product listing/filtering AND single-product lookup, all reading directly @@ -27,17 +29,33 @@ class ProductService public function __construct( private readonly LanguageCache $languages, private readonly AttributeManifest $attributes, + private readonly ProductFilterBuilder $filterBuilder, ) {} /** - * Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result - - * see "Meilisearch driver quirk" below) so a controller/view gets normal - * pagination behaviour ($products->links(), JSON serialization, etc.) - * without ever touching the raw Meilisearch response directly. + * One call for everything a listing page needs: the product page AND + * the price slider's bounds — a controller used to have to call this + * plus priceSliderBounds() separately and glue the results together + * itself; that orchestration now happens in here instead. Still issues + * two Meilisearch requests under the hood (the product search, and a + * separate price-facet-stats query — see priceSliderBounds()'s + * docblock for why they can't be merged into one without changing the + * slider's own UX), but the caller only ever makes one call. + * + * $filters->minPrice/$filters->maxPrice double as both the applied + * product filter AND the "is the slider actually narrowed" comparison + * in priceSliderBounds() — the same values, used two ways, so nothing + * new needs to be threaded through separately. + * + * The paginator itself is a real LengthAwarePaginator (not Scout's own + * paginateRaw() result - see "Meilisearch driver quirk" below) so a + * controller/view gets normal pagination behaviour ($products->links(), + * JSON serialization, etc.) without ever touching the raw Meilisearch + * response directly. */ - public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator + public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): ProductListingResult { - $options = ['filter' => $this->buildFilter($filters)]; + $options = ['filter' => $this->filterBuilder->build($filters)]; if ($sort !== null) { $options['sort'] = [$sort->toMeilisearchSort()]; @@ -51,13 +69,36 @@ class ProductService ->map(fn (array $product) => $this->withLocalizedFields($product)) ->all(); - return new LengthAwarePaginator( + $products = new LengthAwarePaginator( items: $data, total: $paginator->total(), perPage: $paginator->perPage(), currentPage: $paginator->currentPage(), options: ['path' => LengthAwarePaginator::resolveCurrentPath()], ); + + $priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice); + $availableTags = $this->availableTags($filters); + + return new ProductListingResult($products, $priceBounds, $availableTags); + } + + /** + * Every distinct `tags` value present on a product matching $filters, + * excluding $filters->tag itself — same "scoped but not self-collapsing" + * reasoning as priceRange() excluding `price` — so selecting a tag + * doesn't shrink the sidebar down to just that one tag. Sorted + * alphabetically; Meilisearch's facetDistribution has no defined order + * of its own. + * + * @return array + */ + private function availableTags(?ProductFilters $filters): array + { + $filter = $this->filterBuilder->build($filters, exclude: ['tag']); + $tags = $this->rawFacets('tags', $filter)['facetDistribution']['tags'] ?? []; + + return collect($tags)->keys()->sort()->values()->all(); } /** @@ -71,15 +112,18 @@ class ProductService * 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. + * fields make sense here (`brand`, `tags`, `in_stock`) — a numeric field like + * `price` would return one "facet" per exact price, not a usable range bucket. + * Use `priceRange()` for `price` instead. `facets('tags', $filters)` is how a + * category page gets "which tags actually appear on products in this category" — + * pass a $filters that omits `tag` (see `build()`'s $exclude) so the tag list + * itself doesn't collapse to whichever tag is already selected. * * @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] ?? []; + return $this->rawFacets($field, $this->filterBuilder->build($filters))['facetDistribution'][$field] ?? []; } /** @@ -90,12 +134,17 @@ class ProductService * not `facetDistribution` — the right feature for a numeric field's range, * where `facets('price')` would otherwise return one entry per exact price. * + * $query defaults to '' (every product, same as list()'s own default text + * query) — pass the shopper's search text here too so a search page's own + * price slider spans only the products that search actually matched, + * rather than the whole catalog's price range. + * * @return array{min: ?float, max: ?float} null/null if no product matches */ - public function priceRange(?ProductFilters $filters = null): array + public function priceRange(?ProductFilters $filters = null, string $query = ''): array { - $filter = $this->buildFilter($filters, exclude: ['price']); - $stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null; + $filter = $this->filterBuilder->build($filters, exclude: ['price']); + $stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null; return [ 'min' => $stats['min'] ?? null, @@ -103,9 +152,36 @@ class ProductService ]; } - private function rawFacets(string $field, ?string $filter): array + /** + * priceRange() rounded to whole euros (floor/ceil, so the slider's ends + * are never tighter than what's actually in range) plus whether + * $selectedMinPrice/$selectedMaxPrice actually narrow it — the same + * "floor/ceil + is this a real filter" rule CategoryController and + * SearchController each used to duplicate inline. $selectedMinPrice/ + * $selectedMaxPrice are the currently-applied filter values (e.g. + * CategoryListing::$minPrice), not part of $filters itself, since + * $filters here must already exclude price the way priceRange() expects. + */ + public function priceSliderBounds( + ?ProductFilters $filters, + ?float $selectedMinPrice, + ?float $selectedMaxPrice, + string $query = '', + ): PriceSliderBounds { + $priceRange = $this->priceRange($filters, $query); + + $floor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null; + $ceil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null; + + $filtered = ($selectedMinPrice !== null && $selectedMinPrice > ($floor ?? PHP_INT_MIN)) + || ($selectedMaxPrice !== null && $selectedMaxPrice < ($ceil ?? PHP_INT_MAX)); + + return new PriceSliderBounds($floor, $ceil, $filtered); + } + + private function rawFacets(string $field, ?string $filter, string $query = ''): array { - return Product::search('') + return Product::search($query) ->options([ 'filter' => $filter, 'facets' => [$field], @@ -133,15 +209,87 @@ class ProductService return $this->findOneWhere("id = \"{$id}\""); } + /** + * The id/price/image of every variant on a product document (from + * getById()/getBySlug()'s own 'variants' array) — the base price and + * thumbnail a variant picker/swatch list needs, without a caller + * reaching into $product['variants'][n]['prices'][0]/['media'][0] + * itself. Domain shaping (which price/image represents a variant), + * not presentation — a card's href/layout stays a storefront concern + * (e.g. App\Catalog\ProductCard in 3dealer), but "the variant's price + * is its first price row" is a rule about the data, true regardless of + * which app renders it. + * + * @param array $product A document from getById()/getBySlug(). + * @return array + */ + public function variantSummaries(array $product): array + { + return collect($product['variants'] ?? []) + ->map(fn (array $variant) => [ + 'id' => $variant['id'], + 'price' => $variant['prices'][0]['price'] ?? null, + 'image' => $variant['media'][0]['url'] ?? null, + ]) + ->values() + ->all(); + } + + /** + * $limit random products, still scoped to the index's own default + * visibility (channel/status), unlike Eloquent's Product::inRandomOrder() + * which has no notion of that filtering at all — a random pick can never + * surface a hidden/unpublished product this way. Meilisearch itself has + * no ORDER BY RANDOM() equivalent, so this pulls every matching id only + * (attributesToRetrieve: ['id'], the lightest possible request — no + * name/media/variants/etc. for documents that will mostly be discarded), + * shuffles in PHP, then fetches the full localized documents for just + * the $limit ids actually picked. + * + * @return array + */ + public function random(int $limit): array + { + $raw = Product::search('') + ->options(['attributesToRetrieve' => ['id']]) + ->raw(); + + $ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values(); + + if ($ids->isEmpty()) { + return []; + } + + // Meilisearch's `id IN [...]` doesn't preserve the given order — it's + // an unordered set filter, not a list to iterate — so the shuffle + // above would otherwise be silently undone by whatever order the + // re-fetch comes back in. Re-sort the fetched documents back into + // $ids's already-shuffled order instead of trusting the response's. + $products = collect($this->findAllWhere('id IN ['.$ids->implode(', ').']')) + ->keyBy('id'); + + return $ids->map(fn ($id) => $products->get($id))->filter()->values()->all(); + } + private function findOneWhere(string $filter): ?array + { + $products = $this->findAllWhere($filter, limit: 1); + + return $products[0] ?? null; + } + + /** + * @return array + */ + private function findAllWhere(string $filter, int $limit = 1000): array { $paginator = Product::search('') ->options(['filter' => $filter]) - ->paginateRaw(perPage: 1, page: 1); + ->paginateRaw(perPage: $limit, page: 1); - $product = $this->hitsFrom($paginator)[0] ?? null; - - return $product !== null ? $this->withLocalizedFields($product) : null; + return collect($this->hitsFrom($paginator)) + ->map(fn (array $product) => $this->withLocalizedFields($product)) + ->all(); } /** @@ -204,28 +352,4 @@ class ProductService return collect($rawResponse['hits'] ?? [])->values()->all(); } - /** - * @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([ - '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 '); - } } diff --git a/src/Catalog/Support/ProductFilterBuilder.php b/src/Catalog/Support/ProductFilterBuilder.php new file mode 100644 index 0000000..dddf401 --- /dev/null +++ b/src/Catalog/Support/ProductFilterBuilder.php @@ -0,0 +1,41 @@ + $exclude + * filter fields to leave out even if set on $filters — e.g. + * ProductService::priceRange() excludes 'price' so a price slider's own + * bounds don't shrink to whatever range is already selected on it. + */ + public function build(?ProductFilters $filters, array $exclude = []): ?string + { + if ($filters === null) { + return null; + } + + $clauses = Collection::make([ + 'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, + 'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, + 'tag' => $filters->tag !== null ? 'tags = "'.addcslashes($filters->tag, '"\\').'"' : 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 '); + } +} diff --git a/src/Command/TuneProductSearchCommand.php b/src/Command/TuneProductSearchCommand.php new file mode 100644 index 0000000..e0687e1 --- /dev/null +++ b/src/Command/TuneProductSearchCommand.php @@ -0,0 +1,67 @@ +createMeilisearchDriver(); + + $index = $engine->getIndex((new Product)->searchableAs()); + + $this->components->info('Updating typo tolerance for product search...'); + + $task = $index->updateTypoTolerance([ + 'minWordSizeForTypos' => [ + 'oneTypo' => 8, + 'twoTypos' => 12, + ], + ]); + + $engine->waitForTask($task['taskUid']); + + $this->components->info('Disabling prefix search for product search...'); + + $task = $index->updatePrefixSearch('disabled'); + + $engine->waitForTask($task['taskUid']); + + $this->components->info('Product search index tuned.'); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php b/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php index e4582f4..3bb9b0e 100644 --- a/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php +++ b/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php @@ -16,10 +16,16 @@ class ProductOptionResolver // the same option instead of creating a near-duplicate. $handle = Str::slug($name) ?: 'option'; + // 'label' must be set even though nothing here reads it back — a null + // label crashes Lunar's own ProductOptionIndexer::toSearchableArray() + // (foreach (null as ...)) the moment this option gets reindexed, since + // it assumes every ProductOption always has one. Same value as 'name' + // is a reasonable default; Shopify's CSV has no separate "label" concept. return ProductOption::query()->firstOrCreate( ['handle' => $handle], [ 'name' => [DefaultLocale::code() => $name], + 'label' => [DefaultLocale::code() => $name], 'shared' => true, ], ); diff --git a/src/MigrateImport/Shopify/ShopifyExportImporter.php b/src/MigrateImport/Shopify/ShopifyExportImporter.php index 5ac0b6d..72ae6ce 100644 --- a/src/MigrateImport/Shopify/ShopifyExportImporter.php +++ b/src/MigrateImport/Shopify/ShopifyExportImporter.php @@ -25,6 +25,7 @@ use Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver; use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver; use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver; use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver; +use Spatie\MediaLibrary\MediaCollections\Models\Media; class ShopifyExportImporter implements Importer { @@ -113,7 +114,7 @@ class ShopifyExportImporter implements Importer $options = $this->attachOptions($product, $row); foreach ($group->variantRows as $index => $variantRow) { - $this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options); + $this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options, $imagesPath); } foreach ($group->imageRows as $index => $imageRow) { @@ -151,6 +152,7 @@ class ShopifyExportImporter implements Importer TaxClass $taxClass, Currency $currency, array $options, + string $imagesPath, ): void { $externalId = "{$handle}#{$index}"; $existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId); @@ -182,6 +184,26 @@ class ShopifyExportImporter implements Importer : null; $this->priceResolver->resolve($variant, $currency, $price, $comparePrice); + + // Shopify's own "Variant Image" column — the one image a variant picker + // actually swaps to when that variant is selected — distinct from the + // product's full gallery (imageRows below). Often the same file as one + // of the product's own image rows, sometimes not yet imported at all + // (e.g. a variant-only image never listed as its own image row) — either + // way resolveOrImportImage() handles both via the same Image Src dedup + // key, so whichever of importVariant()/importImage() runs first for a + // given src does the actual import. + $variantImageSrc = trim((string) ($row['Variant Image'] ?? '')); + + if ($variantImageSrc !== '') { + $media = $this->resolveOrImportImage($product, $handle, $variantImageSrc, 1, $imagesPath); + + if ($media) { + $variant->images()->syncWithoutDetaching([ + $media->id => ['primary' => true, 'position' => 1], + ]); + } + } } private function importImage( @@ -191,29 +213,54 @@ class ShopifyExportImporter implements Importer array $row, string $imagesPath, ): void { - $externalId = $row['Image Src'] ?: "{$handle}#image-{$index}"; $position = (int) ($row['Image Position'] ?? $index + 1); - if (ImportMapping::resolve(self::SOURCE, 'image', $externalId)) { - return; + $this->resolveOrImportImage($product, $handle, $row['Image Src'], $position, $imagesPath); + } + + /** + * Resolves the Media already imported for $imageSrc (recorded under + * source_type 'image', keyed by Image Src — the same URL Shopify repeats + * across a product's own image rows and any variant's "Variant Image" + * column), importing it via AssetResolver if this is the first time this + * src has been seen. Shared by importImage() (product gallery) and + * importVariant() (variant-specific image) so the same physical file is + * never uploaded to Spatie MediaLibrary twice just because Shopify's flat + * CSV format repeats the URL on multiple rows. + */ + private function resolveOrImportImage( + Product $product, + string $handle, + string $imageSrc, + int $position, + string $imagesPath, + ): ?Media { + $externalId = $imageSrc ?: "{$handle}#image-{$position}"; + + $existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId); + + if ($existing instanceof Media) { + return $existing; } - $localFile = $this->findLocalFile($imagesPath, $row['Image Src']); + $localFile = $this->findLocalFile($imagesPath, $imageSrc); if ($localFile === null) { Log::warning('Shopify import: image file not found', [ 'handle' => $handle, - 'image_src' => $row['Image Src'], + 'image_src' => $imageSrc, ]); - return; + return null; } $media = $this->assetResolver->resolve($product, $localFile, $position); if ($media) { - ImportMapping::record(self::SOURCE, 'image', $externalId, $product); + ImportMapping::record(self::SOURCE, 'image', $externalId, $media); } + + return $media; } private function findLocalFile(string $imagesPath, string $imageSrc): ?string diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 0574c40..2756ca1 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -10,6 +10,7 @@ use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ImportCommand; use Modules\Core\Command\InstallLunarCommand; use Modules\Core\Command\MigrateImportCommand; +use Modules\Core\Command\TuneProductSearchCommand; class CoreServiceProvider extends ServiceProvider { @@ -35,7 +36,7 @@ class CoreServiceProvider extends ServiceProvider ], 'core-assets'); if ($this->app->runningInConsole()) { - $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]); + $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]); //Overriding lunar:install $this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));