| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184 |
- <?php
- namespace App\Console\Commands;
- use App\Services\Asteria\Magento1ProductReader;
- use Illuminate\Console\Command;
- use Illuminate\Support\Collection;
- use Illuminate\Support\Facades\Cache;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Log;
- use Illuminate\Support\Facades\Schema;
- use Illuminate\Support\Str;
- use Longyi\Core\Models\ProductOption;
- use Longyi\Core\Models\ProductOptionValue;
- use Longyi\Core\Models\ProductVariant;
- use Webkul\Attribute\Models\Attribute;
- use Webkul\Attribute\Models\AttributeFamily;
- use Webkul\Attribute\Models\AttributeOption;
- use Webkul\Attribute\Repositories\AttributeRepository;
- use Webkul\Core\Models\Channel;
- use Webkul\Product\Helpers\Indexers\ElasticSearch;
- use Webkul\Product\Helpers\Indexers\Flat;
- use Webkul\Product\Helpers\Indexers\Price;
- use Webkul\Product\Models\Product;
- /**
- * Migrates catalog products (and their EAV attributes) from Asteria (Magento 1.x).
- *
- * Magento custom options become Longyi flexible_variant options/variants,
- * matching catalog:sync / shell/migrate_to_bagisto.php.
- *
- * Usage
- * ─────
- * php artisan products:migrate-asteria
- * php artisan products:migrate-asteria --batch-size=50
- * php artisan products:migrate-asteria --sku=PC001
- * php artisan products:migrate-asteria --limit=5 --dry-run
- * php artisan products:migrate-asteria --attributes-only
- */
- class MigrateAsteriaProducts extends Command
- {
- protected $signature = 'products:migrate-asteria
- {--batch-size=100 : Number of Magento products per batch}
- {--reset-progress : Ignore saved progress and start from entity_id=0}
- {--dry-run : Count records without writing}
- {--connection=asteria : Laravel DB connection for the Asteria database}
- {--sku= : Migrate a single Magento SKU}
- {--limit= : Stop after this many Magento products}
- {--attributes-only : Only sync include_eav_attributes into Bagisto}
- {--products-only : Skip attribute sync}
- {--no-index : Skip rebuilding price/flat/elastic indices}';
- protected $description = 'Migrate Asteria (Magento 1.x) products into Bagisto flexible_variant catalog';
- private const PROGRESS_KEY = 'migrate_asteria_products_last_id';
- /** @var array<string, mixed> */
- private array $config = [];
- private int $familyId;
- private int $generalGroupId;
- private string $channelCode = 'default';
- private string $locale = 'en';
- /** @var array<int, int> */
- private array $channelIds = [];
- /** @var array<int, string> */
- private array $channelCodes = [];
- /** @var array<int, string> */
- private array $localeCodes = [];
- /** @var array<int, int> */
- private array $inventorySourceIds = [];
- /** @var array<string, \Webkul\Attribute\Models\Attribute> */
- private array $familyAttributes = [];
- /** @var array<int, int> */
- private array $touchedProductIds = [];
- public function __construct(protected AttributeRepository $attributeRepository)
- {
- parent::__construct();
- }
- public function handle(): int
- {
- $connection = (string) $this->option('connection');
- $batchSize = max(1, (int) $this->option('batch-size'));
- $resetProgress = (bool) $this->option('reset-progress');
- $dryRun = (bool) $this->option('dry-run');
- $sku = trim((string) $this->option('sku'));
- $limit = $this->option('limit') !== null && $this->option('limit') !== ''
- ? max(1, (int) $this->option('limit'))
- : null;
- $this->config = array_merge(
- Magento1ProductReader::defaultConfig(),
- (array) config('asteria.products', [])
- );
- DB::disableQueryLog();
- try {
- DB::connection($connection)->getPdo();
- } catch (\Throwable $e) {
- $this->error("Cannot connect to Asteria DB (connection='{$connection}'): ".$e->getMessage());
- return self::FAILURE;
- }
- foreach (['catalog_product_entity', 'eav_attribute'] as $table) {
- if (! Schema::connection($connection)->hasTable($table)) {
- $this->error("Asteria table '{$table}' is missing on connection '{$connection}'.");
- return self::FAILURE;
- }
- }
- if (! Schema::hasColumn('products', 'migrated_from_asteria_id')) {
- $this->error('products.migrated_from_asteria_id is missing. Run php artisan migrate.');
- return self::FAILURE;
- }
- $family = $this->resolveFamily();
- if (! $family) {
- $this->error('No Bagisto attribute family found. Seed attributes first.');
- return self::FAILURE;
- }
- $this->familyId = (int) $family->id;
- $groupId = DB::table('attribute_groups')
- ->where('attribute_family_id', $this->familyId)
- ->where('code', 'general')
- ->value('id')
- ?? DB::table('attribute_groups')
- ->where('attribute_family_id', $this->familyId)
- ->orderBy('position')
- ->value('id');
- if (! $groupId) {
- $this->error("Attribute family '{$family->code}' has no attribute group.");
- return self::FAILURE;
- }
- $this->generalGroupId = (int) $groupId;
- if (! $this->resolveChannelsLocalesAndSources()) {
- return self::FAILURE;
- }
- $reader = new Magento1ProductReader($connection, $this->config);
- if (! $this->option('products-only')) {
- $attributeResult = $this->syncAttributes($reader, $dryRun);
- $this->info("Attributes: created={$attributeResult['created']}, skipped={$attributeResult['skipped']}.");
- }
- if ($this->option('attributes-only')) {
- return self::SUCCESS;
- }
- $this->loadFamilyAttributes($family);
- $lastId = ($resetProgress || $sku !== '') ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
- if ($resetProgress) {
- Cache::forget(self::PROGRESS_KEY);
- }
- if ($lastId > 0) {
- $this->line("Resuming from Asteria entity_id > {$lastId} (use --reset-progress to restart).");
- }
- $created = 0;
- $updated = 0;
- $skipped = 0;
- $batchNumber = 0;
- $remaining = $limit;
- $this->info($dryRun ? '[DRY RUN] Scanning Magento products…' : 'Migrating Magento products…');
- do {
- $started = microtime(true);
- $take = $remaining !== null ? min($batchSize, $remaining) : $batchSize;
- $products = $reader->fetchProducts($lastId, $take, $sku !== '' ? $sku : null);
- if ($products->isEmpty()) {
- break;
- }
- $batchNumber++;
- $lastId = (int) $products->max('entity_id');
- if ($dryRun) {
- $created += $products->count();
- $this->line(sprintf(
- ' Batch #%d: %d products (last entity_id=%d) [skipped – dry-run] (%.1fs)',
- $batchNumber,
- $products->count(),
- $lastId,
- microtime(true) - $started
- ));
- } else {
- $result = $this->persistBatch($products);
- if ($sku === '') {
- Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
- }
- $created += $result['created'];
- $updated += $result['updated'];
- $skipped += $result['skipped'];
- $this->line(sprintf(
- ' Batch #%d: created=%d updated=%d skipped=%d (last entity_id=%d) (%.1fs)',
- $batchNumber,
- $result['created'],
- $result['updated'],
- $result['skipped'],
- $lastId,
- microtime(true) - $started
- ));
- Log::info('MigrateAsteriaProducts: batch '.$batchNumber.', last_id='.$lastId);
- }
- if ($remaining !== null) {
- $remaining -= $products->count();
- }
- } while (
- $sku === ''
- && ($remaining === null || $remaining > 0)
- && $products->count() === $take
- );
- if (! $dryRun && ! $this->option('no-index')) {
- $this->reindexProducts();
- }
- $this->newLine();
- $this->info("Done. Batches: {$batchNumber}, created: {$created}, updated: {$updated}, skipped: {$skipped}.");
- return self::SUCCESS;
- }
- /**
- * @return array{created: int, skipped: int}
- */
- private function syncAttributes(Magento1ProductReader $reader, bool $dryRun): array
- {
- $definitions = $reader->fetchAttributeDefinitions();
- $created = 0;
- $skipped = 0;
- $this->info('Syncing '.$definitions->count().' Magento attributes…');
- foreach ($definitions as $row) {
- $code = trim((string) ($row['code'] ?? ''));
- if ($code === '') {
- continue;
- }
- $existing = Attribute::query()->where('code', $code)->first();
- if ($existing) {
- if (! $dryRun) {
- $this->ensureAttributeInFamily((int) $existing->id);
- $this->ensureAttributeOptions($existing, $row['options'] ?? []);
- }
- $skipped++;
- continue;
- }
- if ($dryRun) {
- $created++;
- continue;
- }
- $type = (string) ($row['type'] ?? 'text');
- $name = (string) (($row['name'] ?? '') !== '' ? $row['name'] : $code);
- $data = array_merge([
- 'code' => $code,
- 'admin_name' => $name,
- 'type' => $type,
- 'is_required' => (int) ($row['is_required'] ?? 0),
- 'is_filterable' => (int) ($row['is_filterable'] ?? 0),
- 'is_comparable' => (int) ($row['is_comparable'] ?? 0),
- 'is_configurable' => (int) ($row['is_configurable'] ?? 0),
- 'position' => (int) ($row['position'] ?? 0),
- 'is_user_defined' => 1,
- 'value_per_locale' => in_array($type, ['text', 'textarea'], true) ? 1 : 0,
- 'value_per_channel' => 0,
- ], $this->translations('name', $name));
- if (in_array($type, ['select', 'multiselect', 'checkbox'], true) && ! empty($row['options'])) {
- $options = [];
- foreach (array_values($row['options']) as $i => $option) {
- $label = trim((string) ($option['label'] ?? ''));
- if ($label === '') {
- continue;
- }
- $options[] = array_merge([
- 'admin_name' => $label,
- 'sort_order' => (int) ($option['sort_order'] ?? $i),
- ], $this->translations('label', $label));
- }
- if ($options !== []) {
- $data['options'] = $options;
- }
- }
- $attribute = $this->attributeRepository->create($data);
- $this->ensureAttributeInFamily((int) $attribute->id);
- $created++;
- }
- return ['created' => $created, 'skipped' => $skipped];
- }
- /**
- * @param Collection<int, array<string, mixed>> $products
- * @return array{created: int, updated: int, skipped: int}
- */
- private function persistBatch(Collection $products): array
- {
- $created = 0;
- $updated = 0;
- $skipped = 0;
- $warnAt = (int) ($this->config['max_variants_warn'] ?? 500);
- foreach ($products as $row) {
- $sku = trim((string) ($row['sku'] ?? ''));
- if ($sku === '') {
- $skipped++;
- continue;
- }
- $variantCount = is_array($row['variants'] ?? null) ? count($row['variants']) : 0;
- if ($variantCount > $warnAt) {
- $this->warn("SKU {$sku} will generate {$variantCount} variants (threshold {$warnAt}).");
- }
- try {
- $wasNew = $this->persistProduct($row);
- } catch (\Throwable $e) {
- $skipped++;
- $this->error('Product '.$sku.' failed: '.$e->getMessage());
- Log::error('MigrateAsteriaProducts: '.$sku.' '.$e->getMessage(), ['exception' => $e]);
- continue;
- }
- if ($wasNew) {
- $created++;
- } else {
- $updated++;
- }
- }
- return compact('created', 'updated', 'skipped');
- }
- /**
- * @param array<string, mixed> $row
- */
- private function persistProduct(array $row): bool
- {
- $sku = trim((string) $row['sku']);
- $asteriaId = (int) $row['entity_id'];
- $product = Product::query()->where('migrated_from_asteria_id', $asteriaId)->first()
- ?? Product::query()->firstOrNew(['sku' => $sku]);
- $wasNew = ! $product->exists;
- $product->sku = $sku;
- $product->type = 'flexible_variant';
- $product->attribute_family_id = $this->familyId;
- $product->migrated_from_asteria_id = $asteriaId;
- $product->save();
- if ($wasNew && ! empty($row['created_at'])) {
- DB::table('products')->where('id', $product->id)->update([
- 'created_at' => $row['created_at'],
- ]);
- }
- $this->touchedProductIds[] = (int) $product->id;
- $this->saveChannels($product);
- $this->saveAttributeValues($product, $row);
- $this->saveInventory($product, $row);
- $this->saveImages($product, $row);
- $this->saveCategories($product, is_array($row['categories'] ?? null) ? $row['categories'] : []);
- DB::transaction(function () use ($product, $row) {
- $this->saveOptionsAndVariants($product, $row);
- });
- return $wasNew;
- }
- /**
- * @param array<string, mixed> $row
- */
- private function saveAttributeValues(Product $product, array $row): void
- {
- foreach ([
- 'visible_individually' => 1,
- 'manage_stock' => 1,
- 'status' => 1,
- 'guest_checkout' => 1,
- ] as $code => $default) {
- if (! array_key_exists($code, $row) || $row[$code] === null || $row[$code] === '') {
- $row[$code] = $default;
- }
- }
- $values = [];
- foreach ($this->familyAttributes as $code => $attribute) {
- if (! array_key_exists($code, $row)) {
- continue;
- }
- $rawValue = $row[$code];
- if ($rawValue === null || $rawValue === '') {
- continue;
- }
- $columnValue = $this->castAttributeValue($attribute, $rawValue);
- if ($columnValue === null) {
- continue;
- }
- $channelCodes = $attribute->value_per_channel ? $this->channelCodes : [null];
- $localeCodes = $attribute->value_per_locale ? $this->localeCodes : [null];
- foreach ($channelCodes as $channelCode) {
- foreach ($localeCodes as $localeCode) {
- $valueRow = array_fill_keys(array_values($attribute->attributeTypeFields), null);
- $valueRow['json_value'] = null;
- $valueRow[$attribute->column_name] = $columnValue;
- $valueRow['attribute_id'] = $attribute->id;
- $valueRow['product_id'] = $product->id;
- $valueRow['channel'] = $channelCode;
- $valueRow['locale'] = $localeCode;
- $valueRow['unique_id'] = implode('|', array_filter([
- $valueRow['channel'],
- $valueRow['locale'],
- $valueRow['product_id'],
- $valueRow['attribute_id'],
- ], fn ($part) => $part !== null && $part !== ''));
- $values[$valueRow['unique_id']] = $valueRow;
- }
- }
- }
- if ($values === []) {
- return;
- }
- DB::table('product_attribute_values')->upsert(
- array_values($values),
- ['unique_id'],
- ['text_value', 'boolean_value', 'integer_value', 'float_value', 'datetime_value', 'date_value', 'json_value']
- );
- }
- /**
- * @param array<string, mixed> $row
- */
- private function saveInventory(Product $product, array $row): void
- {
- $qty = $row['qty'] ?? 0;
- if ($qty === null || (is_string($qty) && trim($qty) === '')) {
- $qty = 0;
- }
- foreach ($this->inventorySourceIds as $sourceId) {
- DB::table('product_inventories')->updateOrInsert(
- [
- 'product_id' => $product->id,
- 'inventory_source_id' => $sourceId,
- 'vendor_id' => 0,
- ],
- ['qty' => (int) $qty]
- );
- }
- }
- /**
- * @param array<string, mixed> $row
- */
- private function saveImages(Product $product, array $row): void
- {
- $urls = [];
- if (! empty($row['base_image'])) {
- $urls[] = trim((string) $row['base_image']);
- }
- if (! empty($row['additional_images'])) {
- $additional = $row['additional_images'];
- if (is_array($additional)) {
- $parts = $additional;
- } else {
- $parts = explode('|', (string) $additional);
- }
- foreach ($parts as $url) {
- $url = trim((string) $url);
- if ($url !== '') {
- $urls[] = $url;
- }
- }
- }
- $urls = array_values(array_unique($urls));
- DB::table('product_images')->where('product_id', $product->id)->delete();
- if ($urls === []) {
- return;
- }
- $hasBase = Schema::hasColumn('product_images', 'is_base_image');
- $hasSmall = Schema::hasColumn('product_images', 'is_small_image');
- $hasThumb = Schema::hasColumn('product_images', 'is_thumbnail');
- $records = [];
- foreach ($urls as $position => $url) {
- $record = [
- 'type' => 'images',
- 'path' => $url,
- 'product_id' => $product->id,
- 'position' => $position + 1,
- ];
- if ($hasBase) {
- $record['is_base_image'] = $position === 0 ? 1 : 0;
- }
- if ($hasSmall) {
- $record['is_small_image'] = $position === 0 ? 1 : 0;
- }
- if ($hasThumb) {
- $record['is_thumbnail'] = $position === 0 ? 1 : 0;
- }
- $records[] = $record;
- }
- DB::table('product_images')->insert($records);
- }
- /**
- * @param array<int, string> $names
- */
- private function saveCategories(Product $product, array $names): void
- {
- DB::table('product_categories')->where('product_id', $product->id)->delete();
- $names = array_values(array_unique(array_filter(array_map('trim', $names))));
- if ($names === [] || ! Schema::hasTable('category_translations')) {
- return;
- }
- $query = DB::table('category_translations')->whereIn('name', $names);
- if (Schema::hasColumn('category_translations', 'locale')) {
- $query->where('locale', $this->locale);
- }
- $ids = $query->pluck('category_id')->map(fn ($id) => (int) $id)->unique()->values()->all();
- if ($ids === []) {
- return;
- }
- $hasPosition = Schema::hasColumn('product_categories', 'position');
- $rows = [];
- foreach ($ids as $position => $categoryId) {
- $row = [
- 'product_id' => $product->id,
- 'category_id' => $categoryId,
- ];
- if ($hasPosition) {
- $row['position'] = $position;
- }
- $rows[] = $row;
- }
- DB::table('product_categories')->insert($rows);
- }
- /**
- * @param array<string, mixed> $row
- */
- private function saveOptionsAndVariants(Product $product, array $row): void
- {
- $superCodes = array_values(array_filter(array_map(
- 'trim',
- explode(',', (string) ($row['super_attributes'] ?? ''))
- )));
- $this->clearVariantData($product);
- if ($superCodes === []) {
- return;
- }
- $optionsMeta = $row['options'] ?? [];
- if (is_string($optionsMeta)) {
- $optionsMeta = json_decode($optionsMeta, true) ?: [];
- }
- $variants = $row['variants'] ?? [];
- if (is_string($variants)) {
- $variants = json_decode($variants, true) ?: [];
- }
- $optionsByCode = [];
- foreach ($optionsMeta as $index => $meta) {
- if (! is_array($meta)) {
- continue;
- }
- $title = (string) ($meta['title'] ?? '');
- $optionsByCode[$this->sanitizeAttributeCode($title)] = $meta;
- $optionsByCode[(string) $index] = $meta;
- }
- $labelToValueId = [];
- $syncOptions = [];
- foreach ($superCodes as $index => $code) {
- $meta = $optionsByCode[$code] ?? $optionsMeta[$index] ?? [];
- $optionLabel = (string) ($meta['title'] ?? Str::title(str_replace('_', ' ', $code)));
- $option = ProductOption::query()->firstOrCreate(
- ['code' => $code],
- ['label' => $optionLabel, 'type' => 'select', 'position' => $index]
- );
- $syncOptions[$option->id] = ['position' => $index, 'is_required' => true];
- $existing = ProductOptionValue::query()
- ->where('product_option_id', $option->id)
- ->get()
- ->keyBy('label');
- foreach ($existing as $label => $value) {
- $labelToValueId[$index][$label] = (int) $value->id;
- }
- $orderedLabels = [];
- $valueSku = [];
- foreach (($meta['values'] ?? []) as $valueMeta) {
- $label = (string) ($valueMeta['title'] ?? '');
- if ($label === '' || array_key_exists($label, $valueSku)) {
- continue;
- }
- $orderedLabels[] = $label;
- $valueSku[$label] = (string) ($valueMeta['sku'] ?? '');
- }
- foreach ($variants as $variant) {
- $label = $this->variantLabel($variant, $index, $code, $optionLabel);
- if ($label !== null && $label !== '' && ! array_key_exists($label, $valueSku)) {
- $orderedLabels[] = $label;
- $valueSku[$label] = '';
- }
- }
- $nextPosition = (int) ProductOptionValue::query()
- ->where('product_option_id', $option->id)
- ->max('position');
- foreach ($orderedLabels as $label) {
- if (isset($labelToValueId[$index][$label])) {
- continue;
- }
- $valueCode = $valueSku[$label] !== '' ? $valueSku[$label] : Str::slug($label);
- if ($valueCode === '') {
- $valueCode = 'value-'.($nextPosition + 1);
- }
- $value = ProductOptionValue::query()->create([
- 'product_option_id' => $option->id,
- 'label' => $label,
- 'code' => $valueCode,
- 'position' => ++$nextPosition,
- ]);
- $labelToValueId[$index][$label] = (int) $value->id;
- }
- }
- $product->options()->sync($syncOptions);
- $this->saveVariants($product, $row, $superCodes, $variants, $labelToValueId, $optionsByCode);
- }
- /**
- * @param array<string, mixed> $row
- * @param array<int, string> $superCodes
- * @param array<int, array<string, mixed>> $variants
- * @param array<int, array<string, int>> $labelToValueId
- * @param array<string, array<string, mixed>> $optionsByCode
- */
- private function saveVariants(
- Product $product,
- array $row,
- array $superCodes,
- array $variants,
- array $labelToValueId,
- array $optionsByCode
- ): void {
- if ($variants === []) {
- return;
- }
- $parentName = (string) ($row['name'] ?? '');
- $now = now();
- $variantRecords = [];
- foreach ($variants as $sortOrder => $variant) {
- $sku = trim((string) ($variant['sku'] ?? ''));
- if ($sku === '') {
- continue;
- }
- $variantRecords[] = [
- 'product_id' => $product->id,
- 'sku' => $sku,
- 'name' => $parentName !== '' ? $parentName : null,
- 'price' => (float) ($variant['price'] ?? 0),
- 'quantity' => (int) ($variant['qty'] ?? 0),
- 'status' => 1,
- 'sort_order' => $sortOrder,
- 'created_at' => $now,
- 'updated_at' => $now,
- ];
- }
- if ($variantRecords === []) {
- return;
- }
- foreach (array_chunk($variantRecords, 500) as $chunk) {
- ProductVariant::insert($chunk);
- }
- $variantIdBySku = ProductVariant::query()
- ->where('product_id', $product->id)
- ->pluck('id', 'sku')
- ->all();
- $pivotRecords = [];
- foreach ($variants as $variant) {
- $sku = trim((string) ($variant['sku'] ?? ''));
- $variantId = $variantIdBySku[$sku] ?? null;
- if (! $variantId) {
- continue;
- }
- foreach ($superCodes as $index => $code) {
- $optionLabel = (string) (($optionsByCode[$code]['title'] ?? '') ?: $code);
- $label = $this->variantLabel($variant, $index, $code, $optionLabel);
- if ($label === null || $label === '') {
- continue;
- }
- $valueId = $labelToValueId[$index][$label] ?? null;
- if (! $valueId) {
- continue;
- }
- $pivotRecords[] = [
- 'product_variant_id' => $variantId,
- 'product_option_value_id' => $valueId,
- 'created_at' => $now,
- 'updated_at' => $now,
- ];
- }
- }
- foreach (array_chunk($pivotRecords, 1000) as $chunk) {
- DB::table('product_variant_option_values')->insert($chunk);
- }
- }
- /**
- * @param array<string, mixed> $variant
- */
- private function variantLabel(array $variant, int $index, string $code, string $title): ?string
- {
- foreach ([$code, $title, (string) $index] as $key) {
- if ($key !== '' && array_key_exists($key, $variant) && $variant[$key] !== null && $variant[$key] !== '') {
- return (string) $variant[$key];
- }
- }
- return null;
- }
- private function clearVariantData(Product $product): void
- {
- $variantIds = ProductVariant::withTrashed()
- ->where('product_id', $product->id)
- ->pluck('id')
- ->all();
- if ($variantIds !== []) {
- DB::table('product_variant_option_values')
- ->whereIn('product_variant_id', $variantIds)
- ->delete();
- if (Schema::hasTable('product_variant_images')) {
- DB::table('product_variant_images')
- ->whereIn('product_variant_id', $variantIds)
- ->delete();
- }
- ProductVariant::withTrashed()->whereIn('id', $variantIds)->forceDelete();
- }
- $product->options()->detach();
- }
- private function reindexProducts(): void
- {
- $ids = array_values(array_unique($this->touchedProductIds));
- if ($ids === []) {
- return;
- }
- $this->info('Reindexing '.count($ids).' products…');
- $priceIndexer = app(Price::class);
- $flatIndexer = app(Flat::class);
- $elasticIndexer = $this->elasticEnabled() ? app(ElasticSearch::class) : null;
- $relations = [
- 'attribute_family',
- 'attribute_values',
- 'channels',
- 'price_indices',
- 'customer_group_prices',
- 'catalog_rule_prices',
- 'flexibleVariants',
- 'flexibleVariants.price_indices',
- 'flexibleVariants.customer_group_prices',
- ];
- foreach (array_chunk($ids, 20) as $chunk) {
- $products = Product::query()->with($relations)->whereIn('id', $chunk)->get();
- foreach ($products as $product) {
- try {
- $priceIndexer->reindexRow($product);
- $flatIndexer->reindexRow($product);
- $elasticIndexer?->reindexRow($product);
- } catch (\Throwable $e) {
- $this->error('Reindex failed for product '.$product->sku.': '.$e->getMessage());
- }
- }
- }
- }
- private function elasticEnabled(): bool
- {
- return core()->getConfigData('catalog.products.search.engine') === 'elastic';
- }
- private function castAttributeValue(Attribute $attribute, mixed $value): mixed
- {
- return match ($attribute->type) {
- 'boolean' => (int) (bool) (is_numeric($value) ? (int) $value : $value),
- 'price' => (float) $value,
- 'select' => AttributeOption::query()
- ->where('attribute_id', $attribute->id)
- ->where('admin_name', $value)
- ->value('id'),
- 'multiselect' => $this->castMultiselectValue((int) $attribute->id, (string) $value),
- default => $value,
- };
- }
- private function castMultiselectValue(int $attributeId, string $value): ?string
- {
- $ids = [];
- foreach (array_map('trim', explode(',', $value)) as $label) {
- if ($label === '') {
- continue;
- }
- $optionId = AttributeOption::query()
- ->where('attribute_id', $attributeId)
- ->where('admin_name', $label)
- ->value('id');
- if ($optionId) {
- $ids[] = $optionId;
- }
- }
- return $ids === [] ? null : implode(',', $ids);
- }
- /**
- * @param array<int, array<string, mixed>> $options
- */
- private function ensureAttributeOptions(Attribute $attribute, array $options): void
- {
- if (! in_array($attribute->type, ['select', 'multiselect', 'checkbox'], true) || $options === []) {
- return;
- }
- $nextSort = (int) AttributeOption::query()
- ->where('attribute_id', $attribute->id)
- ->max('sort_order');
- foreach (array_values($options) as $i => $option) {
- $label = trim((string) ($option['label'] ?? ''));
- if ($label === '') {
- continue;
- }
- $exists = AttributeOption::query()
- ->where('attribute_id', $attribute->id)
- ->where('admin_name', $label)
- ->exists();
- if ($exists) {
- continue;
- }
- AttributeOption::query()->create(array_merge([
- 'attribute_id' => $attribute->id,
- 'admin_name' => $label,
- 'sort_order' => (int) ($option['sort_order'] ?? ($nextSort + $i + 1)),
- ], $this->translations('label', $label)));
- }
- }
- private function ensureAttributeInFamily(int $attributeId): void
- {
- $exists = DB::table('attribute_group_mappings')
- ->where('attribute_id', $attributeId)
- ->where('attribute_group_id', $this->generalGroupId)
- ->exists();
- if ($exists) {
- return;
- }
- $position = (int) DB::table('attribute_group_mappings')
- ->where('attribute_group_id', $this->generalGroupId)
- ->max('position');
- DB::table('attribute_group_mappings')->insert([
- 'attribute_id' => $attributeId,
- 'attribute_group_id' => $this->generalGroupId,
- 'position' => $position + 1,
- ]);
- }
- private function loadFamilyAttributes(AttributeFamily $family): void
- {
- $this->familyAttributes = [];
- foreach ($family->custom_attributes()->get() as $attribute) {
- $this->familyAttributes[$attribute->code] = $attribute;
- }
- }
- private function resolveFamily(): ?AttributeFamily
- {
- $preferred = (string) ($this->config['attribute_family'] ?? 'wigs');
- foreach (array_unique(array_filter([$preferred, 'wigs', 'variant_product', 'default'])) as $code) {
- $family = AttributeFamily::query()->where('code', $code)->first();
- if ($family) {
- if ($code !== $preferred) {
- $this->comment("Attribute family '{$preferred}' not found, using '{$code}'.");
- }
- return $family;
- }
- }
- return AttributeFamily::query()->first();
- }
- private function resolveChannelsLocalesAndSources(): bool
- {
- $codes = $this->configuredCodes('channels', 'channel');
- $query = Channel::query()->with(['locales', 'inventory_sources']);
- if ($codes !== []) {
- $query->whereIn('code', $codes);
- }
- $channels = $query->get();
- if ($channels->isEmpty()) {
- $this->error($codes === []
- ? 'No Bagisto channel was found.'
- : 'Configured channel code(s) not found: '.implode(', ', $codes).'.');
- return false;
- }
- if ($codes !== [] && $channels->count() !== count($codes)) {
- $found = $channels->pluck('code')->all();
- $this->warn('Unknown channel codes skipped: '.implode(', ', array_diff($codes, $found)).'.');
- }
- $this->channelIds = $channels->pluck('id')->map(fn ($id) => (int) $id)->all();
- $this->channelCodes = $channels->pluck('code')->map(fn ($code) => (string) $code)->unique()->values()->all();
- $this->channelCode = $this->channelCodes[0];
- $localeCodes = $this->configuredCodes('locales');
- if ($localeCodes === []) {
- $localeCodes = $channels
- ->flatMap(fn (Channel $channel) => $channel->locales->pluck('code'))
- ->filter()
- ->map(fn ($code) => (string) $code)
- ->unique()
- ->values()
- ->all();
- }
- if ($localeCodes === []) {
- $localeCodes = ['en'];
- }
- $this->localeCodes = $localeCodes;
- $this->locale = $this->localeCodes[0];
- $sourceIds = $channels
- ->flatMap(fn (Channel $channel) => $channel->inventory_sources->pluck('id'))
- ->map(fn ($id) => (int) $id)
- ->filter()
- ->unique()
- ->values()
- ->all();
- if ($sourceIds === []) {
- $fallback = (int) (DB::table('inventory_sources')->value('id') ?? 0);
- if ($fallback === 0) {
- $this->error('No inventory_sources found. Run Bagisto seeders for inventory.');
- return false;
- }
- $sourceIds = [$fallback];
- }
- $this->inventorySourceIds = $sourceIds;
- $this->comment('Channels: '.implode(', ', $this->channelCodes));
- $this->comment('Locales: '.implode(', ', $this->localeCodes));
- return true;
- }
- /**
- * @return array<int, string>
- */
- private function configuredCodes(string $listKey, ?string $singleKey = null): array
- {
- $codes = $this->config[$listKey] ?? [];
- if (is_string($codes)) {
- $codes = explode(',', $codes);
- }
- $codes = array_values(array_filter(array_map('trim', array_map('strval', (array) $codes))));
- if ($codes === [] && $singleKey !== null) {
- $single = trim((string) ($this->config[$singleKey] ?? ''));
- if ($single !== '') {
- $codes = [$single];
- }
- }
- return $codes;
- }
- private function saveChannels(Product $product): void
- {
- foreach ($this->channelIds as $channelId) {
- DB::table('product_channels')->updateOrInsert([
- 'product_id' => $product->id,
- 'channel_id' => $channelId,
- ]);
- }
- }
- /**
- * @return array<string, array<string, string>>
- */
- private function translations(string $field, string $value): array
- {
- $payload = [];
- foreach ($this->localeCodes as $locale) {
- $payload[$locale] = [$field => $value];
- }
- if ($payload === []) {
- $payload[$this->locale] = [$field => $value];
- }
- return $payload;
- }
- private function sanitizeAttributeCode(string $title): string
- {
- $code = strtolower($title);
- $code = (string) preg_replace('/[^a-z0-9]+/', '_', $code);
- return trim($code, '_');
- }
- }
|