355 lines
14 KiB
PHP
355 lines
14 KiB
PHP
<?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;
|
||
}
|
||
}
|