| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892 |
- <?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', 'wigs')->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;
- }
- }
|