ResetCatalogCommand.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. <?php
  2. namespace App\Console\Commands;
  3. use Illuminate\Console\Command;
  4. use Illuminate\Support\Facades\DB;
  5. use Illuminate\Support\Facades\Schema;
  6. use Illuminate\Support\Facades\Storage;
  7. use Throwable;
  8. use Webkul\Core\Facades\ElasticSearch;
  9. use Webkul\Core\Models\Channel;
  10. use Webkul\Product\Helpers\Product as ProductIndexName;
  11. /**
  12. * Wipe Bagisto catalog rows so `products:migrate-asteria` (or catalog:sync) can start clean.
  13. *
  14. * Keeps attributes, categories, customers, and orders. Historical sales lines keep their
  15. * SKU snapshots; `product_id` is set to NULL. Reviews / Q&A / carts / wishlists that
  16. * point at products are removed — re-run `reviews:migrate-asteria --reset-progress`
  17. * after the product import if those comments should come back.
  18. *
  19. * Usage
  20. * ─────
  21. * php artisan catalog:reset --dry-run
  22. * php artisan catalog:reset --force
  23. * php artisan catalog:reset --force --purge-files
  24. */
  25. class ResetCatalogCommand extends Command
  26. {
  27. protected $signature = 'catalog:reset
  28. {--dry-run : Count rows only, do not write}
  29. {--force : Skip the confirmation prompt}
  30. {--purge-files : Also delete storage/app/public/product image directories}';
  31. protected $description = 'Truncate existing product catalog data so a fresh product migration can run';
  32. /**
  33. * Catalog tables truncated in child-first order (safe even if FK checks stay on).
  34. *
  35. * @var list<string>
  36. */
  37. private const CATALOG_TABLES = [
  38. // Longyi flexible variants
  39. 'product_variant_option_values',
  40. 'product_variant_images',
  41. 'product_variants',
  42. 'product_product_options',
  43. 'product_option_values',
  44. 'product_options',
  45. // Q&A / reviews
  46. 'product_question_answer_votes',
  47. 'product_question_answers',
  48. 'product_questions',
  49. 'product_review_images',
  50. 'product_reviews',
  51. // Booking
  52. 'booking_product_event_ticket_translations',
  53. 'booking_product_event_tickets',
  54. 'booking_product_appointment_slots',
  55. 'booking_product_default_slots',
  56. 'booking_product_rental_slots',
  57. 'booking_product_table_slots',
  58. 'booking_products',
  59. // Bundle / grouped / customizable / downloadable
  60. 'product_bundle_option_products',
  61. 'product_bundle_option_translations',
  62. 'product_bundle_options',
  63. 'product_grouped_products',
  64. 'product_customizable_option_prices',
  65. 'product_customizable_option_translations',
  66. 'product_customizable_options',
  67. 'product_downloadable_link_translations',
  68. 'product_downloadable_links',
  69. 'product_downloadable_sample_translations',
  70. 'product_downloadable_samples',
  71. // Catalog rule product indices (not the rules themselves)
  72. 'catalog_rule_product_prices',
  73. 'catalog_rule_products',
  74. // Runtime references
  75. 'cart_item_inventories',
  76. 'cart_items',
  77. 'wishlist_items',
  78. 'wishlist',
  79. 'compare_items',
  80. // Associations / media / inventory / indices
  81. 'product_cross_sells',
  82. 'product_up_sells',
  83. 'product_relations',
  84. 'product_super_attributes',
  85. 'product_categories',
  86. 'product_channels',
  87. 'product_attribute_values',
  88. 'product_images',
  89. 'product_videos',
  90. 'product_inventories',
  91. 'product_ordered_inventories',
  92. 'product_inventory_indices',
  93. 'product_salable_inventories',
  94. 'product_price_indices',
  95. 'product_customer_group_prices',
  96. 'product_flat',
  97. 'products',
  98. ];
  99. /**
  100. * Historical rows that keep their snapshot but must not block deleting products.
  101. *
  102. * @var list<array{0: string, 1: string}>
  103. */
  104. private const DETACH_PRODUCT_ID = [
  105. ['order_items', 'product_id'],
  106. ['invoice_items', 'product_id'],
  107. ['shipment_items', 'product_id'],
  108. ['refund_items', 'product_id'],
  109. ['bookings', 'product_id'],
  110. ];
  111. public function handle(): int
  112. {
  113. $dryRun = (bool) $this->option('dry-run');
  114. $tables = $this->existingTables(self::CATALOG_TABLES);
  115. $this->warn('This clears catalog products so they can be re-imported.');
  116. $this->line('Kept: attributes, categories, customers, orders (SKU snapshot stays; product_id set null).');
  117. $this->line('Removed: products, variants, inventories, reviews, Q&A, cart items, wishlists, compare items.');
  118. $this->newLine();
  119. $rows = $this->countRows($tables);
  120. $this->table(['table', 'rows'], collect($rows)->map(fn ($count, $table) => [$table, $count])->values()->all());
  121. $total = array_sum($rows);
  122. $this->info("Catalog rows that would be truncated: {$total}");
  123. $detachCounts = $this->countDetachRows();
  124. if ($detachCounts !== []) {
  125. $this->newLine();
  126. $this->comment('Historical rows whose product_id will be set to NULL:');
  127. $this->table(['table', 'rows with product_id'], collect($detachCounts)->map(fn ($count, $table) => [$table, $count])->values()->all());
  128. }
  129. $rewriteCount = $this->countProductUrlRewrites();
  130. if ($rewriteCount > 0) {
  131. $this->comment("url_rewrites (entity_type=product): {$rewriteCount}");
  132. }
  133. if ($dryRun) {
  134. $this->info('Dry run — nothing written.');
  135. return self::SUCCESS;
  136. }
  137. if (! $this->option('force') && ! $this->confirm('Truncate catalog tables now?', false)) {
  138. $this->info('Aborted.');
  139. return self::SUCCESS;
  140. }
  141. $driver = DB::getDriverName();
  142. try {
  143. $this->disableForeignKeyChecks($driver);
  144. $this->detachHistoricalProductIds();
  145. $this->deleteProductUrlRewrites();
  146. $this->deleteProductVisits();
  147. foreach ($tables as $table) {
  148. $this->emptyTable($driver, $table);
  149. }
  150. } finally {
  151. $this->enableForeignKeyChecks($driver);
  152. }
  153. if ($this->option('purge-files')) {
  154. Storage::deleteDirectory('product');
  155. $this->info('Deleted storage directory: product/');
  156. }
  157. $this->wipeElasticsearchIndices();
  158. $this->newLine();
  159. $this->info('Catalog reset finished. Next:');
  160. $this->line(' php artisan products:migrate-asteria');
  161. $this->line(' php artisan reviews:migrate-asteria --reset-progress --sync # if reviews should be re-imported');
  162. return self::SUCCESS;
  163. }
  164. /**
  165. * @param list<string> $tables
  166. * @return list<string>
  167. */
  168. private function existingTables(array $tables): array
  169. {
  170. return array_values(array_filter($tables, fn (string $table) => Schema::hasTable($table)));
  171. }
  172. /**
  173. * @param list<string> $tables
  174. * @return array<string, int>
  175. */
  176. private function countRows(array $tables): array
  177. {
  178. $counts = [];
  179. foreach ($tables as $table) {
  180. $counts[$table] = (int) DB::table($table)->count();
  181. }
  182. return $counts;
  183. }
  184. /**
  185. * @return array<string, int>
  186. */
  187. private function countDetachRows(): array
  188. {
  189. $counts = [];
  190. foreach (self::DETACH_PRODUCT_ID as [$table, $column]) {
  191. if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
  192. continue;
  193. }
  194. $count = (int) DB::table($table)->whereNotNull($column)->count();
  195. if ($count > 0) {
  196. $counts[$table] = $count;
  197. }
  198. }
  199. return $counts;
  200. }
  201. private function countProductUrlRewrites(): int
  202. {
  203. if (! Schema::hasTable('url_rewrites')) {
  204. return 0;
  205. }
  206. return (int) DB::table('url_rewrites')->where('entity_type', 'product')->count();
  207. }
  208. private function detachHistoricalProductIds(): void
  209. {
  210. foreach (self::DETACH_PRODUCT_ID as [$table, $column]) {
  211. if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
  212. continue;
  213. }
  214. DB::table($table)->whereNotNull($column)->update([$column => null]);
  215. }
  216. }
  217. private function deleteProductUrlRewrites(): void
  218. {
  219. if (! Schema::hasTable('url_rewrites')) {
  220. return;
  221. }
  222. DB::table('url_rewrites')->where('entity_type', 'product')->delete();
  223. }
  224. private function deleteProductVisits(): void
  225. {
  226. if (! Schema::hasTable('visits') || ! Schema::hasColumn('visits', 'visitable_type')) {
  227. return;
  228. }
  229. DB::table('visits')->where('visitable_type', 'like', '%Product%')->delete();
  230. }
  231. private function emptyTable(string $driver, string $table): void
  232. {
  233. if ($driver === 'mysql') {
  234. DB::statement('TRUNCATE TABLE '.$this->quoteTable($table));
  235. return;
  236. }
  237. DB::table($table)->delete();
  238. if ($driver === 'sqlite') {
  239. DB::table('sqlite_sequence')->where('name', DB::getTablePrefix().$table)->delete();
  240. }
  241. }
  242. private function quoteTable(string $table): string
  243. {
  244. $name = DB::getTablePrefix().$table;
  245. return '`'.str_replace('`', '``', $name).'`';
  246. }
  247. private function disableForeignKeyChecks(string $driver): void
  248. {
  249. if ($driver === 'mysql') {
  250. DB::statement('SET FOREIGN_KEY_CHECKS=0');
  251. } elseif ($driver === 'sqlite') {
  252. DB::statement('PRAGMA foreign_keys = OFF');
  253. }
  254. }
  255. private function enableForeignKeyChecks(string $driver): void
  256. {
  257. if ($driver === 'mysql') {
  258. DB::statement('SET FOREIGN_KEY_CHECKS=1');
  259. } elseif ($driver === 'sqlite') {
  260. DB::statement('PRAGMA foreign_keys = ON');
  261. }
  262. }
  263. private function wipeElasticsearchIndices(): void
  264. {
  265. if (core()->getConfigData('catalog.products.search.engine') !== 'elastic') {
  266. return;
  267. }
  268. try {
  269. $channels = Channel::with('locales')->get();
  270. foreach ($channels as $channel) {
  271. foreach ($channel->locales as $locale) {
  272. $index = ProductIndexName::formatElasticSearchIndexName($channel->code, $locale->code);
  273. try {
  274. ElasticSearch::indices()->delete(['index' => $index]);
  275. $this->comment("Deleted Elasticsearch index: {$index}");
  276. } catch (Throwable $e) {
  277. $this->comment("Elasticsearch index {$index} skipped: ".$e->getMessage());
  278. }
  279. }
  280. }
  281. } catch (Throwable $e) {
  282. $this->warn('Elasticsearch wipe skipped: '.$e->getMessage());
  283. }
  284. }
  285. }