companies:merge führt Firmen mit derselben Firmen-E-Mail zu einem Newsroom zusammen — nur über Adressen, die 1a.2 als Dublette oder Namensvariante klassifiziert hat. Es bleibt die Firma mit den meisten jemals veröffentlichten Meldungen; Meldungen, Kontakte, Nutzer- und Zahlungsverknüpfungen wandern per company_id, jede Bewegung steht in company_merge_moves. companies:unmerge spielt sie rückwärts ab. 77 % der Gruppen sind dieselbe Firma auf beiden Portalen (Altsystem: eine Firma je Portal). Der Überlebende wird dann `both`; die Meldungen behalten ihr Portal. Der alte Newsroom-Slug führt per 301 zum Überlebenden. Slug-Kollisionen auf dem anderen Portal werden übersprungen, nicht umbenannt. Company::slugExists() kennt jetzt `both`. Dev-DB: 14 142 Adressen, 16 194 Firmen, 13 297 Meldungen in 2 min; Rückabwicklung in 1:15 min exakt auf den Stand davor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
325 lines
16 KiB
PHP
325 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Enums\CompanyAddressClass;
|
|
use App\Enums\CompanyMergeSubject;
|
|
use App\Enums\Portal;
|
|
use App\Models\Company;
|
|
use App\Models\CompanyEmailGroup;
|
|
use App\Models\CompanyMerge;
|
|
use App\Models\Contact;
|
|
use App\Models\PressRelease;
|
|
use App\Models\User;
|
|
use App\Services\Company\CompanyMergePlan;
|
|
use App\Services\Company\CompanyMergeService;
|
|
use App\Services\Company\CompanyMergeSkip;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Zusammenführung im Bestand (Roadmap 1a.3): rückabwickelbar, mit
|
|
* gespeicherter Ursprungszuordnung. Entschieden am 15.09.2026: Es bleibt die
|
|
* Firma mit den meisten Meldungen, alles wandert per company_id, die
|
|
* wegfallende Firma wird soft-deleted und ihr Slug führt per 301 weiter.
|
|
*/
|
|
function mergeGroup(string $email, CompanyAddressClass $class = CompanyAddressClass::Duplicate, ?CompanyAddressClass $manual = null): CompanyEmailGroup
|
|
{
|
|
return CompanyEmailGroup::query()->create([
|
|
'email' => Company::emailKey($email),
|
|
'classification' => $class,
|
|
'manual_classification' => $manual,
|
|
'companies_count' => 2,
|
|
'distinct_names' => 1,
|
|
'published_companies_count' => 1,
|
|
'threshold' => 3,
|
|
'classified_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
function mergeMember(string $email, Portal $portal, int $published = 0, array $attributes = []): Company
|
|
{
|
|
$company = Company::factory()->create(['email' => $email, 'portal' => $portal->value, 'name' => 'Muster GmbH'] + $attributes);
|
|
|
|
PressRelease::factory()->published()->forPortal($portal)->count($published)->create(['company_id' => $company->id]);
|
|
|
|
return $company;
|
|
}
|
|
|
|
function planFor(string $email): CompanyMergePlan|CompanyMergeSkip
|
|
{
|
|
return app(CompanyMergeService::class)->plan(CompanyEmailGroup::forEmail($email));
|
|
}
|
|
|
|
test('the company with more publications survives and becomes both when halves lie on different portals', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
$pe = mergeMember('info@muster.de', Portal::Presseecho, 2);
|
|
$b24 = mergeMember('info@muster.de', Portal::Businessportal24, 5);
|
|
|
|
$plan = planFor('info@muster.de');
|
|
|
|
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
|
|
->and($plan->survivor->is($b24))->toBeTrue()
|
|
->and($plan->targetPortal)->toBe(Portal::Both)
|
|
->and($plan->pressReleases)->toBe(2);
|
|
|
|
$merge = app(CompanyMergeService::class)->merge($plan, 'test-batch');
|
|
|
|
// Die Meldungen behalten ihr Portal — nur die Firma wird portalübergreifend.
|
|
expect($b24->fresh()->portal)->toBe(Portal::Both)
|
|
->and(PressRelease::withoutGlobalScopes()->where('company_id', $b24->id)->count())->toBe(7)
|
|
->and(PressRelease::withoutGlobalScopes()->where('company_id', $b24->id)->where('portal', 'presseecho')->count())->toBe(2)
|
|
->and(Company::withTrashed()->find($pe->id)->trashed())->toBeTrue()
|
|
->and($merge->moved_press_releases_count)->toBe(2)
|
|
->and($merge->moves()->where('subject', CompanyMergeSubject::PressRelease->value)->count())->toBe(2)
|
|
->and($merge->moves()->where('subject', CompanyMergeSubject::Company->value)->value('subject_id'))->toBe($pe->id);
|
|
});
|
|
|
|
test('on a tie the older company survives', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
$older = mergeMember('info@muster.de', Portal::Presseecho, 1);
|
|
mergeMember('info@muster.de', Portal::Presseecho, 1);
|
|
|
|
$plan = planFor('info@muster.de');
|
|
|
|
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
|
|
->and($plan->survivor->is($older))->toBeTrue()
|
|
->and($plan->targetPortal)->toBe(Portal::Presseecho);
|
|
});
|
|
|
|
test('releases taken offline in the legacy system count for the survivor choice', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
$fresh = mergeMember('info@muster.de', Portal::Presseecho, 1);
|
|
$legacy = mergeMember('info@muster.de', Portal::Businessportal24);
|
|
PressRelease::factory()->count(3)->create(['company_id' => $legacy->id, 'status' => 'rejected', 'hits' => 50]);
|
|
|
|
expect(planFor('info@muster.de')->survivor->is($legacy))->toBeTrue();
|
|
});
|
|
|
|
test('empty profile fields are filled from the merged company, filled ones stay', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'phone' => '030 1', 'logo_path' => null]);
|
|
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'phone' => '030 2', 'logo_path' => 'logos/muster.png']);
|
|
|
|
$merge = app(CompanyMergeService::class)->merge(planFor('info@muster.de'), 'b');
|
|
|
|
$survivor->refresh();
|
|
|
|
expect($survivor->website)->toBe('https://muster.de')
|
|
->and($survivor->logo_path)->toBe('logos/muster.png')
|
|
->and($survivor->phone)->toBe('030 1')
|
|
->and($merge->moves()->where('subject', CompanyMergeSubject::Field->value)->pluck('field')->sort()->values()->all())->toBe(['logo_path', 'website']);
|
|
});
|
|
|
|
test('user links become the union without duplicates and contacts move along', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 1);
|
|
$loser = mergeMember('info@muster.de', Portal::Businessportal24);
|
|
$shared = User::factory()->create();
|
|
$only = User::factory()->create();
|
|
DB::table('company_user')->insert([
|
|
['company_id' => $survivor->id, 'user_id' => $shared->id, 'role' => 'owner'],
|
|
['company_id' => $loser->id, 'user_id' => $shared->id, 'role' => 'member'],
|
|
['company_id' => $loser->id, 'user_id' => $only->id, 'role' => 'member'],
|
|
]);
|
|
$contact = Contact::factory()->create(['company_id' => $loser->id]);
|
|
|
|
$merge = app(CompanyMergeService::class)->merge(planFor('info@muster.de'), 'b');
|
|
|
|
expect(DB::table('company_user')->where('company_id', $survivor->id)->pluck('user_id')->sort()->values()->all())->toBe(collect([$shared->id, $only->id])->sort()->values()->all())
|
|
->and(DB::table('company_user')->where('company_id', $loser->id)->count())->toBe(0)
|
|
->and($contact->fresh()->company_id)->toBe($survivor->id)
|
|
->and($merge->moves()->where('subject', CompanyMergeSubject::CompanyUser->value)->where('dropped', true)->value('subject_id'))->toBe($shared->id);
|
|
});
|
|
|
|
test('unmerge restores exactly what was moved and leaves what came later', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 1, ['website' => null]);
|
|
$loser = mergeMember('info@muster.de', Portal::Businessportal24, 2, ['website' => 'https://muster.de']);
|
|
$loserReleases = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->pluck('id');
|
|
$shared = User::factory()->create();
|
|
DB::table('company_user')->insert([
|
|
['company_id' => $survivor->id, 'user_id' => $shared->id, 'role' => 'owner'],
|
|
['company_id' => $loser->id, 'user_id' => $shared->id, 'role' => 'member'],
|
|
]);
|
|
|
|
$service = app(CompanyMergeService::class);
|
|
$merge = $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
|
|
|
|
// Nach der Zusammenführung entsteht eine neue Meldung beim Überlebenden.
|
|
$later = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create(['company_id' => $survivor->id]);
|
|
|
|
$service->revert($merge);
|
|
|
|
$survivor->refresh();
|
|
|
|
expect($survivor->portal)->toBe(Portal::Presseecho)
|
|
->and($survivor->website)->toBeNull()
|
|
->and(Company::withoutGlobalScopes()->find($loser->id)->trashed())->toBeFalse()
|
|
->and(PressRelease::withoutGlobalScopes()->whereIn('id', $loserReleases)->pluck('company_id')->unique()->all())->toBe([$loser->id])
|
|
->and($later->fresh()->company_id)->toBe($survivor->id)
|
|
->and(DB::table('company_user')->where('company_id', $loser->id)->where('user_id', $shared->id)->value('role'))->toBe('member')
|
|
->and(DB::table('company_user')->where('company_id', $survivor->id)->count())->toBe(1)
|
|
->and($merge->fresh()->isReverted())->toBeTrue();
|
|
|
|
// Ein zweites Mal tut nichts.
|
|
$service->revert($merge->fresh());
|
|
expect(Company::withoutGlobalScopes()->find($loser->id)->trashed())->toBeFalse();
|
|
});
|
|
|
|
test('a slug taken by a foreign company on the other portal skips the address', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
// Beide Hälften tragen verschiedene Slugs, und jeder ist auf dem jeweils
|
|
// anderen Portal von einer fremden Firma belegt — keiner kann `both` werden.
|
|
mergeMember('info@muster.de', Portal::Presseecho, 3, ['slug' => 'muster-a']);
|
|
mergeMember('info@muster.de', Portal::Businessportal24, 1, ['slug' => 'muster-b']);
|
|
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'businessportal24', 'slug' => 'muster-a']);
|
|
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'presseecho', 'slug' => 'muster-b']);
|
|
|
|
$plan = planFor('info@muster.de');
|
|
|
|
expect($plan)->toBeInstanceOf(CompanyMergeSkip::class)
|
|
->and($plan->reason)->toBe(CompanyMergeSkip::SlugCollision)
|
|
->and(Company::withoutGlobalScopes()->where('email', 'info@muster.de')->count())->toBe(2);
|
|
});
|
|
|
|
test('when the best candidate collides but another does not, the other survives', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
mergeMember('info@muster.de', Portal::Presseecho, 3, ['slug' => 'muster']);
|
|
$free = mergeMember('info@muster.de', Portal::Businessportal24, 1, ['slug' => 'muster-gmbh']);
|
|
// „muster" ist auf B24 fremd belegt → die PE-Hälfte kann nicht `both` werden,
|
|
// die B24-Hälfte mit „muster-gmbh" schon.
|
|
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'businessportal24', 'slug' => 'muster']);
|
|
|
|
$plan = planFor('info@muster.de');
|
|
|
|
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
|
|
->and($plan->survivor->is($free))->toBeTrue();
|
|
});
|
|
|
|
test('only mergeable classes are touched and a manual decision overrides', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('agentur@sammel.de', CompanyAddressClass::CollectionAddress);
|
|
mergeGroup('doch@agentur.de', CompanyAddressClass::CollectionAddress, manual: CompanyAddressClass::Duplicate);
|
|
mergeGroup('nicht@dublette.de', CompanyAddressClass::Duplicate, manual: CompanyAddressClass::CollectionAddress);
|
|
foreach (['agentur@sammel.de', 'doch@agentur.de', 'nicht@dublette.de'] as $email) {
|
|
mergeMember($email, Portal::Presseecho, 1);
|
|
mergeMember($email, Portal::Businessportal24);
|
|
}
|
|
|
|
$this->artisan('companies:merge')->assertSuccessful();
|
|
|
|
expect(CompanyMerge::query()->pluck('email')->all())->toBe(['doch@agentur.de'])
|
|
->and(Company::withoutGlobalScopes()->whereNull('deleted_at')->where('email', 'agentur@sammel.de')->count())->toBe(2)
|
|
->and(Company::withoutGlobalScopes()->whereNull('deleted_at')->where('email', 'nicht@dublette.de')->count())->toBe(2);
|
|
});
|
|
|
|
test('companies removed with 410 stay out of the merge', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
mergeMember('info@muster.de', Portal::Presseecho, 1);
|
|
$gone = mergeMember('info@muster.de', Portal::Businessportal24, 4, ['gone_at' => now()]);
|
|
|
|
$plan = planFor('info@muster.de');
|
|
|
|
// Nur eine lebende Firma übrig → nichts zu tun; die entfernte bleibt entfernt.
|
|
expect($plan)->toBeInstanceOf(CompanyMergeSkip::class)
|
|
->and($plan->reason)->toBe(CompanyMergeSkip::NothingToMerge)
|
|
->and($gone->fresh()->gone_at)->not->toBeNull();
|
|
});
|
|
|
|
test('dry run and limit write nothing and count correctly', function () {
|
|
/** @var TestCase $this */
|
|
foreach (['a@muster.de', 'b@muster.de', 'c@muster.de'] as $email) {
|
|
mergeGroup($email);
|
|
mergeMember($email, Portal::Presseecho, 1);
|
|
mergeMember($email, Portal::Businessportal24);
|
|
}
|
|
|
|
$this->artisan('companies:merge', ['--dry-run' => true])
|
|
->expectsOutputToContain('[Dry-Run] 3 Adressen')
|
|
->assertSuccessful();
|
|
|
|
expect(CompanyMerge::query()->count())->toBe(0)
|
|
->and(Company::withoutGlobalScopes()->whereNotNull('deleted_at')->count())->toBe(0);
|
|
|
|
$this->artisan('companies:merge', ['--limit' => 2])->assertSuccessful();
|
|
|
|
expect(CompanyMerge::query()->count())->toBe(2)
|
|
->and(CompanyMerge::query()->distinct()->count('batch'))->toBe(1);
|
|
|
|
// Der zweite Lauf holt den Rest — die erledigten Adressen zählen nicht
|
|
// gegen das Limit.
|
|
$this->artisan('companies:merge', ['--limit' => 2])->assertSuccessful();
|
|
|
|
expect(CompanyMerge::query()->count())->toBe(3);
|
|
});
|
|
|
|
test('unmerge by batch reverts every merge of that run', function () {
|
|
/** @var TestCase $this */
|
|
foreach (['a@muster.de', 'b@muster.de'] as $email) {
|
|
mergeGroup($email);
|
|
mergeMember($email, Portal::Presseecho, 1);
|
|
mergeMember($email, Portal::Businessportal24);
|
|
}
|
|
|
|
$this->artisan('companies:merge')->assertSuccessful();
|
|
$batch = CompanyMerge::query()->value('batch');
|
|
|
|
$this->artisan('companies:unmerge', ['--batch' => $batch])
|
|
->expectsOutputToContain('2 Vorgänge rückabgewickelt')
|
|
->assertSuccessful();
|
|
|
|
expect(Company::withoutGlobalScopes()->whereNotNull('deleted_at')->count())->toBe(0)
|
|
->and(CompanyMerge::query()->whereNull('reverted_at')->count())->toBe(0)
|
|
->and(Company::withoutGlobalScopes()->where('portal', 'both')->count())->toBe(0);
|
|
});
|
|
|
|
test('the old newsroom slug redirects permanently to the survivor', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
$survivor = mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster-gmbh']);
|
|
$loser = mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
|
|
$release = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->first();
|
|
$release->update(['published_at' => now()->subDay()]);
|
|
|
|
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
|
|
|
|
$service = app(CompanyMergeService::class);
|
|
$merge = $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
|
|
|
|
// Alte URL → 301 auf den Überlebenden, der auf diesem Portal über `both`
|
|
// erreichbar ist und die Meldung dieses Portals zeigt.
|
|
$this->get('https://presseecho.test/de/newsroom/muster')
|
|
->assertRedirect('https://presseecho.test/de/newsroom/muster-gmbh')
|
|
->assertStatus(301);
|
|
$this->get('https://presseecho.test/de/newsroom/muster-gmbh')->assertOk()->assertSeeText($release->title);
|
|
|
|
// Nach der Rückabwicklung antwortet die alte URL wieder selbst.
|
|
$service->revert($merge);
|
|
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
|
|
$this->get('https://presseecho.test/de/newsroom/muster-gmbh')->assertNotFound();
|
|
});
|
|
|
|
test('the same slug on both portals needs no redirect', function () {
|
|
/** @var TestCase $this */
|
|
mergeGroup('info@muster.de');
|
|
mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster']);
|
|
$loser = mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
|
|
PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->update(['published_at' => now()->subDay()]);
|
|
|
|
$service = app(CompanyMergeService::class);
|
|
$service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
|
|
|
|
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
|
|
$this->get('https://businessportal24.test/de/newsroom/muster')->assertOk();
|
|
});
|