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>
This commit is contained in:
Kevin Adametz 2026-07-06 12:19:50 +00:00
parent 387d41a782
commit 3320aaaa85
7 changed files with 538 additions and 0 deletions

View file

@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Services\Billing\LegacyBillingAddressImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Übernimmt die Rechnungsadressen der Bestandskunden aus dem Rechnungsarchiv
* (Befund 06.07.2026: ohne Adresse überspringt der MAN-Rechnungslauf die
* Vereinbarung und der Bestandsschutz läuft am Periodenende aus). Läuft
* NACH legacy:grandfather-subscriptions; die Legacy-Verbindungen werden nur
* für die Länderauflösung (country_id → Name) gebraucht. Wiederholbar:
* bestehende Adressen werden nie überschrieben.
*
* Verwendung:
* php artisan legacy:import-billing-addresses --dry-run
* php artisan legacy:import-billing-addresses
*/
class ImportLegacyBillingAddresses extends Command
{
protected $signature = 'legacy:import-billing-addresses
{--dry-run : Nur anzeigen, nichts schreiben}';
protected $description = 'Übernimmt Rechnungsadressen der grandfathered Bestandskunden aus dem Legacy-Rechnungsarchiv.';
private const CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
public function handle(LegacyBillingAddressImporter $importer): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$countryNamesByPortal = [];
foreach (self::CONNECTIONS as $portal => $connection) {
$countryNamesByPortal[$portal] = DB::connection($connection)
->table('country')
->pluck('name', 'id')
->map(fn ($name): string => (string) $name)
->all();
}
$report = $importer->import($countryNamesByPortal, $dryRun);
$this->table(
['Importiert', 'Adresse vorhanden', 'Nicht parsebar', 'Land unbekannt', 'Quell-Rechnung fehlt'],
[[
$report['imported'],
$report['skipped_existing'],
count($report['unparseable']),
count($report['unknown_country']),
count($report['missing_invoice']),
]],
);
foreach ($report['unparseable'] as $line) {
$this->warn("Manuell nachpflegen (Adresse nicht parsebar): {$line}");
}
foreach ($report['unknown_country'] as $line) {
$this->warn("Manuell nachpflegen (Land nicht zuordenbar): {$line}");
}
foreach ($report['missing_invoice'] as $email) {
$this->warn("Manuell prüfen (Quell-Rechnung nicht im Archiv): {$email}");
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,129 @@
<?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;
}
}

View file

@ -0,0 +1,161 @@
<?php
namespace App\Services\Billing;
use App\Enums\UserPaymentOptionStatus;
use App\Models\BillingAddress;
use App\Models\LegacyInvoice;
use App\Models\UserPaymentOption;
use Illuminate\Support\Collection;
/**
* Übernimmt die Rechnungsadressen der Bestandskunden (grandfathered) aus dem
* Rechnungsarchiv nach `billing_addresses` — ohne Adresse überspringt der
* MAN-Rechnungslauf die Vereinbarung und der Bestandsschutz läuft am
* Periodenende faktisch aus (Befund 06.07.2026).
*
* Quelle ist die Quell-Rechnung der Grandfather-Migration
* (`legacy_conditions.source_invoice_number` → `legacy_invoices.pdf_payload.
* billing_address`). Die Alt-Adresse ist ein Freitext-Blob und wird per
* LegacyAddressParser strukturiert; Unparsebares landet im Report zur
* manuellen Nachpflege. Bestehende Adressen werden nie überschrieben.
*/
class LegacyBillingAddressImporter
{
/**
* Legacy-`country.name` (englisch) → ISO-3166-1-alpha-2. Nicht gelistete
* Länder landen im Report (`unknown_country`).
*/
private const COUNTRY_NAME_TO_ISO = [
'Germany' => 'DE',
'Austria' => 'AT',
'Switzerland' => 'CH',
'Liechtenstein' => 'LI',
'Luxembourg' => 'LU',
'Belgium' => 'BE',
'Netherlands' => 'NL',
'France' => 'FR',
'Italy' => 'IT',
'Spain' => 'ES',
'Portugal' => 'PT',
'Poland' => 'PL',
'Czech Republic' => 'CZ',
'Denmark' => 'DK',
'Sweden' => 'SE',
'Norway' => 'NO',
'Finland' => 'FI',
'Ireland' => 'IE',
'Greece' => 'GR',
'Hungary' => 'HU',
'Cyprus' => 'CY',
'United Kingdom' => 'GB',
'United States' => 'US',
'India' => 'IN',
];
public function __construct(private readonly LegacyAddressParser $parser) {}
/**
* @param array<string, array<int, string>> $countryNamesByPortal Portal → [legacy country_id → englischer Name]
* @return array{imported: int, skipped_existing: int, unparseable: list<string>, unknown_country: list<string>, missing_invoice: list<string>}
*/
public function import(array $countryNamesByPortal, bool $dryRun = false): array
{
$report = [
'imported' => 0,
'skipped_existing' => 0,
'unparseable' => [],
'unknown_country' => [],
'missing_invoice' => [],
];
$optionsByUser = UserPaymentOption::query()
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->with('user.billingAddress')
->get()
->filter(fn (UserPaymentOption $option): bool => $option->user !== null)
->groupBy('user_id');
foreach ($optionsByUser as $options) {
$user = $options->first()->user;
if ($user->billingAddress) {
$report['skipped_existing']++;
continue;
}
$invoice = $this->latestSourceInvoice($options);
if (! $invoice) {
$report['missing_invoice'][] = $user->email;
continue;
}
$legacyAddress = $invoice->pdf_payload['billing_address'] ?? [];
$portal = $invoice->legacy_portal->value;
$countryName = $countryNamesByPortal[$portal][(int) ($legacyAddress['country_id'] ?? 0)] ?? null;
$countryCode = self::COUNTRY_NAME_TO_ISO[$countryName] ?? null;
if ($countryCode === null) {
$report['unknown_country'][] = sprintf('%s (country_id %s)', $user->email, $legacyAddress['country_id'] ?? '—');
continue;
}
$parsed = $this->parser->parse($legacyAddress['address'] ?? null, $countryName);
if ($parsed === null) {
$report['unparseable'][] = sprintf('%s ("%s")', $user->email, str_replace(["\r\n", "\n"], ' / ', trim((string) ($legacyAddress['address'] ?? ''))));
continue;
}
if (! $dryRun) {
BillingAddress::query()->create([
'user_id' => $user->id,
'name' => (string) ($legacyAddress['name'] ?? $user->name),
'company' => $parsed['company'],
'address1' => $parsed['address1'],
'address2' => $parsed['address2'],
'postal_code' => $parsed['postal_code'],
'city' => $parsed['city'],
'country_code' => $countryCode,
]);
}
$report['imported']++;
}
return $report;
}
/**
* Jüngste Quell-Rechnung über alle Vereinbarungen des Users — hat er
* mehrere Pakete (z. B. je Spoke eines), zählt der neueste Adress-Stand.
*
* @param Collection<int, UserPaymentOption> $options
*/
private function latestSourceInvoice(Collection $options): ?LegacyInvoice
{
return $options
->map(function (UserPaymentOption $option): ?LegacyInvoice {
$conditions = $option->legacy_conditions ?? [];
$number = $conditions['source_invoice_number'] ?? null;
$portal = $conditions['legacy_portal'] ?? null;
if (! $number || ! $portal) {
return null;
}
return LegacyInvoice::query()
->where('legacy_portal', $portal)
->where('number', $number)
->first();
})
->filter()
->sortByDesc(fn (LegacyInvoice $invoice) => [$invoice->invoice_date, $invoice->id])
->first();
}
}