SyncCatalogCommand.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892
  1. <?php
  2. namespace App\Console\Commands;
  3. use Illuminate\Console\Command;
  4. use Illuminate\Support\Facades\DB;
  5. use Illuminate\Support\Str;
  6. use Longyi\Core\Models\ProductOption;
  7. use Longyi\Core\Models\ProductOptionValue;
  8. use Longyi\Core\Models\ProductVariant;
  9. use PhpOffice\PhpSpreadsheet\IOFactory;
  10. use Webkul\Attribute\Models\Attribute;
  11. use Webkul\Attribute\Models\AttributeFamily;
  12. use Webkul\Attribute\Models\AttributeOption;
  13. use Webkul\Attribute\Repositories\AttributeRepository;
  14. use Webkul\Core\Models\Channel;
  15. use Webkul\Product\Helpers\Indexers\ElasticSearch;
  16. use Webkul\Product\Helpers\Indexers\Flat;
  17. use Webkul\Product\Helpers\Indexers\Price;
  18. use Webkul\Product\Models\Product;
  19. /**
  20. * Import attributes and flexible_variant products from the Magento-style CSV
  21. * exports located in storage/sync/.
  22. *
  23. * - bagisto_attributes.csv : descriptive Bagisto attributes (catalog attributes)
  24. * - bagisto_products.csv : configurable rows turned into flexible_variant products
  25. * (Longyi\Core product_options / product_variants tables)
  26. */
  27. class SyncCatalogCommand extends Command
  28. {
  29. protected $signature = 'catalog:sync
  30. {--attributes-only : Only sync attributes}
  31. {--products-only : Only sync products}
  32. {--limit= : Limit the number of products processed}
  33. {--attributes-file= : Absolute/relative path for attributes file (.csv/.xlsx/.xls)}
  34. {--products-file= : Absolute/relative path for products file (.csv/.xlsx/.xls)}
  35. {--no-index : Skip rebuilding indices at the end}';
  36. protected $description = 'Sync attributes and flexible_variant products from storage/sync CSV files';
  37. protected string $attributesCsv;
  38. protected string $productsCsv;
  39. protected int $familyId;
  40. /** General attribute group of the variant_product family. */
  41. protected int $generalGroupId = 15;
  42. protected string $channelCode = 'default';
  43. protected int $channelId;
  44. protected string $locale = 'en';
  45. /** @var array<string, \Webkul\Attribute\Models\Attribute> code => attribute */
  46. protected array $familyAttributes = [];
  47. /** @var int[] Product ids touched during this run (for targeted reindex). */
  48. protected array $touchedProductIds = [];
  49. public function __construct(protected AttributeRepository $attributeRepository)
  50. {
  51. parent::__construct();
  52. }
  53. public function handle(): int
  54. {
  55. $this->attributesCsv = $this->resolveInputFile(
  56. 'bagisto_attributes',
  57. $this->option('attributes-file')
  58. );
  59. $this->productsCsv = $this->resolveInputFile(
  60. 'bagisto_products',
  61. $this->option('products-file')
  62. );
  63. foreach ([
  64. 'attributes' => $this->attributesCsv,
  65. 'products' => $this->productsCsv,
  66. ] as $name => $file) {
  67. if (! $file || ! is_file($file)) {
  68. $this->error("Input file not found for {$name}: ".($file ?: '[empty path]'));
  69. return self::FAILURE;
  70. }
  71. }
  72. $family = AttributeFamily::where('code', 'wigs')->first();
  73. if (! $family) {
  74. $this->error('Attribute family "variant_product" not found.');
  75. return self::FAILURE;
  76. }
  77. $this->familyId = $family->id;
  78. $channel = Channel::where('code', $this->channelCode)->first() ?? Channel::first();
  79. $this->channelId = $channel->id;
  80. $this->channelCode = $channel->code;
  81. if (! $this->option('products-only')) {
  82. $this->syncAttributes();
  83. }
  84. $this->loadFamilyAttributes($family);
  85. if (! $this->option('attributes-only')) {
  86. $this->syncProducts();
  87. if (! $this->option('no-index')) {
  88. $this->reindexProducts();
  89. }
  90. }
  91. $this->info('Catalog sync finished.');
  92. return self::SUCCESS;
  93. }
  94. /* =========================================================================
  95. | Attributes
  96. * ====================================================================== */
  97. protected function syncAttributes(): void
  98. {
  99. $rows = $this->readCsv($this->attributesCsv);
  100. $this->info('Syncing attributes ('.count($rows).' rows)...');
  101. $created = 0;
  102. $skipped = 0;
  103. foreach ($rows as $row) {
  104. $code = trim((string) ($row['code'] ?? ''));
  105. if ($code === '') {
  106. continue;
  107. }
  108. $existing = Attribute::where('code', $code)->first();
  109. if ($existing) {
  110. $this->ensureAttributeInFamily($existing->id);
  111. $skipped++;
  112. continue;
  113. }
  114. $type = trim((string) $row['type']);
  115. $data = [
  116. 'code' => $code,
  117. 'en' => ['name' => $row['name'] !== '' ? $row['name'] : $code],
  118. 'admin_name' => $row['name'] !== '' ? $row['name'] : $code,
  119. 'type' => $type,
  120. 'is_required' => (int) ($row['is_required'] ?? 0),
  121. 'is_filterable' => (int) ($row['is_filterable'] ?? 0),
  122. 'is_comparable' => (int) ($row['is_comparable'] ?? 0),
  123. 'is_configurable' => (int) ($row['is_configurable'] ?? 0),
  124. 'position' => (int) ($row['position'] ?? 0),
  125. 'is_user_defined' => 1,
  126. 'value_per_locale' => in_array($type, ['text', 'textarea']) ? 1 : 0,
  127. 'value_per_channel' => 0,
  128. ];
  129. if (in_array($type, ['select', 'multiselect', 'checkbox']) && trim((string) ($row['options_labels'] ?? '')) !== '') {
  130. $labels = array_map('trim', explode('|', $row['options_labels']));
  131. $options = [];
  132. foreach (array_values(array_filter($labels, fn ($l) => $l !== '')) as $i => $label) {
  133. $options[] = [
  134. 'admin_name' => $label,
  135. 'sort_order' => $i,
  136. 'en' => ['label' => $label],
  137. ];
  138. }
  139. $data['options'] = $options;
  140. }
  141. $attribute = $this->attributeRepository->create($data);
  142. $this->ensureAttributeInFamily($attribute->id);
  143. $created++;
  144. }
  145. $this->info("Attributes: {$created} created, {$skipped} already existed.");
  146. }
  147. protected function ensureAttributeInFamily(int $attributeId): void
  148. {
  149. $exists = DB::table('attribute_group_mappings')
  150. ->where('attribute_id', $attributeId)
  151. ->where('attribute_group_id', $this->generalGroupId)
  152. ->exists();
  153. if ($exists) {
  154. return;
  155. }
  156. $position = (int) DB::table('attribute_group_mappings')
  157. ->where('attribute_group_id', $this->generalGroupId)
  158. ->max('position');
  159. DB::table('attribute_group_mappings')->insert([
  160. 'attribute_id' => $attributeId,
  161. 'attribute_group_id' => $this->generalGroupId,
  162. 'position' => $position + 1,
  163. ]);
  164. }
  165. protected function loadFamilyAttributes(AttributeFamily $family): void
  166. {
  167. $this->familyAttributes = [];
  168. foreach ($family->custom_attributes()->get() as $attribute) {
  169. $this->familyAttributes[$attribute->code] = $attribute;
  170. }
  171. }
  172. /* =========================================================================
  173. | Products
  174. * ====================================================================== */
  175. protected function syncProducts(): void
  176. {
  177. $rows = $this->readCsv($this->productsCsv);
  178. $limit = $this->option('limit') ? (int) $this->option('limit') : null;
  179. if ($limit) {
  180. $rows = array_slice($rows, 0, $limit);
  181. }
  182. $this->info('Syncing '.count($rows).' products...');
  183. $bar = $this->output->createProgressBar(count($rows));
  184. $bar->start();
  185. foreach ($rows as $row) {
  186. try {
  187. $this->importProduct($row);
  188. } catch (\Throwable $e) {
  189. $this->newLine();
  190. $this->error('Product '.($row['sku'] ?? '?').' failed: '.$e->getMessage());
  191. }
  192. $bar->advance();
  193. }
  194. $bar->finish();
  195. $this->newLine();
  196. }
  197. /**
  198. * Reindex only the products touched during this run.
  199. *
  200. * Done per-product to keep price-index inserts well under MySQL's
  201. * 65,535 placeholder limit (a single flexible_variant product can have
  202. * hundreds of variants × customer groups). The core Inventory indexer
  203. * only covers simple/virtual products, so it is intentionally skipped for
  204. * flexible_variant.
  205. */
  206. protected function reindexProducts(): void
  207. {
  208. $ids = array_values(array_unique($this->touchedProductIds));
  209. if (empty($ids)) {
  210. return;
  211. }
  212. $this->info('Reindexing '.count($ids).' products (price/flat'.($this->elasticEnabled() ? '/elastic' : '').')...');
  213. /** @var \Webkul\Product\Helpers\Indexers\Price $priceIndexer */
  214. $priceIndexer = app(Price::class);
  215. /** @var \Webkul\Product\Helpers\Indexers\Flat $flatIndexer */
  216. $flatIndexer = app(Flat::class);
  217. $elasticIndexer = $this->elasticEnabled() ? app(ElasticSearch::class) : null;
  218. $relations = [
  219. 'attribute_family',
  220. 'attribute_values',
  221. 'channels',
  222. 'price_indices',
  223. 'customer_group_prices',
  224. 'catalog_rule_prices',
  225. 'flexibleVariants',
  226. 'flexibleVariants.price_indices',
  227. 'flexibleVariants.customer_group_prices',
  228. ];
  229. $bar = $this->output->createProgressBar(count($ids));
  230. $bar->start();
  231. foreach (array_chunk($ids, 20) as $chunk) {
  232. $products = Product::with($relations)->whereIn('id', $chunk)->get();
  233. foreach ($products as $product) {
  234. try {
  235. $priceIndexer->reindexRow($product);
  236. $flatIndexer->reindexRow($product);
  237. $elasticIndexer?->reindexRow($product);
  238. } catch (\Throwable $e) {
  239. $this->newLine();
  240. $this->error('Reindex failed for product '.$product->sku.': '.$e->getMessage());
  241. }
  242. $bar->advance();
  243. }
  244. }
  245. $bar->finish();
  246. $this->newLine();
  247. }
  248. protected function elasticEnabled(): bool
  249. {
  250. return core()->getConfigData('catalog.products.search.engine') === 'elastic';
  251. }
  252. protected function importProduct(array $row): void
  253. {
  254. $sku = trim((string) $row['sku']);
  255. if ($sku === '') {
  256. return;
  257. }
  258. $product = Product::firstOrNew(['sku' => $sku]);
  259. $product->type = 'flexible_variant';
  260. $product->attribute_family_id = $this->familyId;
  261. $product->save();
  262. $this->touchedProductIds[] = $product->id;
  263. DB::table('product_channels')->updateOrInsert([
  264. 'product_id' => $product->id,
  265. 'channel_id' => $this->channelId,
  266. ]);
  267. $this->saveAttributeValues($product, $row);
  268. $this->saveInventory($product, $row);
  269. $this->saveImages($product, $row);
  270. DB::transaction(function () use ($product, $row) {
  271. $this->saveOptionsAndVariants($product, $row);
  272. });
  273. }
  274. protected function saveAttributeValues(Product $product, array $row): void
  275. {
  276. $row = $this->applyDefaultProductAttributeValues($row);
  277. $values = [];
  278. foreach ($this->familyAttributes as $code => $attribute) {
  279. if (! array_key_exists($code, $row)) {
  280. continue;
  281. }
  282. $rawValue = $row[$code];
  283. if ($rawValue === null || $rawValue === '') {
  284. continue;
  285. }
  286. $columnValue = $this->castAttributeValue($attribute, $rawValue);
  287. if ($columnValue === null) {
  288. continue;
  289. }
  290. $valueRow = array_fill_keys(array_values($attribute->attributeTypeFields), null);
  291. $valueRow['json_value'] = null;
  292. $valueRow[$attribute->column_name] = $columnValue;
  293. $valueRow['attribute_id'] = $attribute->id;
  294. $valueRow['product_id'] = $product->id;
  295. $valueRow['channel'] = $attribute->value_per_channel ? $this->channelCode : null;
  296. $valueRow['locale'] = $attribute->value_per_locale ? $this->locale : null;
  297. $valueRow['unique_id'] = implode('|', array_filter([
  298. $valueRow['channel'],
  299. $valueRow['locale'],
  300. $valueRow['product_id'],
  301. $valueRow['attribute_id'],
  302. ]));
  303. $values[$valueRow['unique_id']] = $valueRow;
  304. }
  305. if (empty($values)) {
  306. return;
  307. }
  308. DB::table('product_attribute_values')->upsert(
  309. array_values($values),
  310. ['unique_id'],
  311. ['text_value', 'boolean_value', 'integer_value', 'float_value', 'datetime_value', 'date_value', 'json_value']
  312. );
  313. }
  314. protected function applyDefaultProductAttributeValues(array $row): array
  315. {
  316. foreach ([
  317. 'visible_individually' => 1,
  318. 'manage_stock' => 1,
  319. ] as $code => $defaultValue) {
  320. if (
  321. ! array_key_exists($code, $row)
  322. || $row[$code] === null
  323. || (is_string($row[$code]) && trim($row[$code]) === '')
  324. ) {
  325. $row[$code] = $defaultValue;
  326. }
  327. }
  328. return $row;
  329. }
  330. protected function saveInventory(Product $product, array $row): void
  331. {
  332. $qty = $row['qty'] ?? 9999;
  333. if ($qty === null || (is_string($qty) && trim($qty) === '')) {
  334. $qty = 9999;
  335. }
  336. DB::table('product_inventories')->updateOrInsert(
  337. [
  338. 'product_id' => $product->id,
  339. 'inventory_source_id' => 1,
  340. 'vendor_id' => 0,
  341. ],
  342. ['qty' => (int) $qty]
  343. );
  344. }
  345. /**
  346. * @return mixed
  347. */
  348. protected function castAttributeValue(Attribute $attribute, $value)
  349. {
  350. switch ($attribute->type) {
  351. case 'boolean':
  352. return (int) (bool) (is_numeric($value) ? (int) $value : $value);
  353. case 'price':
  354. return (float) $value;
  355. case 'select':
  356. $option = AttributeOption::where('attribute_id', $attribute->id)
  357. ->where('admin_name', $value)
  358. ->first();
  359. return $option?->id;
  360. case 'multiselect':
  361. $ids = [];
  362. foreach (array_map('trim', explode(',', (string) $value)) as $label) {
  363. if ($label === '') {
  364. continue;
  365. }
  366. $option = AttributeOption::where('attribute_id', $attribute->id)
  367. ->where('admin_name', $label)
  368. ->first();
  369. if ($option) {
  370. $ids[] = $option->id;
  371. }
  372. }
  373. return empty($ids) ? null : implode(',', $ids);
  374. default:
  375. return $value;
  376. }
  377. }
  378. protected function saveImages(Product $product, array $row): void
  379. {
  380. $urls = [];
  381. if (! empty($row['base_image'])) {
  382. $urls[] = trim($row['base_image']);
  383. }
  384. if (! empty($row['additional_images'])) {
  385. foreach (explode('|', $row['additional_images']) as $url) {
  386. $url = trim($url);
  387. if ($url !== '') {
  388. $urls[] = $url;
  389. }
  390. }
  391. }
  392. $urls = array_values(array_unique($urls));
  393. if (empty($urls)) {
  394. return;
  395. }
  396. // Rebuild image set for idempotent re-runs.
  397. DB::table('product_images')->where('product_id', $product->id)->delete();
  398. $records = [];
  399. foreach ($urls as $position => $url) {
  400. $records[] = [
  401. 'type' => 'images',
  402. 'path' => $url,
  403. 'product_id' => $product->id,
  404. 'position' => $position + 1,
  405. 'is_base_image' => $position === 0 ? 1 : 0,
  406. ];
  407. }
  408. DB::table('product_images')->insert($records);
  409. }
  410. /* =========================================================================
  411. | Flexible variant options & variants
  412. * ====================================================================== */
  413. protected function saveOptionsAndVariants(Product $product, array $row): void
  414. {
  415. $superCodes = array_values(array_filter(array_map('trim', explode(',', (string) ($row['super_attributes'] ?? '')))));
  416. if (empty($superCodes)) {
  417. return;
  418. }
  419. $optionsMeta = json_decode((string) ($row['options'] ?? '[]'), true) ?: [];
  420. $variants = json_decode((string) ($row['variants'] ?? '[]'), true) ?: [];
  421. // Clean previous flexible variant data for idempotent re-runs.
  422. $this->clearVariantData($product);
  423. /**
  424. * For each super-attribute (dimension) create a product option and its
  425. * values. labelToValueId[dimensionIndex][label] => product_option_value_id
  426. *
  427. * @var array<int, array<string, int>> $labelToValueId
  428. */
  429. $labelToValueId = [];
  430. $syncOptions = [];
  431. foreach ($superCodes as $index => $code) {
  432. $meta = $optionsMeta[$index] ?? [];
  433. $optionLabel = $meta['title'] ?? Str::title(str_replace('_', ' ', $code));
  434. /**
  435. * product_options.code is globally unique, so options are SHARED
  436. * across products. Reuse an existing option for this dimension or
  437. * create it once.
  438. */
  439. $option = ProductOption::firstOrCreate(
  440. ['code' => $code],
  441. ['label' => $optionLabel, 'type' => 'select', 'position' => $index]
  442. );
  443. $syncOptions[$option->id] = ['position' => $index, 'is_required' => true];
  444. // Existing values for this (shared) option, keyed by label.
  445. $existing = ProductOptionValue::where('product_option_id', $option->id)
  446. ->get()
  447. ->keyBy('label');
  448. foreach ($existing as $label => $value) {
  449. $labelToValueId[$index][$label] = $value->id;
  450. }
  451. // Collect distinct labels for this dimension: prefer the options JSON
  452. // value list (gives sku/order), then add any labels seen in variants.
  453. $orderedLabels = [];
  454. $valueSku = [];
  455. foreach (($meta['values'] ?? []) as $valueMeta) {
  456. $label = (string) ($valueMeta['title'] ?? '');
  457. if ($label === '' || array_key_exists($label, $valueSku)) {
  458. continue;
  459. }
  460. $orderedLabels[] = $label;
  461. $valueSku[$label] = (string) ($valueMeta['sku'] ?? '');
  462. }
  463. foreach ($variants as $variant) {
  464. $label = $variant[(string) $index] ?? null;
  465. if ($label !== null && ! array_key_exists($label, $valueSku)) {
  466. $orderedLabels[] = $label;
  467. $valueSku[$label] = '';
  468. }
  469. }
  470. $nextPosition = (int) (ProductOptionValue::where('product_option_id', $option->id)->max('position'));
  471. foreach ($orderedLabels as $label) {
  472. if (isset($labelToValueId[$index][$label])) {
  473. continue;
  474. }
  475. $valueCode = $valueSku[$label] !== '' ? $valueSku[$label] : Str::slug($label);
  476. if ($valueCode === '') {
  477. $valueCode = 'value-'.($nextPosition + 1);
  478. }
  479. $value = ProductOptionValue::create([
  480. 'product_option_id' => $option->id,
  481. 'label' => $label,
  482. 'code' => $valueCode,
  483. 'position' => ++$nextPosition,
  484. ]);
  485. $labelToValueId[$index][$label] = $value->id;
  486. }
  487. }
  488. $product->options()->sync($syncOptions);
  489. $this->saveVariants($product, $row, $superCodes, $variants, $labelToValueId);
  490. }
  491. protected function saveVariants(Product $product, array $row, array $superCodes, array $variants, array $labelToValueId): void
  492. {
  493. if (empty($variants)) {
  494. return;
  495. }
  496. $parentName = (string) ($row['name'] ?? '');
  497. $variantRecords = [];
  498. $now = now();
  499. foreach ($variants as $sortOrder => $variant) {
  500. $sku = trim((string) ($variant['sku'] ?? ''));
  501. if ($sku === '') {
  502. continue;
  503. }
  504. $variantRecords[] = [
  505. 'product_id' => $product->id,
  506. 'sku' => $sku,
  507. 'name' => $parentName !== '' ? $parentName : null,
  508. 'price' => (float) ($variant['price'] ?? 0),
  509. 'quantity' => (int) ($variant['qty'] ?? 0),
  510. 'status' => (int) ($variant['in_stock'] ?? 1) ? 1 : 1,
  511. 'sort_order' => $sortOrder,
  512. 'created_at' => $now,
  513. 'updated_at' => $now,
  514. ];
  515. }
  516. if (empty($variantRecords)) {
  517. return;
  518. }
  519. foreach (array_chunk($variantRecords, 500) as $chunk) {
  520. ProductVariant::insert($chunk);
  521. }
  522. // Map sku => variant id for the pivot inserts.
  523. $variantIdBySku = ProductVariant::where('product_id', $product->id)
  524. ->pluck('id', 'sku')
  525. ->toArray();
  526. $pivotRecords = [];
  527. foreach ($variants as $variant) {
  528. $sku = trim((string) ($variant['sku'] ?? ''));
  529. $variantId = $variantIdBySku[$sku] ?? null;
  530. if (! $variantId) {
  531. continue;
  532. }
  533. foreach ($superCodes as $index => $code) {
  534. $label = $variant[(string) $index] ?? null;
  535. if ($label === null) {
  536. continue;
  537. }
  538. $valueId = $labelToValueId[$index][$label] ?? null;
  539. if (! $valueId) {
  540. continue;
  541. }
  542. $pivotRecords[] = [
  543. 'product_variant_id' => $variantId,
  544. 'product_option_value_id' => $valueId,
  545. 'created_at' => $now,
  546. 'updated_at' => $now,
  547. ];
  548. }
  549. }
  550. foreach (array_chunk($pivotRecords, 1000) as $chunk) {
  551. DB::table('product_variant_option_values')->insert($chunk);
  552. }
  553. }
  554. /**
  555. * Remove this product's variants and its option associations so the command
  556. * can be safely re-run. Shared product_options / product_option_values are
  557. * intentionally left intact (they are global and reused by other products).
  558. */
  559. protected function clearVariantData(Product $product): void
  560. {
  561. $variantIds = ProductVariant::withTrashed()
  562. ->where('product_id', $product->id)
  563. ->pluck('id')
  564. ->toArray();
  565. if (! empty($variantIds)) {
  566. DB::table('product_variant_option_values')
  567. ->whereIn('product_variant_id', $variantIds)
  568. ->delete();
  569. DB::table('product_variant_images')
  570. ->whereIn('product_variant_id', $variantIds)
  571. ->delete();
  572. ProductVariant::withTrashed()->whereIn('id', $variantIds)->forceDelete();
  573. }
  574. $product->options()->detach();
  575. }
  576. /* =========================================================================
  577. | File helper
  578. * ====================================================================== */
  579. /**
  580. * Resolve input file path from:
  581. * 1) explicit option path
  582. * 2) storage/sync/<base>.csv
  583. * 3) storage/sync/<base>.xlsx
  584. * 4) storage/sync/<base>.xls
  585. */
  586. protected function resolveInputFile(string $baseName, ?string $customPath = null): ?string
  587. {
  588. if (is_string($customPath) && trim($customPath) !== '') {
  589. $path = trim($customPath);
  590. if (! Str::startsWith($path, ['/'])) {
  591. $path = base_path($path);
  592. }
  593. return $path;
  594. }
  595. foreach (['csv', 'xlsx', 'xls'] as $ext) {
  596. $path = storage_path("sync/{$baseName}.{$ext}");
  597. if (is_file($path)) {
  598. return $path;
  599. }
  600. }
  601. return null;
  602. }
  603. /**
  604. * Read CSV/XLSX/XLS into associative rows keyed by header.
  605. *
  606. * @return array<int, array<string, string|null>>
  607. */
  608. protected function readCsv(string $path): array
  609. {
  610. $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
  611. if (in_array($extension, ['xlsx', 'xls'], true)) {
  612. return $this->readExcel($path);
  613. }
  614. return $this->readCsvFile($path);
  615. }
  616. /**
  617. * @return array<int, array<string, string|null>>
  618. */
  619. protected function readCsvFile(string $path): array
  620. {
  621. $rows = [];
  622. $handle = fopen($path, 'r');
  623. if ($handle === false) {
  624. return $rows;
  625. }
  626. $header = fgetcsv($handle);
  627. if ($header === false) {
  628. fclose($handle);
  629. return $rows;
  630. }
  631. $header = array_map(fn ($h) => trim((string) $h), $header);
  632. while (($data = fgetcsv($handle)) !== false) {
  633. if ($data === [null] || $data === false) {
  634. continue;
  635. }
  636. $row = [];
  637. foreach ($header as $i => $key) {
  638. if ($key === '') {
  639. continue;
  640. }
  641. $row[$key] = $data[$i] ?? null;
  642. }
  643. // Skip fully empty lines.
  644. if (count(array_filter($row, fn ($v) => $v !== null && $v !== '')) === 0) {
  645. continue;
  646. }
  647. $rows[] = $row;
  648. }
  649. fclose($handle);
  650. return $rows;
  651. }
  652. /**
  653. * @return array<int, array<string, string|null>>
  654. */
  655. protected function readExcel(string $path): array
  656. {
  657. $rows = [];
  658. $spreadsheet = IOFactory::load($path);
  659. $sheetRows = $spreadsheet
  660. ->getActiveSheet()
  661. ->toArray(null, false, false, false);
  662. if (empty($sheetRows)) {
  663. return $rows;
  664. }
  665. $header = array_map(
  666. fn ($h) => trim((string) $h),
  667. array_shift($sheetRows) ?: []
  668. );
  669. foreach ($sheetRows as $data) {
  670. if (! is_array($data)) {
  671. continue;
  672. }
  673. $row = [];
  674. foreach ($header as $i => $key) {
  675. if ($key === '') {
  676. continue;
  677. }
  678. $value = $data[$i] ?? null;
  679. $row[$key] = $value === null ? null : (string) $value;
  680. }
  681. if (count(array_filter($row, fn ($v) => $v !== null && $v !== '')) === 0) {
  682. continue;
  683. }
  684. $rows[] = $row;
  685. }
  686. return $rows;
  687. }
  688. }