Magento1ProductReader.php 38 KB

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