ResetOrdersCommand.php 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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 sales orders so `orders:migrate-asteria` can start clean.
  9. *
  10. * Keeps customers, products, carts, and customer groups. Invoices, shipments,
  11. * refunds, payments, and order addresses are removed. Gift-card / reward /
  12. * booking rows keep their snapshots; `order_id` is set to NULL.
  13. *
  14. * Usage
  15. * ─────
  16. * php artisan orders:reset --dry-run
  17. * php artisan orders:reset --force
  18. */
  19. class ResetOrdersCommand extends Command
  20. {
  21. protected $signature = 'orders:reset
  22. {--dry-run : Count rows only, do not write}
  23. {--force : Skip the confirmation prompt}';
  24. protected $description = 'Truncate existing orders so a fresh order migration can run';
  25. private const PROGRESS_KEY = 'migrate_asteria_orders_last_id';
  26. /**
  27. * Sales tables truncated in child-first order.
  28. *
  29. * @var list<string>
  30. */
  31. private const ORDER_TABLES = [
  32. 'refund_items',
  33. 'refunds',
  34. 'invoice_items',
  35. 'invoices',
  36. 'shipment_items',
  37. 'shipments',
  38. 'order_comments',
  39. 'order_transactions',
  40. 'order_payment',
  41. 'downloadable_product_download_links',
  42. 'downloadable_link_purchased',
  43. 'order_items',
  44. 'notifications',
  45. 'product_ordered_inventories',
  46. 'orders',
  47. ];
  48. /**
  49. * Rows that keep their snapshot but must not block deleting orders.
  50. *
  51. * @var list<array{0: string, 1: string}>
  52. */
  53. private const DETACH_ORDER_ID = [
  54. ['bookings', 'order_id'],
  55. ['bookings', 'order_item_id'],
  56. ['gift_card_usage_logs', 'order_id'],
  57. ['mw_growth_value_history', 'order_id'],
  58. ['mw_reward_point_history', 'history_order_id'],
  59. ['member_log', 'order_id'],
  60. ['payment_attempts', 'order_id'],
  61. ];
  62. /** @var list<string> */
  63. private const ORDER_ADDRESS_TYPES = [
  64. 'order_billing',
  65. 'order_shipping',
  66. 'invoice_billing',
  67. 'invoice_shipping',
  68. ];
  69. public function handle(): int
  70. {
  71. $dryRun = (bool) $this->option('dry-run');
  72. $tables = $this->existingTables(self::ORDER_TABLES);
  73. $this->warn('This clears sales orders so they can be re-imported.');
  74. $this->line('Kept: customers, products, carts, customer groups.');
  75. $this->line('Removed: orders, items, payments, invoices, shipments, refunds, order addresses.');
  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("Order rows that would be truncated: {$total}");
  81. $addressCount = $this->countOrderAddresses();
  82. if ($addressCount > 0) {
  83. $this->comment("addresses (order/invoice): {$addressCount}");
  84. }
  85. $detachCounts = $this->countDetachRows();
  86. if ($detachCounts !== []) {
  87. $this->newLine();
  88. $this->comment('Related rows whose order_id will be set to NULL:');
  89. $this->table(['table.column', 'rows'], collect($detachCounts)->map(fn ($count, $key) => [$key, $count])->values()->all());
  90. }
  91. if ($dryRun) {
  92. $this->info('Dry run — nothing written.');
  93. return self::SUCCESS;
  94. }
  95. if (! $this->option('force') && ! $this->confirm('Truncate order tables now?', false)) {
  96. $this->info('Aborted.');
  97. return self::SUCCESS;
  98. }
  99. $driver = DB::getDriverName();
  100. try {
  101. $this->disableForeignKeyChecks($driver);
  102. $this->deleteOrderAddresses();
  103. $this->detachHistoricalOrderIds();
  104. foreach ($tables as $table) {
  105. $this->emptyTable($driver, $table);
  106. }
  107. } finally {
  108. $this->enableForeignKeyChecks($driver);
  109. }
  110. Cache::forget(self::PROGRESS_KEY);
  111. $this->newLine();
  112. $this->info('Order reset finished. Next:');
  113. $this->line(' php artisan orders:migrate-asteria --reset-progress');
  114. return self::SUCCESS;
  115. }
  116. /**
  117. * @param list<string> $tables
  118. * @return list<string>
  119. */
  120. private function existingTables(array $tables): array
  121. {
  122. return array_values(array_filter($tables, fn (string $table) => Schema::hasTable($table)));
  123. }
  124. /**
  125. * @param list<string> $tables
  126. * @return array<string, int>
  127. */
  128. private function countRows(array $tables): array
  129. {
  130. $counts = [];
  131. foreach ($tables as $table) {
  132. $counts[$table] = (int) DB::table($table)->count();
  133. }
  134. return $counts;
  135. }
  136. /**
  137. * @return array<string, int>
  138. */
  139. private function countDetachRows(): array
  140. {
  141. $counts = [];
  142. foreach (self::DETACH_ORDER_ID as [$table, $column]) {
  143. if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
  144. continue;
  145. }
  146. $count = (int) DB::table($table)->whereNotNull($column)->where($column, '!=', 0)->count();
  147. if ($count > 0) {
  148. $counts[$table.'.'.$column] = $count;
  149. }
  150. }
  151. return $counts;
  152. }
  153. private function countOrderAddresses(): int
  154. {
  155. if (! Schema::hasTable('addresses')) {
  156. return 0;
  157. }
  158. return (int) $this->orderAddressQuery()->count();
  159. }
  160. private function deleteOrderAddresses(): void
  161. {
  162. if (! Schema::hasTable('addresses')) {
  163. return;
  164. }
  165. $this->orderAddressQuery()->delete();
  166. }
  167. private function orderAddressQuery()
  168. {
  169. $query = DB::table('addresses');
  170. return $query->where(function ($builder) {
  171. if (Schema::hasColumn('addresses', 'order_id')) {
  172. $builder->orWhereNotNull('order_id');
  173. }
  174. if (Schema::hasColumn('addresses', 'address_type')) {
  175. $builder->orWhereIn('address_type', self::ORDER_ADDRESS_TYPES);
  176. }
  177. });
  178. }
  179. private function detachHistoricalOrderIds(): void
  180. {
  181. foreach (self::DETACH_ORDER_ID as [$table, $column]) {
  182. if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
  183. continue;
  184. }
  185. DB::table($table)->whereNotNull($column)->update([$column => null]);
  186. }
  187. }
  188. private function emptyTable(string $driver, string $table): void
  189. {
  190. if ($driver === 'mysql') {
  191. DB::statement('TRUNCATE TABLE '.$this->quoteTable($table));
  192. return;
  193. }
  194. DB::table($table)->delete();
  195. if ($driver === 'sqlite') {
  196. DB::table('sqlite_sequence')->where('name', DB::getTablePrefix().$table)->delete();
  197. }
  198. }
  199. private function quoteTable(string $table): string
  200. {
  201. $name = DB::getTablePrefix().$table;
  202. return '`'.str_replace('`', '``', $name).'`';
  203. }
  204. private function disableForeignKeyChecks(string $driver): void
  205. {
  206. if ($driver === 'mysql') {
  207. DB::statement('SET FOREIGN_KEY_CHECKS=0');
  208. } elseif ($driver === 'sqlite') {
  209. DB::statement('PRAGMA foreign_keys = OFF');
  210. }
  211. }
  212. private function enableForeignKeyChecks(string $driver): void
  213. {
  214. if ($driver === 'mysql') {
  215. DB::statement('SET FOREIGN_KEY_CHECKS=1');
  216. } elseif ($driver === 'sqlite') {
  217. DB::statement('PRAGMA foreign_keys = ON');
  218. }
  219. }
  220. }