Magento1ProductReader.php 40 KB

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