MigrateAsteriaReviews.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  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. // Matching ignores Magento/Bagisto product status and inventory — disabled or
  74. // out-of-stock products still receive reviews when the SKU (or Asteria id) exists.
  75. $this->info('Building product map (status/stock ignored)…');
  76. $productIdMap = $this->buildProductIdMap($connection);
  77. $this->line(' '.count($productIdMap).' Magento products matched to Bagisto products (SKU / migrated_from_asteria_id).');
  78. if (empty($productIdMap)) {
  79. $this->warn('No matching products found. Import products first (including disabled: ASTERIA_PRODUCTS_ONLY_ENABLED=false).');
  80. }
  81. $this->info('Building customer e-mail map…');
  82. $customerEmailMap = $this->buildCustomerEmailMap($connection);
  83. $this->line(' '.count($customerEmailMap).' Magento customers matched to Bagisto customers by e-mail.');
  84. // ── Determine starting review_id ──────────────────────────────────────────
  85. $lastId = $resetProgress ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
  86. if ($lastId > 0) {
  87. $this->line("Resuming from Asteria review_id > {$lastId} (use --reset-progress to restart).");
  88. }
  89. // ── Dispatch batches ──────────────────────────────────────────────────────
  90. $totalDispatched = 0;
  91. $totalSkipped = 0;
  92. $batchNumber = 0;
  93. $this->info($dryRun ? '[DRY RUN] Scanning reviews…' : 'Dispatching migration jobs…');
  94. do {
  95. $rows = $this->fetchBatch($connection, $lastId, $batchSize, $statusId);
  96. if ($rows->isEmpty()) {
  97. break;
  98. }
  99. $batchNumber++;
  100. $batchRows = $rows->toArray();
  101. // Collect unique Magento product IDs in this batch, filter unknown ones.
  102. $unknownProducts = array_filter(
  103. array_unique(array_column($batchRows, 'magento_product_id')),
  104. fn ($id) => ! isset($productIdMap[$id])
  105. );
  106. if (! empty($unknownProducts)) {
  107. $this->warn(" Batch #{$batchNumber}: ".count($unknownProducts).
  108. ' Magento product(s) not found in Bagisto: '.implode(', ', array_slice($unknownProducts, 0, 10)));
  109. $totalSkipped += count(array_filter($batchRows, fn ($r) => ! isset($productIdMap[$r->magento_product_id])));
  110. }
  111. $lastId = max(array_column($batchRows, 'review_id'));
  112. if (! $dryRun) {
  113. if ($sync) {
  114. (new MigrateReviewJob(
  115. array_map(fn ($r) => (array) $r, $batchRows),
  116. $productIdMap,
  117. $customerEmailMap,
  118. ))->handle();
  119. } else {
  120. MigrateReviewJob::dispatch(
  121. array_map(fn ($r) => (array) $r, $batchRows),
  122. $productIdMap,
  123. $customerEmailMap,
  124. )->onQueue(self::QUEUE_NAME);
  125. }
  126. Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
  127. }
  128. $totalDispatched += count($batchRows);
  129. $this->line(sprintf(
  130. ' Batch #%d: %d reviews (last review_id=%d)%s',
  131. $batchNumber,
  132. count($batchRows),
  133. $lastId,
  134. $dryRun ? ' [skipped – dry-run]' : ($sync ? ' [processed]' : ' [enqueued]'),
  135. ));
  136. Log::info('MigrateAsteriaReviews: batch '.$batchNumber.', '.count($batchRows).' rows, last_id='.$lastId);
  137. } while ($rows->count() === $batchSize);
  138. // ── Summary ───────────────────────────────────────────────────────────────
  139. $this->newLine();
  140. $this->info("Done. Batches: {$batchNumber}, reviews processed/enqueued: {$totalDispatched}, unmatched: {$totalSkipped}.");
  141. if (! $dryRun && ! $sync) {
  142. $this->line('Run <comment>php artisan queue:work --queue=review-migration</comment> to process the queue.');
  143. }
  144. return self::SUCCESS;
  145. }
  146. // ─────────────────────────────────────────────────────────────────────────────
  147. // Private helpers
  148. // ─────────────────────────────────────────────────────────────────────────────
  149. /**
  150. * Fetch one page of Magento reviews, enriched with rating and customer e-mail.
  151. *
  152. * Magento table names use the default prefix (none). If your Magento
  153. * installation uses a prefix (e.g. `mag_`), set ASTERIA_DB_PREFIX in .env and
  154. * pass it via the `prefix` key in the database connection config.
  155. *
  156. * @return \Illuminate\Support\Collection<int, object>
  157. */
  158. private function fetchBatch(
  159. string $connection,
  160. int $lastId,
  161. int $limit,
  162. ?int $statusId
  163. ): \Illuminate\Support\Collection {
  164. $query = DB::connection($connection)
  165. ->table('review AS r')
  166. ->join('review_detail AS rd', 'rd.review_id', '=', 'r.review_id')
  167. ->leftJoin('customer_entity AS ce', 'ce.entity_id', '=', 'rd.customer_id')
  168. ->leftJoinSub(
  169. // Average all rating dimensions for each review (Quality, Value, Price…)
  170. DB::connection($connection)
  171. ->table('rating_option_vote')
  172. ->select('review_id', DB::raw('AVG(`value`) AS avg_rating'))
  173. ->groupBy('review_id'),
  174. 'rv',
  175. 'rv.review_id',
  176. '=',
  177. 'r.review_id'
  178. )
  179. ->select([
  180. 'r.review_id',
  181. 'r.created_at',
  182. 'r.entity_pk_value AS magento_product_id',
  183. 'r.status_id',
  184. 'rd.title',
  185. 'rd.detail AS comment',
  186. 'rd.nickname AS name',
  187. 'rd.customer_id AS magento_customer_id',
  188. 'ce.email AS customer_email',
  189. 'rv.avg_rating',
  190. ])
  191. ->where('r.entity_id', 1) // entity_id=1 means "product" in Magento
  192. ->where('r.review_id', '>', $lastId)
  193. ->orderBy('r.review_id')
  194. ->limit($limit);
  195. if ($statusId !== null) {
  196. $query->where('r.status_id', $statusId);
  197. }
  198. $reviews = $query->get();
  199. if ($reviews->isEmpty()) {
  200. return $reviews;
  201. }
  202. // Attach image URLs from Senje_Review's review_media_image table (status_id=1 = approved).
  203. $reviewIds = $reviews->pluck('review_id')->toArray();
  204. $imageRows = DB::connection($connection)
  205. ->table('review_media_image')
  206. ->select('review_id', 'image_id', 'url')
  207. ->whereIn('review_id', $reviewIds)
  208. ->where('status_id', 1)
  209. ->orderBy('review_id')
  210. ->orderBy('image_id')
  211. ->get()
  212. ->groupBy('review_id');
  213. return $reviews->map(function ($row) use ($imageRows) {
  214. $row->images = $imageRows->get($row->review_id, collect())
  215. ->pluck('url')
  216. ->toArray();
  217. return $row;
  218. });
  219. }
  220. /**
  221. * Build magento_product_id → bagisto_product_id.
  222. *
  223. * Prefer products.migrated_from_asteria_id, then fall back to SKU.
  224. * Does not filter Magento or Bagisto product status / inventory.
  225. *
  226. * @return array<int, int>
  227. */
  228. private function buildProductIdMap(string $connection): array
  229. {
  230. $magentoRows = DB::connection($connection)
  231. ->table('catalog_product_entity')
  232. ->select('entity_id', 'sku')
  233. ->get();
  234. if ($magentoRows->isEmpty()) {
  235. return [];
  236. }
  237. $map = [];
  238. // 1) Match by migrated_from_asteria_id (covers disabled / out-of-stock Bagisto rows).
  239. if (Schema::hasColumn('products', 'migrated_from_asteria_id')) {
  240. $asteriaIds = $magentoRows->pluck('entity_id')->map(fn ($id) => (int) $id)->all();
  241. foreach (array_chunk($asteriaIds, 500) as $chunk) {
  242. $rows = DB::table('products')
  243. ->select('id', 'migrated_from_asteria_id')
  244. ->whereIn('migrated_from_asteria_id', $chunk)
  245. ->get();
  246. foreach ($rows as $row) {
  247. $map[(int) $row->migrated_from_asteria_id] = (int) $row->id;
  248. }
  249. }
  250. }
  251. // 2) Fall back to SKU for Magento products not yet linked by Asteria id.
  252. $magentoPairs = [];
  253. foreach ($magentoRows as $row) {
  254. $magentoId = (int) $row->entity_id;
  255. $sku = trim((string) $row->sku);
  256. if ($sku === '' || isset($map[$magentoId])) {
  257. continue;
  258. }
  259. $magentoPairs[$sku] = $magentoId;
  260. }
  261. if ($magentoPairs === []) {
  262. return $map;
  263. }
  264. foreach (array_chunk(array_keys($magentoPairs), 500) as $chunk) {
  265. $rows = DB::table('products')
  266. ->select('id', 'sku')
  267. ->whereIn('sku', $chunk)
  268. ->get();
  269. foreach ($rows as $row) {
  270. $magentoId = $magentoPairs[$row->sku] ?? null;
  271. if ($magentoId !== null && ! isset($map[$magentoId])) {
  272. $map[$magentoId] = (int) $row->id;
  273. }
  274. }
  275. }
  276. return $map;
  277. }
  278. /**
  279. * Build a map of lowercase_email → bagisto_customer_id.
  280. *
  281. * @return array<string, int>
  282. */
  283. private function buildCustomerEmailMap(string $connection): array
  284. {
  285. // Fetch Magento customer e-mails.
  286. $magentoEmails = DB::connection($connection)
  287. ->table('customer_entity')
  288. ->select('email')
  289. ->get()
  290. ->pluck('email')
  291. ->map(fn ($e) => strtolower((string) $e))
  292. ->unique()
  293. ->toArray();
  294. if (empty($magentoEmails)) {
  295. return [];
  296. }
  297. $map = [];
  298. foreach (array_chunk($magentoEmails, 500) as $chunk) {
  299. $rows = DB::table('customers')
  300. ->select('id', 'email')
  301. ->whereIn(DB::raw('LOWER(email)'), $chunk)
  302. ->get();
  303. foreach ($rows as $row) {
  304. $map[strtolower($row->email)] = (int) $row->id;
  305. }
  306. }
  307. return $map;
  308. }
  309. /**
  310. * Ensure product_reviews has the deduplication column.
  311. * Prints instructions and returns false if the column is absent and
  312. * cannot be auto-created (requires ALTER TABLE permission).
  313. */
  314. private function ensureMigrationColumnExists(): bool
  315. {
  316. $hasColumn = Schema::hasColumn('product_reviews', 'migrated_from_asteria_id');
  317. if ($hasColumn) {
  318. return true;
  319. }
  320. $this->warn('Column product_reviews.migrated_from_asteria_id is missing.');
  321. $this->line('Run the following migration or SQL:');
  322. $this->line('');
  323. $this->line(' <comment>php artisan make:migration add_migrated_from_asteria_id_to_product_reviews</comment>');
  324. $this->line(' Then add to the up() method:');
  325. $this->line(' <comment>$table->bigInteger(\'migrated_from_asteria_id\')->unsigned()->nullable()->unique();</comment>');
  326. $this->line('');
  327. $this->line(' Or directly in MySQL:');
  328. $this->line(' <comment>ALTER TABLE product_reviews');
  329. $this->line(' ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,');
  330. $this->line(' ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id);</comment>');
  331. $this->line('');
  332. if (! $this->confirm('Attempt to add the column automatically now?', false)) {
  333. return false;
  334. }
  335. try {
  336. DB::statement('
  337. ALTER TABLE product_reviews
  338. ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,
  339. ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id)
  340. ');
  341. $this->info('Column added successfully.');
  342. return true;
  343. } catch (\Throwable $e) {
  344. $this->error('Could not add column automatically: '.$e->getMessage());
  345. return false;
  346. }
  347. }
  348. }