'bronze', 1 => 'bronze', 2 => 'silver', 3 => 'silver', 4 => 'gold', 5 => 'gold', ]; public function handle(ArtworkAwardService $service): int { $legacyConnection = (string) $this->option('connection'); $artworkIds = collect((array) $this->option('artwork-id')) ->map(static fn (mixed $value): int => (int) $value) ->filter(static fn (int $value): bool => $value > 0) ->unique() ->values() ->all(); $showDuplicates = (bool) $this->option('show-duplicates'); $duplicatesLimit = max(1, (int) $this->option('duplicates-limit')); $dryRun = (bool) $this->option('dry-run'); $chunk = max(1, (int) $this->option('chunk')); $skipStats = (bool) $this->option('skip-stats'); $force = (bool) $this->option('force'); if ($dryRun) { $this->warn('[DRY-RUN] No data will be written.'); } // Verify legacy connection is reachable try { DB::connection($legacyConnection)->getPdo(); } catch (\Throwable $e) { $this->error("Cannot connect to legacy database connection [{$legacyConnection}]: " . $e->getMessage()); return self::FAILURE; } if (! DB::connection($legacyConnection)->getSchemaBuilder()->hasTable('users_opinions')) { $this->error("Legacy table `users_opinions` not found on connection [{$legacyConnection}]."); return self::FAILURE; } $legacyQuery = DB::connection($legacyConnection)->table('users_opinions'); if ($artworkIds !== []) { $legacyQuery->whereIn('artwork_id', $artworkIds); $this->info('Restricting import to artwork IDs: ' . implode(', ', $artworkIds)); } // Pre-load sets of valid artwork IDs and user IDs from the new DB $this->info('Loading new-DB artwork and user ID sets…'); $validArtworkIds = DB::table('artworks') ->whereNull('deleted_at') ->pluck('id') ->flip() // flip so we can use isset() for O(1) lookup ->all(); $validUserIds = DB::table('users') ->whereNull('deleted_at') ->pluck('id') ->flip() ->all(); $this->info(sprintf( 'Found %d artworks and %d users in new DB.', count($validArtworkIds), count($validUserIds) )); // Count legacy rows for progress bar $total = (clone $legacyQuery)->count(); $this->info("Legacy rows to process: {$total}"); if ($total === 0) { $this->warn('No legacy rows found. Nothing to do.'); return self::SUCCESS; } $stats = [ 'imported' => 0, 'skipped_score' => 0, 'skipped_artwork' => 0, 'skipped_user' => 0, 'skipped_duplicate'=> 0, 'reported_duplicate'=> 0, 'updated_force' => 0, 'errors' => 0, ]; $affectedArtworkIds = []; $duplicateRows = []; $bar = $this->output->createProgressBar($total); $bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%'); $bar->setMessage('0', 'imported'); $bar->setMessage('0', 'skipped'); $bar->start(); $legacyQuery ->orderBy('opinion_id') ->chunk($chunk, function ($rows) use ( &$stats, &$affectedArtworkIds, &$duplicateRows, $validArtworkIds, $validUserIds, $dryRun, $force, $showDuplicates, $duplicatesLimit, $bar ) { $inserts = []; $now = now(); foreach ($rows as $row) { // Legacy users_opinions semantics: // - artwork_id = the artwork being scored // - author_id = the artwork owner / author // - user_id = the voter who gave the score $artworkId = (int) $row->artwork_id; $userId = (int) $row->user_id; $score = (int) $row->score; $postedAt = $row->post_date ?? $now; // --- score → medal --- $medal = self::SCORE_MAP[$score] ?? null; if ($medal === null) { $stats['skipped_score']++; $bar->advance(); continue; } // --- Artwork must exist in new DB --- if (! isset($validArtworkIds[$artworkId])) { $stats['skipped_artwork']++; $bar->advance(); continue; } // --- User must exist in new DB --- if (! isset($validUserIds[$userId])) { $stats['skipped_user']++; $bar->advance(); continue; } if (! $dryRun) { if ($force) { // Upsert: update medal if row already exists $affected = DB::table('artwork_medals') ->where('artwork_id', $artworkId) ->where('user_id', $userId) ->update([ 'medal_type' => $medal, 'weight' => ArtworkAward::WEIGHTS[$medal], 'updated_at' => $now, ]); if ($affected > 0) { $stats['updated_force']++; $affectedArtworkIds[$artworkId] = true; $bar->advance(); continue; } } else { // Skip if already exists $existingMedal = DB::table('artwork_medals') ->where('artwork_id', $artworkId) ->where('user_id', $userId) ->value('medal_type'); if ($existingMedal !== null) { $stats['skipped_duplicate']++; if ($showDuplicates && count($duplicateRows) < $duplicatesLimit) { $duplicateRows[] = [ 'opinion_id' => (int) ($row->opinion_id ?? 0), 'artwork_id' => $artworkId, 'user_id' => $userId, 'legacy_score' => $score, 'legacy_medal' => $medal, 'existing_medal' => (string) $existingMedal, ]; $stats['reported_duplicate']++; } $bar->advance(); continue; } } $inserts[] = [ 'artwork_id' => $artworkId, 'user_id' => $userId, 'medal_type' => $medal, 'weight' => ArtworkAward::WEIGHTS[$medal], 'created_at' => $postedAt, 'updated_at' => $postedAt, ]; $affectedArtworkIds[$artworkId] = true; } $stats['imported']++; $bar->advance(); } // Bulk insert the batch (DB::table bypasses the observer intentionally; // stats are recalculated in bulk at the end for performance) if (! $dryRun && ! empty($inserts)) { try { DB::table('artwork_medals')->insert($inserts); } catch (\Throwable $e) { // Fallback: insert one-by-one to isolate constraint violations foreach ($inserts as $row) { try { DB::table('artwork_medals')->insertOrIgnore([$row]); } catch (\Throwable) { $stats['errors']++; } } } } $skippedTotal = $stats['skipped_score'] + $stats['skipped_artwork'] + $stats['skipped_user'] + $stats['skipped_duplicate']; $bar->setMessage((string) $stats['imported'], 'imported'); $bar->setMessage((string) $skippedTotal, 'skipped'); }); $bar->finish(); $this->newLine(2); // ------------------------------------------------------------------------- // Recalculate stats for every affected artwork // ------------------------------------------------------------------------- if (! $dryRun && ! $skipStats && ! empty($affectedArtworkIds)) { $artworkCount = count($affectedArtworkIds); $this->info("Recalculating award stats for {$artworkCount} artworks…"); $statsBar = $this->output->createProgressBar($artworkCount); $statsBar->start(); foreach (array_keys($affectedArtworkIds) as $artworkId) { try { $service->recalcStats($artworkId); } catch (\Throwable $e) { $this->newLine(); $this->warn("Stats recalc failed for artwork #{$artworkId}: {$e->getMessage()}"); } $statsBar->advance(); } $statsBar->finish(); $this->newLine(2); } // ------------------------------------------------------------------------- // Summary // ------------------------------------------------------------------------- $this->table( ['Result', 'Count'], [ ['Imported (new rows)', $stats['imported']], ['Forced updates', $stats['updated_force']], ['Skipped – bad score', $stats['skipped_score']], ['Skipped – artwork gone', $stats['skipped_artwork']], ['Skipped – user gone', $stats['skipped_user']], ['Skipped – duplicate', $stats['skipped_duplicate']], ['Errors', $stats['errors']], ] ); if ($showDuplicates && $stats['skipped_duplicate'] > 0) { $this->newLine(); $this->info(sprintf( 'Duplicate rows skipped: %d. Showing %d row(s)%s.', $stats['skipped_duplicate'], count($duplicateRows), $stats['skipped_duplicate'] > count($duplicateRows) ? " (truncated by --duplicates-limit={$duplicatesLimit})" : '' )); if ($duplicateRows !== []) { $this->table( ['Legacy opinion', 'Artwork ID', 'Voter user_id', 'Legacy score', 'Legacy medal', 'Existing medal'], array_map(static fn (array $row): array => [ $row['opinion_id'], $row['artwork_id'], $row['user_id'], $row['legacy_score'], $row['legacy_medal'], $row['existing_medal'], ], $duplicateRows) ); } } if ($dryRun) { $this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.'); } else { $this->info('Migration complete.'); } return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS; } }