MigrateAsteriaProducts.php 37 KB

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