Feature: Creating product import resolvers, wiring import job
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user