diff --git a/app/Console/Commands/MigrateLegacyAgencyNames.php b/app/Console/Commands/MigrateLegacyAgencyNames.php new file mode 100644 index 00000000..7c25b382 --- /dev/null +++ b/app/Console/Commands/MigrateLegacyAgencyNames.php @@ -0,0 +1,131 @@ +option('dry-run'); + + if ($dryRun) { + $this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.'); + } + + // Bewusst ohne is_active-Filter: die Attributions-Zeile hat auch + // inaktive Agentur-Firmen angezeigt; dieses Verhalten bleibt erhalten. + $agencies = Company::withoutGlobalScope(PortalScope::class) + ->where('type', CompanyType::Agency) + ->get(['id', 'portal', 'owner_user_id', 'name', 'is_active', 'updated_at']); + + $report = [ + 'generated_at' => now()->toIso8601String(), + 'dry_run' => $dryRun, + 'summary' => [], + 'multiple_names' => [], + 'skipped_existing' => [], + 'ownerless' => [], + 'missing_owner' => [], + ]; + + foreach ($agencies->whereNull('owner_user_id') as $orphan) { + $report['ownerless'][] = [ + 'company_id' => $orphan->id, + 'portal' => $orphan->portal?->value, + 'name' => $orphan->name, + ]; + } + + $byOwner = $agencies->whereNotNull('owner_user_id')->groupBy('owner_user_id'); + $users = User::withTrashed()->whereIn('id', $byOwner->keys())->get()->keyBy('id'); + + $migrated = 0; + + foreach ($byOwner as $userId => $companies) { + $user = $users->get($userId); + + if ($user === null) { + $report['missing_owner'][] = [ + 'user_id' => $userId, + 'company_ids' => $companies->pluck('id')->all(), + ]; + + continue; + } + + /** @var Company $winner Zuletzt aktualisierter Datensatz gewinnt (Regel Mehrfach-Fälle). */ + $winner = $companies->sortByDesc('updated_at')->first(); + $variants = $companies->pluck('name')->unique()->values(); + + if ($variants->count() > 1) { + $report['multiple_names'][] = [ + 'user_id' => $user->id, + 'email' => $user->email, + 'chosen' => $winner->name, + 'variants' => $variants->all(), + ]; + } + + if (filled($user->agency_name)) { + $report['skipped_existing'][] = [ + 'user_id' => $user->id, + 'email' => $user->email, + 'existing' => $user->agency_name, + 'candidate' => $winner->name, + ]; + + continue; + } + + if (! $dryRun) { + $user->update(['agency_name' => $winner->name]); + } + + $migrated++; + } + + $report['summary'] = [ + 'agency_companies' => $agencies->count(), + 'users_migrated' => $migrated, + 'users_skipped_existing' => count($report['skipped_existing']), + 'users_multiple_names' => count($report['multiple_names']), + 'companies_ownerless' => count($report['ownerless']), + 'owners_missing' => count($report['missing_owner']), + ]; + + $this->table( + ['Agentur-Firmen', 'User migriert', 'Bereits gesetzt', 'Mehrfach-Namen', 'Ohne Owner', 'Owner fehlt'], + [array_values($report['summary'])], + ); + + $path = 'migration/agency-names-'.now()->format('Ymd-His').'.json'; + Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + $this->line("Report geschrieben: storage/app/private/{$path}"); + + return self::SUCCESS; + } +} diff --git a/dev/release/deploy-runbook.md b/dev/release/deploy-runbook.md index 864d4f96..64071f35 100644 --- a/dev/release/deploy-runbook.md +++ b/dev/release/deploy-runbook.md @@ -130,10 +130,14 @@ Events: die Cashier-Defaults (Subscriptions, Invoices, Payment-Intents). `legacy:import` → `legacy:fix-timestamps` → `legacy:migrate-media` (bzw. `legacy:sync-company-logos` / `legacy:sync-press-release-images`) → `legacy:archive-invoices` → `legacy:grandfather-subscriptions`. -2. Ersten Admin-User anlegen (Prod hat keine Demo-User): +2. Agentur-Attribution (läuft NACH dem Firmen-/User-Import aus Schritt 1): + `legacy:migrate-agency-names` — erst mit `--dry-run` (Report unter + `storage/app/private/migration/` prüfen, insbes. Mehrfach-Namen und + Owner-lose Agentur-Firmen), dann scharf. Nie überschreibend. +3. Ersten Admin-User anlegen (Prod hat keine Demo-User): Registrierung + Rolle via Tinker/DB oder dedizierter Befehl. -3. Favicons je Domain deployen (Platzhalter-Generator reicht nicht) [EXTERN R5.4]. -4. Smoke-Test: siehe Abschnitt 8. +4. Favicons je Domain deployen (Platzhalter-Generator reicht nicht) [EXTERN R5.4]. +5. Smoke-Test: siehe Abschnitt 8. ## 8. Smoke-Test nach jedem Deploy diff --git a/tests/Feature/MigrateLegacyAgencyNamesTest.php b/tests/Feature/MigrateLegacyAgencyNamesTest.php new file mode 100644 index 00000000..d8ea761a --- /dev/null +++ b/tests/Feature/MigrateLegacyAgencyNamesTest.php @@ -0,0 +1,120 @@ +create(); + Company::factory()->create([ + 'portal' => Portal::Presseecho, + 'type' => CompanyType::Agency, + 'name' => 'connektar.de Presseverteiler', + 'owner_user_id' => $user->id, + ]); + + $this->artisan('legacy:migrate-agency-names')->assertSuccessful(); + + expect($user->fresh()->agency_name)->toBe('connektar.de Presseverteiler'); +}); + +test('with multiple differently named agency companies the most recently updated wins', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + Company::factory()->create([ + 'portal' => Portal::Presseecho, + 'type' => CompanyType::Agency, + 'name' => 'Alter Verteilername', + 'owner_user_id' => $user->id, + 'updated_at' => now()->subYears(2), + ]); + Company::factory()->create([ + 'portal' => Portal::Businessportal24, + 'type' => CompanyType::Agency, + 'name' => 'Neuer Verteilername', + 'owner_user_id' => $user->id, + 'updated_at' => now()->subDay(), + ]); + + $this->artisan('legacy:migrate-agency-names')->assertSuccessful(); + + expect($user->fresh()->agency_name)->toBe('Neuer Verteilername'); + + $reportFile = collect(Storage::disk('local')->allFiles('migration'))->first(); + $report = json_decode(Storage::disk('local')->get($reportFile), true); + expect($report['multiple_names'])->toHaveCount(1) + ->and($report['multiple_names'][0]['variants']) + ->toContain('Alter Verteilername', 'Neuer Verteilername'); +}); + +test('never overwrites an already set agency name', function () { + /** @var TestCase $this */ + $user = User::factory()->withAgency('Manuell gepflegter Name')->create(); + Company::factory()->create([ + 'portal' => Portal::Presseecho, + 'type' => CompanyType::Agency, + 'name' => 'Legacy-Name', + 'owner_user_id' => $user->id, + ]); + + $this->artisan('legacy:migrate-agency-names')->assertSuccessful(); + + expect($user->fresh()->agency_name)->toBe('Manuell gepflegter Name'); +}); + +test('only reports agency companies without an owner', function () { + /** @var TestCase $this */ + $orphan = Company::factory()->create([ + 'portal' => Portal::Presseecho, + 'type' => CompanyType::Agency, + 'name' => 'Verwaiste Agentur', + 'owner_user_id' => null, + ]); + + $this->artisan('legacy:migrate-agency-names')->assertSuccessful(); + + $reportFile = collect(Storage::disk('local')->allFiles('migration'))->first(); + $report = json_decode(Storage::disk('local')->get($reportFile), true); + expect($report['ownerless'])->toHaveCount(1) + ->and($report['ownerless'][0]['company_id'])->toBe($orphan->id); +}); + +test('dry-run writes nothing but still produces a report', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + Company::factory()->create([ + 'portal' => Portal::Presseecho, + 'type' => CompanyType::Agency, + 'name' => 'connektar.de Presseverteiler', + 'owner_user_id' => $user->id, + ]); + + $this->artisan('legacy:migrate-agency-names', ['--dry-run' => true])->assertSuccessful(); + + expect($user->fresh()->agency_name)->toBeNull() + ->and(Storage::disk('local')->allFiles('migration'))->toHaveCount(1); +}); + +test('ignores companies that are not agencies', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + Company::factory()->create([ + 'portal' => Portal::Presseecho, + 'type' => CompanyType::Company, + 'name' => 'Echte Firma GmbH', + 'owner_user_id' => $user->id, + ]); + + $this->artisan('legacy:migrate-agency-names')->assertSuccessful(); + + expect($user->fresh()->agency_name)->toBeNull(); +});