MigrateAsteriaReviews.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Jobs\MigrateReviewJob;
  4. use Illuminate\Console\Command;
  5. use Illuminate\Support\Facades\Cache;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Log;
  8. use Illuminate\Support\Facades\Schema;
  9. /**
  10. * Migrates product reviews from Asteria (Magento 1.x) into this Bagisto store.
  11. *
  12. * Prerequisites
  13. * ─────────────
  14. * 1. Add the `asteria` database connection to config/database.php (see the
  15. * comment block at the bottom of that file or the README).
  16. * 2. Add the `migrated_from_asteria_id` column to product_reviews:
  17. *
  18. * php artisan migrate (after creating the migration below)
  19. *
  20. * Or run this one-off SQL directly if you prefer:
  21. *
  22. * ALTER TABLE product_reviews
  23. * ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,
  24. * ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id);
  25. *
  26. * 3. Ensure a queue worker is running:
  27. *
  28. * php artisan queue:work --queue=review-migration
  29. *
  30. * Usage
  31. * ─────
  32. * php artisan reviews:migrate-asteria
  33. * php artisan reviews:migrate-asteria --batch-size=200
  34. * php artisan reviews:migrate-asteria --status=1 # approved only
  35. * php artisan reviews:migrate-asteria --reset-progress # start from scratch
  36. * php artisan reviews:migrate-asteria --dry-run # dispatch nothing, just show stats
  37. * php artisan reviews:migrate-asteria --sync # run synchronously (no queue)
  38. */
  39. class MigrateAsteriaReviews extends Command
  40. {
  41. protected $signature = 'reviews:migrate-asteria
  42. {--batch-size=100 : Number of reviews per queue job}
  43. {--status= : Filter by Magento status_id (1=approved,2=pending,3=not-approved)}
  44. {--reset-progress : Ignore saved progress and start from review_id=0}
  45. {--dry-run : Count records without dispatching any jobs}
  46. {--sync : Process synchronously instead of via the queue}
  47. {--connection=asteria : Name of the Laravel DB connection for the Asteria database}';
  48. protected $description = 'Migrate Asteria (Magento 1.x) product reviews into Bagisto asynchronously';
  49. /** Cache key used to store the last successfully enqueued review_id. */
  50. private const PROGRESS_KEY = 'migrate_asteria_reviews_last_id';
  51. /** Default queue name for the migration jobs. */
  52. private const QUEUE_NAME = 'review-migration';
  53. public function handle(): int
  54. {
  55. $connection = (string) $this->option('connection');
  56. $batchSize = max(1, (int) $this->option('batch-size'));
  57. $statusId = $this->option('status') !== null ? (int) $this->option('status') : null;
  58. $resetProgress = (bool) $this->option('reset-progress');
  59. $dryRun = (bool) $this->option('dry-run');
  60. $sync = (bool) $this->option('sync');
  61. // ── Pre-flight: make sure the connection and source tables exist ─────────
  62. try {
  63. DB::connection($connection)->getPdo();
  64. } catch (\Throwable $e) {
  65. $this->error("Cannot connect to Asteria DB (connection='{$connection}'): ".$e->getMessage());
  66. $this->warn('Add an "asteria" entry to config/database.php (see the comment at the end of that file).');
  67. return self::FAILURE;
  68. }
  69. if (! $this->ensureMigrationColumnExists()) {
  70. return self::FAILURE;
  71. }
  72. // ── Build product and customer lookup maps ────────────────────────────────
  73. $this->info('Building product SKU map…');
  74. $productIdMap = $this->buildProductIdMap($connection);
  75. $this->line(' '.count($productIdMap).' Magento products matched to Bagisto products by SKU.');
  76. if (empty($productIdMap)) {
  77. $this->warn('No matching products found. Are the SKUs consistent between the two stores?');
  78. }
  79. $this->info('Building customer e-mail map…');
  80. $customerEmailMap = $this->buildCustomerEmailMap($connection);
  81. $this->line(' '.count($customerEmailMap).' Magento customers matched to Bagisto customers by e-mail.');
  82. // ── Determine starting review_id ──────────────────────────────────────────
  83. $lastId = $resetProgress ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
  84. if ($lastId > 0) {
  85. $this->line("Resuming from Asteria review_id > {$lastId} (use --reset-progress to restart).");
  86. }
  87. // ── Dispatch batches ──────────────────────────────────────────────────────
  88. $totalDispatched = 0;
  89. $totalSkipped = 0;
  90. $batchNumber = 0;
  91. $this->info($dryRun ? '[DRY RUN] Scanning reviews…' : 'Dispatching migration jobs…');
  92. do {
  93. $rows = $this->fetchBatch($connection, $lastId, $batchSize, $statusId);
  94. if ($rows->isEmpty()) {
  95. break;
  96. }
  97. $batchNumber++;
  98. $batchRows = $rows->toArray();
  99. // Collect unique Magento product IDs in this batch, filter unknown ones.
  100. $unknownProducts = array_filter(
  101. array_unique(array_column($batchRows, 'magento_product_id')),
  102. fn ($id) => ! isset($productIdMap[$id])
  103. );
  104. if (! empty($unknownProducts)) {
  105. $this->warn(" Batch #{$batchNumber}: ".count($unknownProducts).
  106. ' Magento product(s) not found in Bagisto: '.implode(', ', array_slice($unknownProducts, 0, 10)));
  107. $totalSkipped += count(array_filter($batchRows, fn ($r) => ! isset($productIdMap[$r->magento_product_id])));
  108. }
  109. $lastId = max(array_column($batchRows, 'review_id'));
  110. if (! $dryRun) {
  111. if ($sync) {
  112. (new MigrateReviewJob(
  113. array_map(fn ($r) => (array) $r, $batchRows),
  114. $productIdMap,
  115. $customerEmailMap,
  116. ))->handle();
  117. } else {
  118. MigrateReviewJob::dispatch(
  119. array_map(fn ($r) => (array) $r, $batchRows),
  120. $productIdMap,
  121. $customerEmailMap,
  122. )->onQueue(self::QUEUE_NAME);
  123. }
  124. Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
  125. }
  126. $totalDispatched += count($batchRows);
  127. $this->line(sprintf(
  128. ' Batch #%d: %d reviews (last review_id=%d)%s',
  129. $batchNumber,
  130. count($batchRows),
  131. $lastId,
  132. $dryRun ? ' [skipped – dry-run]' : ($sync ? ' [processed]' : ' [enqueued]'),
  133. ));
  134. Log::info('MigrateAsteriaReviews: batch '.$batchNumber.', '.count($batchRows).' rows, last_id='.$lastId);
  135. } while ($rows->count() === $batchSize);
  136. // ── Summary ───────────────────────────────────────────────────────────────
  137. $this->newLine();
  138. $this->info("Done. Batches: {$batchNumber}, reviews processed/enqueued: {$totalDispatched}, unmatched: {$totalSkipped}.");
  139. if (! $dryRun && ! $sync) {
  140. $this->line('Run <comment>php artisan queue:work --queue=review-migration</comment> to process the queue.');
  141. }
  142. return self::SUCCESS;
  143. }
  144. // ─────────────────────────────────────────────────────────────────────────────
  145. // Private helpers
  146. // ─────────────────────────────────────────────────────────────────────────────
  147. /**
  148. * Fetch one page of Magento reviews, enriched with rating and customer e-mail.
  149. *
  150. * Magento table names use the default prefix (none). If your Magento
  151. * installation uses a prefix (e.g. `mag_`), set ASTERIA_DB_PREFIX in .env and
  152. * pass it via the `prefix` key in the database connection config.
  153. *
  154. * @return \Illuminate\Support\Collection<int, object>
  155. */
  156. private function fetchBatch(
  157. string $connection,
  158. int $lastId,
  159. int $limit,
  160. ?int $statusId
  161. ): \Illuminate\Support\Collection {
  162. $query = DB::connection($connection)
  163. ->table('review AS r')
  164. ->join('review_detail AS rd', 'rd.review_id', '=', 'r.review_id')
  165. ->leftJoin('customer_entity AS ce', 'ce.entity_id', '=', 'rd.customer_id')
  166. ->leftJoinSub(
  167. // Average all rating dimensions for each review (Quality, Value, Price…)
  168. DB::connection($connection)
  169. ->table('rating_option_vote')
  170. ->select('review_id', DB::raw('AVG(`value`) AS avg_rating'))
  171. ->groupBy('review_id'),
  172. 'rv',
  173. 'rv.review_id',
  174. '=',
  175. 'r.review_id'
  176. )
  177. ->select([
  178. 'r.review_id',
  179. 'r.created_at',
  180. 'r.entity_pk_value AS magento_product_id',
  181. 'r.status_id',
  182. 'rd.title',
  183. 'rd.detail AS comment',
  184. 'rd.nickname AS name',
  185. 'rd.customer_id AS magento_customer_id',
  186. 'ce.email AS customer_email',
  187. 'rv.avg_rating',
  188. ])
  189. ->where('r.entity_id', 1) // entity_id=1 means "product" in Magento
  190. ->where('r.review_id', '>', $lastId)
  191. ->orderBy('r.review_id')
  192. ->limit($limit);
  193. if ($statusId !== null) {
  194. $query->where('r.status_id', $statusId);
  195. }
  196. $reviews = $query->get();
  197. if ($reviews->isEmpty()) {
  198. return $reviews;
  199. }
  200. // Attach image URLs from Senje_Review's review_media_image table (status_id=1 = approved).
  201. $reviewIds = $reviews->pluck('review_id')->toArray();
  202. $imageRows = DB::connection($connection)
  203. ->table('review_media_image')
  204. ->select('review_id', 'image_id', 'url')
  205. ->whereIn('review_id', $reviewIds)
  206. ->where('status_id', 1)
  207. ->orderBy('review_id')
  208. ->orderBy('image_id')
  209. ->get()
  210. ->groupBy('review_id');
  211. return $reviews->map(function ($row) use ($imageRows) {
  212. $row->images = $imageRows->get($row->review_id, collect())
  213. ->pluck('url')
  214. ->toArray();
  215. return $row;
  216. });
  217. }
  218. /**
  219. * Build a map of magento_product_id → bagisto_product_id by joining
  220. * Magento's catalog_product_entity.sku against Bagisto's products.sku.
  221. *
  222. * @return array<int, int>
  223. */
  224. private function buildProductIdMap(string $connection): array
  225. {
  226. // Fetch all Magento SKUs.
  227. $magentoPairs = DB::connection($connection)
  228. ->table('catalog_product_entity')
  229. ->select('entity_id', 'sku')
  230. ->get()
  231. ->pluck('entity_id', 'sku') // sku → magento_id
  232. ->toArray();
  233. if (empty($magentoPairs)) {
  234. return [];
  235. }
  236. // Match against Bagisto's products table.
  237. $skus = array_keys($magentoPairs);
  238. $map = [];
  239. foreach (array_chunk($skus, 500) as $chunk) {
  240. $rows = DB::table('products')
  241. ->select('id', 'sku')
  242. ->whereIn('sku', $chunk)
  243. ->get();
  244. foreach ($rows as $row) {
  245. $magentoId = $magentoPairs[$row->sku] ?? null;
  246. if ($magentoId !== null) {
  247. $map[(int) $magentoId] = (int) $row->id;
  248. }
  249. }
  250. }
  251. return $map;
  252. }
  253. /**
  254. * Build a map of lowercase_email → bagisto_customer_id.
  255. *
  256. * @return array<string, int>
  257. */
  258. private function buildCustomerEmailMap(string $connection): array
  259. {
  260. // Fetch Magento customer e-mails.
  261. $magentoEmails = DB::connection($connection)
  262. ->table('customer_entity')
  263. ->select('email')
  264. ->get()
  265. ->pluck('email')
  266. ->map(fn ($e) => strtolower((string) $e))
  267. ->unique()
  268. ->toArray();
  269. if (empty($magentoEmails)) {
  270. return [];
  271. }
  272. $map = [];
  273. foreach (array_chunk($magentoEmails, 500) as $chunk) {
  274. $rows = DB::table('customers')
  275. ->select('id', 'email')
  276. ->whereIn(DB::raw('LOWER(email)'), $chunk)
  277. ->get();
  278. foreach ($rows as $row) {
  279. $map[strtolower($row->email)] = (int) $row->id;
  280. }
  281. }
  282. return $map;
  283. }
  284. /**
  285. * Ensure product_reviews has the deduplication column.
  286. * Prints instructions and returns false if the column is absent and
  287. * cannot be auto-created (requires ALTER TABLE permission).
  288. */
  289. private function ensureMigrationColumnExists(): bool
  290. {
  291. $hasColumn = Schema::hasColumn('product_reviews', 'migrated_from_asteria_id');
  292. if ($hasColumn) {
  293. return true;
  294. }
  295. $this->warn('Column product_reviews.migrated_from_asteria_id is missing.');
  296. $this->line('Run the following migration or SQL:');
  297. $this->line('');
  298. $this->line(' <comment>php artisan make:migration add_migrated_from_asteria_id_to_product_reviews</comment>');
  299. $this->line(' Then add to the up() method:');
  300. $this->line(' <comment>$table->bigInteger(\'migrated_from_asteria_id\')->unsigned()->nullable()->unique();</comment>');
  301. $this->line('');
  302. $this->line(' Or directly in MySQL:');
  303. $this->line(' <comment>ALTER TABLE product_reviews');
  304. $this->line(' ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,');
  305. $this->line(' ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id);</comment>');
  306. $this->line('');
  307. if (! $this->confirm('Attempt to add the column automatically now?', false)) {
  308. return false;
  309. }
  310. try {
  311. DB::statement('
  312. ALTER TABLE product_reviews
  313. ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,
  314. ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id)
  315. ');
  316. $this->info('Column added successfully.');
  317. return true;
  318. } catch (\Throwable $e) {
  319. $this->error('Could not add column automatically: '.$e->getMessage());
  320. return false;
  321. }
  322. }
  323. }