Magento1ProductReader.php 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113
  1. <?php
  2. namespace App\Services\Asteria;
  3. use Illuminate\Support\Collection;
  4. use Illuminate\Support\Facades\DB;
  5. /**
  6. * Reads Magento 1 catalog products, EAV attributes, images, stock, categories,
  7. * and custom options from the Asteria connection (SELECT only).
  8. *
  9. * Output rows match the shape previously produced by Asteria's
  10. * shell/migrate_to_bagisto.php export, so Bagisto can persist them as
  11. * flexible_variant products.
  12. */
  13. class Magento1ProductReader
  14. {
  15. private Magento1Schema $schema;
  16. /** @var array<string, mixed> */
  17. private array $config;
  18. /** @var array<string, object>|null */
  19. private ?array $productAttributeMap = null;
  20. /** @var array<int, array<int, string>> */
  21. private array $optionLabelCache = [];
  22. /** @var array<int, string>|null */
  23. private ?array $variantOptionTitles = null;
  24. public function __construct(private string $connection = 'asteria', ?array $config = null)
  25. {
  26. $this->schema = new Magento1Schema($connection);
  27. $this->config = array_merge(self::defaultConfig(), $config ?? (array) config('asteria.products', []));
  28. }
  29. /**
  30. * @return array<string, mixed>
  31. */
  32. public static function defaultConfig(): array
  33. {
  34. return [
  35. 'media_base_url' => 'https://img.asteriahair.com/media/catalog/product',
  36. 'store_media_url' => 'https://img.asteriahair.com/media/',
  37. 'channel' => '',
  38. 'channels' => [],
  39. 'locale' => 'en',
  40. 'locales' => [],
  41. 'attribute_family' => 'wigs',
  42. 'base_attributes' => [
  43. 'name', 'short_description', 'description',
  44. 'price', 'special_price', 'cost', 'weight',
  45. 'meta_title', 'meta_keyword', 'meta_description',
  46. 'url_key', 'status',
  47. ],
  48. 'include_eav_attributes' => [
  49. 'feature', 'gmc_color', 'gmc_product_type',
  50. 'hair_closure', 'hair_colorr', 'hair_density',
  51. 'hair_wavy', 'human_hair_type',
  52. 'lace_color', 'lace_sizes',
  53. 'sale_num', 'texturee',
  54. 'video', 'alone',
  55. ],
  56. 'variant_option_titles' => [],
  57. 'skip_option_titles' => [
  58. 'Lace', 'Wig Type', 'Shipping Option', 'Construction', 'Free Gift',
  59. 'Headband Color', 'Color', 'Customized Product', 'X-Mas Gift Random?',
  60. 'Custom Item', 'Hair Type', 'Layering Option', '4C Hairline', 'Customize Item',
  61. 'Free Headband', 'Hair Parting Styles', 'Limited Design',
  62. 'Mannequin Head', 'Texture', 'Time', 'Trim the Ends (Optional)', 'U Part Open Size',
  63. 'Wig Color', 'Wig Style',
  64. ],
  65. 'max_variants_warn' => 500,
  66. 'enabled_status' => 1,
  67. ];
  68. }
  69. /**
  70. * Attribute definitions for include_eav_attributes (Bagisto-shaped).
  71. *
  72. * @return Collection<int, array<string, mixed>>
  73. */
  74. public function fetchAttributeDefinitions(): Collection
  75. {
  76. $codes = array_values(array_filter(array_map(
  77. 'strval',
  78. $this->config['include_eav_attributes'] ?? []
  79. )));
  80. if ($codes === [] || ! $this->schema->hasTable('eav_attribute')) {
  81. return collect();
  82. }
  83. $query = DB::connection($this->connection)
  84. ->table('eav_attribute as ea')
  85. ->select($this->attributeSelectColumns())
  86. ->whereIn('ea.attribute_code', $codes);
  87. $typeId = $this->entityTypeId('catalog_product');
  88. if ($typeId) {
  89. $query->where('ea.entity_type_id', $typeId);
  90. }
  91. if ($this->schema->hasTable('catalog_eav_attribute')) {
  92. $query->leftJoin('catalog_eav_attribute as cea', 'cea.attribute_id', '=', 'ea.attribute_id');
  93. }
  94. $rows = $query->orderBy('ea.attribute_code')->get();
  95. if ($rows->isEmpty()) {
  96. return collect();
  97. }
  98. $optionsByAttr = $this->loadAttributeOptions(
  99. $rows->pluck('attribute_id')->map(fn ($id) => (int) $id)->all()
  100. );
  101. return $rows->map(function ($row) use ($optionsByAttr) {
  102. $attributeId = (int) $row->attribute_id;
  103. return [
  104. 'code' => (string) $row->attribute_code,
  105. 'name' => (string) ($row->frontend_label ?: $row->attribute_code),
  106. 'type' => $this->magentoInputToBagistoType((string) ($row->frontend_input ?? 'text')),
  107. 'magento_input' => (string) ($row->frontend_input ?? ''),
  108. 'is_required' => (bool) (int) ($row->is_required ?? 0),
  109. 'is_unique' => false,
  110. 'is_filterable' => (bool) (int) ($row->is_filterable ?? 0),
  111. 'is_searchable' => (bool) (int) ($row->is_searchable ?? 0),
  112. 'is_comparable' => (bool) (int) ($row->is_comparable ?? 0),
  113. 'is_configurable' => (bool) (int) ($row->is_configurable ?? 0),
  114. 'position' => (int) ($row->position ?? 0),
  115. 'options' => $optionsByAttr[$attributeId] ?? [],
  116. ];
  117. })->values();
  118. }
  119. /**
  120. * Enabled Magento products after $afterEntityId, hydrated for Bagisto persist.
  121. *
  122. * @return Collection<int, array<string, mixed>>
  123. */
  124. public function fetchProducts(int $afterEntityId, int $limit, ?string $sku = null): Collection
  125. {
  126. if (! $this->schema->hasTable('catalog_product_entity')) {
  127. return collect();
  128. }
  129. $staticColumns = $this->schema->existingColumns('catalog_product_entity', [
  130. 'entity_id',
  131. 'sku',
  132. 'type_id',
  133. 'attribute_set_id',
  134. 'created_at',
  135. 'updated_at',
  136. ]);
  137. if ($staticColumns === [] || ! in_array('entity_id', $staticColumns, true)) {
  138. return collect();
  139. }
  140. $query = DB::connection($this->connection)
  141. ->table('catalog_product_entity as pe')
  142. ->select(array_map(fn (string $column) => 'pe.'.$column, $staticColumns))
  143. ->where('pe.entity_id', '>', $afterEntityId)
  144. ->orderBy('pe.entity_id')
  145. ->limit($limit);
  146. if (is_string($sku) && $sku !== '') {
  147. $query->where('pe.sku', $sku);
  148. }
  149. $this->applyEnabledStatusFilter($query);
  150. $rows = $query->get();
  151. if ($rows->isEmpty()) {
  152. return collect();
  153. }
  154. $entityIds = $rows->pluck('entity_id')->map(fn ($id) => (int) $id)->all();
  155. $eav = $this->loadEavValues($entityIds);
  156. $images = $this->loadImages($entityIds);
  157. $categories = $this->loadCategories($entityIds);
  158. $stock = $this->loadStock($entityIds);
  159. $customOptions = $this->loadCustomOptions($entityIds);
  160. $variantTitles = array_flip($this->variantOptionTitles());
  161. return $rows->map(function ($row) use ($eav, $images, $categories, $stock, $customOptions, $variantTitles) {
  162. $id = (int) $row->entity_id;
  163. return $this->assembleProduct(
  164. (array) $row,
  165. $eav[$id] ?? [],
  166. $images[$id] ?? ['base' => '', 'additional' => []],
  167. $categories[$id] ?? [],
  168. $stock[$id] ?? ['qty' => 0, 'is_in_stock' => 0],
  169. $customOptions[$id] ?? [],
  170. $variantTitles
  171. );
  172. })->values();
  173. }
  174. /**
  175. * @param \Illuminate\Database\Query\Builder $query
  176. */
  177. private function applyEnabledStatusFilter($query): void
  178. {
  179. $status = $this->productAttributes()['status'] ?? null;
  180. if (! $status || ! $this->schema->hasTable('catalog_product_entity_int')) {
  181. return;
  182. }
  183. $query->join('catalog_product_entity_int as st', function ($join) use ($status) {
  184. $join->on('st.entity_id', '=', 'pe.entity_id')
  185. ->where('st.attribute_id', (int) $status->attribute_id)
  186. ->where('st.value', (int) ($this->config['enabled_status'] ?? 1));
  187. if (in_array('store_id', $this->schema->columnListing('catalog_product_entity_int'), true)) {
  188. $join->where('st.store_id', 0);
  189. }
  190. });
  191. }
  192. /**
  193. * @param array<string, mixed> $entity
  194. * @param array<string, mixed> $eav
  195. * @param array{base: string, additional: array<int, string>} $images
  196. * @param array<int, string> $categories
  197. * @param array{qty: mixed, is_in_stock: mixed} $stock
  198. * @param array<string, array<string, mixed>> $customOptions
  199. * @param array<string, int> $variantTitles
  200. * @return array<string, mixed>
  201. */
  202. private function assembleProduct(
  203. array $entity,
  204. array $eav,
  205. array $images,
  206. array $categories,
  207. array $stock,
  208. array $customOptions,
  209. array $variantTitles
  210. ): array {
  211. $sku = trim((string) ($entity['sku'] ?? ''));
  212. $name = (string) ($eav['name'] ?? $sku);
  213. $urlKey = trim((string) ($eav['url_key'] ?? ''));
  214. if ($urlKey === '') {
  215. $urlKey = $this->generateUrlKey($name, $sku);
  216. }
  217. $variantOptions = [];
  218. foreach ($customOptions as $title => $option) {
  219. if (! isset($variantTitles[$title])) {
  220. continue;
  221. }
  222. if (! in_array($option['type'], ['drop_down', 'radio', 'checkbox', 'multiple'], true)) {
  223. continue;
  224. }
  225. $variantOptions[$title] = $option;
  226. }
  227. $basePrice = (float) ($eav['price'] ?? 0);
  228. $superAttributes = [];
  229. foreach (array_keys($variantOptions) as $title) {
  230. $superAttributes[] = $this->sanitizeAttributeCode($title);
  231. }
  232. $options = [];
  233. foreach ($variantOptions as $title => $option) {
  234. $options[] = $this->optionPayload($title, $option);
  235. }
  236. $combinations = $variantOptions === [] ? [] : $this->cartesianProduct($variantOptions);
  237. $variants = $this->buildVariants($sku, $stock, $variantOptions, $basePrice, $combinations);
  238. $row = [
  239. 'entity_id' => (int) $entity['entity_id'],
  240. 'sku' => $sku,
  241. 'type_id' => (string) ($entity['type_id'] ?? 'simple'),
  242. 'type' => 'flexible_variant',
  243. 'name' => $name,
  244. 'url_key' => $urlKey,
  245. 'short_description' => $this->htmlToText((string) ($eav['short_description'] ?? '')),
  246. 'description' => $this->replaceMediaDirectives((string) ($eav['description'] ?? '')),
  247. 'price' => $eav['price'] ?? '',
  248. 'special_price' => $eav['special_price'] ?? '',
  249. 'cost' => $eav['cost'] ?? '',
  250. 'weight' => $eav['weight'] ?? '',
  251. 'status' => 1,
  252. 'featured' => 0,
  253. 'new' => 0,
  254. 'guest_checkout' => 1,
  255. 'visible_individually' => 1,
  256. 'manage_stock' => 1,
  257. 'qty' => $stock['qty'] ?? 0,
  258. 'in_stock' => $stock['is_in_stock'] ?? 0,
  259. 'base_image' => $images['base'] ?? '',
  260. 'additional_images' => implode('|', $images['additional'] ?? []),
  261. 'categories' => $categories,
  262. 'super_attributes' => implode(',', $superAttributes),
  263. 'options' => $options,
  264. 'variants' => $variants,
  265. 'meta_title' => $eav['meta_title'] ?? '',
  266. 'meta_keywords' => $eav['meta_keyword'] ?? '',
  267. 'meta_description' => $eav['meta_description'] ?? '',
  268. 'created_at' => $entity['created_at'] ?? null,
  269. ];
  270. foreach ($this->config['include_eav_attributes'] ?? [] as $code) {
  271. if (array_key_exists($code, $row)) {
  272. continue;
  273. }
  274. $row[$code] = $eav[$code] ?? '';
  275. }
  276. return $row;
  277. }
  278. /**
  279. * @param array<int, int> $entityIds
  280. * @return array<int, array<string, mixed>>
  281. */
  282. private function loadEavValues(array $entityIds): array
  283. {
  284. $attributes = $this->productAttributes();
  285. if ($entityIds === [] || $attributes === []) {
  286. return [];
  287. }
  288. $byType = [];
  289. foreach ($attributes as $code => $attribute) {
  290. $type = $attribute->backend_type ?? 'varchar';
  291. if ($type === 'static' || $type === '') {
  292. continue;
  293. }
  294. $byType[$type][(int) $attribute->attribute_id] = $code;
  295. }
  296. $values = [];
  297. foreach ($byType as $type => $idToCode) {
  298. $table = 'catalog_product_entity_'.$type;
  299. if (! $this->schema->hasTable($table)) {
  300. continue;
  301. }
  302. $columns = ['entity_id', 'attribute_id', 'value'];
  303. if (in_array('store_id', $this->schema->columnListing($table), true)) {
  304. $columns[] = 'store_id';
  305. }
  306. $rows = DB::connection($this->connection)
  307. ->table($table)
  308. ->select($columns)
  309. ->whereIn('entity_id', $entityIds)
  310. ->whereIn('attribute_id', array_keys($idToCode))
  311. ->get();
  312. foreach ($rows as $row) {
  313. $code = $idToCode[(int) $row->attribute_id] ?? null;
  314. if ($code === null || $row->value === null || $row->value === '') {
  315. continue;
  316. }
  317. $entityId = (int) $row->entity_id;
  318. $storeId = (int) ($row->store_id ?? 0);
  319. if (! isset($values[$entityId][$code]) || $storeId === 0) {
  320. $values[$entityId][$code] = $row->value;
  321. }
  322. }
  323. }
  324. foreach ($attributes as $code => $info) {
  325. $input = (string) ($info->frontend_input ?? '');
  326. if (! in_array($input, ['select', 'multiselect'], true)) {
  327. continue;
  328. }
  329. foreach ($values as $entityId => $row) {
  330. if (! isset($row[$code])) {
  331. continue;
  332. }
  333. $optionIds = array_filter(explode(',', (string) $row[$code]), 'is_numeric');
  334. $labels = $this->optionLabels((int) $info->attribute_id, $optionIds);
  335. $values[$entityId][$code] = implode(',', $labels);
  336. }
  337. }
  338. return $values;
  339. }
  340. /**
  341. * @param array<int, int> $entityIds
  342. * @return array<int, array{base: string, additional: array<int, string>}>
  343. */
  344. private function loadImages(array $entityIds): array
  345. {
  346. if ($entityIds === [] || ! $this->schema->hasTable('catalog_product_entity_media_gallery')) {
  347. return [];
  348. }
  349. $query = DB::connection($this->connection)
  350. ->table('catalog_product_entity_media_gallery as mg')
  351. ->select('mg.entity_id', 'mg.value');
  352. if ($this->schema->hasTable('catalog_product_entity_media_gallery_value')) {
  353. $query->leftJoin('catalog_product_entity_media_gallery_value as mgv', function ($join) {
  354. $join->on('mgv.value_id', '=', 'mg.value_id');
  355. if (in_array('store_id', $this->schema->columnListing('catalog_product_entity_media_gallery_value'), true)) {
  356. $join->where('mgv.store_id', 0);
  357. }
  358. });
  359. $query->addSelect('mgv.position', 'mgv.disabled');
  360. $query->orderBy('mg.entity_id')->orderByRaw('COALESCE(mgv.position, 999) asc');
  361. } else {
  362. $query->orderBy('mg.entity_id');
  363. }
  364. $rows = $query->whereIn('mg.entity_id', $entityIds)->get();
  365. $baseUrl = rtrim((string) ($this->config['media_base_url'] ?? ''), '/');
  366. $grouped = [];
  367. foreach ($rows as $row) {
  368. if ((int) ($row->disabled ?? 0) === 1) {
  369. continue;
  370. }
  371. $path = (string) ($row->value ?? '');
  372. if ($path === '' || $path === 'no_selection') {
  373. continue;
  374. }
  375. $grouped[(int) $row->entity_id][] = $this->absoluteMediaUrl($baseUrl, $path);
  376. }
  377. $result = [];
  378. foreach ($grouped as $entityId => $urls) {
  379. $urls = array_values(array_unique($urls));
  380. $result[$entityId] = [
  381. 'base' => $urls[0] ?? '',
  382. 'additional' => array_slice($urls, 1),
  383. ];
  384. }
  385. return $result;
  386. }
  387. /**
  388. * @param array<int, int> $entityIds
  389. * @return array<int, array<int, string>>
  390. */
  391. private function loadCategories(array $entityIds): array
  392. {
  393. if (
  394. $entityIds === []
  395. || ! $this->schema->hasTable('catalog_category_product')
  396. || ! $this->schema->hasTable('catalog_category_entity')
  397. || ! $this->schema->hasTable('catalog_category_entity_varchar')
  398. ) {
  399. return [];
  400. }
  401. $nameAttr = $this->attributeByCode('catalog_category', 'name');
  402. if (! $nameAttr) {
  403. return [];
  404. }
  405. $query = DB::connection($this->connection)
  406. ->table('catalog_category_product as ccp')
  407. ->join('catalog_category_entity as cc', 'cc.entity_id', '=', 'ccp.category_id')
  408. ->join('catalog_category_entity_varchar as ccv', function ($join) use ($nameAttr) {
  409. $join->on('ccv.entity_id', '=', 'cc.entity_id')
  410. ->where('ccv.attribute_id', (int) $nameAttr->attribute_id);
  411. if (in_array('store_id', $this->schema->columnListing('catalog_category_entity_varchar'), true)) {
  412. $join->where('ccv.store_id', 0);
  413. }
  414. })
  415. ->whereIn('ccp.product_id', $entityIds)
  416. ->select('ccp.product_id', 'ccv.value as category_name', 'cc.path');
  417. if (in_array('path', $this->schema->columnListing('catalog_category_entity'), true)) {
  418. $query->orderByRaw('LENGTH(cc.path) desc');
  419. }
  420. $result = [];
  421. foreach ($query->get() as $row) {
  422. $name = trim((string) ($row->category_name ?? ''));
  423. if ($name === '') {
  424. continue;
  425. }
  426. $productId = (int) $row->product_id;
  427. if (! isset($result[$productId]) || ! in_array($name, $result[$productId], true)) {
  428. $result[$productId][] = $name;
  429. }
  430. }
  431. return $result;
  432. }
  433. /**
  434. * @param array<int, int> $entityIds
  435. * @return array<int, array{qty: mixed, is_in_stock: mixed}>
  436. */
  437. private function loadStock(array $entityIds): array
  438. {
  439. if ($entityIds === [] || ! $this->schema->hasTable('cataloginventory_stock_item')) {
  440. return [];
  441. }
  442. $columns = $this->schema->existingColumns('cataloginventory_stock_item', [
  443. 'product_id',
  444. 'qty',
  445. 'is_in_stock',
  446. 'stock_id',
  447. ]);
  448. if ($columns === [] || ! in_array('product_id', $columns, true)) {
  449. return [];
  450. }
  451. $query = DB::connection($this->connection)
  452. ->table('cataloginventory_stock_item')
  453. ->select($columns)
  454. ->whereIn('product_id', $entityIds);
  455. if (in_array('stock_id', $columns, true)) {
  456. $query->where('stock_id', 1);
  457. }
  458. $result = [];
  459. foreach ($query->get() as $row) {
  460. $result[(int) $row->product_id] = [
  461. 'qty' => $row->qty ?? 0,
  462. 'is_in_stock' => $row->is_in_stock ?? 0,
  463. ];
  464. }
  465. return $result;
  466. }
  467. /**
  468. * @param array<int, int> $entityIds
  469. * @return array<int, array<string, array<string, mixed>>>
  470. */
  471. private function loadCustomOptions(array $entityIds): array
  472. {
  473. if (
  474. $entityIds === []
  475. || ! $this->schema->hasTable('catalog_product_option')
  476. || ! $this->schema->hasTable('catalog_product_option_title')
  477. ) {
  478. return [];
  479. }
  480. $query = DB::connection($this->connection)
  481. ->table('catalog_product_option as o')
  482. ->leftJoin('catalog_product_option_title as ot', function ($join) {
  483. $join->on('ot.option_id', '=', 'o.option_id');
  484. if (in_array('store_id', $this->schema->columnListing('catalog_product_option_title'), true)) {
  485. $join->where('ot.store_id', 0);
  486. }
  487. })
  488. ->whereIn('o.product_id', $entityIds)
  489. ->select(
  490. 'o.product_id',
  491. 'o.option_id',
  492. 'o.type',
  493. 'o.sort_order as option_sort_order',
  494. 'o.is_require',
  495. 'ot.title as option_title'
  496. )
  497. ->orderBy('o.product_id')
  498. ->orderBy('o.sort_order');
  499. if ($this->schema->hasTable('catalog_product_option_type_value')) {
  500. $query->leftJoin('catalog_product_option_type_value as otv', 'otv.option_id', '=', 'o.option_id')
  501. ->addSelect('otv.option_type_id', 'otv.sku as value_sku', 'otv.sort_order as value_sort_order')
  502. ->orderBy('otv.sort_order');
  503. if ($this->schema->hasTable('catalog_product_option_type_price')) {
  504. $query->leftJoin('catalog_product_option_type_price as otp', function ($join) {
  505. $join->on('otp.option_type_id', '=', 'otv.option_type_id');
  506. if (in_array('store_id', $this->schema->columnListing('catalog_product_option_type_price'), true)) {
  507. $join->where('otp.store_id', 0);
  508. }
  509. })->addSelect('otp.price', 'otp.price_type');
  510. }
  511. if ($this->schema->hasTable('catalog_product_option_type_title')) {
  512. $query->leftJoin('catalog_product_option_type_title as ott', function ($join) {
  513. $join->on('ott.option_type_id', '=', 'otv.option_type_id');
  514. if (in_array('store_id', $this->schema->columnListing('catalog_product_option_type_title'), true)) {
  515. $join->where('ott.store_id', 0);
  516. }
  517. })->addSelect('ott.title as value_title');
  518. }
  519. }
  520. $options = [];
  521. foreach ($query->get() as $row) {
  522. $title = (string) ($row->option_title ?? '');
  523. if ($title === '') {
  524. continue;
  525. }
  526. $productId = (int) $row->product_id;
  527. if (! isset($options[$productId][$title])) {
  528. $options[$productId][$title] = [
  529. 'option_id' => (int) $row->option_id,
  530. 'type' => (string) ($row->type ?? ''),
  531. 'is_require' => (int) ($row->is_require ?? 0),
  532. 'sort_order' => (int) ($row->option_sort_order ?? 0),
  533. 'values' => [],
  534. ];
  535. }
  536. if (! empty($row->option_type_id)) {
  537. $sort = (int) ($row->value_sort_order ?? 0);
  538. $options[$productId][$title]['values'][$sort] = [
  539. 'id' => (int) $row->option_type_id,
  540. 'title' => (string) ($row->value_title ?? ''),
  541. 'sku' => (string) ($row->value_sku ?? ''),
  542. 'price' => (float) ($row->price ?? 0),
  543. 'price_type' => (string) ($row->price_type ?? 'fixed'),
  544. 'sort_order' => $sort,
  545. ];
  546. }
  547. }
  548. foreach ($options as &$productOptions) {
  549. foreach ($productOptions as &$option) {
  550. ksort($option['values']);
  551. }
  552. }
  553. unset($productOptions, $option);
  554. return $options;
  555. }
  556. /**
  557. * @return array<int, string>
  558. */
  559. private function variantOptionTitles(): array
  560. {
  561. if ($this->variantOptionTitles !== null) {
  562. return $this->variantOptionTitles;
  563. }
  564. if (! empty($this->config['variant_option_titles'])) {
  565. return $this->variantOptionTitles = array_values(array_map(
  566. 'strval',
  567. $this->config['variant_option_titles']
  568. ));
  569. }
  570. $skip = array_flip(array_map('strval', $this->config['skip_option_titles'] ?? []));
  571. if (
  572. ! $this->schema->hasTable('catalog_product_option')
  573. || ! $this->schema->hasTable('catalog_product_option_title')
  574. ) {
  575. return $this->variantOptionTitles = [];
  576. }
  577. $query = DB::connection($this->connection)
  578. ->table('catalog_product_option as o')
  579. ->join('catalog_product_option_title as ot', function ($join) {
  580. $join->on('ot.option_id', '=', 'o.option_id');
  581. if (in_array('store_id', $this->schema->columnListing('catalog_product_option_title'), true)) {
  582. $join->where('ot.store_id', 0);
  583. }
  584. })
  585. ->whereIn('o.type', ['drop_down', 'radio'])
  586. ->distinct()
  587. ->orderBy('ot.title')
  588. ->select('ot.title');
  589. $titles = [];
  590. foreach ($query->pluck('title') as $title) {
  591. $title = (string) $title;
  592. if ($title === '' || isset($skip[$title])) {
  593. continue;
  594. }
  595. $titles[] = $title;
  596. }
  597. return $this->variantOptionTitles = $titles;
  598. }
  599. /**
  600. * @param array<string, array<string, mixed>> $options
  601. * @return array<int, array<string, array<string, mixed>>>
  602. */
  603. private function cartesianProduct(array $options): array
  604. {
  605. $sets = [];
  606. $titles = [];
  607. foreach ($options as $title => $option) {
  608. if (empty($option['values'])) {
  609. continue;
  610. }
  611. $titles[] = $title;
  612. $sets[] = array_values($option['values']);
  613. }
  614. if ($sets === []) {
  615. return [];
  616. }
  617. $result = [[]];
  618. foreach ($sets as $idx => $valueList) {
  619. $title = $titles[$idx];
  620. $next = [];
  621. foreach ($result as $existing) {
  622. foreach ($valueList as $value) {
  623. $next[] = array_merge($existing, [$title => $value]);
  624. }
  625. }
  626. $result = $next;
  627. }
  628. return $result;
  629. }
  630. /**
  631. * @param array{qty: mixed, is_in_stock: mixed} $stock
  632. * @param array<string, array<string, mixed>> $variantOptions
  633. * @param array<int, array<string, array<string, mixed>>> $combinations
  634. * @return array<int, array<string, mixed>>
  635. */
  636. private function buildVariants(
  637. string $parentSku,
  638. array $stock,
  639. array $variantOptions,
  640. float $basePrice,
  641. array $combinations
  642. ): array {
  643. if ($combinations === []) {
  644. return [];
  645. }
  646. $variants = [];
  647. foreach ($combinations as $combo) {
  648. $skuParts = [];
  649. $price = $basePrice;
  650. $variant = [
  651. 'sku' => '',
  652. 'price' => '',
  653. 'qty' => (float) ($stock['qty'] ?? 0),
  654. 'in_stock' => (int) ($stock['is_in_stock'] ?? 0),
  655. ];
  656. foreach ($combo as $optTitle => $valueInfo) {
  657. $skuParts[] = (string) ($valueInfo['sort_order'] ?? '');
  658. $price += (float) ($valueInfo['price'] ?? 0);
  659. $label = (string) ($valueInfo['title'] ?? '');
  660. $variant[$optTitle] = $label;
  661. $variant[$this->sanitizeAttributeCode($optTitle)] = $label;
  662. }
  663. $variant['sku'] = $parentSku.'-'.implode('-', $skuParts);
  664. $variant['price'] = number_format($price, 2, '.', '');
  665. $variants[] = $variant;
  666. }
  667. return $variants;
  668. }
  669. /**
  670. * @param array<string, mixed> $option
  671. * @return array<string, mixed>
  672. */
  673. private function optionPayload(string $title, array $option): array
  674. {
  675. $values = [];
  676. foreach ($option['values'] as $value) {
  677. $values[] = [
  678. 'title' => $value['title'],
  679. 'price' => $value['price'],
  680. 'price_type' => $value['price_type'],
  681. 'sku' => $value['sku'],
  682. 'sort_order' => (int) $value['sort_order'],
  683. ];
  684. }
  685. return [
  686. 'title' => $title,
  687. 'type' => $option['type'],
  688. 'is_require' => (bool) $option['is_require'],
  689. 'sort_order' => (int) $option['sort_order'],
  690. 'values' => $values,
  691. ];
  692. }
  693. /**
  694. * @return array<string, object>
  695. */
  696. private function productAttributes(): array
  697. {
  698. if ($this->productAttributeMap !== null) {
  699. return $this->productAttributeMap;
  700. }
  701. $codes = array_values(array_unique(array_merge(
  702. $this->config['base_attributes'] ?? [],
  703. $this->config['include_eav_attributes'] ?? [],
  704. ['status', 'url_key']
  705. )));
  706. return $this->productAttributeMap = $this->attributeMap('catalog_product', $codes);
  707. }
  708. /**
  709. * @param array<int, string> $codes
  710. * @return array<string, object>
  711. */
  712. private function attributeMap(string $entityTypeCode, array $codes): array
  713. {
  714. if ($codes === [] || ! $this->schema->hasTable('eav_attribute')) {
  715. return [];
  716. }
  717. $select = $this->schema->existingColumns('eav_attribute', [
  718. 'attribute_id',
  719. 'attribute_code',
  720. 'backend_type',
  721. 'frontend_input',
  722. 'frontend_label',
  723. 'is_required',
  724. ]);
  725. if ($select === []) {
  726. return [];
  727. }
  728. $query = DB::connection($this->connection)
  729. ->table('eav_attribute')
  730. ->select($select)
  731. ->whereIn('attribute_code', $codes);
  732. $typeId = $this->entityTypeId($entityTypeCode);
  733. if ($typeId) {
  734. $query->where('entity_type_id', $typeId);
  735. }
  736. $map = [];
  737. foreach ($query->get() as $attribute) {
  738. $input = (string) ($attribute->frontend_input ?? '');
  739. if (in_array($input, ['media_image', 'gallery'], true)) {
  740. continue;
  741. }
  742. $map[$attribute->attribute_code] = $attribute;
  743. }
  744. return $map;
  745. }
  746. private function attributeByCode(string $entityTypeCode, string $code): ?object
  747. {
  748. return $this->attributeMap($entityTypeCode, [$code])[$code] ?? null;
  749. }
  750. private function entityTypeId(string $code): ?int
  751. {
  752. if (! $this->schema->hasTable('eav_entity_type')) {
  753. return null;
  754. }
  755. $id = DB::connection($this->connection)
  756. ->table('eav_entity_type')
  757. ->where('entity_type_code', $code)
  758. ->value('entity_type_id');
  759. return $id ? (int) $id : null;
  760. }
  761. /**
  762. * @return array<int, string|\Illuminate\Database\Query\Expression>
  763. */
  764. private function attributeSelectColumns(): array
  765. {
  766. $columns = [
  767. 'ea.attribute_id',
  768. 'ea.attribute_code',
  769. 'ea.backend_type',
  770. ];
  771. $listing = array_fill_keys(array_map('strtolower', $this->schema->columnListing('eav_attribute')), true);
  772. foreach (['frontend_label', 'frontend_input', 'is_required', 'is_user_defined'] as $column) {
  773. if (isset($listing[$column])) {
  774. $columns[] = 'ea.'.$column;
  775. }
  776. }
  777. if ($this->schema->hasTable('catalog_eav_attribute')) {
  778. $cea = array_fill_keys(array_map('strtolower', $this->schema->columnListing('catalog_eav_attribute')), true);
  779. foreach (['is_searchable', 'is_filterable', 'is_comparable', 'is_configurable', 'position'] as $column) {
  780. if (isset($cea[$column])) {
  781. $columns[] = DB::raw('IFNULL(cea.'.$column.', 0) as '.$column);
  782. }
  783. }
  784. }
  785. return $columns;
  786. }
  787. /**
  788. * @param array<int, int> $attributeIds
  789. * @return array<int, array<int, array<string, mixed>>>
  790. */
  791. private function loadAttributeOptions(array $attributeIds): array
  792. {
  793. if (
  794. $attributeIds === []
  795. || ! $this->schema->hasTable('eav_attribute_option')
  796. || ! $this->schema->hasTable('eav_attribute_option_value')
  797. ) {
  798. return [];
  799. }
  800. $rows = DB::connection($this->connection)
  801. ->table('eav_attribute_option as eao')
  802. ->leftJoin('eav_attribute_option_value as eaov', function ($join) {
  803. $join->on('eaov.option_id', '=', 'eao.option_id');
  804. if (in_array('store_id', $this->schema->columnListing('eav_attribute_option_value'), true)) {
  805. $join->where('eaov.store_id', 0);
  806. }
  807. })
  808. ->whereIn('eao.attribute_id', $attributeIds)
  809. ->orderBy('eao.attribute_id')
  810. ->orderBy('eao.sort_order')
  811. ->get(['eao.attribute_id', 'eao.option_id', 'eao.sort_order', 'eaov.value as label']);
  812. $grouped = [];
  813. foreach ($rows as $row) {
  814. $grouped[(int) $row->attribute_id][] = [
  815. 'id' => (int) $row->option_id,
  816. 'label' => (string) ($row->label ?? ''),
  817. 'sort_order' => (int) ($row->sort_order ?? 0),
  818. ];
  819. }
  820. return $grouped;
  821. }
  822. /**
  823. * @param array<int, string> $optionIds
  824. * @return array<int, string>
  825. */
  826. private function optionLabels(int $attributeId, array $optionIds): array
  827. {
  828. $this->warmOptionLabels($attributeId);
  829. $labels = [];
  830. foreach ($optionIds as $optionId) {
  831. $id = (int) $optionId;
  832. if (isset($this->optionLabelCache[$attributeId][$id])) {
  833. $labels[] = $this->optionLabelCache[$attributeId][$id];
  834. }
  835. }
  836. return $labels;
  837. }
  838. private function warmOptionLabels(int $attributeId): void
  839. {
  840. if (isset($this->optionLabelCache[$attributeId])) {
  841. return;
  842. }
  843. $this->optionLabelCache[$attributeId] = [];
  844. foreach ($this->loadAttributeOptions([$attributeId])[$attributeId] ?? [] as $option) {
  845. $this->optionLabelCache[$attributeId][(int) $option['id']] = (string) $option['label'];
  846. }
  847. }
  848. private function magentoInputToBagistoType(string $frontendInput): string
  849. {
  850. return match ($frontendInput) {
  851. 'textarea' => 'textarea',
  852. 'select' => 'select',
  853. 'multiselect' => 'multiselect',
  854. 'boolean' => 'boolean',
  855. 'price' => 'price',
  856. 'date' => 'date',
  857. 'media_image', 'gallery' => 'image',
  858. default => 'text',
  859. };
  860. }
  861. public function sanitizeAttributeCode(string $title): string
  862. {
  863. $code = strtolower($title);
  864. $code = (string) preg_replace('/[^a-z0-9]+/', '_', $code);
  865. return trim($code, '_');
  866. }
  867. private function generateUrlKey(string $name, string $sku): string
  868. {
  869. $key = strtolower($name);
  870. $key = (string) preg_replace('/[^a-z0-9\s-]/', '', $key);
  871. $key = (string) preg_replace('/[\s]+/', '-', trim($key));
  872. $key = (string) preg_replace('/-+/', '-', $key);
  873. $key = trim($key, '-');
  874. if ($key === '') {
  875. $key = strtolower((string) preg_replace('/[^a-zA-Z0-9]/', '-', $sku));
  876. }
  877. return $key;
  878. }
  879. private function htmlToText(string $html): string
  880. {
  881. if ($html === '') {
  882. return '';
  883. }
  884. $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
  885. return trim((string) preg_replace('/\s+/', ' ', $text));
  886. }
  887. private function replaceMediaDirectives(string $html): string
  888. {
  889. $mediaBaseUrl = (string) ($this->config['store_media_url'] ?? '');
  890. if ($html === '' || $mediaBaseUrl === '') {
  891. return $html;
  892. }
  893. $mediaBaseUrl = rtrim($mediaBaseUrl, '/').'/';
  894. return (string) preg_replace_callback(
  895. '/\{\{\s*media\s+url\s*=\s*["\']([^"\']+)["\']\s*\}\}/i',
  896. fn (array $match) => $mediaBaseUrl.ltrim($match[1], '/'),
  897. $html
  898. );
  899. }
  900. private function absoluteMediaUrl(string $baseUrl, string $path): string
  901. {
  902. if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
  903. return $path;
  904. }
  905. if ($baseUrl === '') {
  906. return $path;
  907. }
  908. return $baseUrl.'/'.ltrim($path, '/');
  909. }
  910. }