Files
timebank-cc-public/app/Console/Commands/BulkMigrateOrganizationsToUsersCommand.php

200 lines
7.2 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Organization;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
/**
* Bulk Migrate Organizations to Users Command
*
* Converts all active (non-deleted) organization profiles to user profiles,
* excluding any organization IDs specified via --exclude.
*
* Delegates each individual migration to MigrateOrganizationToUserCommand,
* reusing all its validation, relationship migration, and safety logic.
*
* Usage examples:
* php artisan migrate:organizations-to-users --dry-run
* php artisan migrate:organizations-to-users --exclude=5 --exclude=12
* php artisan migrate:organizations-to-users --exclude=5,12,34 --force
* php artisan migrate:organizations-to-users --only=3,7,9
* php artisan migrate:organizations-to-users --only=3 --only=7 --only=9
* php artisan migrate:organizations-to-users --skip-errors
*/
class BulkMigrateOrganizationsToUsersCommand extends Command
{
protected $signature = 'migrate:organizations-to-users
{--exclude=* : Organization IDs to skip (use --exclude=1 --exclude=2 or --exclude=1,2,3)}
{--only=* : Only convert these organization IDs, ignoring all others (use --only=1 --only=2 or --only=1,2,3)}
{--dry-run : Preview all conversions without executing them}
{--force : Skip confirmation prompts}
{--skip-errors : Continue to the next organization if one fails instead of stopping}';
protected $description = 'Bulk convert all active organization profiles to user profiles, with optional exclusions';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$force = $this->option('force');
$skipErrors = $this->option('skip-errors');
$excludeIds = $this->parseIds($this->option('exclude'));
$onlyIds = $this->parseIds($this->option('only'));
if (!empty($excludeIds) && !empty($onlyIds)) {
$this->error('--exclude and --only cannot be used together.');
return 1;
}
if ($dryRun) {
$this->info('DRY RUN MODE - No changes will be made');
$this->newLine();
}
// Fetch all active organizations, filtered by --only or --exclude
$query = Organization::whereNull('deleted_at');
if (!empty($onlyIds)) {
$query->whereIn('id', $onlyIds);
} elseif (!empty($excludeIds)) {
$query->whereNotIn('id', $excludeIds);
}
$organizations = $query->orderBy('id')->get(['id', 'name', 'email']);
$totalActive = Organization::whereNull('deleted_at')->count();
$excluded = $totalActive - $organizations->count();
$this->info("Active organizations: {$totalActive}");
$this->info("Excluded: {$excluded}");
$this->info("To convert: {$organizations->count()}");
$this->newLine();
if ($organizations->isEmpty()) {
$this->info('No organizations to convert.');
return 0;
}
// List the organizations that will be converted
$this->table(
['ID', 'Name', 'Email'],
$organizations->map(fn($o) => [$o->id, $o->name, $o->email])->toArray()
);
$this->newLine();
if (!empty($onlyIds)) {
$this->line('Only IDs: ' . implode(', ', $onlyIds));
$this->newLine();
} elseif (!empty($excludeIds)) {
$this->line('Excluded IDs: ' . implode(', ', $excludeIds));
$this->newLine();
}
if (!$dryRun && !$force) {
if (!$this->confirm("Convert all {$organizations->count()} organization(s) to user profiles? This cannot be easily undone.", false)) {
$this->info('Bulk migration cancelled.');
return 0;
}
$this->newLine();
}
// Process each organization
$results = [];
$successCount = 0;
$failCount = 0;
foreach ($organizations as $org) {
$this->line("Processing: [{$org->id}] {$org->name}");
$args = [
'organization_id' => $org->id,
'--force' => true,
];
if ($dryRun) {
$args['--dry-run'] = true;
}
try {
$exitCode = Artisan::call('migrate:organization-to-user', $args);
$output = Artisan::output();
if ($exitCode === 0) {
$results[] = [$org->id, $org->name, $dryRun ? 'preview ok' : 'converted'];
$successCount++;
// Indent the sub-command output
foreach (explode("\n", trim($output)) as $line) {
if ($line !== '') {
$this->line(' ' . $line);
}
}
} else {
$results[] = [$org->id, $org->name, 'FAILED'];
$failCount++;
$this->error(" Failed (exit code {$exitCode}):");
foreach (explode("\n", trim($output)) as $line) {
if ($line !== '') {
$this->error(' ' . $line);
}
}
if (!$skipErrors) {
$this->newLine();
$this->error('Stopping due to failure. Use --skip-errors to continue past failures.');
break;
}
}
} catch (\Exception $e) {
$results[] = [$org->id, $org->name, 'EXCEPTION'];
$failCount++;
$this->error(" Exception: " . $e->getMessage());
if (!$skipErrors) {
$this->newLine();
$this->error('Stopping due to exception. Use --skip-errors to continue past failures.');
break;
}
}
$this->newLine();
}
// Final summary
$this->line('─────────────────────────────────────────');
$this->info('Summary:');
$this->table(
['ID', 'Name', 'Result'],
$results
);
$this->newLine();
if ($dryRun) {
$this->info("Dry run complete. {$successCount} organization(s) previewed, {$failCount} would fail.");
$this->info('Run without --dry-run to execute the migrations.');
} else {
$this->info("Bulk migration complete. {$successCount} converted, {$failCount} failed.");
}
return $failCount > 0 ? 1 : 0;
}
/**
* Parse an ID list option, supporting both repeated flags and comma-separated values.
* e.g. --exclude=1 --exclude=2 OR --exclude=1,2,3
*/
private function parseIds(array $rawExclude): array
{
$ids = [];
foreach ($rawExclude as $value) {
foreach (explode(',', $value) as $part) {
$part = trim($part);
if (is_numeric($part) && (int) $part > 0) {
$ids[] = (int) $part;
}
}
}
return array_unique($ids);
}
}