RepairOutOfStockProductStatus.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Services\Asteria\Magento1Schema;
  4. use Illuminate\Console\Command;
  5. use Illuminate\Support\Collection;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Schema;
  8. use Webkul\Product\Helpers\Indexers\ElasticSearch;
  9. use Webkul\Product\Models\Product;
  10. /**
  11. * Disables Bagisto products that were migrated from Asteria while the old
  12. * site stock availability is Out of Stock (is_in_stock = 0).
  13. *
  14. * Does not re-enable products that are In Stock. Products with no Magento
  15. * stock row are left unchanged.
  16. *
  17. * Usage
  18. * ─────
  19. * php artisan products:repair-out-of-stock-status
  20. * php artisan products:repair-out-of-stock-status --dry-run
  21. * php artisan products:repair-out-of-stock-status --sku=PC001
  22. * php artisan products:repair-out-of-stock-status --no-index
  23. */
  24. class RepairOutOfStockProductStatus extends Command
  25. {
  26. protected $signature = 'products:repair-out-of-stock-status
  27. {--dry-run : Count products that would be disabled without writing}
  28. {--connection=asteria : Laravel DB connection for the Asteria database}
  29. {--sku= : Repair one Bagisto product SKU}
  30. {--no-index : Skip rebuilding the elastic index}';
  31. protected $description = 'Disable migrated products whose Asteria stock status is Out of Stock';
  32. private string $connectionName = 'asteria';
  33. private bool $hasStockId = false;
  34. private int $statusAttributeId = 0;
  35. /** @var array<int, string> */
  36. private array $channelCodes = [];
  37. private bool $hasUniqueId = false;
  38. private bool $hasJsonValue = false;
  39. private bool $hasProductFlat = false;
  40. private int $scanned = 0;
  41. private int $disabled = 0;
  42. private int $unchanged = 0;
  43. private int $inStock = 0;
  44. private int $noStockRow = 0;
  45. /** @var array<int, int> */
  46. private array $touchedProductIds = [];
  47. /** @var array<int, string> */
  48. private array $examples = [];
  49. public function handle(): int
  50. {
  51. $dryRun = (bool) $this->option('dry-run');
  52. $this->connectionName = (string) $this->option('connection');
  53. $sku = trim((string) $this->option('sku'));
  54. try {
  55. DB::connection($this->connectionName)->getPdo();
  56. } catch (\Throwable $e) {
  57. $this->error("Cannot connect to Asteria DB (connection='{$this->connectionName}'): ".$e->getMessage());
  58. return self::FAILURE;
  59. }
  60. $schema = new Magento1Schema($this->connectionName);
  61. if (! $schema->hasTable('cataloginventory_stock_item')) {
  62. $this->error("Asteria table 'cataloginventory_stock_item' is missing on connection '{$this->connectionName}'.");
  63. return self::FAILURE;
  64. }
  65. $columns = $schema->existingColumns('cataloginventory_stock_item', [
  66. 'product_id',
  67. 'is_in_stock',
  68. 'stock_id',
  69. ]);
  70. if (! in_array('product_id', $columns, true) || ! in_array('is_in_stock', $columns, true)) {
  71. $this->error('cataloginventory_stock_item is missing product_id or is_in_stock.');
  72. return self::FAILURE;
  73. }
  74. $this->hasStockId = in_array('stock_id', $columns, true);
  75. if (! Schema::hasColumn('products', 'migrated_from_asteria_id')) {
  76. $this->error('products.migrated_from_asteria_id is missing. Run php artisan migrate.');
  77. return self::FAILURE;
  78. }
  79. $statusAttributeId = DB::table('attributes')->where('code', 'status')->value('id');
  80. if (! $statusAttributeId) {
  81. $this->error('Bagisto status attribute is missing.');
  82. return self::FAILURE;
  83. }
  84. $this->statusAttributeId = (int) $statusAttributeId;
  85. $this->channelCodes = DB::table('channels')->orderBy('id')->pluck('code')
  86. ->map(fn ($code) => trim((string) $code))
  87. ->filter()
  88. ->unique()
  89. ->values()
  90. ->all();
  91. if ($this->channelCodes === []) {
  92. $this->channelCodes = ['default'];
  93. }
  94. $this->hasUniqueId = Schema::hasColumn('product_attribute_values', 'unique_id');
  95. $this->hasJsonValue = Schema::hasColumn('product_attribute_values', 'json_value');
  96. $this->hasProductFlat = Schema::hasTable('product_flat') && Schema::hasColumn('product_flat', 'status');
  97. $query = Product::query()
  98. ->whereNotNull('migrated_from_asteria_id')
  99. ->select('id', 'sku', 'migrated_from_asteria_id')
  100. ->orderBy('id');
  101. if ($sku !== '') {
  102. $query->where('sku', $sku);
  103. if (! (clone $query)->exists()) {
  104. $exists = Product::query()->where('sku', $sku)->exists();
  105. $this->error($exists
  106. ? "Product {$sku} was not migrated from Asteria."
  107. : "Product {$sku} was not found.");
  108. return self::FAILURE;
  109. }
  110. }
  111. $query->chunkById(200, function (Collection $products) use ($dryRun) {
  112. $this->repairChunk($products, $dryRun);
  113. });
  114. if ($this->examples !== []) {
  115. $this->table(['sku', 'product_id'], $this->examples);
  116. }
  117. if (! $dryRun && ! $this->option('no-index')) {
  118. $this->reindexProducts();
  119. }
  120. $this->info(sprintf(
  121. '%s scanned=%d disabled=%d unchanged=%d in_stock=%d no_stock_row=%d',
  122. $dryRun ? '[DRY RUN]' : 'Done.',
  123. $this->scanned,
  124. $this->disabled,
  125. $this->unchanged,
  126. $this->inStock,
  127. $this->noStockRow
  128. ));
  129. return self::SUCCESS;
  130. }
  131. private function repairChunk(Collection $products, bool $dryRun): void
  132. {
  133. $asteriaIds = $products
  134. ->pluck('migrated_from_asteria_id')
  135. ->map(fn ($id) => (int) $id)
  136. ->unique()
  137. ->values()
  138. ->all();
  139. $stock = $this->loadStock($asteriaIds);
  140. $outOfStock = $products->filter(function ($product) use ($stock) {
  141. $asteriaId = (int) $product->migrated_from_asteria_id;
  142. return array_key_exists($asteriaId, $stock) && $stock[$asteriaId] === false;
  143. })->values();
  144. foreach ($products as $product) {
  145. $this->scanned++;
  146. $asteriaId = (int) $product->migrated_from_asteria_id;
  147. if (! array_key_exists($asteriaId, $stock)) {
  148. $this->noStockRow++;
  149. } elseif ($stock[$asteriaId] === true) {
  150. $this->inStock++;
  151. }
  152. }
  153. if ($outOfStock->isEmpty()) {
  154. return;
  155. }
  156. $productIds = $outOfStock->pluck('id')->map(fn ($id) => (int) $id)->all();
  157. $statusRows = DB::table('product_attribute_values')
  158. ->where('attribute_id', $this->statusAttributeId)
  159. ->whereIn('product_id', $productIds)
  160. ->get(['id', 'product_id', 'channel', 'boolean_value']);
  161. $rowsByProduct = [];
  162. foreach ($statusRows as $row) {
  163. $rowsByProduct[(int) $row->product_id][] = $row;
  164. }
  165. $flatNeedsUpdate = [];
  166. if ($this->hasProductFlat) {
  167. $flatNeedsUpdate = DB::table('product_flat')
  168. ->whereIn('product_id', $productIds)
  169. ->where(function ($query) {
  170. $query->where('status', '!=', 0)->orWhereNull('status');
  171. })
  172. ->pluck('product_id')
  173. ->mapWithKeys(fn ($id) => [(int) $id => true])
  174. ->all();
  175. }
  176. $changedIds = [];
  177. foreach ($outOfStock as $product) {
  178. $productId = (int) $product->id;
  179. $rows = $rowsByProduct[$productId] ?? [];
  180. $presentChannels = [];
  181. $statusOff = $rows !== [];
  182. foreach ($rows as $row) {
  183. if ((int) $row->boolean_value !== 0) {
  184. $statusOff = false;
  185. }
  186. $channel = trim((string) ($row->channel ?? ''));
  187. if ($channel !== '') {
  188. $presentChannels[$channel] = true;
  189. }
  190. }
  191. $missingChannel = false;
  192. foreach ($this->channelCodes as $channel) {
  193. if (! isset($presentChannels[$channel])) {
  194. $missingChannel = true;
  195. break;
  196. }
  197. }
  198. if ($statusOff && ! $missingChannel && ! isset($flatNeedsUpdate[$productId])) {
  199. $this->unchanged++;
  200. continue;
  201. }
  202. $this->disabled++;
  203. $changedIds[] = $productId;
  204. if (count($this->examples) < 20) {
  205. $this->examples[] = [(string) $product->sku, $productId];
  206. }
  207. }
  208. if ($changedIds === [] || $dryRun) {
  209. return;
  210. }
  211. $this->touchedProductIds = array_merge($this->touchedProductIds, $changedIds);
  212. DB::table('product_attribute_values')
  213. ->where('attribute_id', $this->statusAttributeId)
  214. ->whereIn('product_id', $changedIds)
  215. ->where(function ($query) {
  216. $query->where('boolean_value', '!=', 0)->orWhereNull('boolean_value');
  217. })
  218. ->update(['boolean_value' => 0]);
  219. $inserts = [];
  220. foreach ($changedIds as $productId) {
  221. $presentChannels = [];
  222. foreach ($rowsByProduct[$productId] ?? [] as $row) {
  223. $channel = trim((string) ($row->channel ?? ''));
  224. if ($channel !== '') {
  225. $presentChannels[$channel] = true;
  226. }
  227. }
  228. foreach ($this->channelCodes as $channel) {
  229. if (isset($presentChannels[$channel])) {
  230. continue;
  231. }
  232. $inserts[] = $this->statusValueRow($productId, $channel);
  233. }
  234. }
  235. foreach (array_chunk($inserts, 500) as $chunk) {
  236. DB::table('product_attribute_values')->insert($chunk);
  237. }
  238. if ($this->hasProductFlat) {
  239. $flatUpdate = ['status' => 0];
  240. if (Schema::hasColumn('product_flat', 'updated_at')) {
  241. $flatUpdate['updated_at'] = now();
  242. }
  243. DB::table('product_flat')
  244. ->whereIn('product_id', $changedIds)
  245. ->update($flatUpdate);
  246. }
  247. }
  248. /**
  249. * @param array<int, int> $asteriaIds
  250. * @return array<int, bool> asteria id => true when In Stock
  251. */
  252. private function loadStock(array $asteriaIds): array
  253. {
  254. if ($asteriaIds === []) {
  255. return [];
  256. }
  257. $query = DB::connection($this->connectionName)
  258. ->table('cataloginventory_stock_item')
  259. ->whereIn('product_id', $asteriaIds);
  260. if ($this->hasStockId) {
  261. $query->where('stock_id', 1);
  262. }
  263. $stock = [];
  264. foreach ($query->get(['product_id', 'is_in_stock']) as $row) {
  265. $stock[(int) $row->product_id] = (int) $row->is_in_stock === 1;
  266. }
  267. return $stock;
  268. }
  269. /**
  270. * @return array<string, mixed>
  271. */
  272. private function statusValueRow(int $productId, string $channel): array
  273. {
  274. $row = [
  275. 'locale' => null,
  276. 'channel' => $channel,
  277. 'text_value' => null,
  278. 'boolean_value' => 0,
  279. 'integer_value' => null,
  280. 'float_value' => null,
  281. 'datetime_value'=> null,
  282. 'date_value' => null,
  283. 'product_id' => $productId,
  284. 'attribute_id' => $this->statusAttributeId,
  285. ];
  286. if ($this->hasJsonValue) {
  287. $row['json_value'] = null;
  288. }
  289. if ($this->hasUniqueId) {
  290. $row['unique_id'] = $channel.'|'.$productId.'|'.$this->statusAttributeId;
  291. }
  292. return $row;
  293. }
  294. private function reindexProducts(): void
  295. {
  296. $ids = array_values(array_unique($this->touchedProductIds));
  297. if ($ids === [] || ! $this->elasticEnabled()) {
  298. return;
  299. }
  300. $this->info('Reindexing '.count($ids).' products…');
  301. $elasticIndexer = app(ElasticSearch::class);
  302. foreach (array_chunk($ids, 20) as $chunk) {
  303. $products = Product::query()->with([
  304. 'attribute_family',
  305. 'attribute_values',
  306. 'channels',
  307. 'categories',
  308. ])->whereIn('id', $chunk)->get();
  309. foreach ($products as $product) {
  310. try {
  311. $elasticIndexer->reindexRow($product);
  312. } catch (\Throwable $e) {
  313. $this->error('Reindex failed for product '.$product->sku.': '.$e->getMessage());
  314. }
  315. }
  316. }
  317. }
  318. private function elasticEnabled(): bool
  319. {
  320. return core()->getConfigData('catalog.products.search.engine') === 'elastic';
  321. }
  322. }