Files
SkinbaseNova/app/Console/Commands/ImportLegacyAwards.php

355 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ArtworkAward;
use App\Services\ArtworkAwardService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_medals`.
*
* Score mapping (legacy score → new medal):
* 5 → gold
* 4 → gold
* 3 → silver
* 2 → silver
* 1 → bronze
* 0 → bronze
*
* Usage:
* php artisan awards:import-legacy
* php artisan awards:import-legacy --dry-run
* php artisan awards:import-legacy --chunk=500
* php artisan awards:import-legacy --skip-stats (skip final stats recalc)
*/
class ImportLegacyAwards extends Command
{
protected $signature = 'awards:import-legacy
{--connection=legacy : Legacy database connection name}
{--artwork-id=* : Restrict import to one or more artwork IDs}
{--show-duplicates : Output skipped duplicate artwork/user pairs at the end}
{--duplicates-limit=100 : Maximum duplicate rows to print when --show-duplicates is used}
{--dry-run : Preview only — no writes to DB}
{--chunk=250 : Rows to process per batch}
{--skip-stats : Skip per-artwork stats recalculation at the end}
{--force : Overwrite existing awards instead of skipping duplicates}';
protected $description = 'Import legacy users_opinions into artwork_medals';
/** Maps legacy score value → medal string */
private const SCORE_MAP = [
0 => '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;
}
}