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>
161 lines
5.6 KiB
PHP
161 lines
5.6 KiB
PHP
<?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();
|
|
}
|
|
}
|