companies:retire-agency-type: Firmen-Typ „Agentur" abschaffen
A4 des Umbaus Agentur-Attribution → User-Profil: Agentur-Firmen mit PMs oder Pressekontakten werden zu type=company umtypisiert (sie sind faktisch Newsrooms), leere Agentur-Firmen soft-gelöscht. Geschützt vor Löschung (nur Umtypisierung): Mitgliedschaften Dritter in company_user oder Verknüpfungen in user_payment_option_company. --dry-run + JSON-Report; im Deploy-Runbook nach legacy:migrate-agency-names eingereiht. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
85fddbfdf8
commit
59ac67a4c8
3 changed files with 252 additions and 4 deletions
119
app/Console/Commands/RetireAgencyCompanyType.php
Normal file
119
app/Console/Commands/RetireAgencyCompanyType.php
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\CompanyType;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Scopes\PortalScope;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schafft den Firmen-Typ „Agentur" ab (dev/agentur-attribution/
|
||||||
|
* entwicklungsplan.md, A4): Agentur-Firmen mit PMs oder Pressekontakten
|
||||||
|
* werden zu normalen Firmen umtypisiert (sie sind faktisch Newsrooms),
|
||||||
|
* leere Agentur-Firmen werden soft-gelöscht. Nie gelöscht wird, wenn
|
||||||
|
* Dritte per company_user Mitglied sind oder eine Zahlungsoptions-
|
||||||
|
* Verknüpfung (user_payment_option_company) existiert — dann wird nur
|
||||||
|
* umtypisiert. Läuft im Go-Live NACH legacy:migrate-agency-names.
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* php artisan companies:retire-agency-type --dry-run
|
||||||
|
* php artisan companies:retire-agency-type
|
||||||
|
*/
|
||||||
|
class RetireAgencyCompanyType extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'companies:retire-agency-type
|
||||||
|
{--dry-run : Nur anzeigen und Report schreiben, keine Firmen ändern}';
|
||||||
|
|
||||||
|
protected $description = 'Typisiert Legacy-Agentur-Firmen zu normalen Firmen um bzw. soft-löscht leere Agentur-Firmen.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -130,10 +130,13 @@ Events: die Cashier-Defaults (Subscriptions, Invoices, Payment-Intents).
|
||||||
`legacy:import` → `legacy:fix-timestamps` → `legacy:migrate-media`
|
`legacy:import` → `legacy:fix-timestamps` → `legacy:migrate-media`
|
||||||
(bzw. `legacy:sync-company-logos` / `legacy:sync-press-release-images`)
|
(bzw. `legacy:sync-company-logos` / `legacy:sync-press-release-images`)
|
||||||
→ `legacy:archive-invoices` → `legacy:grandfather-subscriptions`.
|
→ `legacy:archive-invoices` → `legacy:grandfather-subscriptions`.
|
||||||
2. Agentur-Attribution (läuft NACH dem Firmen-/User-Import aus Schritt 1):
|
2. Agentur-Attribution (läuft NACH dem Firmen-/User-Import aus Schritt 1,
|
||||||
`legacy:migrate-agency-names` — erst mit `--dry-run` (Report unter
|
Reihenfolge beachten): `legacy:migrate-agency-names` →
|
||||||
`storage/app/private/migration/` prüfen, insbes. Mehrfach-Namen und
|
`companies:retire-agency-type`. Beide erst mit `--dry-run` (Reports unter
|
||||||
Owner-lose Agentur-Firmen), dann scharf. Nie überschreibend.
|
`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):
|
3. Ersten Admin-User anlegen (Prod hat keine Demo-User):
|
||||||
Registrierung + Rolle via Tinker/DB oder dedizierter Befehl.
|
Registrierung + Rolle via Tinker/DB oder dedizierter Befehl.
|
||||||
4. Favicons je Domain deployen (Platzhalter-Generator reicht nicht) [EXTERN R5.4].
|
4. Favicons je Domain deployen (Platzhalter-Generator reicht nicht) [EXTERN R5.4].
|
||||||
|
|
|
||||||
126
tests/Feature/RetireAgencyCompanyTypeTest.php
Normal file
126
tests/Feature/RetireAgencyCompanyTypeTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\CompanyType;
|
||||||
|
use App\Enums\Portal;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserPaymentOption;
|
||||||
|
use App\Scopes\PortalScope;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
Storage::fake('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
function agencyCompany(array $attributes = []): Company
|
||||||
|
{
|
||||||
|
return Company::factory()->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);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue