*/ private array $examples = []; public function handle(): int { $dryRun = (bool) $this->option('dry-run'); $sku = trim((string) $this->option('sku')); $query = Product::query() ->whereNotNull('migrated_from_asteria_id') ->where('type', 'flexible_variant') ->select('id', 'sku') ->orderBy('id'); if ($sku !== '') { $query->where('sku', $sku); } $query->chunkById(20, function (Collection $products) use ($dryRun) { $this->repairChunk($products, $dryRun); }); if ($this->examples !== []) { $this->table(['variant_id', 'from', 'to'], $this->examples); } $this->info(sprintf( '%s updated=%d unchanged=%d skipped=%d collisions=%d order_skus=%d', $dryRun ? '[DRY RUN]' : 'Done.', $this->updated, $this->unchanged, $this->skipped, $this->collisions, $this->orderItems )); return $this->collisions > 0 ? self::FAILURE : self::SUCCESS; } private function repairChunk(Collection $products, bool $dryRun): void { $productIds = $products->pluck('id')->map(fn ($id) => (int) $id)->all(); $parentSku = $products->mapWithKeys(fn ($product) => [(int) $product->id => trim((string) $product->sku)]); $optionCounts = DB::table('product_product_options') ->whereIn('product_id', $productIds) ->select('product_id', DB::raw('COUNT(*) as aggregate')) ->groupBy('product_id') ->pluck('aggregate', 'product_id'); $rows = DB::table('product_variants as v') ->join('product_variant_option_values as pvov', 'pvov.product_variant_id', '=', 'v.id') ->join('product_option_values as pov', 'pov.id', '=', 'pvov.product_option_value_id') ->join('product_product_options as ppo', function ($join) { $join->on('ppo.product_option_id', '=', 'pov.product_option_id') ->on('ppo.product_id', '=', 'v.product_id'); }) ->whereIn('v.product_id', $productIds) ->whereNull('v.deleted_at') ->orderBy('v.id') ->orderBy('ppo.position') ->orderBy('pov.id') ->join('product_options as po', 'po.id', '=', 'pov.product_option_id') ->get([ 'v.id as variant_id', 'v.product_id', 'v.sku', 'pov.product_option_id', 'po.label as option_label', 'pov.label as value_label', 'pov.code as value_sku', ]); $grouped = []; foreach ($rows as $row) { $variantId = (int) $row->variant_id; $grouped[$variantId]['product_id'] = (int) $row->product_id; $grouped[$variantId]['sku'] = (string) $row->sku; $grouped[$variantId]['parts'][] = $row; } $liveVariantIds = DB::table('product_variants') ->whereIn('product_id', $productIds) ->whereNull('deleted_at') ->pluck('id'); foreach ($liveVariantIds as $variantId) { if (! isset($grouped[(int) $variantId])) { $this->skipped++; } } /** @var array $planned */ $planned = []; /** @var array $previous */ $previous = []; /** @var array}> $targets */ $targets = []; foreach ($grouped as $variantId => $info) { $expected = (int) ($optionCounts[$info['product_id']] ?? 0); $seenOptions = []; $labels = []; $invalid = false; $pairs = []; foreach ($info['parts'] as $part) { $optionId = (int) $part->product_option_id; if (isset($seenOptions[$optionId])) { $invalid = true; break; } $seenOptions[$optionId] = true; $optionLabel = trim((string) $part->option_label); $valueLabel = trim((string) $part->value_label); $valueSku = trim((string) ($part->value_sku ?? '')); if ($valueSku === '') { $valueSku = $valueLabel; } if ($optionLabel === '' || $valueLabel === '' || $valueSku === '') { $invalid = true; break; } $labels[] = $valueSku; $pairs[] = [$optionLabel, $valueLabel]; } if ($invalid || $expected === 0 || count($labels) !== $expected) { $this->skipped++; continue; } $newSku = $parentSku[$info['product_id']].'-'.implode('-', $labels); if (strlen($newSku) > 255) { $this->skipped++; $this->warn("Variant {$variantId} SKU exceeds 255 characters."); continue; } $targets[$variantId] = [ 'product_id' => $info['product_id'], 'sku' => $newSku, 'pairs' => $pairs, ]; if ($newSku === $info['sku']) { $this->unchanged++; continue; } $planned[$variantId] = $newSku; $previous[$variantId] = $info['sku']; } $this->dropCollisions($planned, $previous); foreach ($targets as $variantId => $target) { $current = (string) ($grouped[$variantId]['sku'] ?? ''); if ($target['sku'] !== $current && ! isset($planned[$variantId])) { unset($targets[$variantId]); } } if ($dryRun) { foreach ($planned as $variantId => $newSku) { $this->rememberExample($variantId, $previous[$variantId], $newSku); $this->updated++; } $this->rewriteOrderSkus($targets, $productIds, true); return; } if ($planned !== []) { $this->writeSkus($planned, $previous); $this->updated += count($planned); } $this->rewriteOrderSkus($targets, $productIds, false); } /** * @param array $planned * @param array $previous */ private function dropCollisions(array &$planned, array &$previous): void { if ($planned === []) { return; } $owners = DB::table('product_variants') ->whereIn('sku', array_values($planned)) ->pluck('id', 'sku'); $blocked = []; $seen = []; foreach ($planned as $variantId => $newSku) { if (isset($seen[$newSku])) { $blocked[$variantId] = true; $blocked[$seen[$newSku]] = true; continue; } $seen[$newSku] = $variantId; $ownerId = isset($owners[$newSku]) ? (int) $owners[$newSku] : null; if ($ownerId === null || $ownerId === (int) $variantId) { continue; } $ownerNext = $planned[$ownerId] ?? null; if ($ownerNext === null || $ownerNext === $newSku) { $blocked[$variantId] = true; } } foreach (array_keys($blocked) as $variantId) { $this->warn("SKU collision, left variant {$variantId} as {$previous[$variantId]}."); unset($planned[$variantId], $previous[$variantId]); $this->collisions++; } } /** * @param array $planned * @param array $previous */ private function writeSkus(array $planned, array $previous): void { $now = now()->toDateTimeString(); DB::transaction(function () use ($planned, $previous, $now) { foreach (array_chunk(array_keys($planned), 200) as $ids) { DB::table('product_variants') ->whereIn('id', $ids) ->update([ 'sku' => DB::raw("CONCAT('__sku_repair_', id)"), 'updated_at' => $now, ]); } foreach (array_chunk($planned, 200, true) as $chunk) { $case = 'CASE id'; $bindings = []; $ids = array_keys($chunk); foreach ($chunk as $variantId => $newSku) { $case .= ' WHEN ? THEN ?'; $bindings[] = $variantId; $bindings[] = $newSku; $this->rememberExample($variantId, $previous[$variantId], $newSku); } $case .= ' END'; $placeholders = implode(',', array_fill(0, count($chunk), '?')); DB::update( "UPDATE product_variants SET sku = {$case}, updated_at = ? WHERE id IN ({$placeholders})", array_merge($bindings, [$now], $ids) ); } }); } private function rememberExample(int $variantId, string $from, string $to): void { if (count($this->examples) >= 15) { return; } $this->examples[] = [$variantId, $from, $to]; } /** * Point order documents at the variant SKU. Match variant_id first, then the * option labels stored on the order item. * * @param array}> $targets * @param array $productIds */ private function rewriteOrderSkus(array $targets, array $productIds, bool $dryRun): void { if ($targets === [] || $productIds === [] || ! Schema::hasTable('order_items')) { return; } $byVariant = []; $byProductAndKey = []; $optionNames = []; foreach ($targets as $variantId => $target) { $byVariant[$variantId] = $target['sku']; $key = $this->labelKey($target['pairs']); $byProductAndKey[$target['product_id']][$key][] = $variantId; foreach ($target['pairs'] as [$optionLabel]) { $optionNames[$target['product_id']][$this->normalize($optionLabel)] = true; } } DB::table('order_items') ->whereIn('product_id', $productIds) ->orderBy('id') ->select('id', 'product_id', 'sku', 'additional') ->chunkById(500, function (Collection $items) use ($byVariant, $byProductAndKey, $optionNames, $dryRun) { $skuUpdates = []; $additionalUpdates = []; foreach ($items as $item) { $productId = (int) $item->product_id; $additional = $this->decodeAdditional($item->additional); $newSku = $this->resolveOrderSku($productId, $additional, $byVariant, $byProductAndKey, $optionNames); if ($newSku === null) { continue; } if ((string) $item->sku !== $newSku) { $skuUpdates[(int) $item->id] = $newSku; } if ($this->additionalVariantSku($additional, $newSku)) { $additionalUpdates[(int) $item->id] = $additional; } } $this->orderItems += count($skuUpdates); $now = now()->toDateTimeString(); $this->syncRelatedItemSkus($skuUpdates, $dryRun, $now); if ($dryRun || ($skuUpdates === [] && $additionalUpdates === [])) { return; } $this->writeColumnSkus('order_items', $skuUpdates, $now); $this->writeAdditional($additionalUpdates, $now); }); } /** * @param array $additional * @param array $byVariant * @param array>> $byProductAndKey * @param array> $optionNames */ private function resolveOrderSku( int $productId, array $additional, array $byVariant, array $byProductAndKey, array $optionNames ): ?string { $variantId = (int) ($additional['variant_id'] ?? 0); if ($variantId > 0 && isset($byVariant[$variantId])) { return $byVariant[$variantId]; } $pairs = $this->orderLabelPairs($additional, $optionNames[$productId] ?? []); if ($pairs === []) { return null; } $ids = $byProductAndKey[$productId][$this->labelKey($pairs)] ?? []; if (count($ids) !== 1) { return null; } return $byVariant[$ids[0]] ?? null; } /** * @param array $additional * @param array $optionNames * @return array */ private function orderLabelPairs(array $additional, array $optionNames): array { $attributes = $additional['attributes'] ?? null; if (! is_array($attributes) || $attributes === []) { return []; } $pairs = []; $list = array_is_list($attributes); foreach ($attributes as $row) { if (! is_array($row)) { continue; } if ($list) { $option = trim((string) ($row['attribute_name'] ?? $row['label'] ?? '')); $value = trim((string) ($row['option_label'] ?? $row['value_label'] ?? '')); } else { $option = trim((string) ($row['attribute_name'] ?? $row['option_label'] ?? '')); $value = trim((string) ($row['value_label'] ?? '')); } if ($option === '' || $value === '' || ! isset($optionNames[$this->normalize($option)])) { continue; } $pairs[] = [$option, $value]; } return $pairs; } /** * @param array $additional */ private function additionalVariantSku(array &$additional, string $newSku): bool { $hasVariant = ! empty($additional['variant_id']) || array_key_exists('variant_sku', $additional); if (! $hasVariant || (string) ($additional['variant_sku'] ?? '') === $newSku) { return false; } $additional['variant_sku'] = $newSku; return true; } /** * @param array $pairs */ private function labelKey(array $pairs): string { $parts = []; foreach ($pairs as [$option, $value]) { $parts[] = $this->normalize($option)."\n".$value; } sort($parts); return implode("\n---\n", $parts); } private function normalize(string $value): string { return mb_strtolower(trim($value)); } /** * @return array */ private function decodeAdditional(mixed $value): array { if (is_array($value)) { return $value; } if (is_string($value) && $value !== '') { $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } return []; } /** * @param array $skuUpdates */ private function syncRelatedItemSkus(array $skuUpdates, bool $dryRun, string $now): void { if ($skuUpdates === []) { return; } foreach (['invoice_items', 'shipment_items', 'refund_items'] as $table) { if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'sku') || ! Schema::hasColumn($table, 'order_item_id')) { continue; } $rows = DB::table($table) ->whereIn('order_item_id', array_keys($skuUpdates)) ->get(['id', 'order_item_id', 'sku']); $updates = []; foreach ($rows as $row) { $newSku = $skuUpdates[(int) $row->order_item_id] ?? null; if ($newSku === null || (string) $row->sku === $newSku) { continue; } $updates[(int) $row->id] = $newSku; } $this->orderItems += count($updates); if (! $dryRun && $updates !== []) { $this->writeColumnSkus($table, $updates, $now); } } } /** * @param array $updates */ private function writeColumnSkus(string $table, array $updates, string $now): void { foreach (array_chunk($updates, 200, true) as $chunk) { $case = 'CASE id'; $bindings = []; foreach ($chunk as $id => $sku) { $case .= ' WHEN ? THEN ?'; $bindings[] = $id; $bindings[] = $sku; } $case .= ' END'; $placeholders = implode(',', array_fill(0, count($chunk), '?')); DB::update( "UPDATE {$table} SET sku = {$case}, updated_at = ? WHERE id IN ({$placeholders})", array_merge($bindings, [$now], array_keys($chunk)) ); } } /** * @param array> $updates */ private function writeAdditional(array $updates, string $now): void { foreach ($updates as $id => $additional) { $encoded = json_encode($additional, JSON_UNESCAPED_UNICODE); if ($encoded === false) { continue; } DB::table('order_items')->where('id', $id)->update([ 'additional' => $encoded, 'updated_at' => $now, ]); } } }