Feat: Updates to Product Indexer, Shopify Importer, Adding cascade delete on reviews
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* product_reviews.product_id's original foreign key (2026_07_10_000001) had
|
||||||
|
* no ON DELETE clause, so deleting a Product with reviews throws a
|
||||||
|
* constraint violation instead of the review rows going with it — unlike
|
||||||
|
* every other Product-dependent table (variants, media, etc.), which does
|
||||||
|
* cascade. A review is dependent, disposable data, not something worth
|
||||||
|
* blocking a product deletion over.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): 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')
|
||||||
|
->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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`. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,14 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
|||||||
* - slugs (every locale's Url::slug for the product, filterable) — lets
|
* - slugs (every locale's Url::slug for the product, filterable) — lets
|
||||||
* ProductService::getBySlug() resolve a product from the index directly, with
|
* ProductService::getBySlug() resolve a product from the index directly, with
|
||||||
* no database read at all
|
* 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
|
* - 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)
|
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
|
||||||
* - tags
|
* - tags
|
||||||
* - reviews: {items: [...], count, average_rating} — items are public-safe fields
|
* - reviews: {items: [...], count, average_rating} — items are public-safe fields
|
||||||
@@ -83,6 +89,7 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
'channel_ids',
|
'channel_ids',
|
||||||
'in_stock',
|
'in_stock',
|
||||||
'recommendations.id',
|
'recommendations.id',
|
||||||
|
'skus',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +133,7 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->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['tags'] = $model->tags->pluck('value')->all();
|
||||||
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->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();
|
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
|
||||||
@@ -161,8 +169,22 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'sku' => $variant->sku,
|
'sku' => $variant->sku,
|
||||||
|
'gtin' => $variant->gtin,
|
||||||
|
'mpn' => $variant->mpn,
|
||||||
|
'ean' => $variant->ean,
|
||||||
'stock' => $variant->stock,
|
'stock' => $variant->stock,
|
||||||
|
'backorder' => $variant->backorder,
|
||||||
|
'unit_quantity' => $variant->unit_quantity,
|
||||||
'purchasable' => $variant->purchasable,
|
'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) => [
|
'options' => $variant->values->map(fn ($value) => [
|
||||||
'option' => $this->translatedName($value->option->name),
|
'option' => $this->translatedName($value->option->name),
|
||||||
'handle' => $value->option->handle,
|
'handle' => $value->option->handle,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver;
|
|||||||
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
|
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
|
||||||
use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver;
|
use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver;
|
||||||
use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver;
|
use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver;
|
||||||
|
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||||
|
|
||||||
class ShopifyExportImporter implements Importer
|
class ShopifyExportImporter implements Importer
|
||||||
{
|
{
|
||||||
@@ -113,7 +114,7 @@ class ShopifyExportImporter implements Importer
|
|||||||
$options = $this->attachOptions($product, $row);
|
$options = $this->attachOptions($product, $row);
|
||||||
|
|
||||||
foreach ($group->variantRows as $index => $variantRow) {
|
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) {
|
foreach ($group->imageRows as $index => $imageRow) {
|
||||||
@@ -151,6 +152,7 @@ class ShopifyExportImporter implements Importer
|
|||||||
TaxClass $taxClass,
|
TaxClass $taxClass,
|
||||||
Currency $currency,
|
Currency $currency,
|
||||||
array $options,
|
array $options,
|
||||||
|
string $imagesPath,
|
||||||
): void {
|
): void {
|
||||||
$externalId = "{$handle}#{$index}";
|
$externalId = "{$handle}#{$index}";
|
||||||
$existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId);
|
$existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId);
|
||||||
@@ -182,6 +184,26 @@ class ShopifyExportImporter implements Importer
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$this->priceResolver->resolve($variant, $currency, $price, $comparePrice);
|
$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(
|
private function importImage(
|
||||||
@@ -191,29 +213,54 @@ class ShopifyExportImporter implements Importer
|
|||||||
array $row,
|
array $row,
|
||||||
string $imagesPath,
|
string $imagesPath,
|
||||||
): void {
|
): void {
|
||||||
$externalId = $row['Image Src'] ?: "{$handle}#image-{$index}";
|
|
||||||
$position = (int) ($row['Image Position'] ?? $index + 1);
|
$position = (int) ($row['Image Position'] ?? $index + 1);
|
||||||
|
|
||||||
if (ImportMapping::resolve(self::SOURCE, 'image', $externalId)) {
|
$this->resolveOrImportImage($product, $handle, $row['Image Src'], $position, $imagesPath);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$localFile = $this->findLocalFile($imagesPath, $row['Image Src']);
|
/**
|
||||||
|
* 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, $imageSrc);
|
||||||
|
|
||||||
if ($localFile === null) {
|
if ($localFile === null) {
|
||||||
Log::warning('Shopify import: image file not found', [
|
Log::warning('Shopify import: image file not found', [
|
||||||
'handle' => $handle,
|
'handle' => $handle,
|
||||||
'image_src' => $row['Image Src'],
|
'image_src' => $imageSrc,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$media = $this->assetResolver->resolve($product, $localFile, $position);
|
$media = $this->assetResolver->resolve($product, $localFile, $position);
|
||||||
|
|
||||||
if ($media) {
|
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
|
private function findLocalFile(string $imagesPath, string $imageSrc): ?string
|
||||||
|
|||||||
Reference in New Issue
Block a user