Explorar o código

老站缺货商品迁入时改为禁用,并补一条命令修复已经迁过的商品。

Co-authored-by: Cursor <cursoragent@cursor.com>
chengwl hai 19 horas
pai
achega
aaefaaeee2

+ 4 - 2
app/Console/Commands/MigrateAsteriaProducts.php

@@ -29,6 +29,8 @@ use Webkul\Product\Models\Product;
  *
  * Magento custom options become Longyi flexible_variant options/variants,
  * matching catalog:sync / shell/migrate_to_bagisto.php.
+ * Magento Out of Stock (cataloginventory_stock_item.is_in_stock = 0) is stored
+ * as Bagisto product status = 0 (disabled).
  *
  * Usage
  * ─────
@@ -78,7 +80,7 @@ class MigrateAsteriaProducts extends Command
     /** @var array<int, int> */
     private array $inventorySourceIds = [];
 
-    /** @var array<string, \Webkul\Attribute\Models\Attribute> */
+    /** @var array<string, Attribute> */
     private array $familyAttributes = [];
 
     /** @var array<int, int> */
@@ -970,7 +972,7 @@ class MigrateAsteriaProducts extends Command
                 ->where('admin_name', $value)
                 ->value('id'),
             'multiselect' => $this->castMultiselectValue((int) $attribute->id, (string) $value),
-            default => $value,
+            default       => $value,
         };
     }
 

+ 419 - 0
app/Console/Commands/RepairOutOfStockProductStatus.php

