presseportale/app/Services/Billing/LegacyAddressParser.php
Kevin Adametz 3320aaaa85 Rechnungsadressen der Bestandskunden aus dem Legacy-Archiv uebernehmen
Befund 06.07.: Der MAN-Rechnungslauf ueberspringt Vereinbarungen ohne
Rechnungsadresse — der Bestandsschutz laeuft dann am Periodenende faktisch
aus. Der Grandfather-Import nahm die Adressen bisher nicht mit, weil die
Altportale sie nur als Freitext-Blob fuehren.

- legacy:import-billing-addresses: uebernimmt Adressen der grandfathered
  Kunden aus der Quell-Rechnung im Archiv (pdf_payload.billing_address),
  nie ueberschreibend, mit Dry-Run und Report der manuell nachzupflegenden
  Faelle (leere/unparsebare Blobs, unbekannte Laender)
- LegacyAddressParser: Best-Effort-Strukturierung der Blobs (mehrzeilig,
  kommasepariert, CH-PLZ-Praefix, Firmen-/Zusatzzeilen); raet nie
- Laenderaufloesung ueber die country-Tabellen der Legacy-Verbindungen
- config/countries.php: CY ergaenzt (reale Bestandskunden aus Zypern)
- WS-7- und QA-Checkliste: Import-Schritt nach dem Grandfathering

Lokal: 11 von 21 Bestandskunden importiert, 9 im Report (7 leere Blobs);
MAN-Lauf rechnet zuvor blockierte Vereinbarungen jetzt ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:19:50 +00:00

129 lines
4.1 KiB
PHP

<?php
namespace App\Services\Billing;
/**
* Zerlegt die unstrukturierten Adress-Blobs der Altportale (Freitextfeld
* `invoice_billing_address.address`, z. B. "Einbock GmbH\r\nPrinzenstraße 1\r\n
* 30419 Hannover") in die strukturierten Felder der neuen `billing_addresses`.
*
* Best-Effort: Kommt keine PLZ+Ort-Zeile vor (leerer Blob, "N/A", nur eine
* Straße), gibt `parse()` null zurück — solche Fälle landen im Report des
* Imports und müssen manuell nachgepflegt werden. Es wird nie geraten.
*/
class LegacyAddressParser
{
/**
* Firmenkennungen, an denen eine Zeile als Firmenname erkannt wird.
*/
private const COMPANY_PATTERN = '/\b(GmbH|mbH|AG|Ltd\.?|UG|KG|OHG|GbR|e\.\s?K\.|e\.?V\.?|Inc\.?|LLC|S\.?A\.?|Corp\.?)(\b|$)/iu';
/**
* PLZ+Ort-Zeile, optional mit Länderpräfix ("CH-8048 Zürich",
* "30419 Hannover", "1070 Nicosia").
*/
private const POSTAL_CITY_PATTERN = '/^(?:[A-Z]{1,3}[- ])?(\d{4,5})\s+(\S.*)$/u';
/**
* @return array{company: ?string, address1: string, address2: ?string, postal_code: string, city: string}|null
*/
public function parse(?string $blob, ?string $countryName = null): ?array
{
$lines = $this->splitIntoLines((string) $blob, $countryName);
if ($lines === []) {
return null;
}
// PLZ+Ort von hinten suchen — alles danach wurde bereits als
// Länderzeile aussortiert.
$postalIndex = null;
$postalCode = null;
$city = null;
for ($i = count($lines) - 1; $i >= 0; $i--) {
if (preg_match(self::POSTAL_CITY_PATTERN, $lines[$i], $matches) === 1) {
$postalIndex = $i;
$postalCode = $matches[1];
$city = trim($matches[2]);
break;
}
}
if ($postalIndex === null || $postalIndex === 0) {
// Keine PLZ+Ort-Zeile oder nichts davor (keine Straße) → manuell.
return null;
}
$before = array_slice($lines, 0, $postalIndex);
$company = null;
foreach ($before as $index => $line) {
if (preg_match(self::COMPANY_PATTERN, $line) === 1) {
$company = $line;
unset($before[$index]);
break;
}
}
$before = array_values($before);
if ($before === []) {
return null;
}
// Straße = erste Zeile mit Hausnummer (Ziffer); Zusätze wie
// "5th Floor" oder Ansprechpartner wandern nach address2.
$address1 = null;
foreach ($before as $index => $line) {
if (preg_match('/\d/', $line) === 1) {
$address1 = $line;
unset($before[$index]);
break;
}
}
if ($address1 === null) {
$address1 = array_shift($before);
}
$address2 = $before === [] ? null : implode(', ', $before);
return [
'company' => $company,
'address1' => $address1,
'address2' => $address2,
'postal_code' => $postalCode,
'city' => $city,
];
}
/**
* Blob in bereinigte Zeilen zerlegen: Zeilenumbrüche, bei einzeiligen
* Blobs Kommata als Trenner; eine reine Länderzeile am Ende (z. B.
* "Cyprus") wird entfernt.
*
* @return list<string>
*/
private function splitIntoLines(string $blob, ?string $countryName): array
{
$lines = preg_split('/\R/u', trim($blob)) ?: [];
$lines = array_values(array_filter(array_map(trim(...), $lines), fn (string $line): bool => $line !== ''));
if (count($lines) === 1 && str_contains($lines[0], ',') && preg_match(self::POSTAL_CITY_PATTERN, $lines[0]) !== 1) {
$lines = array_values(array_filter(array_map(trim(...), explode(',', $lines[0])), fn (string $line): bool => $line !== ''));
}
if ($countryName !== null && $lines !== []
&& strcasecmp((string) end($lines), $countryName) === 0) {
array_pop($lines);
}
return $lines;
}
}