From f43f72f63308245bfbd15c8f16b9dc0905efa25b Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 3 Sep 2026 12:26:00 +0300 Subject: [PATCH] Feat: Updates to Product Indexer, Shopify Importer, Adding cascade delete on reviews --- ...e_delete_to_product_reviews_product_id.php | 43 +++++++++++++ docs/product-listing.md | 3 +- src/Catalog/Services/ProductIndexer.php | 24 ++++++- .../Shopify/ShopifyExportImporter.php | 63 ++++++++++++++++--- 4 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php 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 9931c4f..02b4934 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -135,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/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index 261bbfa..8f9176b 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,7 @@ class ProductIndexer extends BaseProductIndexer 'channel_ids', 'in_stock', 'recommendations.id', + 'skus', ]; } @@ -126,6 +133,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 +169,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/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