@@ -0,0 +1,419 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Asteria\Magento1Schema;
+use Illuminate\Console\Command;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Webkul\Product\Helpers\Indexers\ElasticSearch;
+use Webkul\Product\Models\Product;
+
+/**
+ * Disables Bagisto products that were migrated from Asteria while the old
+ * site stock availability is Out of Stock (is_in_stock = 0).
+ *
+ * Does not re-enable products that are In Stock. Products with no Magento
+ * stock row are left unchanged.
+ *
+ * Usage
+ * ─────
+ * php artisan products:repair-out-of-stock-status
+ * php artisan products:repair-out-of-stock-status --dry-run
+ * php artisan products:repair-out-of-stock-status --sku=PC001
+ * php artisan products:repair-out-of-stock-status --no-index
+ */
+class RepairOutOfStockProductStatus extends Command
+{
+    protected $signature = 'products:repair-out-of-stock-status
+        {--dry-run : Count products that would be disabled without writing}
+        {--connection=asteria : Laravel DB connection for the Asteria database}
+        {--sku= : Repair one Bagisto product SKU}
+        {--no-index : Skip rebuilding the elastic index}';
+
+    protected $description = 'Disable migrated products whose Asteria stock status is Out of Stock';
+
+    private string $connectionName = 'asteria';
+
+    private bool $hasStockId = false;
+
+    private int $statusAttributeId = 0;
+
+    /** @var array<int, string> */
+    private array $channelCodes = [];
+
+    private bool $hasUniqueId = false;
+
+    private bool $hasJsonValue = false;
+
+    private bool $hasProductFlat = false;
+
+    private int $scanned = 0;
+
+    private int $disabled = 0;
+
+    private int $unchanged = 0;
+
+    private int $inStock = 0;
+
+    private int $noStockRow = 0;
+
+    /** @var array<int, int> */
+    private array $touchedProductIds = [];
+
+    /** @var array<int, string> */
+    private array $examples = [];
+
+    public function handle(): int
+    {
+        $dryRun = (bool) $this->option('dry-run');
+        $this->connectionName = (string) $this->option('connection');
+        $sku = trim((string) $this->option('sku'));
+
+        try {
+            DB::connection($this->connectionName)->getPdo();
+        } catch (\Throwable $e) {
+            $this->error("Cannot connect to Asteria DB (connection='{$this->connectionName}'): ".$e->getMessage());
+
+            return self::FAILURE;
+        }
+
+        $schema = new Magento1Schema($this->connectionName);
+
+        if (! $schema->hasTable('cataloginventory_stock_item')) {
+            $this->error("Asteria table 'cataloginventory_stock_item' is missing on connection '{$this->connectionName}'.");
+
+            return self::FAILURE;
+        }
+
+        $columns = $schema->existingColumns('cataloginventory_stock_item', [
+            'product_id',
+            'is_in_stock',
+            'stock_id',
+        ]);
+
+        if (! in_array('product_id', $columns, true) || ! in_array('is_in_stock', $columns, true)) {
+            $this->error('cataloginventory_stock_item is missing product_id or is_in_stock.');
+
+            return self::FAILURE;
+        }
+
+        $this->hasStockId = in_array('stock_id', $columns, true);
+
+        if (! Schema::hasColumn('products', 'migrated_from_asteria_id')) {
+            $this->error('products.migrated_from_asteria_id is missing. Run php artisan migrate.');
+
+            return self::FAILURE;
+        }
+
+        $statusAttributeId = DB::table('attributes')->where('code', 'status')->value('id');
+
+        if (! $statusAttributeId) {
+            $this->error('Bagisto status attribute is missing.');
+
+            return self::FAILURE;
+        }
+
+        $this->statusAttributeId = (int) $statusAttributeId;
+        $this->channelCodes = DB::table('channels')->orderBy('id')->pluck('code')
+            ->map(fn ($code) => trim((string) $code))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+
+        if ($this->channelCodes === []) {
+            $this->channelCodes = ['default'];
+        }
+
+        $this->hasUniqueId = Schema::hasColumn('product_attribute_values', 'unique_id');
+        $this->hasJsonValue = Schema::hasColumn('product_attribute_values', 'json_value');
+        $this->hasProductFlat = Schema::hasTable('product_flat') && Schema::hasColumn('product_flat', 'status');
+
+        $query = Product::query()
+            ->whereNotNull('migrated_from_asteria_id')
+            ->select('id', 'sku', 'migrated_from_asteria_id')
+            ->orderBy('id');
+
+        if ($sku !== '') {
+            $query->where('sku', $sku);
+
+            if (! (clone $query)->exists()) {
+                $exists = Product::query()->where('sku', $sku)->exists();
+                $this->error($exists
+                    ? "Product {$sku} was not migrated from Asteria."
+                    : "Product {$sku} was not found.");
+
+                return self::FAILURE;
+            }
+        }
+
+        $query->chunkById(200, function (Collection $products) use ($dryRun) {
+            $this->repairChunk($products, $dryRun);
+        });
+
+        if ($this->examples !== []) {
+            $this->table(['sku', 'product_id'], $this->examples);
+        }
+
+        if (! $dryRun && ! $this->option('no-index')) {
+            $this->reindexProducts();
+        }
+
+        $this->info(sprintf(
+            '%s scanned=%d disabled=%d unchanged=%d in_stock=%d no_stock_row=%d',
+            $dryRun ? '[DRY RUN]' : 'Done.',
+            $this->scanned,
+            $this->disabled,
+            $this->unchanged,
+            $this->inStock,
+            $this->noStockRow
+        ));
+
+        return self::SUCCESS;
+    }
+
+    private function repairChunk(Collection $products, bool $dryRun): void
+    {
+        $asteriaIds = $products
+            ->pluck('migrated_from_asteria_id')
+            ->map(fn ($id) => (int) $id)
+            ->unique()
+            ->values()
+            ->all();
+
+        $stock = $this->loadStock($asteriaIds);
+        $outOfStock = $products->filter(function ($product) use ($stock) {
+            $asteriaId = (int) $product->migrated_from_asteria_id;
+
+            return array_key_exists($asteriaId, $stock) && $stock[$asteriaId] === false;
+        })->values();
+
+        foreach ($products as $product) {
+            $this->scanned++;
+            $asteriaId = (int) $product->migrated_from_asteria_id;
+
+            if (! array_key_exists($asteriaId, $stock)) {
+                $this->noStockRow++;
+            } elseif ($stock[$asteriaId] === true) {
+                $this->inStock++;
+            }
+        }
+
+        if ($outOfStock->isEmpty()) {
+            return;
+        }
+
+        $productIds = $outOfStock->pluck('id')->map(fn ($id) => (int) $id)->all();
+        $statusRows = DB::table('product_attribute_values')
+            ->where('attribute_id', $this->statusAttributeId)
+            ->whereIn('product_id', $productIds)
+            ->get(['id', 'product_id', 'channel', 'boolean_value']);
+
+        $rowsByProduct = [];
+
+        foreach ($statusRows as $row) {
+            $rowsByProduct[(int) $row->product_id][] = $row;
+        }
+
+        $flatNeedsUpdate = [];
+
+        if ($this->hasProductFlat) {
+            $flatNeedsUpdate = DB::table('product_flat')
+                ->whereIn('product_id', $productIds)
+                ->where(function ($query) {
+                    $query->where('status', '!=', 0)->orWhereNull('status');
+                })
+                ->pluck('product_id')
+                ->mapWithKeys(fn ($id) => [(int) $id => true])
+                ->all();
+        }
+
+        $changedIds = [];
+
+        foreach ($outOfStock as $product) {
+            $productId = (int) $product->id;
+            $rows = $rowsByProduct[$productId] ?? [];
+            $presentChannels = [];
+            $statusOff = $rows !== [];
+
+            foreach ($rows as $row) {
+                if ((int) $row->boolean_value !== 0) {
+                    $statusOff = false;
+                }
+
+                $channel = trim((string) ($row->channel ?? ''));
+
+                if ($channel !== '') {
+                    $presentChannels[$channel] = true;
+                }
+            }
+
+            $missingChannel = false;
+
+            foreach ($this->channelCodes as $channel) {
+                if (! isset($presentChannels[$channel])) {
+                    $missingChannel = true;
+                    break;
+                }
+            }
+
+            if ($statusOff && ! $missingChannel && ! isset($flatNeedsUpdate[$productId])) {
+                $this->unchanged++;
+
+                continue;
+            }
+
+            $this->disabled++;
+            $changedIds[] = $productId;
+
+            if (count($this->examples) < 20) {
+                $this->examples[] = [(string) $product->sku, $productId];
+            }
+        }
+
+        if ($changedIds === [] || $dryRun) {
+            return;
+        }
+
+        $this->touchedProductIds = array_merge($this->touchedProductIds, $changedIds);
+
+        DB::table('product_attribute_values')
+            ->where('attribute_id', $this->statusAttributeId)
+            ->whereIn('product_id', $changedIds)
+            ->where(function ($query) {
+                $query->where('boolean_value', '!=', 0)->orWhereNull('boolean_value');
+            })
+            ->update(['boolean_value' => 0]);
+
+        $inserts = [];
+
+        foreach ($changedIds as $productId) {
+            $presentChannels = [];
+
+            foreach ($rowsByProduct[$productId] ?? [] as $row) {
+                $channel = trim((string) ($row->channel ?? ''));
+
+                if ($channel !== '') {
+                    $presentChannels[$channel] = true;
+                }
+            }
+
+            foreach ($this->channelCodes as $channel) {
+                if (isset($presentChannels[$channel])) {
+                    continue;
+                }
+
+                $inserts[] = $this->statusValueRow($productId, $channel);
+            }
+        }
+
+        foreach (array_chunk($inserts, 500) as $chunk) {
+            DB::table('product_attribute_values')->insert($chunk);
+        }
+
+        if ($this->hasProductFlat) {
+            $flatUpdate = ['status' => 0];
+
+            if (Schema::hasColumn('product_flat', 'updated_at')) {
+                $flatUpdate['updated_at'] = now();
+            }
+
+            DB::table('product_flat')
+                ->whereIn('product_id', $changedIds)
+                ->update($flatUpdate);
+        }
+    }
+
+    /**
+     * @param  array<int, int>  $asteriaIds
+     * @return array<int, bool> asteria id => true when In Stock
+     */
+    private function loadStock(array $asteriaIds): array
+    {
+        if ($asteriaIds === []) {
+            return [];
+        }
+
+        $query = DB::connection($this->connectionName)
+            ->table('cataloginventory_stock_item')
+            ->whereIn('product_id', $asteriaIds);
+
+        if ($this->hasStockId) {
+            $query->where('stock_id', 1);
+        }
+
+        $stock = [];
+
+        foreach ($query->get(['product_id', 'is_in_stock']) as $row) {
+            $stock[(int) $row->product_id] = (int) $row->is_in_stock === 1;
+        }
+
+        return $stock;
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function statusValueRow(int $productId, string $channel): array
+    {
+        $row = [
+            'locale'        => null,
+            'channel'       => $channel,
+            'text_value'    => null,
+            'boolean_value' => 0,
+            'integer_value' => null,
+            'float_value'   => null,
+            'datetime_value'=> null,
+            'date_value'    => null,
+            'product_id'    => $productId,
+            'attribute_id'  => $this->statusAttributeId,
+        ];
+
+        if ($this->hasJsonValue) {
+            $row['json_value'] = null;
+        }
+
+        if ($this->hasUniqueId) {
+            $row['unique_id'] = $channel.'|'.$productId.'|'.$this->statusAttributeId;
+        }
+
+        return $row;
+    }
+
+    private function reindexProducts(): void
+    {
+        $ids = array_values(array_unique($this->touchedProductIds));
+
+        if ($ids === [] || ! $this->elasticEnabled()) {
+            return;
+        }
+
+        $this->info('Reindexing '.count($ids).' products…');
+
+        $elasticIndexer = app(ElasticSearch::class);
+
+        foreach (array_chunk($ids, 20) as $chunk) {
+            $products = Product::query()->with([
+                'attribute_family',
+                'attribute_values',
+                'channels',
+                'categories',
+            ])->whereIn('id', $chunk)->get();
+
+            foreach ($products as $product) {
+                try {
+                    $elasticIndexer->reindexRow($product);
+                } catch (\Throwable $e) {
+                    $this->error('Reindex failed for product '.$product->sku.': '.$e->getMessage());
+                }
+            }
+        }
+    }
+
+    private function elasticEnabled(): bool
+    {
+        return core()->getConfigData('catalog.products.search.engine') === 'elastic';
+    }
+}

+ 45 - 13
app/Services/Asteria/Magento1ProductReader.php

@@ -2,6 +2,8 @@
 
 namespace App\Services\Asteria;
 
+use Illuminate\Database\Query\Builder;
+use Illuminate\Database\Query\Expression;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
 
@@ -75,7 +77,8 @@ class Magento1ProductReader
             // Magento status attribute value for "Enabled". Used when only_enabled=true.
             'enabled_status'         => 1,
             // false = also import disabled Magento products (status mapped to 0) so
-            // reviews / orders can still match by SKU. Stock is never used as a filter.
+            // reviews / orders can still match by SKU. Stock is not an import filter;
+            // Out of Stock (is_in_stock=0) still forces Bagisto status=0.
             'only_enabled'           => false,
         ];
     }
