MigrateAsteriaProducts.php 39 KB

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