MigrateAsteriaProducts.php 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Services\Asteria\Magento1ProductReader;
  4. use App\Services\Asteria\MagentoPrimaryKey;
  5. use Illuminate\Console\Command;
  6. use Illuminate\Support\Collection;
  7. use Illuminate\Support\Facades\Cache;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Log;
  10. use Illuminate\Support\Facades\Schema;
  11. use Illuminate\Support\Str;
  12. use Longyi\Core\Models\ProductOption;
  13. use Longyi\Core\Models\ProductOptionValue;
  14. use Longyi\Core\Models\ProductVariant;
  15. use Webkul\Attribute\Models\Attribute;
  16. use Webkul\Attribute\Models\AttributeFamily;
  17. use Webkul\Attribute\Models\AttributeOption;
  18. use Webkul\Attribute\Repositories\AttributeRepository;
  19. use Webkul\Core\Models\Channel;
  20. use Webkul\Product\Helpers\Indexers\ElasticSearch;
  21. use Webkul\Product\Helpers\Indexers\Flat;
  22. use Webkul\Product\Helpers\Indexers\Price;
  23. use Webkul\Product\Models\Product;
  24. /**
  25. * Migrates catalog products (and their EAV attributes) from Asteria (Magento 1.x).
  26. *
  27. * Magento custom options become Longyi flexible_variant options/variants,
  28. * matching catalog:sync / shell/migrate_to_bagisto.php.
  29. *
  30. * Usage
  31. * ─────
  32. * php artisan products:migrate-asteria
  33. * php artisan products:migrate-asteria --batch-size=50
  34. * php artisan products:migrate-asteria --sku=PC001
  35. * php artisan products:migrate-asteria --limit=5 --dry-run
  36. * php artisan products:migrate-asteria --attributes-only
  37. */
  38. class MigrateAsteriaProducts extends Command
  39. {
  40. protected $signature = 'products:migrate-asteria
  41. {--batch-size=100 : Number of Magento products per batch}
  42. {--reset-progress : Ignore saved progress and start from entity_id=0}
  43. {--dry-run : Count records without writing}
  44. {--connection=asteria : Laravel DB connection for the Asteria database}
  45. {--sku= : Migrate a single Magento SKU}
  46. {--limit= : Stop after this many Magento products}
  47. {--attributes-only : Only sync include_eav_attributes into Bagisto}
  48. {--products-only : Skip attribute sync}
  49. {--no-index : Skip rebuilding price/flat/elastic indices}';
  50. protected $description = 'Migrate Asteria (Magento 1.x) products into Bagisto flexible_variant catalog';
  51. private const PROGRESS_KEY = 'migrate_asteria_products_last_id';
  52. /** @var array<string, mixed> */
  53. private array $config = [];
  54. private int $familyId;
  55. private int $generalGroupId;
  56. private string $channelCode = 'default';
  57. private string $locale = 'en';
  58. /** @var array<int, int> */
  59. private array $channelIds = [];
  60. /** @var array<int, string> */
  61. private array $channelCodes = [];
  62. /** @var array<int, string> */
  63. private array $localeCodes = [];
  64. /** @var array<int, int> */
  65. private array $inventorySourceIds = [];
  66. /** @var array<string, \Webkul\Attribute\Models\Attribute> */
  67. private array $familyAttributes = [];
  68. /** @var array<int, int> */
  69. private array $touchedProductIds = [];
  70. public function __construct(protected AttributeRepository $attributeRepository)
  71. {
  72. parent::__construct();
  73. }
  74. public function handle(): int
  75. {
  76. $connection = (string) $this->option('connection');
  77. $batchSize = max(1, (int) $this->option('batch-size'));
  78. $resetProgress = (bool) $this->option('reset-progress');
  79. $dryRun = (bool) $this->option('dry-run');
  80. $sku = trim((string) $this->option('sku'));
  81. $limit = $this->option('limit') !== null && $this->option('limit') !== ''
  82. ? max(1, (int) $this->option('limit'))
  83. : null;
  84. $this->config = array_merge(
  85. Magento1ProductReader::defaultConfig(),
  86. (array) config('asteria.products', [])
  87. );
  88. DB::disableQueryLog();
  89. try {
  90. DB::connection($connection)->getPdo();
  91. } catch (\Throwable $e) {
  92. $this->error("Cannot connect to Asteria DB (connection='{$connection}'): ".$e->getMessage());
  93. return self::FAILURE;
  94. }
  95. foreach (['catalog_product_entity', 'eav_attribute'] as $table) {
  96. if (! Schema::connection($connection)->hasTable($table)) {
  97. $this->error("Asteria table '{$table}' is missing on connection '{$connection}'.");
  98. return self::FAILURE;
  99. }
  100. }
  101. if (! Schema::hasColumn('products', 'migrated_from_asteria_id')) {
  102. $this->error('products.migrated_from_asteria_id is missing. Run php artisan migrate.');
  103. return self::FAILURE;
  104. }
  105. $family = $this->resolveFamily();
  106. if (! $family) {
  107. $this->error('No Bagisto attribute family found. Seed attributes first.');
  108. return self::FAILURE;
  109. }
  110. $this->familyId = (int) $family->id;
  111. $groupId = DB::table('attribute_groups')
  112. ->where('attribute_family_id', $this->familyId)
  113. ->where('code', 'general')
  114. ->value('id')
  115. ?? DB::table('attribute_groups')
  116. ->where('attribute_family_id', $this->familyId)
  117. ->orderBy('position')
  118. ->value('id');
  119. if (! $groupId) {
  120. $this->error("Attribute family '{$family->code}' has no attribute group.");
  121. return self::FAILURE;
  122. }
  123. $this->generalGroupId = (int) $groupId;
  124. if (! $this->resolveChannelsLocalesAndSources()) {
  125. return self::FAILURE;
  126. }
  127. $reader = new Magento1ProductReader($connection, $this->config);
  128. if (! $this->option('products-only')) {
  129. $attributeResult = $this->syncAttributes($reader, $dryRun);
  130. $this->info("Attributes: created={$attributeResult['created']}, skipped={$attributeResult['skipped']}.");
  131. }
  132. if ($this->option('attributes-only')) {
  133. return self::SUCCESS;
  134. }
  135. $this->loadFamilyAttributes($family);
  136. $lastId = ($resetProgress || $sku !== '') ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
  137. if ($resetProgress) {
  138. Cache::forget(self::PROGRESS_KEY);
  139. }
  140. if ($lastId > 0) {
  141. $this->line("Resuming from Asteria entity_id > {$lastId} (use --reset-progress to restart).");
  142. }
  143. $created = 0;
  144. $updated = 0;
  145. $skipped = 0;
  146. $batchNumber = 0;
  147. $remaining = $limit;
  148. $this->info($dryRun ? '[DRY RUN] Scanning Magento products…' : 'Migrating Magento products…');
  149. do {
  150. $started = microtime(true);
  151. $take = $remaining !== null ? min($batchSize, $remaining) : $batchSize;
  152. $products = $reader->fetchProducts($lastId, $take, $sku !== '' ? $sku : null);
  153. if ($products->isEmpty()) {
  154. break;
  155. }
  156. $batchNumber++;
  157. $lastId = (int) $products->max('entity_id');
  158. if ($dryRun) {
  159. $simpleCount = $products->filter(fn (array $row) => $this->isSimpleMagentoProduct($row))->count();
  160. $created += $simpleCount;
  161. $skipped += $products->count() - $simpleCount;
  162. $this->line(sprintf(
  163. ' Batch #%d: %d products (last entity_id=%d) [skipped – dry-run] (%.1fs)',
  164. $batchNumber,
  165. $products->count(),
  166. $lastId,
  167. microtime(true) - $started
  168. ));
  169. } else {
  170. $result = $this->persistBatch($products);
  171. if ($sku === '') {
  172. Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
  173. }
  174. $created += $result['created'];
  175. $updated += $result['updated'];
  176. $skipped += $result['skipped'];
  177. $this->line(sprintf(
  178. ' Batch #%d: created=%d updated=%d skipped=%d (last entity_id=%d) (%.1fs)',
  179. $batchNumber,
  180. $result['created'],
  181. $result['updated'],
  182. $result['skipped'],
  183. $lastId,
  184. microtime(true) - $started
  185. ));
  186. Log::info('MigrateAsteriaProducts: batch '.$batchNumber.', last_id='.$lastId);
  187. }
  188. if ($remaining !== null) {
  189. $remaining -= $products->count();
  190. }
  191. } while (
  192. $sku === ''
  193. && ($remaining === null || $remaining > 0)
  194. && $products->count() === $take
  195. );
  196. if (! $dryRun && ! $this->option('no-index')) {
  197. $this->reindexProducts();
  198. }
  199. $this->newLine();
  200. $this->info("Done. Batches: {$batchNumber}, created: {$created}, updated: {$updated}, skipped: {$skipped}.");
  201. if (! $dryRun && ! $this->option('attributes-only')) {
  202. MagentoPrimaryKey::bumpAutoIncrement('products');
  203. }
  204. return self::SUCCESS;
  205. }
  206. /**
  207. * @return array{created: int, skipped: int}
  208. */
  209. private function syncAttributes(Magento1ProductReader $reader, bool $dryRun): array
  210. {
  211. $definitions = $reader->fetchAttributeDefinitions();
  212. $created = 0;
  213. $skipped = 0;
  214. $this->info('Syncing '.$definitions->count().' Magento attributes…');
  215. foreach ($definitions as $row) {
  216. $code = trim((string) ($row['code'] ?? ''));
  217. if ($code === '') {
  218. continue;
  219. }
  220. $existing = Attribute::query()->where('code', $code)->first();
  221. if ($existing) {
  222. if (! $dryRun) {
  223. $this->ensureAttributeInFamily((int) $existing->id);
  224. $this->ensureAttributeOptions($existing, $row['options'] ?? []);
  225. }
  226. $skipped++;
  227. continue;
  228. }
  229. if ($dryRun) {
  230. $created++;
  231. continue;
  232. }
  233. $type = (string) ($row['type'] ?? 'text');
  234. $name = (string) (($row['name'] ?? '') !== '' ? $row['name'] : $code);
  235. $data = array_merge([
  236. 'code' => $code,
  237. 'admin_name' => $name,
  238. 'type' => $type,
  239. 'is_required' => (int) ($row['is_required'] ?? 0),
  240. 'is_filterable' => (int) ($row['is_filterable'] ?? 0),
  241. 'is_comparable' => (int) ($row['is_comparable'] ?? 0),
  242. 'is_configurable' => (int) ($row['is_configurable'] ?? 0),
  243. 'position' => (int) ($row['position'] ?? 0),
  244. 'is_user_defined' => 1,
  245. 'value_per_locale' => in_array($type, ['text', 'textarea'], true) ? 1 : 0,
  246. 'value_per_channel' => 0,
  247. ], $this->translations('name', $name));
  248. if (in_array($type, ['select', 'multiselect', 'checkbox'], true) && ! empty($row['options'])) {
  249. $options = [];
  250. foreach (array_values($row['options']) as $i => $option) {
  251. $label = trim((string) ($option['label'] ?? ''));
  252. if ($label === '') {
  253. continue;
  254. }
  255. $options[] = array_merge([
  256. 'admin_name' => $label,
  257. 'sort_order' => (int) ($option['sort_order'] ?? $i),
  258. ], $this->translations('label', $label));
  259. }
  260. if ($options !== []) {
  261. $data['options'] = $options;
  262. }
  263. }
  264. $attribute = $this->attributeRepository->create($data);
  265. $this->ensureAttributeInFamily((int) $attribute->id);
  266. $created++;
  267. }
  268. return ['created' => $created, 'skipped' => $skipped];
  269. }
  270. /**
  271. * @param Collection<int, array<string, mixed>> $products
  272. * @return array{created: int, updated: int, skipped: int}
  273. */
  274. private function persistBatch(Collection $products): array
  275. {
  276. $created = 0;
  277. $updated = 0;
  278. $skipped = 0;
  279. $warnAt = (int) ($this->config['max_variants_warn'] ?? 500);
  280. foreach ($products as $row) {
  281. $sku = trim((string) ($row['sku'] ?? ''));
  282. if ($sku === '') {
  283. $skipped++;
  284. continue;
  285. }
  286. if (! $this->isSimpleMagentoProduct($row)) {
  287. $typeId = (string) ($row['type_id'] ?? '');
  288. $skipped++;
  289. $this->warn("SKU {$sku} skipped: Magento type_id '{$typeId}' is not simple.");
  290. Log::warning("MigrateAsteriaProducts: skipping non-simple SKU {$sku} (type_id={$typeId}).");
  291. continue;
  292. }
  293. $variantCount = is_array($row['variants'] ?? null) ? count($row['variants']) : 0;
  294. if ($variantCount > $warnAt) {
  295. $this->warn("SKU {$sku} will generate {$variantCount} variants (threshold {$warnAt}).");
  296. }
  297. try {
  298. $wasNew = $this->persistProduct($row);
  299. } catch (\Throwable $e) {
  300. $skipped++;
  301. $this->error('Product '.$sku.' failed: '.$e->getMessage());
  302. Log::error('MigrateAsteriaProducts: '.$sku.' '.$e->getMessage(), ['exception' => $e]);
  303. continue;
  304. }
  305. if ($wasNew) {
  306. $created++;
  307. } else {
  308. $updated++;
  309. }
  310. }
  311. return compact('created', 'updated', 'skipped');
  312. }
  313. /**
  314. * @param array<string, mixed> $row
  315. */
  316. private function persistProduct(array $row): bool
  317. {
  318. $sku = trim((string) $row['sku']);
  319. $asteriaId = (int) $row['entity_id'];
  320. $product = Product::query()->where('migrated_from_asteria_id', $asteriaId)->first()
  321. ?? Product::query()->firstOrNew(['sku' => $sku]);
  322. $wasNew = ! $product->exists;
  323. if ($wasNew) {
  324. if (isset(MagentoPrimaryKey::occupiedIds('products', [$asteriaId])[$asteriaId])) {
  325. throw new \RuntimeException("Product id {$asteriaId} is already occupied; refusing to overwrite for SKU {$sku}.");
  326. }
  327. $product->id = $asteriaId;
  328. $product->incrementing = false;
  329. }
  330. $product->sku = $sku;
  331. $product->type = 'flexible_variant';
  332. $product->attribute_family_id = $this->familyId;
  333. $product->migrated_from_asteria_id = $asteriaId;
  334. $product->save();
  335. if ($wasNew) {
  336. $product->incrementing = true;
  337. }
  338. if ($wasNew && ! empty($row['created_at'])) {
  339. DB::table('products')->where('id', $product->id)->update([
  340. 'created_at' => $row['created_at'],
  341. ]);
  342. }
  343. $this->touchedProductIds[] = (int) $product->id;
  344. $this->saveChannels($product);
  345. $this->saveAttributeValues($product, $row);
  346. $this->saveInventory($product, $row);
  347. $this->saveImages($product, $row);
  348. $this->saveCategories($product, is_array($row['categories'] ?? null) ? $row['categories'] : []);
  349. DB::transaction(function () use ($product, $row) {
  350. $this->saveOptionsAndVariants($product, $row);
  351. });
  352. return $wasNew;
  353. }
  354. /**
  355. * @param array<string, mixed> $row
  356. */
  357. private function isSimpleMagentoProduct(array $row): bool
  358. {
  359. return strtolower(trim((string) ($row['type_id'] ?? 'simple'))) === 'simple';
  360. }
  361. /**
  362. * @param array<string, mixed> $row
  363. */
  364. private function saveAttributeValues(Product $product, array $row): void
  365. {
  366. foreach ([
  367. 'visible_individually' => 1,
  368. 'manage_stock' => 1,
  369. 'status' => 1,
  370. 'guest_checkout' => 1,
  371. ] as $code => $default) {
  372. if (! array_key_exists($code, $row) || $row[$code] === null || $row[$code] === '') {
  373. $row[$code] = $default;
  374. }
  375. }
  376. $values = [];
  377. foreach ($this->familyAttributes as $code => $attribute) {
  378. if (! array_key_exists($code, $row)) {
  379. continue;
  380. }
  381. $rawValue = $row[$code];
  382. if ($rawValue === null || $rawValue === '') {
  383. continue;
  384. }
  385. $columnValue = $this->castAttributeValue($attribute, $rawValue);
  386. if ($columnValue === null) {
  387. continue;
  388. }
  389. $channelCodes = $attribute->value_per_channel ? $this->channelCodes : [null];
  390. $localeCodes = $attribute->value_per_locale ? $this->localeCodes : [null];
  391. foreach ($channelCodes as $channelCode) {
  392. foreach ($localeCodes as $localeCode) {
  393. $valueRow = array_fill_keys(array_values($attribute->attributeTypeFields), null);
  394. $valueRow['json_value'] = null;
  395. $valueRow[$attribute->column_name] = $columnValue;
  396. $valueRow['attribute_id'] = $attribute->id;
  397. $valueRow['product_id'] = $product->id;
  398. $valueRow['channel'] = $channelCode;
  399. $valueRow['locale'] = $localeCode;
  400. $valueRow['unique_id'] = implode('|', array_filter([
  401. $valueRow['channel'],
  402. $valueRow['locale'],
  403. $valueRow['product_id'],
  404. $valueRow['attribute_id'],
  405. ], fn ($part) => $part !== null && $part !== ''));
  406. $values[$valueRow['unique_id']] = $valueRow;
  407. }
  408. }
  409. }
  410. if ($values === []) {
  411. return;
  412. }
  413. DB::table('product_attribute_values')->upsert(
  414. array_values($values),
  415. ['unique_id'],
  416. ['text_value', 'boolean_value', 'integer_value', 'float_value', 'datetime_value', 'date_value', 'json_value']
  417. );
  418. }
  419. /**
  420. * @param array<string, mixed> $row
  421. */
  422. private function saveInventory(Product $product, array $row): void
  423. {
  424. $qty = $row['qty'] ?? 0;
  425. if ($qty === null || (is_string($qty) && trim($qty) === '')) {
  426. $qty = 0;
  427. }
  428. foreach ($this->inventorySourceIds as $sourceId) {
  429. DB::table('product_inventories')->updateOrInsert(
  430. [
  431. 'product_id' => $product->id,
  432. 'inventory_source_id' => $sourceId,
  433. 'vendor_id' => 0,
  434. ],
  435. ['qty' => (int) $qty]
  436. );
  437. }
  438. }
  439. /**
  440. * @param array<string, mixed> $row
  441. */
  442. private function saveImages(Product $product, array $row): void
  443. {
  444. $urls = [];
  445. if (! empty($row['base_image'])) {
  446. $urls[] = trim((string) $row['base_image']);
  447. }
  448. if (! empty($row['additional_images'])) {
  449. $additional = $row['additional_images'];
  450. if (is_array($additional)) {
  451. $parts = $additional;
  452. } else {
  453. $parts = explode('|', (string) $additional);
  454. }
  455. foreach ($parts as $url) {
  456. $url = trim((string) $url);
  457. if ($url !== '') {
  458. $urls[] = $url;
  459. }
  460. }
  461. }
  462. $urls = array_values(array_unique($urls));
  463. DB::table('product_images')->where('product_id', $product->id)->delete();
  464. if ($urls === []) {
  465. return;
  466. }
  467. $hasBase = Schema::hasColumn('product_images', 'is_base_image');
  468. $hasSmall = Schema::hasColumn('product_images', 'is_small_image');
  469. $hasThumb = Schema::hasColumn('product_images', 'is_thumbnail');
  470. $records = [];
  471. foreach ($urls as $position => $url) {
  472. $record = [
  473. 'type' => 'images',
  474. 'path' => $url,
  475. 'product_id' => $product->id,
  476. 'position' => $position + 1,
  477. ];
  478. if ($hasBase) {
  479. $record['is_base_image'] = $position === 0 ? 1 : 0;
  480. }
  481. if ($hasSmall) {
  482. $record['is_small_image'] = $position === 0 ? 1 : 0;
  483. }
  484. if ($hasThumb) {
  485. $record['is_thumbnail'] = $position === 0 ? 1 : 0;
  486. }
  487. $records[] = $record;
  488. }
  489. DB::table('product_images')->insert($records);
  490. }
  491. /**
  492. * @param array<int, string> $names
  493. */
  494. private function saveCategories(Product $product, array $names): void
  495. {
  496. DB::table('product_categories')->where('product_id', $product->id)->delete();
  497. $names = array_values(array_unique(array_filter(array_map('trim', $names))));
  498. if ($names === [] || ! Schema::hasTable('category_translations')) {
  499. return;
  500. }
  501. $query = DB::table('category_translations')->whereIn('name', $names);
  502. if (Schema::hasColumn('category_translations', 'locale')) {
  503. $query->where('locale', $this->locale);
  504. }
  505. $ids = $query->pluck('category_id')->map(fn ($id) => (int) $id)->unique()->values()->all();
  506. if ($ids === []) {
  507. return;
  508. }
  509. $hasPosition = Schema::hasColumn('product_categories', 'position');
  510. $rows = [];
  511. foreach ($ids as $position => $categoryId) {
  512. $row = [
  513. 'product_id' => $product->id,
  514. 'category_id' => $categoryId,
  515. ];
  516. if ($hasPosition) {
  517. $row['position'] = $position;
  518. }
  519. $rows[] = $row;
  520. }
  521. DB::table('product_categories')->insert($rows);
  522. }
  523. /**
  524. * @param array<string, mixed> $row
  525. */
  526. private function saveOptionsAndVariants(Product $product, array $row): void
  527. {
  528. $superCodes = array_values(array_filter(array_map(
  529. 'trim',
  530. explode(',', (string) ($row['super_attributes'] ?? ''))
  531. )));
  532. $this->clearVariantData($product);
  533. if ($superCodes === []) {
  534. return;
  535. }
  536. $optionsMeta = $row['options'] ?? [];
  537. if (is_string($optionsMeta)) {
  538. $optionsMeta = json_decode($optionsMeta, true) ?: [];
  539. }
  540. $variants = $row['variants'] ?? [];
  541. if (is_string($variants)) {
  542. $variants = json_decode($variants, true) ?: [];
  543. }
  544. $optionsByCode = [];
  545. foreach ($optionsMeta as $index => $meta) {
  546. if (! is_array($meta)) {
  547. continue;
  548. }
  549. $title = (string) ($meta['title'] ?? '');
  550. $optionsByCode[$this->sanitizeAttributeCode($title)] = $meta;
  551. $optionsByCode[(string) $index] = $meta;
  552. }
  553. $labelToValueId = [];
  554. $syncOptions = [];
  555. foreach ($superCodes as $index => $code) {
  556. $meta = $optionsByCode[$code] ?? $optionsMeta[$index] ?? [];
  557. $optionLabel = (string) ($meta['title'] ?? Str::title(str_replace('_', ' ', $code)));
  558. $option = ProductOption::query()->firstOrCreate(
  559. ['code' => $code],
  560. ['label' => $optionLabel, 'type' => 'select', 'position' => $index]
  561. );
  562. $syncOptions[$option->id] = ['position' => $index, 'is_required' => true];
  563. $existing = ProductOptionValue::query()
  564. ->where('product_option_id', $option->id)
  565. ->get()
  566. ->keyBy('label');
  567. foreach ($existing as $label => $value) {
  568. $labelToValueId[$index][$label] = (int) $value->id;
  569. }
  570. $orderedLabels = [];
  571. $valueSku = [];
  572. foreach (($meta['values'] ?? []) as $valueMeta) {
  573. $label = (string) ($valueMeta['title'] ?? '');
  574. if ($label === '' || array_key_exists($label, $valueSku)) {
  575. continue;
  576. }
  577. $orderedLabels[] = $label;
  578. $valueSku[$label] = (string) ($valueMeta['sku'] ?? '');
  579. }
  580. foreach ($variants as $variant) {
  581. $label = $this->variantLabel($variant, $index, $code, $optionLabel);
  582. if ($label !== null && $label !== '' && ! array_key_exists($label, $valueSku)) {
  583. $orderedLabels[] = $label;
  584. $valueSku[$label] = '';
  585. }
  586. }
  587. $nextPosition = (int) ProductOptionValue::query()
  588. ->where('product_option_id', $option->id)
  589. ->max('position');
  590. foreach ($orderedLabels as $label) {
  591. if (isset($labelToValueId[$index][$label])) {
  592. continue;
  593. }
  594. $valueCode = $valueSku[$label] !== '' ? $valueSku[$label] : Str::slug($label);
  595. if ($valueCode === '') {
  596. $valueCode = 'value-'.($nextPosition + 1);
  597. }
  598. $value = ProductOptionValue::query()->create([
  599. 'product_option_id' => $option->id,
  600. 'label' => $label,
  601. 'code' => $valueCode,
  602. 'position' => ++$nextPosition,
  603. ]);
  604. $labelToValueId[$index][$label] = (int) $value->id;
  605. }
  606. }
  607. $product->options()->sync($syncOptions);
  608. $this->saveVariants($product, $row, $superCodes, $variants, $labelToValueId, $optionsByCode);
  609. }
  610. /**
  611. * @param array<string, mixed> $row
  612. * @param array<int, string> $superCodes
  613. * @param array<int, array<string, mixed>> $variants
  614. * @param array<int, array<string, int>> $labelToValueId
  615. * @param array<string, array<string, mixed>> $optionsByCode
  616. */
  617. private function saveVariants(
  618. Product $product,
  619. array $row,
  620. array $superCodes,
  621. array $variants,
  622. array $labelToValueId,
  623. array $optionsByCode
  624. ): void {
  625. if ($variants === []) {
  626. return;
  627. }
  628. $parentName = (string) ($row['name'] ?? '');
  629. $now = now();
  630. $variantRecords = [];
  631. foreach ($variants as $sortOrder => $variant) {
  632. $sku = trim((string) ($variant['sku'] ?? ''));
  633. if ($sku === '') {
  634. continue;
  635. }
  636. $variantRecords[] = [
  637. 'product_id' => $product->id,
  638. 'sku' => $sku,
  639. 'name' => $parentName !== '' ? $parentName : null,
  640. 'price' => (float) ($variant['price'] ?? 0),
  641. 'quantity' => (int) ($variant['qty'] ?? 0),
  642. 'status' => 1,
  643. 'sort_order' => $sortOrder,
  644. 'created_at' => $now,
  645. 'updated_at' => $now,
  646. ];
  647. }
  648. if ($variantRecords === []) {
  649. return;
  650. }
  651. foreach (array_chunk($variantRecords, 500) as $chunk) {
  652. ProductVariant::insert($chunk);
  653. }
  654. $variantIdBySku = ProductVariant::query()
  655. ->where('product_id', $product->id)
  656. ->pluck('id', 'sku')
  657. ->all();
  658. $pivotRecords = [];
  659. foreach ($variants as $variant) {
  660. $sku = trim((string) ($variant['sku'] ?? ''));
  661. $variantId = $variantIdBySku[$sku] ?? null;
  662. if (! $variantId) {
  663. continue;
  664. }
  665. foreach ($superCodes as $index => $code) {
  666. $optionLabel = (string) (($optionsByCode[$code]['title'] ?? '') ?: $code);
  667. $label = $this->variantLabel($variant, $index, $code, $optionLabel);
  668. if ($label === null || $label === '') {
  669. continue;
  670. }
  671. $valueId = $labelToValueId[$index][$label] ?? null;
  672. if (! $valueId) {
  673. continue;
  674. }
  675. $pivotRecords[] = [
  676. 'product_variant_id' => $variantId,
  677. 'product_option_value_id' => $valueId,
  678. 'created_at' => $now,
  679. 'updated_at' => $now,
  680. ];
  681. }
  682. }
  683. foreach (array_chunk($pivotRecords, 1000) as $chunk) {
  684. DB::table('product_variant_option_values')->insert($chunk);
  685. }
  686. }
  687. /**
  688. * @param array<string, mixed> $variant
  689. */
  690. private function variantLabel(array $variant, int $index, string $code, string $title): ?string
  691. {
  692. foreach ([$code, $title, (string) $index] as $key) {
  693. if ($key !== '' && array_key_exists($key, $variant) && $variant[$key] !== null && $variant[$key] !== '') {
  694. return (string) $variant[$key];
  695. }
  696. }
  697. return null;
  698. }
  699. private function clearVariantData(Product $product): void
  700. {
  701. $variantIds = ProductVariant::withTrashed()
  702. ->where('product_id', $product->id)
  703. ->pluck('id')
  704. ->all();
  705. if ($variantIds !== []) {
  706. DB::table('product_variant_option_values')
  707. ->whereIn('product_variant_id', $variantIds)
  708. ->delete();
  709. if (Schema::hasTable('product_variant_images')) {
  710. DB::table('product_variant_images')
  711. ->whereIn('product_variant_id', $variantIds)
  712. ->delete();
  713. }
  714. ProductVariant::withTrashed()->whereIn('id', $variantIds)->forceDelete();
  715. }
  716. $product->options()->detach();
  717. }
  718. private function reindexProducts(): void
  719. {
  720. $ids = array_values(array_unique($this->touchedProductIds));
  721. if ($ids === []) {
  722. return;
  723. }
  724. $this->info('Reindexing '.count($ids).' products…');
  725. $priceIndexer = app(Price::class);
  726. $flatIndexer = app(Flat::class);
  727. $elasticIndexer = $this->elasticEnabled() ? app(ElasticSearch::class) : null;
  728. $relations = [
  729. 'attribute_family',
  730. 'attribute_values',
  731. 'channels',
  732. 'price_indices',
  733. 'customer_group_prices',
  734. 'catalog_rule_prices',
  735. 'flexibleVariants',
  736. 'flexibleVariants.price_indices',
  737. 'flexibleVariants.customer_group_prices',
  738. ];
  739. foreach (array_chunk($ids, 20) as $chunk) {
  740. $products = Product::query()->with($relations)->whereIn('id', $chunk)->get();
  741. foreach ($products as $product) {
  742. try {
  743. $priceIndexer->reindexRow($product);
  744. $flatIndexer->reindexRow($product);
  745. $elasticIndexer?->reindexRow($product);
  746. } catch (\Throwable $e) {
  747. $this->error('Reindex failed for product '.$product->sku.': '.$e->getMessage());
  748. }
  749. }
  750. }
  751. }
  752. private function elasticEnabled(): bool
  753. {
  754. return core()->getConfigData('catalog.products.search.engine') === 'elastic';
  755. }
  756. private function castAttributeValue(Attribute $attribute, mixed $value): mixed
  757. {
  758. return match ($attribute->type) {
  759. 'boolean' => (int) (bool) (is_numeric($value) ? (int) $value : $value),
  760. 'price' => (float) $value,
  761. 'select' => AttributeOption::query()
  762. ->where('attribute_id', $attribute->id)
  763. ->where('admin_name', $value)
  764. ->value('id'),
  765. 'multiselect' => $this->castMultiselectValue((int) $attribute->id, (string) $value),
  766. default => $value,
  767. };
  768. }
  769. private function castMultiselectValue(int $attributeId, string $value): ?string
  770. {
  771. $ids = [];
  772. foreach (array_map('trim', explode(',', $value)) as $label) {
  773. if ($label === '') {
  774. continue;
  775. }
  776. $optionId = AttributeOption::query()
  777. ->where('attribute_id', $attributeId)
  778. ->where('admin_name', $label)
  779. ->value('id');
  780. if ($optionId) {
  781. $ids[] = $optionId;
  782. }
  783. }
  784. return $ids === [] ? null : implode(',', $ids);
  785. }
  786. /**
  787. * @param array<int, array<string, mixed>> $options
  788. */
  789. private function ensureAttributeOptions(Attribute $attribute, array $options): void
  790. {
  791. if (! in_array($attribute->type, ['select', 'multiselect', 'checkbox'], true) || $options === []) {
  792. return;
  793. }
  794. $nextSort = (int) AttributeOption::query()
  795. ->where('attribute_id', $attribute->id)
  796. ->max('sort_order');
  797. foreach (array_values($options) as $i => $option) {
  798. $label = trim((string) ($option['label'] ?? ''));
  799. if ($label === '') {
  800. continue;
  801. }
  802. $exists = AttributeOption::query()
  803. ->where('attribute_id', $attribute->id)
  804. ->where('admin_name', $label)
  805. ->exists();
  806. if ($exists) {
  807. continue;
  808. }
  809. AttributeOption::query()->create(array_merge([
  810. 'attribute_id' => $attribute->id,
  811. 'admin_name' => $label,
  812. 'sort_order' => (int) ($option['sort_order'] ?? ($nextSort + $i + 1)),
  813. ], $this->translations('label', $label)));
  814. }
  815. }
  816. private function ensureAttributeInFamily(int $attributeId): void
  817. {
  818. $exists = DB::table('attribute_group_mappings')
  819. ->where('attribute_id', $attributeId)
  820. ->where('attribute_group_id', $this->generalGroupId)
  821. ->exists();
  822. if ($exists) {
  823. return;
  824. }
  825. $position = (int) DB::table('attribute_group_mappings')
  826. ->where('attribute_group_id', $this->generalGroupId)
  827. ->max('position');
  828. DB::table('attribute_group_mappings')->insert([
  829. 'attribute_id' => $attributeId,
  830. 'attribute_group_id' => $this->generalGroupId,
  831. 'position' => $position + 1,
  832. ]);
  833. }
  834. private function loadFamilyAttributes(AttributeFamily $family): void
  835. {
  836. $this->familyAttributes = [];
  837. foreach ($family->custom_attributes()->get() as $attribute) {
  838. $this->familyAttributes[$attribute->code] = $attribute;
  839. }
  840. }
  841. private function resolveFamily(): ?AttributeFamily
  842. {
  843. $preferred = (string) ($this->config['attribute_family'] ?? 'wigs');
  844. foreach (array_unique(array_filter([$preferred, 'wigs', 'variant_product', 'default'])) as $code) {
  845. $family = AttributeFamily::query()->where('code', $code)->first();
  846. if ($family) {
  847. if ($code !== $preferred) {
  848. $this->comment("Attribute family '{$preferred}' not found, using '{$code}'.");
  849. }
  850. return $family;
  851. }
  852. }
  853. return AttributeFamily::query()->first();
  854. }
  855. private function resolveChannelsLocalesAndSources(): bool
  856. {
  857. $codes = $this->configuredCodes('channels', 'channel');
  858. $query = Channel::query()->with(['locales', 'inventory_sources']);
  859. if ($codes !== []) {
  860. $query->whereIn('code', $codes);
  861. }
  862. $channels = $query->get();
  863. if ($channels->isEmpty()) {
  864. $this->error($codes === []
  865. ? 'No Bagisto channel was found.'
  866. : 'Configured channel code(s) not found: '.implode(', ', $codes).'.');
  867. return false;
  868. }
  869. if ($codes !== [] && $channels->count() !== count($codes)) {
  870. $found = $channels->pluck('code')->all();
  871. $this->warn('Unknown channel codes skipped: '.implode(', ', array_diff($codes, $found)).'.');
  872. }
  873. $this->channelIds = $channels->pluck('id')->map(fn ($id) => (int) $id)->all();
  874. $this->channelCodes = $channels->pluck('code')->map(fn ($code) => (string) $code)->unique()->values()->all();
  875. $this->channelCode = $this->channelCodes[0];
  876. $localeCodes = $this->configuredCodes('locales');
  877. if ($localeCodes === []) {
  878. $localeCodes = $channels
  879. ->flatMap(fn (Channel $channel) => $channel->locales->pluck('code'))
  880. ->filter()
  881. ->map(fn ($code) => (string) $code)
  882. ->unique()
  883. ->values()
  884. ->all();
  885. }
  886. if ($localeCodes === []) {
  887. $localeCodes = ['en'];
  888. }
  889. $this->localeCodes = $localeCodes;
  890. $this->locale = $this->localeCodes[0];
  891. $sourceIds = $channels
  892. ->flatMap(fn (Channel $channel) => $channel->inventory_sources->pluck('id'))
  893. ->map(fn ($id) => (int) $id)
  894. ->filter()
  895. ->unique()
  896. ->values()
  897. ->all();
  898. if ($sourceIds === []) {
  899. $fallback = (int) (DB::table('inventory_sources')->value('id') ?? 0);
  900. if ($fallback === 0) {
  901. $this->error('No inventory_sources found. Run Bagisto seeders for inventory.');
  902. return false;
  903. }
  904. $sourceIds = [$fallback];
  905. }
  906. $this->inventorySourceIds = $sourceIds;
  907. $this->comment('Channels: '.implode(', ', $this->channelCodes));
  908. $this->comment('Locales: '.implode(', ', $this->localeCodes));
  909. return true;
  910. }
  911. /**
  912. * @return array<int, string>
  913. */
  914. private function configuredCodes(string $listKey, ?string $singleKey = null): array
  915. {
  916. $codes = $this->config[$listKey] ?? [];
  917. if (is_string($codes)) {
  918. $codes = explode(',', $codes);
  919. }
  920. $codes = array_values(array_filter(array_map('trim', array_map('strval', (array) $codes))));
  921. if ($codes === [] && $singleKey !== null) {
  922. $single = trim((string) ($this->config[$singleKey] ?? ''));
  923. if ($single !== '') {
  924. $codes = [$single];
  925. }
  926. }
  927. return $codes;
  928. }
  929. private function saveChannels(Product $product): void
  930. {
  931. foreach ($this->channelIds as $channelId) {
  932. DB::table('product_channels')->updateOrInsert([
  933. 'product_id' => $product->id,
  934. 'channel_id' => $channelId,
  935. ]);
  936. }
  937. }
  938. /**
  939. * @return array<string, array<string, string>>
  940. */
  941. private function translations(string $field, string $value): array
  942. {
  943. $payload = [];
  944. foreach ($this->localeCodes as $locale) {
  945. $payload[$locale] = [$field => $value];
  946. }
  947. if ($payload === []) {
  948. $payload[$this->locale] = [$field => $value];
  949. }
  950. return $payload;
  951. }
  952. private function sanitizeAttributeCode(string $title): string
  953. {
  954. $code = strtolower($title);
  955. $code = (string) preg_replace('/[^a-z0-9]+/', '_', $code);
  956. return trim($code, '_');
  957. }
  958. }