@@ -202,7 +205,7 @@ class Magento1ProductReader
                 $eav[$id] ?? [],
                 $images[$id] ?? ['base' => '', 'additional' => []],
                 $categories[$id] ?? [],
-                $stock[$id] ?? ['qty' => 0, 'is_in_stock' => 0],
+                $stock[$id] ?? ['qty' => 0, 'is_in_stock' => null],
                 $customOptions[$id] ?? [],
                 $variantTitles
             );
@@ -215,7 +218,7 @@ class Magento1ProductReader
     }
 
     /**
-     * @param  \Illuminate\Database\Query\Builder  $query
+     * @param  Builder  $query
      */
     private function applyEnabledStatusFilter($query): void
     {
@@ -306,8 +309,8 @@ class Magento1ProductReader
             'special_price'         => $eav['special_price'] ?? '',
             'cost'                  => $eav['cost'] ?? '',
             'weight'                => $eav['weight'] ?? '',
-            // Magento: 1=Enabled, 2=Disabled. Keep disabled products importable for review matching.
-            'status'                => $this->mapMagentoStatus($eav['status'] ?? 1),
+            // Magento: 1=Enabled, 2=Disabled. Out of Stock forces Bagisto status=0.
+            'status'                => $this->resolveBagistoStatus($eav['status'] ?? 1, $stock),
             'featured'              => 0,
             'new'                   => 0,
             'guest_checkout'        => 1,
@@ -338,6 +341,35 @@ class Magento1ProductReader
         return $row;
     }
 
