From cd9632a6bf16c6395ade5f9eff6f1a94b1eea5a6 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Mon, 6 Jul 2026 03:42:24 +0300 Subject: [PATCH 1/5] Feature: Creating Skeleton for import command, interfaces, shopify importer --- src/Command/MigrateImportCommand.php | 61 +++++++++++++++++++ src/MigrateImport/ImportSpec.php | 14 +++++ src/MigrateImport/Importer.php | 8 +++ src/MigrateImport/ImporterFactory.php | 21 +++++++ src/MigrateImport/RunMigrateImportJob.php | 28 +++++++++ .../Shopify/ShopifyExportImporter.php | 13 ++++ src/Providers/CoreServiceProvider.php | 3 +- 7 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/Command/MigrateImportCommand.php create mode 100644 src/MigrateImport/ImportSpec.php create mode 100644 src/MigrateImport/Importer.php create mode 100644 src/MigrateImport/ImporterFactory.php create mode 100644 src/MigrateImport/RunMigrateImportJob.php create mode 100644 src/MigrateImport/Shopify/ShopifyExportImporter.php diff --git a/src/Command/MigrateImportCommand.php b/src/Command/MigrateImportCommand.php new file mode 100644 index 0000000..02baf4c --- /dev/null +++ b/src/Command/MigrateImportCommand.php @@ -0,0 +1,61 @@ +option("source") + ?? $this->choice("Select a source:", self::SOURCES, 0); + + $type = $this->option("type") + ?? $this->choice("Select an import type:", self::TYPES, 0); + + if ($type === "api") { + $credentials = [ + "api_key" => $this->secret("API key:"), + "api_secret" => $this->secret("API secret:"), + ]; + $filePath = null; + } else { + $filePath = $this->option("file") + ?? $this->ask("Path to the export file:"); + $credentials = null; + } + + $spec = new ImportSpec( + source: $source, + type: $type, + filePath: $filePath, + credentials: $credentials, + ); + + RunMigrateImportJob::dispatch($spec); + + $this->info("Import queued."); + } +} diff --git a/src/MigrateImport/ImportSpec.php b/src/MigrateImport/ImportSpec.php new file mode 100644 index 0000000..d5a8016 --- /dev/null +++ b/src/MigrateImport/ImportSpec.php @@ -0,0 +1,14 @@ +source, $spec->type]) { + ["shopify", "export"] => new ShopifyExportImporter(), + // ["shopify", "api"] => new ShopifyApiImporter(), + // ["woocommerce", "export"] => new WooCommerceExportImporter(), + // ["woocommerce", "api"] => new WooCommerceApiImporter(), + default => throw new \InvalidArgumentException( + "No importer available for source \"{$spec->source}\" with type \"{$spec->type}\".", + ), + }; + } +} diff --git a/src/MigrateImport/RunMigrateImportJob.php b/src/MigrateImport/RunMigrateImportJob.php new file mode 100644 index 0000000..16dfb4d --- /dev/null +++ b/src/MigrateImport/RunMigrateImportJob.php @@ -0,0 +1,28 @@ +spec); + $importer->import($this->spec); + } +} diff --git a/src/MigrateImport/Shopify/ShopifyExportImporter.php b/src/MigrateImport/Shopify/ShopifyExportImporter.php new file mode 100644 index 0000000..2b8c864 --- /dev/null +++ b/src/MigrateImport/Shopify/ShopifyExportImporter.php @@ -0,0 +1,13 @@ +app->runningInConsole()) { - $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class]); + $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]); } } } -- 2.43.0 From 825ce885c035c4569221dabeb7f68e1bcd83a91a Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 8 Jul 2026 23:41:23 +0300 Subject: [PATCH 2/5] Feature: Adding Migration To Track import mappings --- ...06_000001_create_import_mappings_table.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 database/migrations/2026_07_06_000001_create_import_mappings_table.php diff --git a/database/migrations/2026_07_06_000001_create_import_mappings_table.php b/database/migrations/2026_07_06_000001_create_import_mappings_table.php new file mode 100644 index 0000000..3e66b9b --- /dev/null +++ b/database/migrations/2026_07_06_000001_create_import_mappings_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('source'); + $table->string('source_type'); + $table->string('external_id'); + $table->morphs('model'); + $table->timestamps(); + + $table->unique(['source', 'source_type', 'external_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('import_mappings'); + } +}; -- 2.43.0 From df03866285b1d2909859668285298ab68124f83c Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 9 Jul 2026 00:37:14 +0300 Subject: [PATCH 3/5] Feature: Creating product import resolvers, wiring import job --- src/Command/MigrateImportCommand.php | 36 ++- src/MigrateImport/DefaultLocale.php | 13 ++ src/MigrateImport/Models/ImportMapping.php | 41 ++++ src/MigrateImport/Shopify/ProductGroup.php | 18 ++ .../Shopify/Resolvers/AssetResolver.php | 21 ++ .../Shopify/Resolvers/BrandResolver.php | 19 ++ .../Shopify/Resolvers/CollectionResolver.php | 65 ++++++ .../Resolvers/ImportAttributeResolver.php | 70 ++++++ .../Shopify/Resolvers/PriceResolver.php | 29 +++ .../Resolvers/ProductAttributeResolver.php | 49 ++++ .../Resolvers/ProductOptionResolver.php | 46 ++++ .../Shopify/Resolvers/ProductTypeResolver.php | 31 +++ .../Shopify/Resolvers/TagResolver.php | 29 +++ .../Shopify/Resolvers/TaxClassResolver.php | 13 ++ .../Shopify/ShopifyCsvReader.php | 55 +++++ .../Shopify/ShopifyExportImporter.php | 211 ++++++++++++++++++ 16 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 src/MigrateImport/DefaultLocale.php create mode 100644 src/MigrateImport/Models/ImportMapping.php create mode 100644 src/MigrateImport/Shopify/ProductGroup.php create mode 100644 src/MigrateImport/Shopify/Resolvers/AssetResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/BrandResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/CollectionResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/PriceResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/ProductTypeResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/TagResolver.php create mode 100644 src/MigrateImport/Shopify/Resolvers/TaxClassResolver.php create mode 100644 src/MigrateImport/Shopify/ShopifyCsvReader.php diff --git a/src/Command/MigrateImportCommand.php b/src/Command/MigrateImportCommand.php index 02baf4c..c96b541 100644 --- a/src/Command/MigrateImportCommand.php +++ b/src/Command/MigrateImportCommand.php @@ -42,8 +42,22 @@ class MigrateImportCommand extends Command ]; $filePath = null; } else { + $importsPath = storage_path("app/private/imports"); + $filePath = $this->option("file") - ?? $this->ask("Path to the export file:"); + ?? $this->resolveImportPath($importsPath, $this->ask("Path to the export file, relative to {$importsPath}:")); + + while (blank($filePath) || ! is_file($filePath)) { + if (filled($filePath)) { + $this->error("File not found: {$filePath}"); + } + + $filePath = $this->resolveImportPath( + $importsPath, + $this->ask("Path to the export file, relative to {$importsPath}:"), + ); + } + $credentials = null; } @@ -58,4 +72,24 @@ class MigrateImportCommand extends Command $this->info("Import queued."); } + + // Answers are relative to storage/app/private/imports (e.g. "shopify" or + // "shopify/products_export.csv"); absolute paths are used as-is. A + // directory answer picks the first CSV file found inside it. + private function resolveImportPath(string $importsPath, ?string $answer): ?string + { + if (blank($answer)) { + return $answer; + } + + $path = str_starts_with($answer, "/") ? $answer : "{$importsPath}/{$answer}"; + + if (is_dir($path)) { + $csv = collect(glob("{$path}/*.csv"))->first(); + + return $csv ?? $path; + } + + return $path; + } } diff --git a/src/MigrateImport/DefaultLocale.php b/src/MigrateImport/DefaultLocale.php new file mode 100644 index 0000000..f85bd17 --- /dev/null +++ b/src/MigrateImport/DefaultLocale.php @@ -0,0 +1,13 @@ +code; + } +} diff --git a/src/MigrateImport/Models/ImportMapping.php b/src/MigrateImport/Models/ImportMapping.php new file mode 100644 index 0000000..7c37098 --- /dev/null +++ b/src/MigrateImport/Models/ImportMapping.php @@ -0,0 +1,41 @@ +morphTo(); + } + + public static function resolve(string $source, string $sourceType, string $externalId): ?Model + { + return static::query() + ->where('source', $source) + ->where('source_type', $sourceType) + ->where('external_id', $externalId) + ->first() + ?->model; + } + + public static function record(string $source, string $sourceType, string $externalId, Model $model): self + { + return static::query()->updateOrCreate( + [ + 'source' => $source, + 'source_type' => $sourceType, + 'external_id' => $externalId, + ], + [ + 'model_type' => $model->getMorphClass(), + 'model_id' => $model->getKey(), + ], + ); + } +} diff --git a/src/MigrateImport/Shopify/ProductGroup.php b/src/MigrateImport/Shopify/ProductGroup.php new file mode 100644 index 0000000..3ace15b --- /dev/null +++ b/src/MigrateImport/Shopify/ProductGroup.php @@ -0,0 +1,18 @@ +> */ + public array $variantRows = []; + + /** @var array> */ + public array $imageRows = []; + + public function __construct( + public readonly string $handle, + public readonly array $productRow, + ) { + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/AssetResolver.php b/src/MigrateImport/Shopify/Resolvers/AssetResolver.php new file mode 100644 index 0000000..67bc09d --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/AssetResolver.php @@ -0,0 +1,21 @@ +addMedia($localFilePath) + ->preservingOriginal() + ->withCustomProperties(['position' => $position]) + ->toMediaCollection(config('lunar.media.collection')); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/BrandResolver.php b/src/MigrateImport/Shopify/Resolvers/BrandResolver.php new file mode 100644 index 0000000..0255bc4 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/BrandResolver.php @@ -0,0 +1,19 @@ + $vendor]); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php b/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php new file mode 100644 index 0000000..c95e127 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php @@ -0,0 +1,65 @@ +', (string) $categoryPath)) + ->map(fn (string $segment) => trim($segment)) + ->filter(); + + if ($segments->isEmpty()) { + return; + } + + $parent = null; + + foreach ($segments as $segment) { + $parent = $this->findChild($group, $parent, $segment) ?? $this->create($group, $parent, $segment); + } + + $product->collections()->syncWithoutDetaching([$parent->id]); + } + + private function findChild(CollectionGroup $group, ?Collection $parent, string $name): ?Collection + { + return Collection::query() + ->where('collection_group_id', $group->id) + ->where('parent_id', $parent?->id) + ->get() + ->first(fn (Collection $collection) => $collection->translateAttribute('name') === $name); + } + + private function create(CollectionGroup $group, ?Collection $parent, string $name): Collection + { + $collection = new Collection([ + 'collection_group_id' => $group->id, + 'attribute_data' => [ + 'name' => new TranslatedText(collect([ + DefaultLocale::code() => new Text($name), + ])), + ], + ]); + + // Mass-assigning parent_id triggers NodeTrait's setParentIdAttribute + // mutator before BaseModel's constructor has prefixed the table, + // causing an "undefined table" query. appendToNode()/saveAsRoot() + // avoid that mutator entirely. + if ($parent) { + $collection->appendToNode($parent)->save(); + } else { + $collection->saveAsRoot(); + } + + return $collection; + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php b/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php new file mode 100644 index 0000000..eaa892c --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php @@ -0,0 +1,70 @@ + ['label' => 'Cost per item', 'type' => Number::class], + 'seo_title' => ['label' => 'SEO Title', 'type' => TranslatedText::class], + 'seo_description' => ['label' => 'SEO Description', 'type' => TranslatedText::class], + ]; + + public function ensureMapped(ProductType $productType): void + { + $mappedHandles = $productType->productAttributes()->pluck('handle')->all(); + $missing = array_diff(array_keys(self::EXTRA_ATTRIBUTES), $mappedHandles); + + if (empty($missing)) { + return; + } + + $group = $this->importAttributeGroup(); + $nextPosition = Attribute::where('attribute_group_id', $group->id)->max('position') + 1; + + foreach ($missing as $handle) { + $definition = self::EXTRA_ATTRIBUTES[$handle]; + + $attribute = Attribute::firstOrCreate( + ['attribute_type' => Product::morphName(), 'handle' => $handle], + [ + 'attribute_group_id' => $group->id, + 'position' => $nextPosition++, + 'name' => [DefaultLocale::code() => $definition['label']], + 'section' => 'main', + 'type' => $definition['type'], + 'required' => false, + 'default_value' => null, + 'configuration' => $definition['type'] === TranslatedText::class + ? ['richtext' => false] + : [], + 'system' => false, + 'description' => [DefaultLocale::code() => ''], + ], + ); + + $productType->mappedAttributes()->syncWithoutDetaching([$attribute->id]); + } + } + + private function importAttributeGroup(): AttributeGroup + { + return AttributeGroup::firstOrCreate( + ['attributable_type' => Product::morphName(), 'handle' => 'import'], + [ + 'name' => [DefaultLocale::code() => 'Additional Details'], + 'position' => 100, + ], + ); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/PriceResolver.php b/src/MigrateImport/Shopify/Resolvers/PriceResolver.php new file mode 100644 index 0000000..754778f --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/PriceResolver.php @@ -0,0 +1,29 @@ + $variant->getMorphClass(), + 'priceable_id' => $variant->id, + 'currency_id' => $currency->id, + 'customer_group_id' => null, + ], + [ + 'price' => (int) round($price * (10 ** $currency->decimal_places)), + 'compare_price' => $comparePrice !== null + ? (int) round($comparePrice * (10 ** $currency->decimal_places)) + : null, + 'min_quantity' => 1, + ], + ); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php b/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php new file mode 100644 index 0000000..99ceb20 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php @@ -0,0 +1,49 @@ + $values keyed by attribute handle, e.g. ['name' => ..., 'description' => ...] + */ + public function resolve(ProductType $productType, array $values): array + { + $mappedHandles = $productType->productAttributes()->pluck('handle')->all(); + + $attributeData = []; + + foreach ($values as $handle => $value) { + if (! in_array($handle, $mappedHandles, true)) { + continue; + } + + if (trim((string) $value) === '') { + continue; + } + + $attributeData[$handle] = $this->fieldFor($handle, $value); + } + + return $attributeData; + } + + private function fieldFor(string $handle, string $value): Number|TranslatedText + { + $type = ImportAttributeResolver::EXTRA_ATTRIBUTES[$handle]['type'] ?? TranslatedText::class; + + if ($type === Number::class) { + return new Number((float) $value); + } + + return new TranslatedText(collect([ + DefaultLocale::code() => new Text($value), + ])); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php b/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php new file mode 100644 index 0000000..e4582f4 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php @@ -0,0 +1,46 @@ +firstOrCreate( + ['handle' => $handle], + [ + 'name' => [DefaultLocale::code() => $name], + 'shared' => true, + ], + ); + } + + public function resolveValue(ProductOption $option, string $value): ProductOptionValue + { + $slug = Str::slug($value) ?: 'value'; + + // Query fresh rather than $option->values: that relation is lazily + // cached on first access, so within one product's variant loop it + // would miss a value created earlier in the same loop, creating a + // duplicate for the same option+value pair. + $existing = ProductOptionValue::query() + ->where('product_option_id', $option->id) + ->get() + ->first(fn (ProductOptionValue $optionValue) => Str::slug($optionValue->translate('name')) === $slug); + + return $existing ?? ProductOptionValue::create([ + 'product_option_id' => $option->id, + 'name' => [DefaultLocale::code() => $value], + ]); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/ProductTypeResolver.php b/src/MigrateImport/Shopify/Resolvers/ProductTypeResolver.php new file mode 100644 index 0000000..523e4e5 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/ProductTypeResolver.php @@ -0,0 +1,31 @@ +first(); + + if ($existing) { + return $existing; + } + + $productType = ProductType::create(['name' => $name]); + + $productType->mappedAttributes()->attach( + Attribute::whereAttributeType(Product::morphName())->pluck('id'), + ); + + return $productType; + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/TagResolver.php b/src/MigrateImport/Shopify/Resolvers/TagResolver.php new file mode 100644 index 0000000..bf0daf1 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/TagResolver.php @@ -0,0 +1,29 @@ +map(fn (string $tag) => trim($tag)) + ->filter(); + + $this->sync($product, $values); + } + + private function sync(Product $product, Collection $tags): void + { + $tagIds = $tags + ->map(fn (string $tag) => Str::upper($tag)) + ->map(fn (string $tag) => Tag::firstOrCreate(['value' => $tag])->id); + + $product->tags()->sync($tagIds); + } +} diff --git a/src/MigrateImport/Shopify/Resolvers/TaxClassResolver.php b/src/MigrateImport/Shopify/Resolvers/TaxClassResolver.php new file mode 100644 index 0000000..92ce199 --- /dev/null +++ b/src/MigrateImport/Shopify/Resolvers/TaxClassResolver.php @@ -0,0 +1,13 @@ + + */ + public function read(string $csvPath): array + { + $handle = fopen($csvPath, 'r'); + + if ($handle === false) { + throw new \RuntimeException("Could not open CSV file: {$csvPath}"); + } + + $headers = fgetcsv($handle); + + /** @var array $groups */ + $groups = []; + $order = []; + + while (($row = fgetcsv($handle)) !== false) { + $data = array_combine($headers, $row); + $productHandle = trim($data['Handle'] ?? ''); + + if ($productHandle === '') { + continue; + } + + if (! isset($groups[$productHandle])) { + $groups[$productHandle] = new ProductGroup($productHandle, $data); + $order[] = $productHandle; + } + + $group = $groups[$productHandle]; + + $hasVariantData = trim((string) ($data['Option1 Value'] ?? '')) !== '' + || trim((string) ($data['Variant SKU'] ?? '')) !== ''; + + if ($hasVariantData) { + $group->variantRows[] = $data; + } + + if (trim((string) ($data['Image Src'] ?? '')) !== '') { + $group->imageRows[] = $data; + } + } + + fclose($handle); + + return array_map(fn (string $handle) => $groups[$handle], $order); + } +} diff --git a/src/MigrateImport/Shopify/ShopifyExportImporter.php b/src/MigrateImport/Shopify/ShopifyExportImporter.php index 2b8c864..8937a25 100644 --- a/src/MigrateImport/Shopify/ShopifyExportImporter.php +++ b/src/MigrateImport/Shopify/ShopifyExportImporter.php @@ -2,12 +2,223 @@ namespace Modules\Core\MigrateImport\Shopify; +use Illuminate\Support\Facades\Log; +use Lunar\Models\Collection; +use Lunar\Models\CollectionGroup; +use Lunar\Models\Currency; +use Lunar\Models\Product; +use Lunar\Models\ProductVariant; use Modules\Core\MigrateImport\ImportSpec; use Modules\Core\MigrateImport\Importer; +use Modules\Core\MigrateImport\Models\ImportMapping; +use Modules\Core\MigrateImport\Shopify\Resolvers\AssetResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\BrandResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\CollectionResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\ImportAttributeResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\PriceResolver; +use Modules\Core\MigrateImport\Shopify\Resolvers\ProductAttributeResolver; +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; class ShopifyExportImporter implements Importer { + private const SOURCE = 'shopify'; + + public function __construct( + private readonly ShopifyCsvReader $csvReader = new ShopifyCsvReader(), + private readonly TaxClassResolver $taxClassResolver = new TaxClassResolver(), + private readonly ProductTypeResolver $productTypeResolver = new ProductTypeResolver(), + private readonly BrandResolver $brandResolver = new BrandResolver(), + private readonly TagResolver $tagResolver = new TagResolver(), + private readonly CollectionResolver $collectionResolver = new CollectionResolver(), + private readonly ProductOptionResolver $productOptionResolver = new ProductOptionResolver(), + private readonly AssetResolver $assetResolver = new AssetResolver(), + private readonly PriceResolver $priceResolver = new PriceResolver(), + private readonly ImportAttributeResolver $importAttributeResolver = new ImportAttributeResolver(), + private readonly ProductAttributeResolver $productAttributeResolver = new ProductAttributeResolver(), + ) { + } + public function import(ImportSpec $spec): void { + $groups = $this->csvReader->read($spec->filePath); + $imagesPath = dirname($spec->filePath).'/files'; + $collectionGroup = CollectionGroup::firstOrCreate( + ['handle' => 'shopify'], + ['name' => 'Shopify'], + ); + $currency = Currency::getDefault(); + + foreach ($groups as $group) { + $this->importProduct($group, $imagesPath, $collectionGroup, $currency); + } + } + + private function importProduct( + ProductGroup $group, + string $imagesPath, + CollectionGroup $collectionGroup, + Currency $currency, + ): void { + $row = $group->productRow; + + $taxClass = $this->taxClassResolver->resolve( + filter_var($row['Variant Taxable'] ?? 'true', FILTER_VALIDATE_BOOLEAN), + ); + $productType = $this->productTypeResolver->resolve($row['Type'] ?? null); + $brand = $this->brandResolver->resolve($row['Vendor'] ?? null); + + $this->importAttributeResolver->ensureMapped($productType); + + $attributeData = $this->productAttributeResolver->resolve($productType->fresh(), [ + 'name' => $row['Title'] ?? '', + 'description' => $row['Body (HTML)'] ?? '', + 'cost_per_item' => $row['Cost per item'] ?? '', + 'seo_title' => $row['SEO Title'] ?? '', + 'seo_description' => $row['SEO Description'] ?? '', + ]); + + $existing = ImportMapping::resolve(self::SOURCE, 'product', $group->handle); + + $product = $existing instanceof Product ? $existing : new Product(); + $product->product_type_id = $productType->id; + $product->status = filter_var($row['Published'] ?? 'true', FILTER_VALIDATE_BOOLEAN) ? 'published' : 'draft'; + $product->brand_id = $brand?->id; + $product->attribute_data = array_merge($product->attribute_data?->toArray() ?? [], $attributeData); + $product->save(); + + ImportMapping::record(self::SOURCE, 'product', $group->handle, $product); + + $this->tagResolver->resolve($product, $row['Tags'] ?? null); + $this->collectionResolver->resolve($product, $collectionGroup, $row['Product Category'] ?? null); + + $options = $this->attachOptions($product, $row); + + foreach ($group->variantRows as $index => $variantRow) { + $this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options); + } + + foreach ($group->imageRows as $index => $imageRow) { + $this->importImage($product, $group->handle, $index, $imageRow, $imagesPath); + } + } + + /** + * @return array + */ + private function attachOptions(Product $product, array $row): array + { + $options = []; + + foreach ([1, 2, 3] as $position) { + $name = trim((string) ($row["Option{$position} Name"] ?? '')); + + if ($name === '' || $name === 'Title') { + continue; + } + + $option = $this->productOptionResolver->resolveOption($name); + $product->productOptions()->syncWithoutDetaching([$option->id => ['position' => $position]]); + $options[$position] = $option; + } + + return $options; + } + + private function importVariant( + Product $product, + string $handle, + int $index, + array $row, + \Lunar\Models\TaxClass $taxClass, + Currency $currency, + array $options, + ): void { + $externalId = "{$handle}#{$index}"; + $existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId); + + $variant = $existing instanceof ProductVariant ? $existing : new ProductVariant(); + $variant->product_id = $product->id; + $variant->tax_class_id = $taxClass->id; + $variant->sku = trim((string) ($row['Variant SKU'] ?? '')) ?: null; + $variant->stock = (int) ($row['Variant Inventory Qty'] ?? 0); + $variant->shippable = filter_var($row['Variant Requires Shipping'] ?? 'true', FILTER_VALIDATE_BOOLEAN); + $variant->save(); + + ImportMapping::record(self::SOURCE, 'variant', $externalId, $variant); + + foreach ($options as $position => $option) { + $value = trim((string) ($row["Option{$position} Value"] ?? '')); + + if ($value === '') { + continue; + } + + $optionValue = $this->productOptionResolver->resolveValue($option, $value); + $variant->values()->syncWithoutDetaching([$optionValue->id]); + } + + $price = (float) ($row['Variant Price'] ?? 0); + $comparePrice = trim((string) ($row['Variant Compare At Price'] ?? '')) !== '' + ? (float) $row['Variant Compare At Price'] + : null; + + $this->priceResolver->resolve($variant, $currency, $price, $comparePrice); + } + + private function importImage( + Product $product, + string $handle, + int $index, + 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; + } + + $localFile = $this->findLocalFile($imagesPath, $row['Image Src']); + + if ($localFile === null) { + Log::warning('Shopify import: image file not found', [ + 'handle' => $handle, + 'image_src' => $row['Image Src'], + ]); + + return; + } + + $media = $this->assetResolver->resolve($product, $localFile, $position); + + if ($media) { + ImportMapping::record(self::SOURCE, 'image', $externalId, $product); + } + } + + private function findLocalFile(string $imagesPath, string $imageSrc): ?string + { + $imagesPath = rtrim($imagesPath, '/'); + $filename = basename(parse_url($imageSrc, PHP_URL_PATH) ?? ''); + + // Shopify CDN filenames often embed a UUID (e.g. "1_ab5a6923-...-b130c6.jpg"). + // Export folders name files by that UUID alone, so try matching on it + // before falling back to an exact filename match. + if (preg_match('/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i', $filename, $matches)) { + $extension = pathinfo($filename, PATHINFO_EXTENSION); + $byUuid = "{$imagesPath}/{$matches[0]}".($extension ? ".{$extension}" : ''); + + if (is_file($byUuid)) { + return $byUuid; + } + } + + $byFilename = "{$imagesPath}/{$filename}"; + + return is_file($byFilename) ? $byFilename : null; } } -- 2.43.0 From 90ac32094c7f198a6933e2dac63895bb17ed56dd Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 9 Jul 2026 00:37:32 +0300 Subject: [PATCH 4/5] Feature: Adding Docs, CONTRIBUTE.md --- CONTRIBUTE.md | 67 ++++++++++++++++++ docs/lunar.md | 15 +++++ docs/shopify-import.md | 150 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 CONTRIBUTE.md create mode 100644 docs/shopify-import.md diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md new file mode 100644 index 0000000..d2dc4a1 --- /dev/null +++ b/CONTRIBUTE.md @@ -0,0 +1,67 @@ +# Contributing to boboko-core + +This is a Composer library, not a runnable app — you can't `php artisan serve` it directly. To develop and verify changes, you need a consumer app wired to a local checkout via a Composer path repository, plus a real database, since a large part of this package (Lunar models, migrations, Filament panel resources) can only be meaningfully verified against a live Lunar install. + +## Local dev setup + +This works against any consumer app that follows the same convention — `boboko-test`, `boboko-starter`, `boboko-3dealer`, etc. — checked out next to this repo: + +``` +RadicalElements/ +├── boboko-core/ (this repo) +└── boboko-test/ (or boboko-starter, boboko-3dealer, ... — consumer app, Docker-based) +``` + +Each of these consumer apps ships a `bin/dc-core.sh` helper that wraps the Docker Compose overlay needed to bind-mount a local `boboko-core` checkout into the app container: + +```bash +./bin/dc-core.sh exec app +``` + +This is shorthand for `docker compose -f docker-compose.dev.yml -f docker-compose.core-dev.yml exec app `. Use `./bin/dc-core.sh` for everything below instead of typing the full compose invocation. + +1. **Path repository.** In the consumer app's `composer.json`, the `repositories` array needs a path entry pointing at `../boboko-core`. If it only exists in a disabled block (e.g. `_repositories`), move it into the live array. +2. **Relaxed version constraint.** The consumer app's `composer.json` should require `"boboko/core": "0.*"` (not a tight `^0.0.1` caret) — otherwise Composer rejects newer `0.0.x` versions resolved from the path repo. +3. **Bind mount.** The consumer app's `docker-compose.core-dev.yml` overlays `../boboko-core` into the container at `/var/www/boboko-core`, matching where the path repo resolves it relative to `/var/www/html`. +4. **Re-resolve after every change.** Composer's path repo does not hot-reload — after editing anything in `boboko-core` (including adding new files, which need autoload discovery), the container needs to re-run `composer update boboko/core`. In `boboko-test`, the entrypoint does this automatically on every dev boot (see `docker/entrypoint.sh`), so `./bin/dc-core.sh up` alone picks up local core changes. If a consumer app's entrypoint doesn't do this yet, run it manually: + + ```bash + ./bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies + ``` + + Skipping this step is the most common cause of "my change isn't showing up." + +## Verifying changes against a real database + +There is no automated test suite for this package — too much of Lunar's behavior (table prefixing, nested sets, translatable attributes, Filament panel filters) only breaks in combination, against real Postgres, in a way that's impractical to fake in isolation. Instead, verify changes directly against a consumer app's live database. The practical workflow used throughout this package's `MigrateImport` feature: + +**One-off checks**, via `php artisan tinker --execute="..."` in the consumer app's container: + +```bash +./bin/dc-core.sh exec app php artisan tinker --execute=" +use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver; +\$type = (new ProductTypeResolver())->resolve('Test Type'); +echo \$type->name . PHP_EOL; +" +``` + +**Multi-step scripts** (creating related records, checking round-trips), via a raw PHP script bootstrapped like an Artisan command — this avoids tinker's persistent-session quirks and more closely matches how code actually runs in a queued job: + +```bash +./bin/dc-core.sh exec app php -r " +require 'vendor/autoload.php'; +\$app = require 'bootstrap/app.php'; +\$kernel = \$app->make(Illuminate\Contracts\Console\Kernel::class); +\$kernel->bootstrap(); + +// ... your verification code ... +" +``` + +**Always clean up fixtures** created this way — either via `DB::statement('DELETE FROM ...')` in the same script (respecting foreign key order — Lunar has cascading relations like `lunar_customer_group_product` that block naive deletes), or leave them if they're harmless and the DB is a disposable dev/test instance. + +Non-obvious Lunar bugs/traps hit while building against it are documented in [docs/lunar.md](docs/lunar.md#gotchas), not here — check there before debugging something that looks like a Lunar quirk. + +## Code organization + +Feature work lives under `src//`, with models in a `Models/` subdirectory (see `src/Auth/Models`, `src/Customer/Models`, `src/MigrateImport/Models`). Source-specific importer logic is grouped by source name (e.g. `src/MigrateImport/Shopify/`), with resolver classes (one responsibility each — resolve-or-create a single Lunar entity) grouped further under `Resolvers/`. diff --git a/docs/lunar.md b/docs/lunar.md index 58ce4d8..89e8a9e 100644 --- a/docs/lunar.md +++ b/docs/lunar.md @@ -1191,3 +1191,18 @@ php artisan vendor:publish --tag=lunar.migrations # publish migrations php artisan scout:import "Lunar\Models\Product" # index products php artisan scout:flush "Lunar\Models\Order" # flush order index ``` + +--- + +## Gotchas + +Real bugs/traps hit while building against Lunar in this package — not obvious from reading Lunar's source in isolation. + +- **`Lunar\Base\BaseModel` prefixes table names at construction time.** Mass-assigning a `parent_id` on a model using `kalnoy/nestedset`'s `NodeTrait` (e.g. `Collection`) triggers a mutator that queries the database *before* the table prefix is applied, throwing "relation does not exist". Use `appendToNode()`/`saveAsRoot()` instead of setting `parent_id` directly. See `MigrateImport\Shopify\Resolvers\CollectionResolver` for the pattern. +- **Don't trust a schema read from memory — re-check the actual migration/model file.** `products.brand` was assumed to be a plain string column based on an earlier read; it's actually `brand_id`, a real FK to a `Brand` model. Verify column names against the live `Schema::getColumnListing()` or the actual migration file, not recollection. +- **Lunar's default `Language` may not be `en`.** Don't hardcode `app()->getLocale()` for `TranslatedText`/translatable fields — use `Lunar\Models\Language::getDefault()->code` (wrapped here as `MigrateImport\DefaultLocale::code()`). A mismatch means data saves under the wrong locale key and silently doesn't render in the panel. +- **`attribute_data` is not a free-form array.** Values must be `Lunar\Base\FieldType` instances (`Text`, `TranslatedText`, `Number`, etc.), and the handle must be mapped to the product's `ProductType` via `mappedAttributes()`/`productAttributes()` — otherwise the value is silently dropped or won't render in the panel. +- **New `ProductType`s start with zero mapped attributes.** Creating one via `ProductType::create()` alone means `name`/`description` won't work until you `attach()` the existing system attributes to it. +- **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness. +- **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead. +- **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken. diff --git a/docs/shopify-import.md b/docs/shopify-import.md new file mode 100644 index 0000000..8534085 --- /dev/null +++ b/docs/shopify-import.md @@ -0,0 +1,150 @@ +# Shopify → Lunar product import + +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`. + +## 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. + +**Solution: a dedicated `import_mappings` table**, not columns on Lunar's core tables. + +```php +Schema::create($this->prefix.'import_mappings', function (Blueprint $table) { + $table->id(); + $table->string('source'); // 'shopify' + $table->string('source_type'); // 'product', 'variant', 'image', 'collection', 'tag' + $table->string('external_id'); // Shopify handle, or handle+position for variants/images + $table->morphs('model'); // model_type + model_id -> Product, ProductVariant, Asset, Collection, Tag + $table->timestamps(); + + $table->unique(['source', 'source_type', 'external_id']); +}); +``` + +Every resolver looks up `import_mappings` by `(source, source_type, external_id)` first — update if found, create + insert mapping row if not. + +## CSV shape + +Shopify's export is flat: one row per (product × variant × extra image), with `Handle` repeated across all rows belonging to the same product. Only the first row per handle carries `Title`/`Body (HTML)`/`Vendor`/etc — later rows are variant-only or pure image rows (no `Option1 Value` at all). + +The importer must first group consecutive CSV rows by `Handle` into `{ handle, productFields, variants: [...], images: [...] }` before touching Lunar. + +## Field mapping: CSV → Lunar + +| CSV column | Lunar equivalent | Notes | +|---|---|---| +| `Title`, `Body (HTML)` | `products.attribute_data` (JSON, translatable) | goes into attribute_data, not a flat column | +| `Vendor` | `products.brand` (plain string column) | direct passthrough | +| `Product Category` | `Collection` (nested set) inside a `CollectionGroup` | Google-taxonomy breadcrumb (e.g. `Toys & Games > Toys > Bath Toys`) — parse into a nested Collection chain, product attached to the deepest node. See "Collections" below. | +| `Type` | `product_type_id` (required FK) | must resolve/create a `ProductType`; needs a default fallback (e.g. "General") | +| `Tags` | `Tag` model (separate, not built into Product) | split CSV string, find/create, attach | +| `Published` / `Status` | `products.status` (plain string, indexed) | direct mapping | +| `Option1/2/3 Name+Value` | `product_options` + `product_option_values` + variant pivot | find/create option (by name), find/create value (by option+value), attach to variant | +| `Variant SKU` | `product_variants.sku` | direct | +| `Variant Grams` | `product_variants` dimensions (`$table->dimensions()`) | needs unit conversion check — macro likely adds `weight_value`/`weight_unit`, not raw grams | +| `Variant Inventory Qty` | `product_variants.stock` | direct | +| `Variant Price` / `Compare At Price` | `prices` table (polymorphic `priceable`, per-currency, per-customer-group) | **not a variant column** — create a `Price` row per variant per currency | +| `Cost per item` | *(nothing)* | Lunar has no cost-price field — stash in `attribute_data` as a custom attribute, in scope for v1 | +| `Variant Barcode` | closest is `gtin`/`ean` on variant | ambiguous, needs a mapping rule | +| `Variant Requires Shipping` | `product_variants.shippable` (boolean) | direct | +| `Variant Taxable` | `tax_class_id` (required FK, not boolean) | must resolve to an actual `TaxClass` row (e.g. "Standard" vs "None") | +| `Image Src` (+ image-only rows) | `Asset` model + Spatie MediaLibrary, ordered via `media_product_variant` pivot (`position`) | see "Images" below — file is local, not a URL to download | +| `SEO Title` / `SEO Description` | *(nothing structured)* | stash in `attribute_data` as custom attributes, in scope for v1 | +| Metafield columns (see below) | *(nothing)* | **all empty across every row in the sample export — skip entirely** | +| `Handle` | `Url` model (`urls` table, polymorphic, per-language slug) | create a `Url` row per product (slug = handle), not a plain column | + +## Collections — Lunar supports multiple collections per product + +Lunar's `collection_product` table is a plain many-to-many pivot (`collection_id`, `product_id`, `position`) with no uniqueness constraint tying a product to a single collection. A `Product` can belong to any number of `Collection`s simultaneously, and `Collection`s themselves are a nested-set tree (`NodeTrait`, `_lft`/`_rgt`/`parent_id`) scoped inside a `CollectionGroup` (`collection_group_id`). + +This matters for the importer in two ways: + +- **The breadcrumb import doesn't have to be exclusive.** Attaching a product to `Toys & Games > Toys > Bath Toys > Rubber Duckies` doesn't prevent it from also being attached to a separately-curated collection (e.g. a manual "Best Sellers" collection) — no conflict, since it's a plain pivot. +- **Resolve the breadcrumb into whichever `CollectionGroup` fits the target site's structure** — the known Shopify category tree can be parsed straight into it; no need for a separate import-only group unless you specifically want to isolate/purge imported collections independently later. +- Only the **deepest node** of the breadcrumb needs the product attached directly — Lunar's nested-set ancestry (`getBreadcrumbAttribute()`) already exposes the full parent chain via `$collection->ancestors`, so there's no need to attach the product to every intermediate level. + +Resolver logic: split `Product Category` on `>`, trim each segment, walk down creating/reusing `Collection` nodes as a chain (`parent_id` chained) within the fixed import `CollectionGroup`, then attach the product only to the final (leaf) `Collection` via `collection_product`. + +## Images — the export includes a local files folder + +The Shopify export isn't just the CSV — it also produces a folder of the actual image files, named by UUID (not by the original filename or `Image Src` URL). The `Image Src` column in the CSV is the *original* Shopify CDN URL, which is **not** how the file is named on disk in the export folder. + +This changes the AssetResolver from an HTTP-download step to a local-file-lookup step: + +- The importer needs a way to map each CSV `Image Src` row to its corresponding UUID-named file in the export folder — check whether the export also provides a manifest/mapping file, or whether the mapping has to be inferred from row order per `Handle` (i.e. Nth image row for a handle = Nth file in that handle's image set, if the export preserves ordering). +- Once resolved, the local file is what gets added to the `Asset` + Spatie MediaLibrary (`addMedia($localPath)`), not a downloaded copy of the CDN URL — no HTTP client / retry / timeout handling needed for images, but a **file-existence check** is needed (missing/renamed file in the export folder should not silently fail the whole product import). +- `import_mappings` for images should still key on something stable from the CSV (e.g. `Image Src` URL, or `handle + position`) rather than the UUID filename, since the UUID is regenerated on every fresh export and isn't a stable external ID across re-imports. + +## Metafield columns — confirmed empty, skipped + +These are Shopify's auto-generated category-specific metafields (populated once a product has a `Product Category` assigned). In the sample export they were empty for effectively every row (a few had `color-pattern`, `material`, or `target-gender` populated). **Decision: skip all of these for v1**, since none carry data worth preserving in this dataset. + +``` +Product rating count (product.metafields.reviews.rating_count) +Age group (product.metafields.shopify.age-group) +Barware material (product.metafields.shopify.barware-material) +Board game mechanics (product.metafields.shopify.board-game-mechanics) +Color (product.metafields.shopify.color-pattern) +Construction set theme (product.metafields.shopify.construction-set-theme) +Decoration material (product.metafields.shopify.decoration-material) +Dexterity skills (product.metafields.shopify.dexterity-skills) +Dice shape (product.metafields.shopify.dice-shape) +Difficulty level (product.metafields.shopify.difficulty-level) +Doll/Playset features (product.metafields.shopify.doll-playset-features) +Finish (product.metafields.shopify.finish) +Gameplay skills (product.metafields.shopify.gameplay-skills) +Hardware material (product.metafields.shopify.hardware-material) +Material (product.metafields.shopify.material) +Pet trainer type (product.metafields.shopify.pet-trainer-type) +Plant class (product.metafields.shopify.plant-class) +Plant name (product.metafields.shopify.plant-name) +Puzzle theme (product.metafields.shopify.puzzle-theme) +Puzzle type (product.metafields.shopify.puzzle-type) +Recommended age group (product.metafields.shopify.recommended-age-group) +Shape (product.metafields.shopify.shape) +Stationery binding type (product.metafields.shopify.stationery-binding-type) +Suitable space (product.metafields.shopify.suitable-space) +Target gender (product.metafields.shopify.target-gender) +Theme (product.metafields.shopify.theme) +Toy/Game material (product.metafields.shopify.toy-game-material) +Usage type (product.metafields.shopify.usage-type) +Vase shape (product.metafields.shopify.vase-shape) +``` + +## Resolvers, in dependency order + +Each resolver is idempotent: find-by-natural-key-or-create, checking `import_mappings` where applicable. + +1. **TaxClassResolver** — maps `Variant Taxable` (bool) to a fixed `TaxClass` ("Standard" vs "None"), not a per-row lookup. +2. **ProductTypeResolver** — maps CSV `Type` to `ProductType`; falls back to a default (e.g. "General") since `product_type_id` is required. +3. **BrandResolver** — `Vendor` → `products.brand`; likely a passthrough, not a real resolver, since it's a plain string column. +4. **TagResolver** — splits `Tags`, finds/creates `Tag` rows, attaches to product. +5. **CollectionResolver** — parses `Product Category` breadcrumb into a nested `Collection` chain within the target `CollectionGroup` (find/create per level), attaches product only to the deepest (leaf) Collection. Does not touch or conflict with any other collections the product may already belong to — a product can be in multiple collections at once. +6. **ProductOptionResolver** — for each `Option1/2/3 Name`, find/create `ProductOption` + `ProductOptionValue`, reused across products. +7. **AssetResolver** — resolves each CSV image row to its local UUID-named file in the export's images folder (see "Images" above), creates `Asset` + Spatie media from that local file, attaches to product and/or specific variants with `position` ordering. +8. **PriceResolver** — creates `Price` row(s) per variant per currency from `Variant Price` / `Variant Compare At Price`. + +## Orchestration order per product group + +``` +1. Resolve/create ProductType, TaxClass, Brand +2. Create or update Product (import_mappings: source_type='product', external_id=handle) + - write Title/Body, Cost per item, SEO title/description into attribute_data +3. Create/attach Url (slug = handle) +4. Resolve/attach Tags +5. Resolve/attach Collection chain (parsed from Product Category breadcrumb, within the target CollectionGroup; attach product to leaf Collection only) +6. For each Option1/2/3 Name -> resolve ProductOption +7. For each variant row: + - resolve ProductOptionValues for that variant's option combination + - create or update ProductVariant (import_mappings: source_type='variant', external_id=handle+variant-index) + - attach ProductOptionValues to variant + - create/update Price row(s) +8. For each image row: + - resolve the CSV row to its local UUID-named file in the export's images folder; skip with a logged warning if the file is missing + - resolve/create Asset from the local file (import_mappings: source_type='image', external_id=image URL or handle+position — not the UUID filename, which is regenerated per export) + - attach to Product, and to specific ProductVariant if the image is variant-scoped +``` + +## Explicitly out of scope for v1 + +- Metafield columns (confirmed empty — see above). -- 2.43.0 From 51c43c11b55918c7f7856acb7db4f1c3a2f408c4 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 9 Jul 2026 00:39:30 +0300 Subject: [PATCH 5/5] Bump version to 0.2.0 --- CHANGELOG.md | 20 ++++++++++++++++++++ composer.json | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b3f45..1e45a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 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.2.0] - 2026-07-09 + +### Added +- **Shipping**: registered Lunar's `lunarphp/table-rate-shipping` plugin (`ShippingPlugin`) directly on `CorePlugin`, so table-rate shipping is available to every consumer app without per-app wiring. +- **Product migration/import framework** (`Modules\Core\MigrateImport`): a source-agnostic pipeline for importing a vendor's product catalog into Lunar. + - `boboko:migrate:import` Artisan command — interactively prompts for source, type (export/API), and credentials or file path, then dispatches the import as a queued job (`RunMigrateImportJob`) on the default queue. The file-path prompt resolves relative to `storage/app/private/imports/`, so answering e.g. `shopify` picks up the first CSV found in `imports/shopify/` automatically. + - `ImportSpec`, `Importer` interface, and `ImporterFactory` (source+type → importer class) as the extension points for future sources (WooCommerce, etc.) and mechanisms (API vs. file export). + - `import_mappings` table + `ImportMapping` model: a polymorphic (source, source_type, external_id) → model mapping used by every resolver to make imports idempotent and safely re-runnable. + - `DefaultLocale` helper wrapping Lunar's `Language::getDefault()->code`, used anywhere a translatable field needs a locale key, instead of assuming `app()->getLocale()` matches Lunar's configured default. + - **Shopify CSV export importer** (`Shopify\ShopifyExportImporter`), the first working source/type combination, verified end-to-end against a real 183-product/693-variant/332-image Shopify export (row counts in the CSV match 1:1 with imported Products/Variants/Media): + - `ShopifyCsvReader` + `ProductGroup` group Shopify's flat, repeated-handle CSV rows into one row-group per product (product row, variant rows, image rows). + - Ten resolvers under `Shopify\Resolvers`, each responsible for idempotently resolving-or-creating one Lunar entity: `TaxClassResolver`, `ProductTypeResolver` (auto-attaches system attributes to new types), `BrandResolver`, `TagResolver`, `CollectionResolver` (multi-level, multi-collection support via `>`-delimited breadcrumbs), `ProductOptionResolver` (dedupes options/values by slugified name so case variants like "Size"/"size" resolve to one row), `AssetResolver` (Spatie MediaLibrary via `Product::addMedia()`, matches local export images by UUID first, filename fallback), `PriceResolver` (minor-unit conversion per currency), `ImportAttributeResolver` and `ProductAttributeResolver` (custom `cost_per_item`/`seo_title`/`seo_description` attributes, field-type-aware `attribute_data` writing). + - `docs/shopify-import.md` — full CSV-to-Lunar field mapping reference and import design notes. + - `docs/lunar.md` — new "Gotchas" section documenting non-obvious Lunar behavior hit while building the importer (table-prefix/nested-set race, required `ProductOption.handle`, per-group `Attribute.position`, etc.). + - `CONTRIBUTE.md` — local dev setup (path-repo + `bin/dc-core.sh`), and the manual DB-verification workflow used to build this feature. + +### Fixed +- `ProductOptionResolver` created duplicate `ProductOption`/`ProductOptionValue` rows when the same option or value appeared with different casing across products (e.g. Shopify export rows using both "Size" and "size"), and could create a duplicate value within a single product's own variant rows due to relying on a stale lazy-loaded relation. Both now resolve by normalized (slugified) identity queried fresh from the database. +- `boboko:migrate:import` could dispatch an import job with a blank file path (silent no-op failure) if the file-path prompt was answered empty; it now re-prompts until a valid, existing file is given. + ## [0.0.1] - 2026-07-03 First release. diff --git a/composer.json b/composer.json index 34209ec..e5391e0 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.0.2", + "version": "0.2.0", "autoload": { "psr-4": { "Modules\\Core\\": "src/" -- 2.43.0