|
@@ -0,0 +1,892 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Console\Commands;
|
|
|
|
|
+
|
|
|
|
|
+use Illuminate\Console\Command;
|
|
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
|
|
+use Illuminate\Support\Str;
|
|
|
|
|
+use Longyi\Core\Models\ProductOption;
|
|
|
|
|
+use Longyi\Core\Models\ProductOptionValue;
|
|
|
|
|
+use Longyi\Core\Models\ProductVariant;
|
|
|
|
|
+use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
|
|
|
+use Webkul\Attribute\Models\Attribute;
|
|
|
|
|
+use Webkul\Attribute\Models\AttributeFamily;
|
|
|
|
|
+use Webkul\Attribute\Models\AttributeOption;
|
|
|
|
|
+use Webkul\Attribute\Repositories\AttributeRepository;
|
|
|
|
|
+use Webkul\Core\Models\Channel;
|
|
|
|
|
+use Webkul\Product\Helpers\Indexers\ElasticSearch;
|
|
|
|
|
+use Webkul\Product\Helpers\Indexers\Flat;
|
|
|
|
|
+use Webkul\Product\Helpers\Indexers\Price;
|
|
|
|
|
+use Webkul\Product\Models\Product;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Import attributes and flexible_variant products from the Magento-style CSV
|
|
|
|
|
+ * exports located in storage/sync/.
|
|
|
|
|
+ *
|
|
|
|
|
+ * - bagisto_attributes.csv : descriptive Bagisto attributes (catalog attributes)
|
|
|
|
|
+ * - bagisto_products.csv : configurable rows turned into flexible_variant products
|
|
|
|
|
+ * (Longyi\Core product_options / product_variants tables)
|
|
|
|
|
+ */
|
|
|
|
|
+class SyncCatalogCommand extends Command
|
|
|
|
|
+{
|
|
|
|
|
+ protected $signature = 'catalog:sync
|
|
|
|
|
+ {--attributes-only : Only sync attributes}
|
|
|
|
|
+ {--products-only : Only sync products}
|
|
|
|
|
+ {--limit= : Limit the number of products processed}
|
|
|
|
|
+ {--attributes-file= : Absolute/relative path for attributes file (.csv/.xlsx/.xls)}
|
|
|
|
|
+ {--products-file= : Absolute/relative path for products file (.csv/.xlsx/.xls)}
|
|
|
|
|
+ {--no-index : Skip rebuilding indices at the end}';
|
|
|
|
|
+
|
|
|
|
|
+ protected $description = 'Sync attributes and flexible_variant products from storage/sync CSV files';
|
|
|
|
|
+
|
|
|
|
|
+ protected string $attributesCsv;
|
|
|
|
|
+
|
|
|
|
|
+ protected string $productsCsv;
|
|
|
|
|
+
|
|
|
|
|
+ protected int $familyId;
|
|
|
|
|
+
|
|
|
|
|
+ /** General attribute group of the variant_product family. */
|
|
|
|
|
+ protected int $generalGroupId = 15;
|
|
|
|
|
+
|
|
|
|
|
+ protected string $channelCode = 'default';
|
|
|
|
|
+
|
|
|
|
|
+ protected int $channelId;
|
|
|
|
|
+
|
|
|
|
|
+ protected string $locale = 'en';
|
|
|
|
|
+
|
|
|
|
|
+ /** @var array<string, \Webkul\Attribute\Models\Attribute> code => attribute */
|
|
|
|
|
+ protected array $familyAttributes = [];
|
|
|
|
|
+
|
|
|
|
|
+ /** @var int[] Product ids touched during this run (for targeted reindex). */
|
|
|
|
|
+ protected array $touchedProductIds = [];
|
|
|
|
|
+
|
|
|
|
|
+ public function __construct(protected AttributeRepository $attributeRepository)
|
|
|
|
|
+ {
|
|
|
|
|
+ parent::__construct();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function handle(): int
|
|
|
|
|
+ {
|
|
|
|
|
+ $this->attributesCsv = $this->resolveInputFile(
|
|
|
|
|
+ 'bagisto_attributes',
|
|
|
|
|
+ $this->option('attributes-file')
|
|
|
|
|
+ );
|
|
|
|
|
+ $this->productsCsv = $this->resolveInputFile(
|
|
|
|
|
+ 'bagisto_products',
|
|
|
|
|
+ $this->option('products-file')
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ foreach ([
|
|
|
|
|
+ 'attributes' => $this->attributesCsv,
|
|
|
|
|
+ 'products' => $this->productsCsv,
|
|
|
|
|
+ ] as $name => $file) {
|
|
|
|
|
+ if (! $file || ! is_file($file)) {
|
|
|
|
|
+ $this->error("Input file not found for {$name}: ".($file ?: '[empty path]'));
|
|
|
|
|
+
|
|
|
|
|
+ return self::FAILURE;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $family = AttributeFamily::where('code', 'variant_product')->first();
|
|
|
|
|
+
|
|
|
|
|
+ if (! $family) {
|
|
|
|
|
+ $this->error('Attribute family "variant_product" not found.');
|
|
|
|
|
+
|
|
|
|
|
+ return self::FAILURE;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->familyId = $family->id;
|
|
|
|
|
+
|
|
|
|
|
+ $channel = Channel::where('code', $this->channelCode)->first() ?? Channel::first();
|
|
|
|
|
+ $this->channelId = $channel->id;
|
|
|
|
|
+ $this->channelCode = $channel->code;
|
|
|
|
|
+
|
|
|
|
|
+ if (! $this->option('products-only')) {
|
|
|
|
|
+ $this->syncAttributes();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->loadFamilyAttributes($family);
|
|
|
|
|
+
|
|
|
|
|
+ if (! $this->option('attributes-only')) {
|
|
|
|
|
+ $this->syncProducts();
|
|
|
|
|
+
|
|
|
|
|
+ if (! $this->option('no-index')) {
|
|
|
|
|
+ $this->reindexProducts();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->info('Catalog sync finished.');
|
|
|
|
|
+
|
|
|
|
|
+ return self::SUCCESS;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* =========================================================================
|
|
|
|
|
+ | Attributes
|
|
|
|
|
+ * ====================================================================== */
|
|
|
|
|
+
|
|
|
|
|
+ protected function syncAttributes(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = $this->readCsv($this->attributesCsv);
|
|
|
|
|
+
|
|
|
|
|
+ $this->info('Syncing attributes ('.count($rows).' rows)...');
|
|
|
|
|
+
|
|
|
|
|
+ $created = 0;
|
|
|
|
|
+ $skipped = 0;
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($rows as $row) {
|
|
|
|
|
+ $code = trim((string) ($row['code'] ?? ''));
|
|
|
|
|
+
|
|
|
|
|
+ if ($code === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $existing = Attribute::where('code', $code)->first();
|
|
|
|
|
+
|
|
|
|
|
+ if ($existing) {
|
|
|
|
|
+ $this->ensureAttributeInFamily($existing->id);
|
|
|
|
|
+ $skipped++;
|
|
|
|
|
+
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $type = trim((string) $row['type']);
|
|
|
|
|
+
|
|
|
|
|
+ $data = [
|
|
|
|
|
+ 'code' => $code,
|
|
|
|
|
+ 'en' => ['name' => $row['name'] !== '' ? $row['name'] : $code],
|
|
|
|
|
+ 'admin_name' => $row['name'] !== '' ? $row['name'] : $code,
|
|
|
|
|
+ 'type' => $type,
|
|
|
|
|
+ 'is_required' => (int) ($row['is_required'] ?? 0),
|
|
|
|
|
+ 'is_filterable' => (int) ($row['is_filterable'] ?? 0),
|
|
|
|
|
+ 'is_comparable' => (int) ($row['is_comparable'] ?? 0),
|
|
|
|
|
+ 'is_configurable' => (int) ($row['is_configurable'] ?? 0),
|
|
|
|
|
+ 'position' => (int) ($row['position'] ?? 0),
|
|
|
|
|
+ 'is_user_defined' => 1,
|
|
|
|
|
+ 'value_per_locale' => in_array($type, ['text', 'textarea']) ? 1 : 0,
|
|
|
|
|
+ 'value_per_channel' => 0,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ if (in_array($type, ['select', 'multiselect', 'checkbox']) && trim((string) ($row['options_labels'] ?? '')) !== '') {
|
|
|
|
|
+ $labels = array_map('trim', explode('|', $row['options_labels']));
|
|
|
|
|
+
|
|
|
|
|
+ $options = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach (array_values(array_filter($labels, fn ($l) => $l !== '')) as $i => $label) {
|
|
|
|
|
+ $options[] = [
|
|
|
|
|
+ 'admin_name' => $label,
|
|
|
|
|
+ 'sort_order' => $i,
|
|
|
|
|
+ 'en' => ['label' => $label],
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $data['options'] = $options;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $attribute = $this->attributeRepository->create($data);
|
|
|
|
|
+
|
|
|
|
|
+ $this->ensureAttributeInFamily($attribute->id);
|
|
|
|
|
+
|
|
|
|
|
+ $created++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->info("Attributes: {$created} created, {$skipped} already existed.");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function ensureAttributeInFamily(int $attributeId): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $exists = DB::table('attribute_group_mappings')
|
|
|
|
|
+ ->where('attribute_id', $attributeId)
|
|
|
|
|
+ ->where('attribute_group_id', $this->generalGroupId)
|
|
|
|
|
+ ->exists();
|
|
|
|
|
+
|
|
|
|
|
+ if ($exists) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $position = (int) DB::table('attribute_group_mappings')
|
|
|
|
|
+ ->where('attribute_group_id', $this->generalGroupId)
|
|
|
|
|
+ ->max('position');
|
|
|
|
|
+
|
|
|
|
|
+ DB::table('attribute_group_mappings')->insert([
|
|
|
|
|
+ 'attribute_id' => $attributeId,
|
|
|
|
|
+ 'attribute_group_id' => $this->generalGroupId,
|
|
|
|
|
+ 'position' => $position + 1,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function loadFamilyAttributes(AttributeFamily $family): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $this->familyAttributes = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($family->custom_attributes()->get() as $attribute) {
|
|
|
|
|
+ $this->familyAttributes[$attribute->code] = $attribute;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* =========================================================================
|
|
|
|
|
+ | Products
|
|
|
|
|
+ * ====================================================================== */
|
|
|
|
|
+
|
|
|
|
|
+ protected function syncProducts(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = $this->readCsv($this->productsCsv);
|
|
|
|
|
+
|
|
|
|
|
+ $limit = $this->option('limit') ? (int) $this->option('limit') : null;
|
|
|
|
|
+
|
|
|
|
|
+ if ($limit) {
|
|
|
|
|
+ $rows = array_slice($rows, 0, $limit);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->info('Syncing '.count($rows).' products...');
|
|
|
|
|
+
|
|
|
|
|
+ $bar = $this->output->createProgressBar(count($rows));
|
|
|
|
|
+ $bar->start();
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($rows as $row) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ $this->importProduct($row);
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ $this->newLine();
|
|
|
|
|
+ $this->error('Product '.($row['sku'] ?? '?').' failed: '.$e->getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $bar->advance();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $bar->finish();
|
|
|
|
|
+ $this->newLine();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Reindex only the products touched during this run.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Done per-product to keep price-index inserts well under MySQL's
|
|
|
|
|
+ * 65,535 placeholder limit (a single flexible_variant product can have
|
|
|
|
|
+ * hundreds of variants × customer groups). The core Inventory indexer
|
|
|
|
|
+ * only covers simple/virtual products, so it is intentionally skipped for
|
|
|
|
|
+ * flexible_variant.
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function reindexProducts(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $ids = array_values(array_unique($this->touchedProductIds));
|
|
|
|
|
+
|
|
|
|
|
+ if (empty($ids)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->info('Reindexing '.count($ids).' products (price/flat'.($this->elasticEnabled() ? '/elastic' : '').')...');
|
|
|
|
|
+
|
|
|
|
|
+ /** @var \Webkul\Product\Helpers\Indexers\Price $priceIndexer */
|
|
|
|
|
+ $priceIndexer = app(Price::class);
|
|
|
|
|
+ /** @var \Webkul\Product\Helpers\Indexers\Flat $flatIndexer */
|
|
|
|
|
+ $flatIndexer = app(Flat::class);
|
|
|
|
|
+ $elasticIndexer = $this->elasticEnabled() ? app(ElasticSearch::class) : null;
|
|
|
|
|
+
|
|
|
|
|
+ $relations = [
|
|
|
|
|
+ 'attribute_family',
|
|
|
|
|
+ 'attribute_values',
|
|
|
|
|
+ 'channels',
|
|
|
|
|
+ 'price_indices',
|
|
|
|
|
+ 'customer_group_prices',
|
|
|
|
|
+ 'catalog_rule_prices',
|
|
|
|
|
+ 'flexibleVariants',
|
|
|
|
|
+ 'flexibleVariants.price_indices',
|
|
|
|
|
+ 'flexibleVariants.customer_group_prices',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ $bar = $this->output->createProgressBar(count($ids));
|
|
|
|
|
+ $bar->start();
|
|
|
|
|
+
|
|
|
|
|
+ foreach (array_chunk($ids, 20) as $chunk) {
|
|
|
|
|
+ $products = Product::with($relations)->whereIn('id', $chunk)->get();
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($products as $product) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ $priceIndexer->reindexRow($product);
|
|
|
|
|
+ $flatIndexer->reindexRow($product);
|
|
|
|
|
+ $elasticIndexer?->reindexRow($product);
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ $this->newLine();
|
|
|
|
|
+ $this->error('Reindex failed for product '.$product->sku.': '.$e->getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $bar->advance();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $bar->finish();
|
|
|
|
|
+ $this->newLine();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function elasticEnabled(): bool
|
|
|
|
|
+ {
|
|
|
|
|
+ return core()->getConfigData('catalog.products.search.engine') === 'elastic';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function importProduct(array $row): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $sku = trim((string) $row['sku']);
|
|
|
|
|
+
|
|
|
|
|
+ if ($sku === '') {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $product = Product::firstOrNew(['sku' => $sku]);
|
|
|
|
|
+ $product->type = 'flexible_variant';
|
|
|
|
|
+ $product->attribute_family_id = $this->familyId;
|
|
|
|
|
+ $product->save();
|
|
|
|
|
+
|
|
|
|
|
+ $this->touchedProductIds[] = $product->id;
|
|
|
|
|
+
|
|
|
|
|
+ DB::table('product_channels')->updateOrInsert([
|
|
|
|
|
+ 'product_id' => $product->id,
|
|
|
|
|
+ 'channel_id' => $this->channelId,
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $this->saveAttributeValues($product, $row);
|
|
|
|
|
+ $this->saveInventory($product, $row);
|
|
|
|
|
+
|
|
|
|
|
+ $this->saveImages($product, $row);
|
|
|
|
|
+
|
|
|
|
|
+ DB::transaction(function () use ($product, $row) {
|
|
|
|
|
+ $this->saveOptionsAndVariants($product, $row);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function saveAttributeValues(Product $product, array $row): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $row = $this->applyDefaultProductAttributeValues($row);
|
|
|
|
|
+
|
|
|
|
|
+ $values = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($this->familyAttributes as $code => $attribute) {
|
|
|
|
|
+ if (! array_key_exists($code, $row)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $rawValue = $row[$code];
|
|
|
|
|
+
|
|
|
|
|
+ if ($rawValue === null || $rawValue === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $columnValue = $this->castAttributeValue($attribute, $rawValue);
|
|
|
|
|
+
|
|
|
|
|
+ if ($columnValue === null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $valueRow = array_fill_keys(array_values($attribute->attributeTypeFields), null);
|
|
|
|
|
+
|
|
|
|
|
+ $valueRow['json_value'] = null;
|
|
|
|
|
+ $valueRow[$attribute->column_name] = $columnValue;
|
|
|
|
|
+ $valueRow['attribute_id'] = $attribute->id;
|
|
|
|
|
+ $valueRow['product_id'] = $product->id;
|
|
|
|
|
+ $valueRow['channel'] = $attribute->value_per_channel ? $this->channelCode : null;
|
|
|
|
|
+ $valueRow['locale'] = $attribute->value_per_locale ? $this->locale : null;
|
|
|
|
|
+ $valueRow['unique_id'] = implode('|', array_filter([
|
|
|
|
|
+ $valueRow['channel'],
|
|
|
|
|
+ $valueRow['locale'],
|
|
|
|
|
+ $valueRow['product_id'],
|
|
|
|
|
+ $valueRow['attribute_id'],
|
|
|
|
|
+ ]));
|
|
|
|
|
+
|
|
|
|
|
+ $values[$valueRow['unique_id']] = $valueRow;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (empty($values)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ DB::table('product_attribute_values')->upsert(
|
|
|
|
|
+ array_values($values),
|
|
|
|
|
+ ['unique_id'],
|
|
|
|
|
+ ['text_value', 'boolean_value', 'integer_value', 'float_value', 'datetime_value', 'date_value', 'json_value']
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function applyDefaultProductAttributeValues(array $row): array
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach ([
|
|
|
|
|
+ 'visible_individually' => 1,
|
|
|
|
|
+ 'manage_stock' => 1,
|
|
|
|
|
+ ] as $code => $defaultValue) {
|
|
|
|
|
+ if (
|
|
|
|
|
+ ! array_key_exists($code, $row)
|
|
|
|
|
+ || $row[$code] === null
|
|
|
|
|
+ || (is_string($row[$code]) && trim($row[$code]) === '')
|
|
|
|
|
+ ) {
|
|
|
|
|
+ $row[$code] = $defaultValue;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $row;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function saveInventory(Product $product, array $row): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $qty = $row['qty'] ?? 9999;
|
|
|
|
|
+
|
|
|
|
|
+ if ($qty === null || (is_string($qty) && trim($qty) === '')) {
|
|
|
|
|
+ $qty = 9999;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ DB::table('product_inventories')->updateOrInsert(
|
|
|
|
|
+ [
|
|
|
|
|
+ 'product_id' => $product->id,
|
|
|
|
|
+ 'inventory_source_id' => 1,
|
|
|
|
|
+ 'vendor_id' => 0,
|
|
|
|
|
+ ],
|
|
|
|
|
+ ['qty' => (int) $qty]
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @return mixed
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function castAttributeValue(Attribute $attribute, $value)
|
|
|
|
|
+ {
|
|
|
|
|
+ switch ($attribute->type) {
|
|
|
|
|
+ case 'boolean':
|
|
|
|
|
+ return (int) (bool) (is_numeric($value) ? (int) $value : $value);
|
|
|
|
|
+
|
|
|
|
|
+ case 'price':
|
|
|
|
|
+ return (float) $value;
|
|
|
|
|
+
|
|
|
|
|
+ case 'select':
|
|
|
|
|
+ $option = AttributeOption::where('attribute_id', $attribute->id)
|
|
|
|
|
+ ->where('admin_name', $value)
|
|
|
|
|
+ ->first();
|
|
|
|
|
+
|
|
|
|
|
+ return $option?->id;
|
|
|
|
|
+
|
|
|
|
|
+ case 'multiselect':
|
|
|
|
|
+ $ids = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach (array_map('trim', explode(',', (string) $value)) as $label) {
|
|
|
|
|
+ if ($label === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $option = AttributeOption::where('attribute_id', $attribute->id)
|
|
|
|
|
+ ->where('admin_name', $label)
|
|
|
|
|
+ ->first();
|
|
|
|
|
+
|
|
|
|
|
+ if ($option) {
|
|
|
|
|
+ $ids[] = $option->id;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return empty($ids) ? null : implode(',', $ids);
|
|
|
|
|
+
|
|
|
|
|
+ default:
|
|
|
|
|
+ return $value;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function saveImages(Product $product, array $row): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $urls = [];
|
|
|
|
|
+
|
|
|
|
|
+ if (! empty($row['base_image'])) {
|
|
|
|
|
+ $urls[] = trim($row['base_image']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (! empty($row['additional_images'])) {
|
|
|
|
|
+ foreach (explode('|', $row['additional_images']) as $url) {
|
|
|
|
|
+ $url = trim($url);
|
|
|
|
|
+
|
|
|
|
|
+ if ($url !== '') {
|
|
|
|
|
+ $urls[] = $url;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $urls = array_values(array_unique($urls));
|
|
|
|
|
+
|
|
|
|
|
+ if (empty($urls)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Rebuild image set for idempotent re-runs.
|
|
|
|
|
+ DB::table('product_images')->where('product_id', $product->id)->delete();
|
|
|
|
|
+
|
|
|
|
|
+ $records = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($urls as $position => $url) {
|
|
|
|
|
+ $records[] = [
|
|
|
|
|
+ 'type' => 'images',
|
|
|
|
|
+ 'path' => $url,
|
|
|
|
|
+ 'product_id' => $product->id,
|
|
|
|
|
+ 'position' => $position + 1,
|
|
|
|
|
+ 'is_base_image' => $position === 0 ? 1 : 0,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ DB::table('product_images')->insert($records);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* =========================================================================
|
|
|
|
|
+ | Flexible variant options & variants
|
|
|
|
|
+ * ====================================================================== */
|
|
|
|
|
+
|
|
|
|
|
+ protected function saveOptionsAndVariants(Product $product, array $row): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $superCodes = array_values(array_filter(array_map('trim', explode(',', (string) ($row['super_attributes'] ?? '')))));
|
|
|
|
|
+
|
|
|
|
|
+ if (empty($superCodes)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $optionsMeta = json_decode((string) ($row['options'] ?? '[]'), true) ?: [];
|
|
|
|
|
+ $variants = json_decode((string) ($row['variants'] ?? '[]'), true) ?: [];
|
|
|
|
|
+
|
|
|
|
|
+ // Clean previous flexible variant data for idempotent re-runs.
|
|
|
|
|
+ $this->clearVariantData($product);
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * For each super-attribute (dimension) create a product option and its
|
|
|
|
|
+ * values. labelToValueId[dimensionIndex][label] => product_option_value_id
|
|
|
|
|
+ *
|
|
|
|
|
+ * @var array<int, array<string, int>> $labelToValueId
|
|
|
|
|
+ */
|
|
|
|
|
+ $labelToValueId = [];
|
|
|
|
|
+
|
|
|
|
|
+ $syncOptions = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($superCodes as $index => $code) {
|
|
|
|
|
+ $meta = $optionsMeta[$index] ?? [];
|
|
|
|
|
+
|
|
|
|
|
+ $optionLabel = $meta['title'] ?? Str::title(str_replace('_', ' ', $code));
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * product_options.code is globally unique, so options are SHARED
|
|
|
|
|
+ * across products. Reuse an existing option for this dimension or
|
|
|
|
|
+ * create it once.
|
|
|
|
|
+ */
|
|
|
|
|
+ $option = ProductOption::firstOrCreate(
|
|
|
|
|
+ ['code' => $code],
|
|
|
|
|
+ ['label' => $optionLabel, 'type' => 'select', 'position' => $index]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ $syncOptions[$option->id] = ['position' => $index, 'is_required' => true];
|
|
|
|
|
+
|
|
|
|
|
+ // Existing values for this (shared) option, keyed by label.
|
|
|
|
|
+ $existing = ProductOptionValue::where('product_option_id', $option->id)
|
|
|
|
|
+ ->get()
|
|
|
|
|
+ ->keyBy('label');
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($existing as $label => $value) {
|
|
|
|
|
+ $labelToValueId[$index][$label] = $value->id;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Collect distinct labels for this dimension: prefer the options JSON
|
|
|
|
|
+ // value list (gives sku/order), then add any labels seen in variants.
|
|
|
|
|
+ $orderedLabels = [];
|
|
|
|
|
+ $valueSku = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach (($meta['values'] ?? []) as $valueMeta) {
|
|
|
|
|
+ $label = (string) ($valueMeta['title'] ?? '');
|
|
|
|
|
+
|
|
|
|
|
+ if ($label === '' || array_key_exists($label, $valueSku)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $orderedLabels[] = $label;
|
|
|
|
|
+ $valueSku[$label] = (string) ($valueMeta['sku'] ?? '');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($variants as $variant) {
|
|
|
|
|
+ $label = $variant[(string) $index] ?? null;
|
|
|
|
|
+
|
|
|
|
|
+ if ($label !== null && ! array_key_exists($label, $valueSku)) {
|
|
|
|
|
+ $orderedLabels[] = $label;
|
|
|
|
|
+ $valueSku[$label] = '';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $nextPosition = (int) (ProductOptionValue::where('product_option_id', $option->id)->max('position'));
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($orderedLabels as $label) {
|
|
|
|
|
+ if (isset($labelToValueId[$index][$label])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $valueCode = $valueSku[$label] !== '' ? $valueSku[$label] : Str::slug($label);
|
|
|
|
|
+
|
|
|
|
|
+ if ($valueCode === '') {
|
|
|
|
|
+ $valueCode = 'value-'.($nextPosition + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $value = ProductOptionValue::create([
|
|
|
|
|
+ 'product_option_id' => $option->id,
|
|
|
|
|
+ 'label' => $label,
|
|
|
|
|
+ 'code' => $valueCode,
|
|
|
|
|
+ 'position' => ++$nextPosition,
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ $labelToValueId[$index][$label] = $value->id;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $product->options()->sync($syncOptions);
|
|
|
|
|
+
|
|
|
|
|
+ $this->saveVariants($product, $row, $superCodes, $variants, $labelToValueId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function saveVariants(Product $product, array $row, array $superCodes, array $variants, array $labelToValueId): void
|
|
|
|
|
+ {
|
|
|
|
|
+ if (empty($variants)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $parentName = (string) ($row['name'] ?? '');
|
|
|
|
|
+
|
|
|
|
|
+ $variantRecords = [];
|
|
|
|
|
+ $now = now();
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($variants as $sortOrder => $variant) {
|
|
|
|
|
+ $sku = trim((string) ($variant['sku'] ?? ''));
|
|
|
|
|
+
|
|
|
|
|
+ if ($sku === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $variantRecords[] = [
|
|
|
|
|
+ 'product_id' => $product->id,
|
|
|
|
|
+ 'sku' => $sku,
|
|
|
|
|
+ 'name' => $parentName !== '' ? $parentName : null,
|
|
|
|
|
+ 'price' => (float) ($variant['price'] ?? 0),
|
|
|
|
|
+ 'quantity' => (int) ($variant['qty'] ?? 0),
|
|
|
|
|
+ 'status' => (int) ($variant['in_stock'] ?? 1) ? 1 : 1,
|
|
|
|
|
+ 'sort_order' => $sortOrder,
|
|
|
|
|
+ 'created_at' => $now,
|
|
|
|
|
+ 'updated_at' => $now,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (empty($variantRecords)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach (array_chunk($variantRecords, 500) as $chunk) {
|
|
|
|
|
+ ProductVariant::insert($chunk);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Map sku => variant id for the pivot inserts.
|
|
|
|
|
+ $variantIdBySku = ProductVariant::where('product_id', $product->id)
|
|
|
|
|
+ ->pluck('id', 'sku')
|
|
|
|
|
+ ->toArray();
|
|
|
|
|
+
|
|
|
|
|
+ $pivotRecords = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($variants as $variant) {
|
|
|
|
|
+ $sku = trim((string) ($variant['sku'] ?? ''));
|
|
|
|
|
+ $variantId = $variantIdBySku[$sku] ?? null;
|
|
|
|
|
+
|
|
|
|
|
+ if (! $variantId) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($superCodes as $index => $code) {
|
|
|
|
|
+ $label = $variant[(string) $index] ?? null;
|
|
|
|
|
+
|
|
|
|
|
+ if ($label === null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $valueId = $labelToValueId[$index][$label] ?? null;
|
|
|
|
|
+
|
|
|
|
|
+ if (! $valueId) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $pivotRecords[] = [
|
|
|
|
|
+ 'product_variant_id' => $variantId,
|
|
|
|
|
+ 'product_option_value_id' => $valueId,
|
|
|
|
|
+ 'created_at' => $now,
|
|
|
|
|
+ 'updated_at' => $now,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach (array_chunk($pivotRecords, 1000) as $chunk) {
|
|
|
|
|
+ DB::table('product_variant_option_values')->insert($chunk);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Remove this product's variants and its option associations so the command
|
|
|
|
|
+ * can be safely re-run. Shared product_options / product_option_values are
|
|
|
|
|
+ * intentionally left intact (they are global and reused by other products).
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function clearVariantData(Product $product): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $variantIds = ProductVariant::withTrashed()
|
|
|
|
|
+ ->where('product_id', $product->id)
|
|
|
|
|
+ ->pluck('id')
|
|
|
|
|
+ ->toArray();
|
|
|
|
|
+
|
|
|
|
|
+ if (! empty($variantIds)) {
|
|
|
|
|
+ DB::table('product_variant_option_values')
|
|
|
|
|
+ ->whereIn('product_variant_id', $variantIds)
|
|
|
|
|
+ ->delete();
|
|
|
|
|
+
|
|
|
|
|
+ DB::table('product_variant_images')
|
|
|
|
|
+ ->whereIn('product_variant_id', $variantIds)
|
|
|
|
|
+ ->delete();
|
|
|
|
|
+
|
|
|
|
|
+ ProductVariant::withTrashed()->whereIn('id', $variantIds)->forceDelete();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $product->options()->detach();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* =========================================================================
|
|
|
|
|
+ | File helper
|
|
|
|
|
+ * ====================================================================== */
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Resolve input file path from:
|
|
|
|
|
+ * 1) explicit option path
|
|
|
|
|
+ * 2) storage/sync/<base>.csv
|
|
|
|
|
+ * 3) storage/sync/<base>.xlsx
|
|
|
|
|
+ * 4) storage/sync/<base>.xls
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function resolveInputFile(string $baseName, ?string $customPath = null): ?string
|
|
|
|
|
+ {
|
|
|
|
|
+ if (is_string($customPath) && trim($customPath) !== '') {
|
|
|
|
|
+ $path = trim($customPath);
|
|
|
|
|
+
|
|
|
|
|
+ if (! Str::startsWith($path, ['/'])) {
|
|
|
|
|
+ $path = base_path($path);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $path;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach (['csv', 'xlsx', 'xls'] as $ext) {
|
|
|
|
|
+ $path = storage_path("sync/{$baseName}.{$ext}");
|
|
|
|
|
+
|
|
|
|
|
+ if (is_file($path)) {
|
|
|
|
|
+ return $path;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Read CSV/XLSX/XLS into associative rows keyed by header.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return array<int, array<string, string|null>>
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function readCsv(string $path): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
|
|
|
|
|
+
|
|
|
|
|
+ if (in_array($extension, ['xlsx', 'xls'], true)) {
|
|
|
|
|
+ return $this->readExcel($path);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $this->readCsvFile($path);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @return array<int, array<string, string|null>>
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function readCsvFile(string $path): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = [];
|
|
|
|
|
+
|
|
|
|
|
+ $handle = fopen($path, 'r');
|
|
|
|
|
+
|
|
|
|
|
+ if ($handle === false) {
|
|
|
|
|
+ return $rows;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $header = fgetcsv($handle);
|
|
|
|
|
+
|
|
|
|
|
+ if ($header === false) {
|
|
|
|
|
+ fclose($handle);
|
|
|
|
|
+
|
|
|
|
|
+ return $rows;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $header = array_map(fn ($h) => trim((string) $h), $header);
|
|
|
|
|
+
|
|
|
|
|
+ while (($data = fgetcsv($handle)) !== false) {
|
|
|
|
|
+ if ($data === [null] || $data === false) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $row = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($header as $i => $key) {
|
|
|
|
|
+ if ($key === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $row[$key] = $data[$i] ?? null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Skip fully empty lines.
|
|
|
|
|
+ if (count(array_filter($row, fn ($v) => $v !== null && $v !== '')) === 0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $rows[] = $row;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fclose($handle);
|
|
|
|
|
+
|
|
|
|
|
+ return $rows;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @return array<int, array<string, string|null>>
|
|
|
|
|
+ */
|
|
|
|
|
+ protected function readExcel(string $path): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $rows = [];
|
|
|
|
|
+
|
|
|
|
|
+ $spreadsheet = IOFactory::load($path);
|
|
|
|
|
+ $sheetRows = $spreadsheet
|
|
|
|
|
+ ->getActiveSheet()
|
|
|
|
|
+ ->toArray(null, false, false, false);
|
|
|
|
|
+
|
|
|
|
|
+ if (empty($sheetRows)) {
|
|
|
|
|
+ return $rows;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $header = array_map(
|
|
|
|
|
+ fn ($h) => trim((string) $h),
|
|
|
|
|
+ array_shift($sheetRows) ?: []
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($sheetRows as $data) {
|
|
|
|
|
+ if (! is_array($data)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $row = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($header as $i => $key) {
|
|
|
|
|
+ if ($key === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $value = $data[$i] ?? null;
|
|
|
|
|
+ $row[$key] = $value === null ? null : (string) $value;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (count(array_filter($row, fn ($v) => $v !== null && $v !== '')) === 0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $rows[] = $row;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $rows;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|