+    /**
+     * Bagisto status is 0 when Magento stock availability is Out of Stock.
+     * A missing stock row keeps the Magento enabled/disabled mapping.
+     *
+     * @param  array{qty?: mixed, is_in_stock?: mixed}  $stock
+     */
+    private function resolveBagistoStatus(mixed $magentoStatus, array $stock): int
+    {
+        if ($this->isExplicitlyOutOfStock($stock)) {
+            return 0;
+        }
+
+        return $this->mapMagentoStatus($magentoStatus);
+    }
+
+    /**
+     * Magento cataloginventory_stock_item.is_in_stock: 1 = In Stock, 0 = Out of Stock.
+     *
+     * @param  array{qty?: mixed, is_in_stock?: mixed}  $stock
+     */
+    private function isExplicitlyOutOfStock(array $stock): bool
+    {
+        if (! array_key_exists('is_in_stock', $stock) || $stock['is_in_stock'] === null || $stock['is_in_stock'] === '') {
+            return false;
+        }
+
+        return (int) $stock['is_in_stock'] !== 1;
+    }
+
     /**
      * Magento 1 product status: 1=Enabled, 2=Disabled. Bagisto uses 1/0.
      */
@@ -982,7 +1014,7 @@ class Magento1ProductReader
     }
 
     /**
-     * @return array<int, string|\Illuminate\Database\Query\Expression>
+     * @return array<int, string|Expression>
      */
     private function attributeSelectColumns(): array
     {
@@ -1091,14 +1123,14 @@ class Magento1ProductReader
     private function magentoInputToBagistoType(string $frontendInput): string
     {
         return match ($frontendInput) {
-            'textarea'    => 'textarea',
-            'select'      => 'select',
-            'multiselect' => 'multiselect',
-            'boolean'     => 'boolean',
-            'price'       => 'price',
-            'date'        => 'date',
+            'textarea'               => 'textarea',
+            'select'                 => 'select',
+            'multiselect'            => 'multiselect',
+            'boolean'                => 'boolean',
+            'price'                  => 'price',
+            'date'                   => 'date',
             'media_image', 'gallery' => 'image',
-            default       => 'text',
+            default                  => 'text',
         };
     }
 

+ 1 - 1
config/asteria.php

@@ -67,7 +67,7 @@ return [
         'enabled_status' => 1,
 
         // false = import disabled Magento products as Bagisto status=0 so reviews can match by SKU.
-        // Stock / is_in_stock is never used as an import filter.
+        // Stock is not an import filter. is_in_stock=0 (Out of Stock) forces Bagisto status=0.
         // Set ASTERIA_PRODUCTS_ONLY_ENABLED=true to keep the old "enabled only" behaviour.
         'only_enabled' => filter_var(env('ASTERIA_PRODUCTS_ONLY_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
     ],

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 10 - 1
docs/asteria-migration.md


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

@@ -109,6 +109,35 @@ class Magento1ProductReaderTest extends TestCase
         $this->assertSame(['WIG-001', 'WIG-002', 'WIG-OFF'], $skus);
     }
 
+    public function test_it_disables_products_whose_stock_status_is_out_of_stock(): void
+    {
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id'   => 21,
+            'sku'         => 'WIG-OOS',
+            'name'        => 'Out Of Stock',
+            'price'       => 40,
+            'status'      => 1,
+            'qty'         => 8,
+            'is_in_stock' => 0,
+        ]);
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id'   => 22,
+            'sku'         => 'WIG-OFF-STOCKED',
+            'name'        => 'Disabled But In Stock',
+            'price'       => 40,
+            'status'      => 2,
+            'qty'         => 3,
+            'is_in_stock' => 1,
+        ]);
+
+        $reader = new Magento1ProductReader($this->connection, $this->readerConfig);
+        $page = $reader->fetchProducts(0, 50);
+
+        $this->assertSame(0, (int) $page->firstWhere('sku', 'WIG-OOS')['status']);
+        $this->assertSame(0, (int) $page->firstWhere('sku', 'WIG-OFF-STOCKED')['status']);
+        $this->assertSame(1, (int) $page->firstWhere('sku', 'WIG-001')['status']);
+    }
+
     public function test_it_can_skip_disabled_products_when_only_enabled(): void
     {
         MagentoSchema::seedProduct($this->connection, [

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

@@ -192,6 +192,38 @@ class MigrateAsteriaProductsCommandTest extends BagistoApiTestCase
         $this->assertSame(1, Product::query()->where('sku', 'AST-TEST-WIG-001')->count());
     }
 
+    public function test_it_imports_out_of_stock_products_as_disabled(): void
+    {
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id'   => self::MID + 30,
+            'sku'         => 'AST-TEST-OOS-001',
+            'name'        => 'Out Of Stock Wig',
+            'price'       => '40.00',
+            'status'      => 1,
+            'qty'         => 8,
+            'is_in_stock' => 0,
+        ]);
+        $this->seedMagentoProduct();
+
+        $this->artisan('products:migrate-asteria', [
+            '--connection'     => $this->connection,
+            '--reset-progress' => true,
+            '--no-index'       => true,
+        ])->assertSuccessful();
+
+        $outOfStock = Product::query()->where('sku', 'AST-TEST-OOS-001')->first();
+        $inStock = Product::query()->where('sku', 'AST-TEST-WIG-001')->first();
+
+        $this->assertNotNull($outOfStock);
+        $this->assertNotNull($inStock);
+        $this->assertSame(0, $this->productStatus((int) $outOfStock->id));
+        $this->assertSame(1, $this->productStatus((int) $inStock->id));
+        $this->assertDatabaseHas('product_inventories', [
+            'product_id' => $outOfStock->id,
+            'qty'        => 8,
+        ]);
+    }
+
     public function test_it_skips_non_simple_magento_products(): void
     {
         MagentoSchema::seedProduct($this->connection, [
@@ -248,6 +280,16 @@ class MigrateAsteriaProductsCommandTest extends BagistoApiTestCase
         ]);
     }
 
