Browse Source

迁移变体 SKU 改为按 option value sku 拼接,并修复已有变体和订单 SKU。

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl 3 days ago
parent
commit
c012ec3997

+ 596 - 0
app/Console/Commands/RepairMigratedVariantSkus.php

@@ -0,0 +1,596 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Webkul\Product\Models\Product;
+
+/**
+ * Rewrites flexible-variant SKUs that were migrated with option sort_order suffixes.
+ *
+ * The new suffix is the option value SKU (`product_option_values.code`), in option position order.
+ * Variant rows, images, and ids stay in place. Order, invoice, shipment, and
+ * refund item SKUs that belong to those variants are rewritten to the same SKU.
+ */
+class RepairMigratedVariantSkus extends Command
+{
+    protected $signature = 'products:repair-variant-skus
+        {--dry-run : Print the SKU changes without writing}
+        {--sku= : Repair one parent product SKU}';
+
+    protected $description = 'Rewrite migrated variant and order item SKUs from option value SKUs';
+
+    private int $updated = 0;
+
+    private int $unchanged = 0;
+
+    private int $skipped = 0;
+
+    private int $collisions = 0;
+
+    private int $orderItems = 0;
+
+    /** @var array<int, array{0: int, 1: string, 2: string}> */
+    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<int, string> $planned */
+        $planned = [];
+        /** @var array<int, string> $previous */
+        $previous = [];
+        /** @var array<int, array{product_id: int, sku: string, pairs: array<int, array{0: string, 1: string}>}> $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<int, string>  $planned
+     * @param  array<int, string>  $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<int, string>  $planned
+     * @param  array<int, string>  $previous
+     */
+    private function writeSkus(array $planned, array $previous): void
+    {
+        $now = now()->toDateTimeString();
+
+        DB::transaction(function () use ($planned, $previous, $now) {
+            foreach (array_chunk($planned, 200, true) as $chunk) {
+                $ids = array_keys($chunk);
+
+                DB::table('product_variants')
+                    ->whereIn('id', $ids)
+                    ->update([
+                        'sku'        => DB::raw("CONCAT('__sku_repair_', id)"),
+                        'updated_at' => $now,
+                    ]);
+
+                $case = 'CASE id';
+                $bindings = [];
+
+                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<int, array{product_id: int, sku: string, pairs: array<int, array{0: string, 1: string}>}>  $targets
+     * @param  array<int, int>  $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<string, mixed>  $additional
+     * @param  array<int, string>  $byVariant
+     * @param  array<int, array<string, array<int, int>>>  $byProductAndKey
+     * @param  array<int, array<string, true>>  $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<string, mixed>  $additional
+     * @param  array<string, true>  $optionNames
+     * @return array<int, array{0: string, 1: string}>
+     */
+    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<string, mixed>  $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<int, array{0: string, 1: string}>  $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<string, mixed>
+     */
+    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<int, string>  $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<int, string>  $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<int, array<string, mixed>>  $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,
+            ]);
+        }
+    }
+}

+ 22 - 1
app/Services/Asteria/Magento1ProductReader.php

@@ -832,7 +832,7 @@ class Magento1ProductReader
             ];
 
             foreach ($combo as $optTitle => $valueInfo) {
-                $skuParts[] = (string) ($valueInfo['sort_order'] ?? '');
+                $skuParts[] = $this->variantSkuPart($valueInfo);
                 $price += (float) ($valueInfo['price'] ?? 0);
                 $label = (string) ($valueInfo['title'] ?? '');
                 $variant[$optTitle] = $label;
@@ -847,6 +847,27 @@ class Magento1ProductReader
         return $variants;
     }
 
