Feature: Creating product import resolvers, wiring import job

This commit is contained in:
2026-07-09 00:37:14 +03:00
parent 825ce885c0
commit df03866285
16 changed files with 745 additions and 1 deletions
+35 -1
View File
@@ -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;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\MigrateImport;
use Lunar\Models\Language;
class DefaultLocale
{
public static function code(): string
{
return Language::getDefault()->code;
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Core\MigrateImport\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ImportMapping extends Model
{
protected $guarded = [];
public function model(): MorphTo
{
return $this->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(),
],
);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
class ProductGroup
{
/** @var array<int, array<string, string>> */
public array $variantRows = [];
/** @var array<int, array<string, string>> */
public array $imageRows = [];
public function __construct(
public readonly string $handle,
public readonly array $productRow,
) {
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Product;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class AssetResolver
{
public function resolve(Product $product, string $localFilePath, int $position): ?Media
{
if (! is_file($localFilePath)) {
return null;
}
return $product->addMedia($localFilePath)
->preservingOriginal()
->withCustomProperties(['position' => $position])
->toMediaCollection(config('lunar.media.collection'));
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Brand;
class BrandResolver
{
public function resolve(?string $vendor): ?Brand
{
$vendor = trim((string) $vendor);
if ($vendor === '') {
return null;
}
return Brand::firstOrCreate(['name' => $vendor]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup;
use Lunar\Models\Product;
use Modules\Core\MigrateImport\DefaultLocale;
class CollectionResolver
{
public function resolve(Product $product, CollectionGroup $group, ?string $categoryPath): void
{
$segments = collect(explode('>', (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;
}
}
@@ -0,0 +1,70 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\FieldTypes\Number;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Attribute;
use Lunar\Models\AttributeGroup;
use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale;
class ImportAttributeResolver
{
// Attributes Lunar doesn't ship by default but this importer needs,
// with the field type each one should use.
public const EXTRA_ATTRIBUTES = [
'cost_per_item' => ['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,
],
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Currency;
use Lunar\Models\Price;
use Lunar\Models\ProductVariant;
class PriceResolver
{
public function resolve(ProductVariant $variant, Currency $currency, float $price, ?float $comparePrice): Price
{
return Price::updateOrCreate(
[
'priceable_type' => $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,
],
);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\FieldTypes\Number;
use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale;
class ProductAttributeResolver
{
/**
* @param array<string, ?string> $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),
]));
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Illuminate\Support\Str;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Modules\Core\MigrateImport\DefaultLocale;
class ProductOptionResolver
{
public function resolveOption(string $name): ProductOption
{
// Match by slugified name (== handle), not raw name, so Shopify's
// inconsistent casing across products ("Size" vs "size") resolves to
// the same option instead of creating a near-duplicate.
$handle = Str::slug($name) ?: 'option';
return ProductOption::query()->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],
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\Attribute;
use Lunar\Models\Product;
use Lunar\Models\ProductType;
class ProductTypeResolver
{
private const DEFAULT_NAME = 'General';
public function resolve(?string $type): ProductType
{
$name = trim((string) $type) !== '' ? trim($type) : self::DEFAULT_NAME;
$existing = ProductType::where('name', $name)->first();
if ($existing) {
return $existing;
}
$productType = ProductType::create(['name' => $name]);
$productType->mappedAttributes()->attach(
Attribute::whereAttributeType(Product::morphName())->pluck('id'),
);
return $productType;
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Lunar\Models\Product;
use Lunar\Models\Tag;
class TagResolver
{
public function resolve(Product $product, ?string $tags): void
{
$values = collect(explode(',', (string) $tags))
->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);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Lunar\Models\TaxClass;
class TaxClassResolver
{
public function resolve(bool $taxable): TaxClass
{
return TaxClass::getDefault();
}
}
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\MigrateImport\Shopify;
class ShopifyCsvReader
{
/**
* @return array<int, ProductGroup>
*/
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<string, ProductGroup> $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);
}
}
@@ -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<int, \Lunar\Models\ProductOption>
*/
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;
}
}