From 8dad652d67c52a9c184446ea32e61fb87cf5e976 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 15 Sep 2026 10:26:13 +0000 Subject: [PATCH] api: neue Firmen dem bestehenden Newsroom zuordnen (Roadmap 1a.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connektar legt je Meldung eine Firma an. ApiNewsroomAssignment prüft vor dem Anlegen, ob unter derselben Firmen-E-Mail schon dieselbe Firma steht (Namensregel der Klassifizierung), und gibt dann deren ID zurück — der Alt-Vertrag (201 + Location) bleibt. Nie-veröffentlicht-Adressen werden nie zugeordnet, abweichende Namen bekommen wie bisher eine neue Firma. Liegt die Firma auf dem anderen Portal, wird sie both; bei fremd belegtem Slug bleibt es beim alten Verhalten. LegacyPressReleaseController: both ist kein Ziel-Portal für eine Meldung — dann entscheidet das Portal des API-Nutzers. Co-Authored-By: Claude Opus 5 (1M context) --- .../Api/Legacy/LegacyCompanyController.php | 19 +- .../Legacy/LegacyPressReleaseController.php | 4 +- app/Models/Company.php | 25 +++ .../Company/ApiNewsroomAssignment.php | 107 ++++++++++++ .../Company/CompanyAddressClassifier.php | 10 ++ app/Services/Company/CompanyMergeService.php | 29 +--- dev/2026-09-01/umsetzung-protokoll.md | 43 +++++ .../Feature/Api/ApiNewsroomAssignmentTest.php | 164 ++++++++++++++++++ 8 files changed, 367 insertions(+), 34 deletions(-) create mode 100644 app/Services/Company/ApiNewsroomAssignment.php create mode 100644 tests/Feature/Api/ApiNewsroomAssignmentTest.php diff --git a/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php b/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php index d06f9394..fb6d7ecf 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyCompanyController.php @@ -7,6 +7,7 @@ use App\Enums\Portal; use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal; use App\Http\Controllers\Controller; use App\Models\Company; +use App\Services\Company\ApiNewsroomAssignment; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -21,7 +22,7 @@ class LegacyCompanyController extends Controller { use ResolvesRequestedPortal, RespondsLikeLegacyApi; - public function store(Request $request): JsonResponse + public function store(Request $request, ApiNewsroomAssignment $assignment): JsonResponse { abort_unless($request->user()->tokenCan('press-releases:write'), 403); @@ -46,11 +47,19 @@ class LegacyCompanyController extends Controller // Portal aus Parameter/Domain, sonst wie bisher vom User — der // User-Fallback allein ließ alle Firmen (und damit deren PMs) von // Kunden mit Keys beider Altportale im selben Portal landen. + $portal = $this->resolveRequestedPortal($validated['portal'] ?? null, $user->portal); + + // Steht unter dieser Firmen-E-Mail schon dieselbe Firma, bekommt der + // Aufrufer deren ID — statt je Meldung eines neuen Newsrooms + // (Roadmap 1a.4). Der Vertrag bleibt: 201 + Location. + $existing = $assignment->assign($user, $validated['email'] ?? null, $validated['name'], $portal); + + if ($existing) { + return $this->legacyCreated(url("/service/users/me/companies/{$existing->id}")); + } + $company = new Company([ - 'portal' => $this->resolveRequestedPortal( - $validated['portal'] ?? null, - $user->portal, - )->value, + 'portal' => $portal->value, 'type' => CompanyType::Company->value, 'name' => $validated['name'], 'address' => $validated['address'] ?? null, diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php index 6d55cfa8..19a8c2b2 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php @@ -108,9 +108,11 @@ class LegacyPressReleaseController extends Controller ]); $pressRelease->uuid = (string) Str::uuid(); $pressRelease->user_id = $user->id; + // Firmen können seit 1a.3 `both` sein — das ist kein Ziel-Portal für + // eine Meldung. Dann entscheidet das Portal des API-Nutzers. $pressRelease->portal = $this->resolveRequestedPortal( $validated['portal'] ?? null, - $company->portal, + $company->portal === Portal::Both ? $user->portal : $company->portal, ); $pressRelease->ai_declaration_confirmed_at = now(); $pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [ diff --git a/app/Models/Company.php b/app/Models/Company.php index bb686110..1f837f9a 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -161,6 +161,31 @@ class Company extends Model return $this->gone_at !== null; } + /** + * Trägt eine fremde, lebende Firma auf einem ANDEREN Portal diesen Slug? + * Dann darf diese Firma nicht `both` werden: Die öffentliche Auflösung + * fragt `portal IN (eigenes, both)` und fände zwei Firmen. + * + * Die anderen Portale werden ausdrücklich aufgezählt statt `portal != ?` + * — nur so greift der Unique-Index (portal, slug). + * + * @param list $exceptIds Firmen, die nicht zählen (z. B. die eigene Adressgruppe) + */ + public function slugTakenOnOtherPortals(array $exceptIds = []): bool + { + $otherPortals = array_values(array_diff( + array_map(fn (Portal $portal): string => $portal->value, Portal::cases()), + [$this->portal->value], + )); + + return static::query() + ->withoutGlobalScope(PortalScope::class) + ->whereIn('portal', $otherPortals) + ->where('slug', $this->slug) + ->whereKeyNot([$this->getKey(), ...$exceptIds]) + ->exists(); + } + /** * Normalisierte Firmen-E-Mail — der Schlüssel der Newsroom-Konsolidierung. * diff --git a/app/Services/Company/ApiNewsroomAssignment.php b/app/Services/Company/ApiNewsroomAssignment.php new file mode 100644 index 00000000..adc4fd07 --- /dev/null +++ b/app/Services/Company/ApiNewsroomAssignment.php @@ -0,0 +1,107 @@ +effectiveClassification() === CompanyAddressClass::NeverPublished) { + return null; + } + + // Schlüssel UND Rohwert abfragen: Der Index auf `email` greift so auf + // jeder Datenbank, und die Kleinschreibung entscheidet der Filter + // danach — nicht die Kollation. + $matches = Company::query() + ->withoutGlobalScope(PortalScope::class) + ->whereIn('email', array_values(array_unique([$key, trim((string) $email)]))) + ->whereNull('gone_at') + ->orderBy('id') + ->get() + ->filter(fn (Company $company): bool => Company::emailKey($company->email) === $key + && $this->classifier->namesMatch($company->name, $name)); + + if ($matches->isEmpty()) { + return null; + } + + // Mehrere Treffer gibt es nach 1a.3 nur noch als Slug-Kollision oder + // Handentscheidung. Dann die, die auf dem angefragten Portal schon + // erreichbar ist, sonst die älteste. + $company = $matches->first(fn (Company $company): bool => $company->portal === $portal || $company->portal === Portal::Both) + ?? $matches->first(); + + if ($portal !== Portal::Both && $company->portal !== Portal::Both && $company->portal !== $portal) { + if ($company->slugTakenOnOtherPortals()) { + return null; + } + + $company->forceFill(['portal' => Portal::Both->value])->save(); + } + + if (! $company->users()->whereKey($user->id)->exists() && $company->owner_user_id !== $user->id) { + $company->users()->attach($user->id, ['role' => 'member']); + } + + $this->activityLog->record( + ActivityType::Correction, + sprintf('API-Firma „%s“ dem bestehenden Newsroom #%d „%s“ zugeordnet (%s)', $name, $company->getKey(), $company->name, $key), + $user, + $company, + ['api_newsroom_assignment' => true, 'requested_name' => $name, 'portal' => $portal->value], + actorLabel: 'api', + ); + + return $company; + } +} diff --git a/app/Services/Company/CompanyAddressClassifier.php b/app/Services/Company/CompanyAddressClassifier.php index aa2cf2b4..0986fb9f 100644 --- a/app/Services/Company/CompanyAddressClassifier.php +++ b/app/Services/Company/CompanyAddressClassifier.php @@ -237,6 +237,16 @@ class CompanyAddressClassifier return count($representatives); } + /** + * Sind zwei Firmennamen dieselbe Firma? Dieselbe Frage, die die + * Klassifizierung je Adresse stellt — hier für die laufende Zuordnung + * (1a.4) einer einzelnen neuen Firma zu einer bestehenden. + */ + public function namesMatch(string $a, string $b): bool + { + return $this->similar($this->normalise($a), $this->normalise($b)); + } + private function similar(string $a, string $b): bool { if ($a === $b) { diff --git a/app/Services/Company/CompanyMergeService.php b/app/Services/Company/CompanyMergeService.php index b335147e..7041e81a 100644 --- a/app/Services/Company/CompanyMergeService.php +++ b/app/Services/Company/CompanyMergeService.php @@ -96,7 +96,7 @@ class CompanyMergeService $targetPortal = $this->targetPortal($survivor, $portals); if ($targetPortal === Portal::Both && $survivor->portal !== Portal::Both - && $this->slugTakenElsewhere($survivor, $candidates)) { + && $survivor->slugTakenOnOtherPortals($candidates->modelKeys())) { $blocked[] = $survivor->slug; continue; @@ -294,33 +294,6 @@ class CompanyMergeService return $survivor->portal; } - /** - * Trägt eine fremde, lebende Firma auf einem anderen Portal denselben - * Slug? Dann fände die öffentliche Auflösung (`portal IN (eigenes, both)`) - * nach dem Wechsel auf `both` zwei Firmen. Die eigenen Gruppenmitglieder - * zählen nicht — die sind danach soft-deleted. - * - * @param Collection $group - */ - private function slugTakenElsewhere(Company $survivor, Collection $group): bool - { - // Die anderen Portale ausdrücklich aufzählen statt `portal != ?`: - // Nur so greift der Unique-Index (portal, slug) — der Unterschied ist - // 24 ms gegen 0 ms je Adresse, bei vierzehntausend Adressen sechs - // Minuten gegen eine Sekunde. - $otherPortals = array_values(array_diff( - array_map(fn (Portal $portal): string => $portal->value, Portal::cases()), - [$survivor->portal->value], - )); - - return Company::query() - ->withoutGlobalScope(PortalScope::class) - ->whereIn('portal', $otherPortals) - ->where('slug', $survivor->slug) - ->whereKeyNot($group->modelKeys()) - ->exists(); - } - /** * @param list $companyIds * @return Builder diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 3a0719f8..7e2da6a5 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -852,3 +852,46 @@ Offen: die 6 Slug-Kollisionen einzeln ansehen (pizza.de, x84.de, 2× eposso-energy, anzeiger24, togev). Möglich ist, die fremde Firma mit dem blockierenden Slug zu prüfen — oft ist das dieselbe Firma unter anderer Adresse (Vorschlag-Klasse) — und danach mit `--email=` nachzuziehen. + +## 1a.4 — Laufende Zuordnung neuer API-Meldungen + +Der Alt-Vertrag der API legt vor jeder Meldung eine Firma an: connektar +schickt je Meldung `POST /service/users/me/companies` und dann die Meldung +mit der zurückgegebenen ID. Deshalb entstand je Meldung ein Newsroom — +`a.helf@ubc-collection.com` hatte sieben aus einer Woche. + +**`ApiNewsroomAssignment`** prüft jetzt VOR dem Anlegen, ob unter derselben +Firmen-E-Mail schon eine Firma mit demselben Namen steht — „derselbe Name" +ist `CompanyAddressClassifier::namesMatch()`, also exakt die Regel der +Klassifizierung (Rechtsform, Tippfehler, gemeinsamer Anfang). Bei Treffer +bekommt der Aufrufer deren ID zurück. Der Vertrag bleibt: 201 + Location. +connektar merkt nichts davon, außer dass die ID eine alte ist. + +Regeln: + +- **Kein Treffer bei abweichendem Namen** → neue Firma wie bisher. Das ist + der Agenturfall: neuer Kunde unter der Sammeladresse. +- **Nie-veröffentlicht-Adresse** (`company_email_groups`, Handentscheidung + übersteuert) → nie zuordnen. +- **410-entfernte und aufgegangene (soft-deleted) Firmen** werden nicht + getroffen — der Überlebende schon. +- **Anderes Portal:** Die Firma wird `both`, die neue Meldung bekommt das + angefragte Portal, die alten behalten ihres. Ist der Slug auf dem anderen + Portal fremd belegt, bleibt es beim alten Verhalten (neue Firma) — dieselbe + Kollisionsregel wie in 1a.3, jetzt als `Company::slugTakenOnOtherPortals()` + an einer Stelle. +- Der API-Nutzer wird als `member` an die Firma gehängt, sonst dürfte er dort + keine Meldung anlegen. Jede Zuordnung steht im Aktivitätsverlauf + („API-Firma … dem bestehenden Newsroom #… zugeordnet"). + +**Folgeänderung in `LegacyPressReleaseController`:** `both` ist kein +Ziel-Portal für eine Meldung. Fällt die Portal-Auflösung auf das Portal der +Firma zurück und das ist `both`, entscheidet das Portal des API-Nutzers. +Vorher hätte eine Meldung ohne Parameter und Domain-Kontext `both` bekommen. + +Tests: `tests/Feature/Api/ApiNewsroomAssignmentTest.php` (9). + +**Messung:** `companies:classify-addresses` nach ein, zwei Wochen — Dublette +und Namensvariante müssen bei den 7 aus den Slug-Kollisionen bleiben. Steigt +die Zahl, trifft die Zuordnung nicht; `companies:merges` und der +Aktivitätsverlauf zeigen, was zugeordnet wurde. diff --git a/tests/Feature/Api/ApiNewsroomAssignmentTest.php b/tests/Feature/Api/ApiNewsroomAssignmentTest.php new file mode 100644 index 00000000..86cf68b0 --- /dev/null +++ b/tests/Feature/Api/ApiNewsroomAssignmentTest.php @@ -0,0 +1,164 @@ +seed(RolesAndPermissionsSeeder::class); + config()->set('billing.enforce_booking', false); + config()->set('app.theme', 'portal'); + + $user = User::factory()->create(['is_active' => true, 'portal' => $portal]); + $user->forceFill(['registration_type' => 'apiuser'])->save(); + + Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']); + + return $user; +} + +/** + * @param array $overrides + */ +function postApiCompany(array $overrides = []): int +{ + $response = test()->postJson('/service/users/me/companies', [ + 'company' => array_merge(['name' => 'Muster GmbH', 'email' => 'info@muster.de'], $overrides), + ])->assertCreated(); + + return (int) basename((string) $response->headers->get('Location')); +} + +function assignmentGroup(string $email, CompanyAddressClass $class, ?CompanyAddressClass $manual = null): void +{ + CompanyEmailGroup::query()->create([ + 'email' => $email, 'classification' => $class, 'manual_classification' => $manual, + 'companies_count' => 30, 'distinct_names' => 30, 'published_companies_count' => 0, 'threshold' => 3, 'classified_at' => now(), + ]); +} + +test('the same company under the same email is reused instead of created', function () { + /** @var TestCase $this */ + $user = assignmentApiUser(); + $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); + + $id = postApiCompany(['email' => 'Info@Muster.de']); + + expect($id)->toBe($existing->id) + ->and(Company::withoutGlobalScopes()->where('email', 'like', '%muster.de')->count())->toBe(1) + ->and($existing->users()->whereKey($user->id)->wherePivot('role', 'member')->exists())->toBeTrue() + ->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeTrue(); +}); + +test('a name that differs only by legal form or a typo still matches', function () { + /** @var TestCase $this */ + assignmentApiUser(); + $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster Unternehmensberatung', 'portal' => 'businessportal24']); + + expect(postApiCompany(['name' => 'Muster Unternehmensberatung GmbH & Co. KG']))->toBe($existing->id) + ->and(postApiCompany(['name' => 'Muster Unternehmensbertung']))->toBe($existing->id); +}); + +test('a different name under the same address gets its own company as before', function () { + /** @var TestCase $this */ + assignmentApiUser(); + Company::factory()->create(['email' => 'presse@agentur.de', 'name' => 'Kunde Eins GmbH', 'portal' => 'businessportal24']); + + $id = postApiCompany(['name' => 'Ganz Anderer Kunde AG', 'email' => 'presse@agentur.de']); + + expect(Company::withoutGlobalScopes()->find($id)->name)->toBe('Ganz Anderer Kunde AG') + ->and(Company::withoutGlobalScopes()->where('email', 'presse@agentur.de')->count())->toBe(2); +}); + +test('under a never-published address nothing is ever assigned', function () { + /** @var TestCase $this */ + assignmentApiUser(); + assignmentGroup('yahoo@yahoo.com', CompanyAddressClass::NeverPublished); + $existing = Company::factory()->create(['email' => 'yahoo@yahoo.com', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); + + expect(postApiCompany(['email' => 'yahoo@yahoo.com']))->not->toBe($existing->id); +}); + +test('a manual decision can turn a never-published address back into a normal one', function () { + /** @var TestCase $this */ + assignmentApiUser(); + assignmentGroup('presse@pr4you.de', CompanyAddressClass::NeverPublished, manual: CompanyAddressClass::CollectionAddress); + $existing = Company::factory()->create(['email' => 'presse@pr4you.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); + + expect(postApiCompany(['email' => 'presse@pr4you.de']))->toBe($existing->id); +}); + +test('a company on the other portal becomes both and the release lands on the requested portal', function () { + /** @var TestCase $this */ + $user = assignmentApiUser(Portal::Presseecho); + $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); + $category = Category::factory()->create(); + Queue::fake(); + + $id = postApiCompany(); + + expect($id)->toBe($existing->id) + ->and($existing->fresh()->portal)->toBe(Portal::Both); + + $this->postJson('/service/pressreleases', [ + 'press_release' => [ + 'company_id' => $id, + 'category_id' => $category->legacy_id ?? $category->id, + 'language' => 'de', + 'title' => 'Zuordnungs-Test', + 'text' => 'Inhalt der Test-Pressemitteilung mit genug Text.', + ], + ])->assertCreated(); + + // `both` ist kein Ziel-Portal für eine Meldung — ohne Parameter und + // Domain-Kontext entscheidet das Portal des API-Nutzers. + $release = PressRelease::withoutGlobalScopes()->where('title', 'Zuordnungs-Test')->firstOrFail(); + expect($release->portal)->toBe(Portal::Presseecho) + ->and($release->company_id)->toBe($existing->id) + ->and($release->user_id)->toBe($user->id); +}); + +test('a slug taken on the other portal prevents widening and falls back to a new company', function () { + /** @var TestCase $this */ + assignmentApiUser(Portal::Presseecho); + $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24', 'slug' => 'muster-gmbh']); + Company::factory()->create(['email' => 'anders@fremd.de', 'name' => 'Muster GmbH', 'portal' => 'presseecho', 'slug' => 'muster-gmbh']); + + $id = postApiCompany(); + + expect($id)->not->toBe($existing->id) + ->and($existing->fresh()->portal)->toBe(Portal::Businessportal24); +}); + +test('a merged-away company is not matched, its survivor is', function () { + /** @var TestCase $this */ + assignmentApiUser(Portal::Presseecho); + $survivor = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'both']); + $gone = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'presseecho']); + $gone->delete(); + + expect(postApiCompany())->toBe($survivor->id); +}); + +test('without an email a company is created as before', function () { + /** @var TestCase $this */ + assignmentApiUser(); + $existing = Company::factory()->create(['email' => null, 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); + + expect(postApiCompany(['email' => null]))->not->toBe($existing->id); +});