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:
parent
52006862a3
commit
5d199f0130
7 changed files with 538 additions and 0 deletions
160
tests/Feature/Billing/LegacyBillingAddressImportTest.php
Normal file
160
tests/Feature/Billing/LegacyBillingAddressImportTest.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\BillingAddress;
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\Billing\LegacyAddressParser;
|
||||
use App\Services\Billing\LegacyBillingAddressImporter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const COUNTRY_NAMES = [
|
||||
'presseecho' => [108 => 'Cyprus', 177 => 'Germany', 196 => 'Switzerland'],
|
||||
'businessportal24' => [108 => 'Cyprus', 177 => 'Germany', 196 => 'Switzerland'],
|
||||
];
|
||||
|
||||
function createGrandfatheredUserWithArchiveAddress(array $billingAddressPayload, string $portal = 'presseecho'): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
LegacyInvoice::query()->create([
|
||||
'legacy_portal' => $portal,
|
||||
'legacy_id' => fake()->unique()->numberBetween(1, 100000),
|
||||
'user_id' => $user->id,
|
||||
'legacy_user_id' => $user->id,
|
||||
'number' => 'LEG-'.fake()->unique()->numberBetween(1, 100000),
|
||||
'total_cents' => 119000,
|
||||
'status' => 'paid',
|
||||
'invoice_date' => '2025-06-15',
|
||||
'paid_at' => now()->subYear(),
|
||||
'imported_at' => now(),
|
||||
'pdf_payload' => ['billing_address' => $billingAddressPayload],
|
||||
]);
|
||||
|
||||
$invoiceNumber = LegacyInvoice::query()->where('user_id', $user->id)->value('number');
|
||||
|
||||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'grandfathered_until' => null,
|
||||
'stripe_subscription_id' => null,
|
||||
'legacy_conditions' => [
|
||||
'legacy_portal' => $portal,
|
||||
'source_invoice_number' => $invoiceNumber,
|
||||
'interval' => 'yearly',
|
||||
'net_cents' => 100000,
|
||||
],
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
test('importer creates a structured billing address from the legacy blob', function () {
|
||||
$user = createGrandfatheredUserWithArchiveAddress([
|
||||
'name' => 'Sebastian Einbock',
|
||||
'address' => "Einbock GmbH\r\nPrinzenstraße 1\r\n30419 Hannover",
|
||||
'country_id' => 177,
|
||||
]);
|
||||
|
||||
$report = app(LegacyBillingAddressImporter::class)->import(COUNTRY_NAMES);
|
||||
|
||||
expect($report['imported'])->toBe(1);
|
||||
|
||||
$address = BillingAddress::query()->where('user_id', $user->id)->first();
|
||||
|
||||
expect($address)->not->toBeNull()
|
||||
->and($address->name)->toBe('Sebastian Einbock')
|
||||
->and($address->company)->toBe('Einbock GmbH')
|
||||
->and($address->address1)->toBe('Prinzenstraße 1')
|
||||
->and($address->postal_code)->toBe('30419')
|
||||
->and($address->city)->toBe('Hannover')
|
||||
->and($address->country_code)->toBe('DE');
|
||||
});
|
||||
|
||||
test('importer never overwrites an existing billing address', function () {
|
||||
$user = createGrandfatheredUserWithArchiveAddress([
|
||||
'name' => 'Alt Name',
|
||||
'address' => "Altstraße 1\r\n11111 Altstadt",
|
||||
'country_id' => 177,
|
||||
]);
|
||||
|
||||
BillingAddress::factory()->create(['user_id' => $user->id, 'address1' => 'Vom Kunden gepflegt 5']);
|
||||
|
||||
$report = app(LegacyBillingAddressImporter::class)->import(COUNTRY_NAMES);
|
||||
|
||||
expect($report['imported'])->toBe(0)
|
||||
->and($report['skipped_existing'])->toBe(1)
|
||||
->and($user->billingAddress()->first()->address1)->toBe('Vom Kunden gepflegt 5');
|
||||
});
|
||||
|
||||
test('unparseable blobs and unknown countries are reported instead of guessed', function () {
|
||||
$empty = createGrandfatheredUserWithArchiveAddress([
|
||||
'name' => 'Leerer Blob', 'address' => '', 'country_id' => 177,
|
||||
]);
|
||||
$unknownCountry = createGrandfatheredUserWithArchiveAddress([
|
||||
'name' => 'Exot', 'address' => "Somewhere 1\r\n12345 Faraway", 'country_id' => 999,
|
||||
]);
|
||||
|
||||
$report = app(LegacyBillingAddressImporter::class)->import(COUNTRY_NAMES);
|
||||
|
||||
expect($report['imported'])->toBe(0)
|
||||
->and($report['unparseable'])->toHaveCount(1)
|
||||
->and($report['unparseable'][0])->toContain($empty->email)
|
||||
->and($report['unknown_country'])->toHaveCount(1)
|
||||
->and($report['unknown_country'][0])->toContain($unknownCountry->email)
|
||||
->and(BillingAddress::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('dry run reports without writing', function () {
|
||||
createGrandfatheredUserWithArchiveAddress([
|
||||
'name' => 'Trocken Lauf',
|
||||
'address' => "Teststraße 2\r\n22222 Testhausen",
|
||||
'country_id' => 177,
|
||||
]);
|
||||
|
||||
$report = app(LegacyBillingAddressImporter::class)->import(COUNTRY_NAMES, dryRun: true);
|
||||
|
||||
expect($report['imported'])->toBe(1)
|
||||
->and(BillingAddress::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('parser handles the address shapes found in the legacy archive', function (string $blob, ?string $countryName, ?array $expected) {
|
||||
$parsed = app(LegacyAddressParser::class)->parse($blob, $countryName);
|
||||
|
||||
if ($expected === null) {
|
||||
expect($parsed)->toBeNull();
|
||||
} else {
|
||||
expect($parsed)->toMatchArray($expected);
|
||||
}
|
||||
})->with([
|
||||
'mehrzeilig mit Firma' => [
|
||||
"Einbock GmbH\r\nPrinzenstraße 1\r\n30419 Hannover", 'Germany',
|
||||
['company' => 'Einbock GmbH', 'address1' => 'Prinzenstraße 1', 'postal_code' => '30419', 'city' => 'Hannover'],
|
||||
],
|
||||
'Adresszusatz vor PLZ-Zeile' => [
|
||||
"73, Makarios Avenue\r\n5th Floor\r\n1070 Nicosia", 'Cyprus',
|
||||
['company' => null, 'address1' => '73, Makarios Avenue', 'address2' => '5th Floor', 'postal_code' => '1070', 'city' => 'Nicosia'],
|
||||
],
|
||||
'Ansprechpartner statt Firma' => [
|
||||
"Waldemar Eider\r\nHauptstr.29\r\n67806 Bisterschied", 'Germany',
|
||||
['company' => null, 'address1' => 'Hauptstr.29', 'address2' => 'Waldemar Eider', 'postal_code' => '67806', 'city' => 'Bisterschied'],
|
||||
],
|
||||
'einzeilig mit Kommata' => [
|
||||
'Wotanstr. 16, 10365 Berlin', 'Germany',
|
||||
['company' => null, 'address1' => 'Wotanstr. 16', 'postal_code' => '10365', 'city' => 'Berlin'],
|
||||
],
|
||||
'einzeilig mit Landesname am Ende' => [
|
||||
'73 Makarios Avenue, 5th floor, 1070 Nicosia, Cyprus', 'Cyprus',
|
||||
['company' => null, 'address1' => '73 Makarios Avenue', 'address2' => '5th floor', 'postal_code' => '1070', 'city' => 'Nicosia'],
|
||||
],
|
||||
'Schweizer PLZ-Präfix' => [
|
||||
"Max-Högger-Strasse 6\r\nCH-8048 Zürich", 'Switzerland',
|
||||
['company' => null, 'address1' => 'Max-Högger-Strasse 6', 'postal_code' => '8048', 'city' => 'Zürich'],
|
||||
],
|
||||
'nur Straße ohne PLZ/Ort' => ['Christophstraße 15-17', 'Germany', null],
|
||||
'Platzhalter N/A' => ['N/A', 'Germany', null],
|
||||
'leer' => ['', 'Germany', null],
|
||||
]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue