ResetCustomersCommand.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. <?php
  2. namespace App\Console\Commands;
  3. use Illuminate\Console\Command;
  4. use Illuminate\Support\Facades\Cache;
  5. use Illuminate\Support\Facades\DB;
  6. use Illuminate\Support\Facades\Schema;
  7. /**
  8. * Wipe Bagisto storefront customers so `customers:migrate-asteria` can start clean.
  9. *
  10. * Keeps customer_groups, admins, products, and orders. Historical orders / reviews /
  11. * Q&A keep their snapshots; `customer_id` is set to NULL. Customer addresses,
  12. * newsletters, wishlists, compare items, reward/growth balances, and login tokens
  13. * are removed.
  14. *
  15. * Usage
  16. * ─────
  17. * php artisan customers:reset --dry-run
  18. * php artisan customers:reset --force
  19. */
  20. class ResetCustomersCommand extends Command
  21. {
  22. protected $signature = 'customers:reset
  23. {--dry-run : Count rows only, do not write}
  24. {--force : Skip the confirmation prompt}';
  25. protected $description = 'Truncate existing storefront customers so a fresh customer migration can run';
  26. private const PROGRESS_KEY = 'migrate_asteria_customers_last_id';
  27. /**
  28. * Customer-owned tables truncated in child-first order.
  29. *
  30. * @var list<string>
  31. */
  32. private const CUSTOMER_TABLES = [
  33. 'customer_notes',
  34. 'customer_social_accounts',
  35. 'customer_password_resets',
  36. 'gdpr_data_request',
  37. 'wishlist_items',
  38. 'wishlist',
  39. 'compare_items',
  40. 'cart_rule_customers',
  41. 'cart_rule_coupon_usage',
  42. 'mw_reward_point_customer_sign',
  43. 'mw_reward_point_history',
  44. 'mw_reward_point_customer',
  45. 'mw_growth_value_history',
  46. 'mw_growth_value_customer',
  47. 'member_log',
  48. 'downloadable_link_purchased',
  49. 'subscribers_list',
  50. 'customers',
  51. ];
  52. /**
  53. * Historical / catalog rows that keep their snapshot but must not block deleting customers.
  54. *
  55. * @var list<array{0: string, 1: string}>
  56. */
  57. private const DETACH_CUSTOMER_ID = [
  58. ['orders', 'customer_id'],
  59. ['shipments', 'customer_id'],
  60. ['product_reviews', 'customer_id'],
  61. ['product_questions', 'customer_id'],
  62. ['product_question_answers', 'customer_id'],
  63. ['product_question_answer_votes', 'customer_id'],
  64. ['cart_rule_coupons', 'customer_id'],
  65. ['gift_cards', 'customer_id'],
  66. ['gift_card_usage_logs', 'customer_id'],
  67. ['addresses', 'customer_id'],
  68. ];
  69. public function handle(): int
  70. {
  71. $dryRun = (bool) $this->option('dry-run');
  72. $tables = $this->existingTables(self::CUSTOMER_TABLES);
  73. $this->warn('This clears storefront customers so they can be re-imported.');
  74. $this->line('Kept: customer groups, admins, products, orders (email snapshot stays; customer_id set null).');
  75. $this->line('Removed: customers, customer addresses, newsletters, wishlists, compare, reward/growth balances, tokens.');
  76. $this->newLine();
  77. $rows = $this->countRows($tables);
  78. $this->table(['table', 'rows'], collect($rows)->map(fn ($count, $table) => [$table, $count])->values()->all());
  79. $total = array_sum($rows);
  80. $this->info("Customer rows that would be truncated: {$total}");
  81. $addressCount = $this->countCustomerAddresses();
  82. if ($addressCount > 0) {
  83. $this->comment("addresses (address_type=customer): {$addressCount}");
  84. }
  85. $cartCount = $this->countCustomerCarts();
  86. if ($cartCount > 0) {
  87. $this->comment("cart (logged-in customers): {$cartCount}");
  88. }
  89. $tokenCount = $this->countCustomerTokens();
  90. if ($tokenCount > 0) {
  91. $this->comment("personal_access_tokens (customers): {$tokenCount}");
  92. }
  93. $detachCounts = $this->countDetachRows();
  94. if ($detachCounts !== []) {
  95. $this->newLine();
  96. $this->comment('Historical rows whose customer_id will be set to NULL:');
  97. $this->table(['table', 'rows with customer_id'], collect($detachCounts)->map(fn ($count, $table) => [$table, $count])->values()->all());
  98. }
  99. if ($dryRun) {
  100. $this->info('Dry run — nothing written.');
  101. return self::SUCCESS;
  102. }
  103. if (! $this->option('force') && ! $this->confirm('Truncate customer tables now?', false)) {
  104. $this->info('Aborted.');
  105. return self::SUCCESS;
  106. }
  107. $driver = DB::getDriverName();
  108. try {
  109. $this->disableForeignKeyChecks($driver);
  110. $this->deleteCustomerAddresses();
  111. $this->deleteCustomerCarts();
  112. $this->deleteCustomerTokens();
  113. $this->deleteCustomerVisits();
  114. $this->detachHistoricalCustomerIds();
  115. foreach ($tables as $table) {
  116. $this->emptyTable($driver, $table);
  117. }
  118. } finally {
  119. $this->enableForeignKeyChecks($driver);
  120. }
  121. Cache::forget(self::PROGRESS_KEY);
  122. $this->newLine();
  123. $this->info('Customer reset finished. Next:');
  124. $this->line(' php artisan customers:migrate-asteria --reset-progress');
  125. $this->line(' php artisan orders:migrate-asteria --reset-progress # if orders should re-link to new customer ids');
  126. $this->line(' php artisan reviews:migrate-asteria --reset-progress --sync');
  127. return self::SUCCESS;
  128. }
  129. /**
  130. * @param list<string> $tables
  131. * @return list<string>
  132. */
  133. private function existingTables(array $tables): array
  134. {
  135. return array_values(array_filter($tables, fn (string $table) => Schema::hasTable($table)));
  136. }
  137. /**
  138. * @param list<string> $tables
  139. * @return array<string, int>
  140. */
  141. private function countRows(array $tables): array
  142. {
  143. $counts = [];
  144. foreach ($tables as $table) {
  145. $counts[$table] = (int) DB::table($table)->count();
  146. }
  147. return $counts;
  148. }
  149. /**
  150. * @return array<string, int>
  151. */
  152. private function countDetachRows(): array
  153. {
  154. $counts = [];
  155. foreach (self::DETACH_CUSTOMER_ID as [$table, $column]) {
  156. if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
  157. continue;
  158. }
  159. $query = DB::table($table)->whereNotNull($column);
  160. if ($table === 'gift_cards') {
  161. $query->where($column, '!=', 0);
  162. }
  163. if ($table === 'addresses' && Schema::hasColumn($table, 'address_type')) {
  164. $query->where('address_type', '!=', 'customer');
  165. }
  166. $count = (int) $query->count();
  167. if ($count > 0) {
  168. $counts[$table] = $count;
  169. }
  170. }
  171. return $counts;
  172. }
  173. private function countCustomerAddresses(): int
  174. {
  175. if (! Schema::hasTable('addresses') || ! Schema::hasColumn('addresses', 'address_type')) {
  176. return 0;
  177. }
  178. return (int) DB::table('addresses')->where('address_type', 'customer')->count();
  179. }
  180. private function countCustomerCarts(): int
  181. {
  182. if (! Schema::hasTable('cart') || ! Schema::hasColumn('cart', 'customer_id')) {
  183. return 0;
  184. }
  185. return (int) DB::table('cart')->whereNotNull('customer_id')->count();
  186. }
  187. private function countCustomerTokens(): int
  188. {
  189. if (! Schema::hasTable('personal_access_tokens')) {
  190. return 0;
  191. }
  192. return (int) $this->customerTokenQuery()->count();
  193. }
  194. private function deleteCustomerAddresses(): void
  195. {
  196. if (! Schema::hasTable('addresses') || ! Schema::hasColumn('addresses', 'address_type')) {
  197. return;
  198. }
  199. DB::table('addresses')->where('address_type', 'customer')->delete();
  200. }
  201. private function deleteCustomerCarts(): void
  202. {
  203. if (! Schema::hasTable('cart') || ! Schema::hasColumn('cart', 'customer_id')) {
  204. return;
  205. }
  206. $cartIds = DB::table('cart')->whereNotNull('customer_id')->pluck('id');
  207. if ($cartIds->isEmpty()) {
  208. return;
  209. }
  210. $itemIds = Schema::hasTable('cart_items')
  211. ? DB::table('cart_items')->whereIn('cart_id', $cartIds)->pluck('id')
  212. : collect();
  213. if ($itemIds->isNotEmpty() && Schema::hasTable('cart_item_inventories')) {
  214. DB::table('cart_item_inventories')->whereIn('cart_item_id', $itemIds)->delete();
  215. }
  216. if ($itemIds->isNotEmpty()) {
  217. DB::table('cart_items')->whereIn('id', $itemIds)->delete();
  218. }
  219. if (Schema::hasTable('cart_payment')) {
  220. DB::table('cart_payment')->whereIn('cart_id', $cartIds)->delete();
  221. }
  222. if (Schema::hasTable('guest_cart_tokens')) {
  223. DB::table('guest_cart_tokens')->whereIn('cart_id', $cartIds)->delete();
  224. }
  225. $addressIds = collect();
  226. if (Schema::hasTable('addresses') && Schema::hasColumn('addresses', 'cart_id')) {
  227. $addressIds = DB::table('addresses')->whereIn('cart_id', $cartIds)->pluck('id');
  228. }
  229. if ($addressIds->isNotEmpty() && Schema::hasTable('cart_shipping_rates') && Schema::hasColumn('cart_shipping_rates', 'cart_address_id')) {
  230. DB::table('cart_shipping_rates')->whereIn('cart_address_id', $addressIds)->delete();
  231. }
  232. if ($addressIds->isNotEmpty()) {
  233. DB::table('addresses')->whereIn('id', $addressIds)->delete();
  234. }
  235. DB::table('cart')->whereIn('id', $cartIds)->delete();
  236. }
  237. private function deleteCustomerTokens(): void
  238. {
  239. if (! Schema::hasTable('personal_access_tokens')) {
  240. return;
  241. }
  242. $this->customerTokenQuery()->delete();
  243. }
  244. private function customerTokenQuery()
  245. {
  246. return DB::table('personal_access_tokens')
  247. ->where('tokenable_type', 'like', '%Customer%');
  248. }
  249. private function deleteCustomerVisits(): void
  250. {
  251. if (! Schema::hasTable('visits') || ! Schema::hasColumn('visits', 'visitable_type')) {
  252. return;
  253. }
  254. DB::table('visits')->where('visitable_type', 'like', '%Customer%')->delete();
  255. }
  256. private function detachHistoricalCustomerIds(): void
  257. {
  258. foreach (self::DETACH_CUSTOMER_ID as [$table, $column]) {
  259. if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
  260. continue;
  261. }
  262. $query = DB::table($table)->whereNotNull($column);
  263. if ($table === 'gift_cards') {
  264. $query->where($column, '!=', 0);
  265. }
  266. $query->update([$column => null]);
  267. }
  268. }
  269. private function emptyTable(string $driver, string $table): void
  270. {
  271. if ($driver === 'mysql') {
  272. DB::statement('TRUNCATE TABLE '.$this->quoteTable($table));
  273. return;
  274. }
  275. DB::table($table)->delete();
  276. if ($driver === 'sqlite') {
  277. DB::table('sqlite_sequence')->where('name', DB::getTablePrefix().$table)->delete();
  278. }
  279. }
  280. private function quoteTable(string $table): string
  281. {
  282. $name = DB::getTablePrefix().$table;
  283. return '`'.str_replace('`', '``', $name).'`';
  284. }
  285. private function disableForeignKeyChecks(string $driver): void
  286. {
  287. if ($driver === 'mysql') {
  288. DB::statement('SET FOREIGN_KEY_CHECKS=0');
  289. } elseif ($driver === 'sqlite') {
  290. DB::statement('PRAGMA foreign_keys = OFF');
  291. }
  292. }
  293. private function enableForeignKeyChecks(string $driver): void
  294. {
  295. if ($driver === 'mysql') {
  296. DB::statement('SET FOREIGN_KEY_CHECKS=1');
  297. } elseif ($driver === 'sqlite') {
  298. DB::statement('PRAGMA foreign_keys = ON');
  299. }
  300. }
  301. }