+    private function productStatus(int $productId): int
+    {
+        $attributeId = DB::table('attributes')->where('code', 'status')->value('id');
+
+        return (int) DB::table('product_attribute_values')
+            ->where('product_id', $productId)
+            ->where('attribute_id', $attributeId)
+            ->value('boolean_value');
+    }
+
     private function attributeText(int $productId, string $code): ?string
     {
         $attributeId = DB::table('attributes')->where('code', $code)->value('id');

+ 195 - 0
packages/Webkul/BagistoApi/tests/Unit/Migration/RepairOutOfStockProductStatusCommandTest.php

@@ -0,0 +1,195 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Migration;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Webkul\BagistoApi\Tests\BagistoApiTestCase;
+use Webkul\Product\Models\Product;
+
+class RepairOutOfStockProductStatusCommandTest extends BagistoApiTestCase
+{
+    private string $sqlitePath;
+
+    private string $connection = 'asteria_test';
+
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        if (! Schema::hasColumn('products', 'migrated_from_asteria_id')) {
+            $this->markTestSkipped('Run php artisan migrate to add Asteria product columns.');
+        }
+
+        if (! DB::table('attributes')->where('code', 'status')->exists()) {
+            $this->markTestSkipped('Bagisto status attribute is missing.');
+        }
+
+        $this->sqlitePath = sys_get_temp_dir().'/asteria_oos_repair_'.uniqid('', true).'.sqlite';
+        touch($this->sqlitePath);
+
+        config()->set('database.connections.'.$this->connection, [
+            'driver'                  => 'sqlite',
+            'database'                => $this->sqlitePath,
+            'prefix'                  => '',
+            'foreign_key_constraints' => false,
+        ]);
+
+        DB::purge($this->connection);
+        MagentoSchema::create($this->connection);
+    }
+
+    public function tearDown(): void
+    {
+        DB::purge($this->connection);
+
+        if (isset($this->sqlitePath) && is_file($this->sqlitePath)) {
+            @unlink($this->sqlitePath);
+        }
+
+        parent::tearDown();
+    }
+
+    public function test_it_disables_migrated_products_that_are_out_of_stock_on_asteria(): void
+    {
+        $outOfStock = $this->seedBagistoProduct('AST-OOS-REPAIR-001', 991910001, 1);
+        $inStock = $this->seedBagistoProduct('AST-OOS-REPAIR-002', 991910002, 1);
+        $noStockRow = $this->seedBagistoProduct('AST-OOS-REPAIR-003', 991910003, 1);
+        $local = $this->seedBagistoProduct('AST-OOS-REPAIR-004', null, 1);
+
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id'   => 991910001,
+            'sku'         => 'AST-OOS-REPAIR-001',
+            'name'        => 'Out Of Stock',
+            'price'       => 10,
+            'status'      => 1,
+            'qty'         => 6,
+            'is_in_stock' => 0,
+        ]);
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id'   => 991910002,
+            'sku'         => 'AST-OOS-REPAIR-002',
+            'name'        => 'In Stock',
+            'price'       => 10,
+            'status'      => 1,
+            'qty'         => 4,
+            'is_in_stock' => 1,
+        ]);
+
+        $this->artisan('products:repair-out-of-stock-status', [
+            '--connection' => $this->connection,
+            '--no-index'   => true,
+        ])->assertSuccessful();
+
+        $this->assertSame(0, $this->productStatus((int) $outOfStock->id));
+        $this->assertSame(0, $this->flatStatus((int) $outOfStock->id));
+        $this->assertSame(1, $this->productStatus((int) $inStock->id));
+        $this->assertSame(1, $this->productStatus((int) $noStockRow->id));
+        $this->assertSame(1, $this->productStatus((int) $local->id));
+    }
+
+    public function test_dry_run_does_not_disable_products(): void
+    {
+        $product = $this->seedBagistoProduct('AST-OOS-REPAIR-005', 991910005, 1);
+
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id'   => 991910005,
+            'sku'         => 'AST-OOS-REPAIR-005',
+            'name'        => 'Out Of Stock',
+            'price'       => 10,
+            'qty'         => 1,
+            'is_in_stock' => 0,
+        ]);
+
+        $this->artisan('products:repair-out-of-stock-status', [
+            '--connection' => $this->connection,
+            '--dry-run'    => true,
+            '--no-index'   => true,
+            '--sku'        => 'AST-OOS-REPAIR-005',
+        ])->assertSuccessful();
+
+        $this->assertSame(1, $this->productStatus((int) $product->id));
+    }
+
+    public function test_it_leaves_products_that_are_already_disabled(): void
+    {
+        $product = $this->seedBagistoProduct('AST-OOS-REPAIR-006', 991910006, 0);
+
+        MagentoSchema::seedProduct($this->connection, [
+            'entity_id'   => 991910006,
+            'sku'         => 'AST-OOS-REPAIR-006',
+            'name'        => 'Already Disabled',
+            'price'       => 10,
+            'qty'         => 0,
+            'is_in_stock' => 0,
+        ]);
+
+        $this->artisan('products:repair-out-of-stock-status', [
+            '--connection' => $this->connection,
+            '--no-index'   => true,
+            '--sku'        => 'AST-OOS-REPAIR-006',
+        ])->assertSuccessful();
+
+        $this->assertSame(0, $this->productStatus((int) $product->id));
+    }
+
+    private function seedBagistoProduct(string $sku, ?int $asteriaId, int $status): Product
+    {
+        $product = new Product;
+        $product->sku = $sku;
+        $product->type = 'flexible_variant';
+        $product->migrated_from_asteria_id = $asteriaId;
+        $product->attribute_family_id = (int) (DB::table('attribute_families')->value('id') ?? 1);
+        $product->save();
+
+        $attributeId = (int) DB::table('attributes')->where('code', 'status')->value('id');
+        $channel = (string) (DB::table('channels')->orderBy('id')->value('code') ?: 'default');
+
+        DB::table('product_attribute_values')->insert([
+            'locale'        => null,
+            'channel'       => $channel,
+            'text_value'    => null,
+            'boolean_value' => $status,
+            'integer_value' => null,
+            'float_value'   => null,
+            'datetime_value'=> null,
+            'date_value'    => null,
+            'json_value'    => null,
+            'product_id'    => $product->id,
+            'attribute_id'  => $attributeId,
+            'unique_id'     => $channel.'|'.$product->id.'|'.$attributeId,
+        ]);
+
+        if (Schema::hasTable('product_flat')) {
+            DB::table('product_flat')->insert([
+                'sku'        => $sku,
+                'type'       => 'flexible_variant',
+                'status'     => $status,
+                'product_id' => $product->id,
+                'channel'    => $channel,
+                'locale'     => 'en',
+            ]);
+        }
+
+        return $product;
+    }
+
+    private function productStatus(int $productId): int
+    {
+        $attributeId = DB::table('attributes')->where('code', 'status')->value('id');
+
+        return (int) DB::table('product_attribute_values')
+            ->where('product_id', $productId)
+            ->where('attribute_id', $attributeId)
+            ->value('boolean_value');
+    }
+
+    private function flatStatus(int $productId): int
+    {
+        if (! Schema::hasTable('product_flat')) {
+            return 0;
+        }
+
+        return (int) DB::table('product_flat')->where('product_id', $productId)->value('status');
+    }
+}