diff --git a/app/Console/Commands/RetireAgencyCompanyType.php b/app/Console/Commands/RetireAgencyCompanyType.php new file mode 100644 index 00000000..1243958e --- /dev/null +++ b/app/Console/Commands/RetireAgencyCompanyType.php @@ -0,0 +1,119 @@ +option('dry-run'); + + if ($dryRun) { + $this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.'); + } + + $agencies = Company::withoutGlobalScope(PortalScope::class) + ->where('type', CompanyType::Agency) + ->withExists([ + // PMs inkl. soft-gelöschter und portalfremder Datensätze — + // eine Firma mit irgendeiner PM-Historie bleibt Newsroom. + 'pressReleases as has_press_releases' => fn (Builder $query) => $query->withoutGlobalScopes(), + 'contacts as has_contacts', + 'userPaymentOptions as has_payment_option_links', + // Mitgliedschaften Dritter: jeder Pivot-Eintrag, der nicht der + // Owner selbst ist (bei Owner-losen Firmen jeder Eintrag). + 'users as has_third_party_members' => fn (Builder $query) => $query->where( + fn (Builder $query) => $query + ->whereColumn('company_user.user_id', '!=', 'companies.owner_user_id') + ->orWhereNull('companies.owner_user_id'), + ), + ]) + ->get(); + + $report = [ + 'generated_at' => now()->toIso8601String(), + 'dry_run' => $dryRun, + 'summary' => [], + 'retyped' => [], + 'deleted' => [], + 'protected' => [], + ]; + + foreach ($agencies as $agency) { + $entry = [ + 'company_id' => $agency->id, + 'portal' => $agency->portal?->value, + 'name' => $agency->name, + ]; + + $isNewsroom = $agency->has_press_releases || $agency->has_contacts; + $isProtected = $agency->has_third_party_members || $agency->has_payment_option_links; + + if (! $isNewsroom && ! $isProtected) { + if (! $dryRun) { + $agency->delete(); + } + + $report['deleted'][] = $entry; + + continue; + } + + if (! $isNewsroom && $isProtected) { + $report['protected'][] = $entry + [ + 'third_party_members' => (bool) $agency->has_third_party_members, + 'payment_option_links' => (bool) $agency->has_payment_option_links, + ]; + } + + if (! $dryRun) { + $agency->update(['type' => CompanyType::Company]); + } + + $report['retyped'][] = $entry; + } + + $report['summary'] = [ + 'agency_companies' => $agencies->count(), + 'retyped' => count($report['retyped']), + 'deleted' => count($report['deleted']), + 'protected' => count($report['protected']), + ]; + + $this->table( + ['Agentur-Firmen', 'Umtypisiert', 'Soft-gelöscht', 'Geschützt (nur umtypisiert)'], + [array_values($report['summary'])], + ); + + $path = 'migration/retire-agency-type-'.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 64071f35..d1ea1b0e 100644 --- a/dev/release/deploy-runbook.md +++ b/dev/release/deploy-runbook.md @@ -130,10 +130,13 @@ 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. 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. +2. Agentur-Attribution (läuft NACH dem Firmen-/User-Import aus Schritt 1, + Reihenfolge beachten): `legacy:migrate-agency-names` → + `companies:retire-agency-type`. Beide erst mit `--dry-run` (Reports unter + `storage/app/private/migration/` prüfen, insbes. Mehrfach-Namen, + Owner-lose Agentur-Firmen und geschützte Fälle), dann scharf. + Namen werden nie überschrieben; gelöscht wird nur soft und nur ohne + PMs/Kontakte/Dritt-Mitgliedschaften/Zahlungsoptions-Verknüpfung. 3. Ersten Admin-User anlegen (Prod hat keine Demo-User): Registrierung + Rolle via Tinker/DB oder dedizierter Befehl. 4. Favicons je Domain deployen (Platzhalter-Generator reicht nicht) [EXTERN R5.4]. diff --git a/tests/Feature/RetireAgencyCompanyTypeTest.php b/tests/Feature/RetireAgencyCompanyTypeTest.php new file mode 100644 index 00000000..dd2c0365 --- /dev/null +++ b/tests/Feature/RetireAgencyCompanyTypeTest.php @@ -0,0 +1,126 @@ +create(array_merge([ + 'portal' => Portal::Presseecho, + 'type' => CompanyType::Agency, + 'owner_user_id' => User::factory()->create()->id, + ], $attributes)); +} + +test('an agency with press releases is retyped to company', function () { + /** @var TestCase $this */ + $agency = agencyCompany(); + PressRelease::factory()->forPortal(Portal::Presseecho)->create(['company_id' => $agency->id]); + + $this->artisan('companies:retire-agency-type')->assertSuccessful(); + + $fresh = $agency->fresh(); + expect($fresh->type)->toBe(CompanyType::Company) + ->and($fresh->trashed())->toBeFalse(); +}); + +test('an agency with press contacts is retyped to company', function () { + /** @var TestCase $this */ + $agency = agencyCompany(); + Contact::factory()->create(['company_id' => $agency->id, 'portal' => Portal::Presseecho->value]); + + $this->artisan('companies:retire-agency-type')->assertSuccessful(); + + expect($agency->fresh()->type)->toBe(CompanyType::Company); +}); + +test('an agency without press releases and contacts is soft-deleted', function () { + /** @var TestCase $this */ + $agency = agencyCompany(); + + $this->artisan('companies:retire-agency-type')->assertSuccessful(); + + // Ohne PortalScope, aber MIT SoftDeletes-Scope: gelöscht = nicht auffindbar. + expect(Company::withoutGlobalScope(PortalScope::class)->find($agency->id))->toBeNull() + ->and(Company::withoutGlobalScopes()->find($agency->id)->trashed())->toBeTrue(); +}); + +test('an empty agency with third-party members is only retyped, never deleted', function () { + /** @var TestCase $this */ + $agency = agencyCompany(); + $agency->users()->attach(User::factory()->create()->id, ['role' => 'member']); + + $this->artisan('companies:retire-agency-type')->assertSuccessful(); + + $fresh = $agency->fresh(); + expect($fresh)->not->toBeNull() + ->and($fresh->type)->toBe(CompanyType::Company); + + $reportFile = collect(Storage::disk('local')->allFiles('migration'))->first(); + $report = json_decode(Storage::disk('local')->get($reportFile), true); + expect($report['protected'])->toHaveCount(1) + ->and($report['protected'][0]['company_id'])->toBe($agency->id); +}); + +test('an empty agency with a payment option link is only retyped, never deleted', function () { + /** @var TestCase $this */ + $agency = agencyCompany(); + UserPaymentOption::factory()->create()->companies()->attach($agency->id, ['is_active' => true]); + + $this->artisan('companies:retire-agency-type')->assertSuccessful(); + + $fresh = $agency->fresh(); + expect($fresh)->not->toBeNull() + ->and($fresh->type)->toBe(CompanyType::Company); +}); + +test('a membership of the owner does not protect an empty agency from deletion', function () { + /** @var TestCase $this */ + $owner = User::factory()->create(); + $agency = agencyCompany(['owner_user_id' => $owner->id]); + $agency->users()->attach($owner->id, ['role' => 'owner']); + + $this->artisan('companies:retire-agency-type')->assertSuccessful(); + + expect(Company::withoutGlobalScopes()->withTrashed()->find($agency->id)->trashed())->toBeTrue(); +}); + +test('regular companies stay untouched', function () { + /** @var TestCase $this */ + $company = Company::factory()->create([ + 'portal' => Portal::Presseecho, + 'type' => CompanyType::Company, + ]); + + $this->artisan('companies:retire-agency-type')->assertSuccessful(); + + $fresh = $company->fresh(); + expect($fresh->type)->toBe(CompanyType::Company) + ->and($fresh->trashed())->toBeFalse(); +}); + +test('dry-run changes nothing but writes a report', function () { + /** @var TestCase $this */ + $newsroomAgency = agencyCompany(); + PressRelease::factory()->forPortal(Portal::Presseecho)->create(['company_id' => $newsroomAgency->id]); + $emptyAgency = agencyCompany(); + + $this->artisan('companies:retire-agency-type', ['--dry-run' => true])->assertSuccessful(); + + expect($newsroomAgency->fresh()->type)->toBe(CompanyType::Agency) + ->and($emptyAgency->fresh()->trashed())->toBeFalse() + ->and(Storage::disk('local')->allFiles('migration'))->toHaveCount(1); +});