+    /**
+     * Magento option-value SKU. Fall back to the value title, then sort order,
+     * when that SKU is empty.
+     *
+     * @param  array<string, mixed>  $valueInfo
+     */
+    private function variantSkuPart(array $valueInfo): string
+    {
+        $part = trim((string) ($valueInfo['sku'] ?? ''));
+
+        if ($part === '') {
+            $part = trim((string) ($valueInfo['title'] ?? ''));
+        }
+
+        if ($part === '') {
+            $part = (string) ($valueInfo['sort_order'] ?? '');
+        }
+
+        return $part;
+    }
+
     /**
      * @param  array<string, mixed>  $option
      * @return array<string, mixed>

+ 5 - 5
packages/Webkul/BagistoApi/tests/Unit/Migration/Magento1ProductReaderTest.php

@@ -75,7 +75,7 @@ class Magento1ProductReaderTest extends TestCase
         $this->assertSame('hair_length,hair_color', $product['super_attributes']);
         $this->assertCount(2, $product['options']);
         $this->assertCount(4, $product['variants']);
-        $this->assertSame('WIG-001-1-1', $product['variants'][0]['sku']);
+        $this->assertSame('WIG-001-12IN-1B', $product['variants'][0]['sku']);
         $this->assertSame(1, (int) $product['status']);
         $this->assertSame('12"', $product['variants'][0]['Hair Length']);
         $this->assertSame('12"', $product['variants'][0]['hair_length']);
@@ -173,8 +173,8 @@ class Magento1ProductReaderTest extends TestCase
                     'is_require' => 1,
                     'sort_order' => 1,
                     'values'     => [
-                        ['title' => '12"', 'price' => 0, 'sort_order' => 1],
-                        ['title' => '14"', 'price' => 10, 'sort_order' => 2],
+                        ['title' => '12"', 'sku' => '12IN', 'price' => 0, 'sort_order' => 1],
+                        ['title' => '14"', 'sku' => '14IN', 'price' => 10, 'sort_order' => 2],
                     ],
                 ],
                 [
@@ -183,8 +183,8 @@ class Magento1ProductReaderTest extends TestCase
                     'is_require' => 1,
                     'sort_order' => 2,
                     'values'     => [
-                        ['title' => 'Natural Black', 'price' => 0, 'sort_order' => 1],
-                        ['title' => 'Brown', 'price' => 5, 'sort_order' => 2],
+                        ['title' => 'Natural Black', 'sku' => '1B', 'price' => 0, 'sort_order' => 1],
+                        ['title' => 'Brown', 'sku' => '4', 'price' => 5, 'sort_order' => 2],
                     ],
                 ],
                 [

+ 5 - 5
packages/Webkul/BagistoApi/tests/Unit/Migration/MigrateAsteriaProductsCommandTest.php

@@ -111,7 +111,7 @@ class MigrateAsteriaProductsCommandTest extends BagistoApiTestCase
 
         $this->assertSame(4, ProductVariant::query()->where('product_id', $product->id)->count());
         $this->assertTrue(
-            ProductVariant::query()->where('product_id', $product->id)->where('sku', 'AST-TEST-WIG-001-1-1')->exists()
+            ProductVariant::query()->where('product_id', $product->id)->where('sku', 'AST-TEST-WIG-001-12IN-1B')->exists()
         );
 
         $channelIds = Channel::query()->pluck('id')->map(fn ($id) => (int) $id)->all();
@@ -231,8 +231,8 @@ class MigrateAsteriaProductsCommandTest extends BagistoApiTestCase
                     'type'       => 'drop_down',
                     'sort_order' => 1,
                     'values'     => [
-                        ['title' => '12"', 'price' => 0, 'sort_order' => 1],
-                        ['title' => '14"', 'price' => 10, 'sort_order' => 2],
+                        ['title' => '12"', 'sku' => '12IN', 'price' => 0, 'sort_order' => 1],
+                        ['title' => '14"', 'sku' => '14IN', 'price' => 10, 'sort_order' => 2],
                     ],
                 ],
                 [
@@ -240,8 +240,8 @@ class MigrateAsteriaProductsCommandTest extends BagistoApiTestCase
                     'type'       => 'drop_down',
                     'sort_order' => 2,
                     'values'     => [
-                        ['title' => 'Natural Black', 'price' => 0, 'sort_order' => 1],
-                        ['title' => 'Brown', 'price' => 5, 'sort_order' => 2],
+                        ['title' => 'Natural Black', 'sku' => '1B', 'price' => 0, 'sort_order' => 1],
+                        ['title' => 'Brown', 'sku' => '4', 'price' => 5, 'sort_order' => 2],
                     ],
                 ],
             ],

+ 250 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/RepairMigratedVariantSkusCommandTest.php

@@ -0,0 +1,250 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Longyi\Core\Models\ProductOption;
+use Longyi\Core\Models\ProductOptionValue;
+use Longyi\Core\Models\ProductVariant;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Product\Models\Product;
+
+class RepairMigratedVariantSkusCommandTest extends BagistoApiTestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasColumn('products', 'migrated_from_asteria_id')
+            || ! Schema::hasTable('product_variants')
+            || ! Schema::hasTable('product_variant_option_values')
+        ) {
+            $this->markTestSkipped('Flexible variant tables are missing.');
+        }
+    }
+
+    public function test_it_rewrites_sort_order_skus_from_option_value_labels(): void
+    {
+        [$product, $variant] = $this->seedVariant('REPAIR-WIG-001', 'REPAIR-WIG-001-1-1', 991900001);
+        $variantId = (int) $variant->id;
+
+        $this->artisan('products:repair-variant-skus', [
+            '--sku' => 'REPAIR-WIG-001',
+        ])->assertSuccessful();
+
+        $fresh = ProductVariant::query()->find($variantId);
+        $this->assertNotNull($fresh);
+        $this->assertSame($variantId, (int) $fresh->id);
+        $this->assertSame('REPAIR-WIG-001-12IN-1B', $fresh->sku);
+        $this->assertSame('REPAIR-WIG-001', $product->fresh()->sku);
+    }
+
+    public function test_dry_run_does_not_write_skus(): void
+    {
+        [, $variant] = $this->seedVariant('REPAIR-WIG-002', 'REPAIR-WIG-002-1-1', 991900002);
+
+        $this->artisan('products:repair-variant-skus', [
+            '--sku'    => 'REPAIR-WIG-002',
+            '--dry-run' => true,
+        ])->assertSuccessful();
+
+        $this->assertSame('REPAIR-WIG-002-1-1', $variant->fresh()->sku);
+    }
+
+    public function test_it_leaves_products_that_were_not_migrated_from_asteria(): void
+    {
+        [, $variant] = $this->seedVariant('REPAIR-WIG-003', 'REPAIR-WIG-003-1-1', null);
+
+        $this->artisan('products:repair-variant-skus', [
+            '--sku' => 'REPAIR-WIG-003',
+        ])->assertSuccessful();
+
+        $this->assertSame('REPAIR-WIG-003-1-1', $variant->fresh()->sku);
+    }
+
+    public function test_it_rewrites_order_invoice_and_shipment_skus(): void
+    {
+        [$product, $variant] = $this->seedVariant('REPAIR-WIG-004', 'REPAIR-WIG-004-1-1', 991900004);
+        $newSku = 'REPAIR-WIG-004-12IN-1B';
+        $now = now();
+
+        $orderId = DB::table('orders')->insertGetId([
+            'increment_id' => 'REPAIR-ORD-004',
+            'status'       => 'processing',
+            'is_guest'     => 1,
+            'created_at'   => $now,
+            'updated_at'   => $now,
+        ]);
+
+        $nativeId = DB::table('order_items')->insertGetId([
+            'order_id'    => $orderId,
+            'product_id'  => $product->id,
+            'sku'         => 'REPAIR-WIG-004-1-1',
+            'name'        => 'Wig',
+            'qty_ordered' => 1,
+            'additional'  => json_encode([
+                'variant_id'  => $variant->id,
+                'variant_sku' => 'REPAIR-WIG-004-1-1',
+                'attributes'  => [
+                    'hair_length' => [
+                        'attribute_name' => 'Hair Length',
+                        'option_label'   => 'Hair Length',
+                        'value_label'    => '12"',
+                    ],
+                    'hair_color' => [
+                        'attribute_name' => 'Hair Color',
+                        'option_label'   => 'Hair Color',
+                        'value_label'    => 'Natural Black',
+                    ],
+                ],
+            ], JSON_UNESCAPED_UNICODE),
+            'created_at'  => $now,
+            'updated_at'  => $now,
+        ]);
+
+        $migratedId = DB::table('order_items')->insertGetId([
+            'order_id'    => $orderId,
+            'product_id'  => $product->id,
+            'sku'         => 'REPAIR-WIG-004',
+            'name'        => 'Wig',
+            'qty_ordered' => 1,
+            'additional'  => json_encode([
+                'attributes' => [
+                    ['attribute_name' => 'Hair Length', 'option_label' => '12"', 'attribute_type' => 'drop_down'],
+                    ['attribute_name' => 'Free Gift', 'option_label' => 'Yes', 'attribute_type' => 'drop_down'],
+                    ['attribute_name' => 'Hair Color', 'option_label' => 'Natural Black', 'attribute_type' => 'drop_down'],
+                ],
+            ], JSON_UNESCAPED_UNICODE),
+            'created_at'  => $now,
+            'updated_at'  => $now,
+        ]);
+
+        $invoiceId = DB::table('invoice_items')->insertGetId([
+            'order_item_id' => $nativeId,
+            'sku'           => 'REPAIR-WIG-004-1-1',
+            'name'          => 'Wig',
+            'qty'           => 1,
+            'created_at'    => $now,
+            'updated_at'    => $now,
+        ]);
+
+        $shipmentId = DB::table('shipment_items')->insertGetId([
+            'shipment_id'   => 1,
+            'order_item_id' => $migratedId,
+            'sku'           => 'REPAIR-WIG-004',
+            'name'          => 'Wig',
+            'qty'           => 1,
+            'created_at'    => $now,
+            'updated_at'    => $now,
+        ]);
+
+        $this->artisan('products:repair-variant-skus', [
+            '--sku'     => 'REPAIR-WIG-004',
+            '--dry-run' => true,
+        ])->assertSuccessful();
+
+        $this->assertSame('REPAIR-WIG-004-1-1', DB::table('order_items')->where('id', $nativeId)->value('sku'));
+
+        $this->artisan('products:repair-variant-skus', [
+            '--sku' => 'REPAIR-WIG-004',
+        ])->assertSuccessful();
+
+        $this->assertSame($newSku, $variant->fresh()->sku);
+        $this->assertSame($newSku, DB::table('order_items')->where('id', $nativeId)->value('sku'));
+        $this->assertSame($newSku, DB::table('order_items')->where('id', $migratedId)->value('sku'));
+        $this->assertSame($newSku, DB::table('invoice_items')->where('id', $invoiceId)->value('sku'));
+        $this->assertSame($newSku, DB::table('shipment_items')->where('id', $shipmentId)->value('sku'));
+
+        $nativeAdditional = json_decode((string) DB::table('order_items')->where('id', $nativeId)->value('additional'), true);
+        $this->assertSame($newSku, $nativeAdditional['variant_sku']);
+
+        $migratedAdditional = json_decode((string) DB::table('order_items')->where('id', $migratedId)->value('additional'), true);
+        $this->assertArrayNotHasKey('variant_sku', $migratedAdditional);
+    }
+
+    /**
+     * @return array{0: Product, 1: ProductVariant}
+     */
+    private function seedVariant(string $parentSku, string $variantSku, ?int $asteriaId): array
+    {
+        $suffix = str_replace('-', '_', strtolower($parentSku));
+
+        $product = new Product;
+        $product->sku = $parentSku;
+        $product->type = 'flexible_variant';
+        $product->migrated_from_asteria_id = $asteriaId;
+        $product->save();
+
+        $length = ProductOption::query()->create([
+            'label' => 'Hair Length',
+            'type'  => 'select',
+            'code'  => $suffix.'_length',
+        ]);
+        $color = ProductOption::query()->create([
+            'label' => 'Hair Color',
+            'type'  => 'select',
+            'code'  => $suffix.'_color',
+        ]);
+
+        $twelve = ProductOptionValue::query()->create([
+            'product_option_id' => $length->id,
+            'label'             => '12"',
+            'code'              => '12IN',
+            'position'          => 1,
+        ]);
+        $black = ProductOptionValue::query()->create([
+            'product_option_id' => $color->id,
+            'label'             => 'Natural Black',
+            'code'              => '1B',
+            'position'          => 1,
+        ]);
+
+        $now = now();
+
+        DB::table('product_product_options')->insert([
+            [
+                'product_id'        => $product->id,
+                'product_option_id' => $length->id,
+                'position'          => 0,
+                'is_required'       => 1,
+                'created_at'        => $now,
+                'updated_at'        => $now,
+            ],
+            [
+                'product_id'        => $product->id,
+                'product_option_id' => $color->id,
+                'position'          => 1,
+                'is_required'       => 1,
+                'created_at'        => $now,
+                'updated_at'        => $now,
+            ],
+        ]);
+
+        $variant = ProductVariant::query()->create([
+            'product_id' => $product->id,
+            'sku'        => $variantSku,
+            'price'      => 99,
+            'quantity'   => 1,
+            'status'     => 1,
+            'sort_order' => 0,
+        ]);
+
+        DB::table('product_variant_option_values')->insert([
+            [
+                'product_variant_id'      => $variant->id,
+                'product_option_value_id' => $twelve->id,
+                'created_at'              => $now,
+                'updated_at'              => $now,
+            ],
+            [
+                'product_variant_id'      => $variant->id,
+                'product_option_value_id' => $black->id,
+                'created_at'              => $now,
+                'updated_at'              => $now,
+            ],
+        ]);
+
+        return [$product, $variant];
+    }
+}