| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419 |
- <?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';
- }
- }
|