Newsletter-Listen: Empfaengerlisten filtern, speichern und exportieren
Admin-Bereich "Newsletter-Listen" (Plan dev/_tickets/entwicklungsplan- newsletter-listen.md): Berater und Kunden nach Filtern zusammenstellen, als Liste speichern und wieder laden, Export als XLSX/CSV. Jeder Export wird protokolliert (newsletter_list_exports). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef7e333bd4
commit
d21d94a060
26 changed files with 3664 additions and 0 deletions
364
tests/Feature/Newsletter/NewsletterListServiceTest.php
Normal file
364
tests/Feature/Newsletter/NewsletterListServiceTest.php
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserLevel;
|
||||
use App\Services\Newsletter\NewsletterListFilter;
|
||||
use App\Services\Newsletter\NewsletterListResult;
|
||||
use App\Services\Newsletter\NewsletterListService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function newsletterCountry(string $code = 'DE'): Country
|
||||
{
|
||||
return Country::firstOrCreate(['code' => $code], [
|
||||
'phone' => '49', 'en' => $code, 'de' => $code, 'es' => $code, 'fr' => $code, 'it' => $code, 'ru' => $code,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function newsletterCustomer(array $attributes = []): ShoppingUser
|
||||
{
|
||||
return ShoppingUser::forceCreate(array_merge([
|
||||
'auth_user_id' => 0,
|
||||
'billing_email' => 'kunde-'.uniqid('', true).'@example.com',
|
||||
'billing_firstname' => 'Erika',
|
||||
'billing_lastname' => 'Muster',
|
||||
'billing_salutation' => 'ms',
|
||||
'billing_address' => 'Musterweg 1',
|
||||
'billing_zipcode' => '12345',
|
||||
'billing_city' => 'Musterstadt',
|
||||
'billing_country_id' => newsletterCountry()->id,
|
||||
'shipping_country_id' => 1,
|
||||
'language' => 'de',
|
||||
'subscribed' => 1,
|
||||
'has_buyed' => 1,
|
||||
'faker_mail' => 0,
|
||||
'number' => 'K-'.uniqid('', true),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $userAttributes
|
||||
* @param array<string, mixed> $accountAttributes
|
||||
*/
|
||||
function newsletterConsultant(array $userAttributes = [], array $accountAttributes = []): User
|
||||
{
|
||||
$account = UserAccount::forceCreate(array_merge([
|
||||
'm_account' => 'B-'.uniqid('', true),
|
||||
'salutation' => 'mr',
|
||||
'first_name' => 'Max',
|
||||
'last_name' => 'Berater',
|
||||
'address' => 'Beraterstraße 2',
|
||||
'zipcode' => '54321',
|
||||
'city' => 'Beraterstadt',
|
||||
'country_id' => newsletterCountry()->id,
|
||||
], $accountAttributes));
|
||||
|
||||
return User::forceCreate(array_merge([
|
||||
'email' => 'berater-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'account_id' => $account->id,
|
||||
'active' => 1,
|
||||
'blocked' => 0,
|
||||
'admin' => 0,
|
||||
'test_mode' => 0,
|
||||
], $userAttributes))->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
function newsletterBuild(array $input = []): NewsletterListResult
|
||||
{
|
||||
return app(NewsletterListService::class)->build(NewsletterListFilter::fromArray($input));
|
||||
}
|
||||
|
||||
it('liefert Kunden mit den Vorlage-Spalten und dem zugeordneten Berater', function () {
|
||||
$consultant = newsletterConsultant([], ['first_name' => 'Anna', 'last_name' => 'Sponsor']);
|
||||
$customer = newsletterCustomer(['billing_email' => 'Erika.Muster@Example.com ', 'member_id' => $consultant->id, 'billing_address_2' => 'Hinterhaus']);
|
||||
|
||||
$result = newsletterBuild(['source' => 'customers']);
|
||||
|
||||
expect($result->rows)->toHaveCount(1);
|
||||
$row = $result->rows->first();
|
||||
expect($row['email'])->toBe('erika.muster@example.com')
|
||||
->and($row['firstname'])->toBe('Erika')
|
||||
->and($row['salutation_label'])->toBe('Frau')
|
||||
->and($row['street'])->toBe('Musterweg 1, Hinterhaus')
|
||||
->and($row['consultant'])->toBe('Anna Sponsor')
|
||||
->and($row['consultant_number'])->toBe($consultant->account->m_account)
|
||||
->and($row['country'])->toBe('DE')
|
||||
->and($row['source'])->toBe('customer')
|
||||
->and($row['optin'])->toBe('yes')
|
||||
->and($row['number'])->toBe($customer->number);
|
||||
});
|
||||
|
||||
it('schließt gelöschte, anonymisierte, Fake-, Test- und Berater-Kundendatensätze immer aus', function () {
|
||||
newsletterCustomer(['billing_email' => 'ok@example.com']);
|
||||
newsletterCustomer(['billing_email' => 'faker@example.com', 'faker_mail' => 1]);
|
||||
newsletterCustomer(['billing_email' => 'geloescht_5@dsgvo.invalid']);
|
||||
newsletterCustomer(['billing_email' => 'x-faker@mivita.care']);
|
||||
newsletterCustomer(['billing_email' => 'dev@example.com', 'mode' => 'dev']);
|
||||
newsletterCustomer(['billing_email' => 'prev@example.com', 'mode' => 'prev']);
|
||||
newsletterCustomer(['billing_email' => 'live@example.com', 'mode' => 'live']);
|
||||
newsletterCustomer(['billing_email' => 'berater-shop@example.com', 'auth_user_id' => 99]);
|
||||
newsletterCustomer(['billing_email' => 'userdeleted@example.com', 'user_deleted_at' => now()]);
|
||||
newsletterCustomer(['billing_email' => 'softdeleted@example.com'])->delete();
|
||||
|
||||
$emails = newsletterBuild(['source' => 'customers'])->rows->pluck('email')->all();
|
||||
|
||||
expect($emails)->toEqualCanonicalizing(['ok@example.com', 'live@example.com']);
|
||||
});
|
||||
|
||||
it('schließt gesperrte, gelöschte, Test- und Admin-Berater aus und kann Admins optional einbeziehen', function () {
|
||||
newsletterConsultant(['email' => 'aktiv@example.com']);
|
||||
newsletterConsultant(['email' => 'vip@example.com', 'admin' => 1]);
|
||||
newsletterConsultant(['email' => 'admin@example.com', 'admin' => 2]);
|
||||
newsletterConsultant(['email' => 'sysadmin@example.com', 'admin' => 5]);
|
||||
newsletterConsultant(['email' => 'blocked@example.com', 'blocked' => 1]);
|
||||
newsletterConsultant(['email' => 'testmode@example.com', 'test_mode' => 1]);
|
||||
newsletterConsultant(['email' => 'predeleted@example.com', 'pre_deleted_at' => now()]);
|
||||
newsletterConsultant(['email' => 'delete-7-alt@example.com']);
|
||||
newsletterConsultant(['email' => 'softdeleted@example.com'])->delete();
|
||||
|
||||
$default = newsletterBuild(['source' => 'consultants'])->rows->pluck('email')->all();
|
||||
$withAdmins = newsletterBuild(['source' => 'consultants', 'include_admins' => 1])->rows->pluck('email')->all();
|
||||
|
||||
expect($default)->toEqualCanonicalizing(['aktiv@example.com', 'vip@example.com'])
|
||||
->and($withAdmins)->toEqualCanonicalizing(['aktiv@example.com', 'vip@example.com', 'admin@example.com']);
|
||||
});
|
||||
|
||||
it('entfernt Duplikate über beide Quellen, Berater gewinnt vor Kunde und Opt-in vor ohne Opt-in', function () {
|
||||
newsletterConsultant(['email' => 'Doppelt@Example.com']);
|
||||
newsletterCustomer(['billing_email' => 'doppelt@example.com', 'billing_firstname' => 'Kunde']);
|
||||
newsletterCustomer(['billing_email' => 'zweimal@example.com', 'billing_firstname' => 'Ohne', 'subscribed' => 0]);
|
||||
newsletterCustomer(['billing_email' => 'ZWEIMAL@example.com', 'billing_firstname' => 'Mit', 'subscribed' => 1]);
|
||||
newsletterCustomer(['billing_email' => 'zweimal@example.com', 'billing_firstname' => 'Ohne2', 'subscribed' => 0]);
|
||||
|
||||
$result = newsletterBuild(['source' => 'both', 'consultant_status' => 'all']);
|
||||
$byEmail = $result->rows->keyBy('email');
|
||||
|
||||
expect($result->rows)->toHaveCount(2)
|
||||
->and($byEmail['doppelt@example.com']['source'])->toBe('consultant')
|
||||
->and($byEmail['zweimal@example.com']['firstname'])->toBe('Mit')
|
||||
->and($result->stats['duplicates'])->toBe(3)
|
||||
->and($result->stats['total'])->toBe(2)
|
||||
->and($result->stats['customers'])->toBe(1)
|
||||
->and($result->stats['consultants'])->toBe(1);
|
||||
});
|
||||
|
||||
it('verwirft ungültige E-Mail-Adressen und zählt sie', function () {
|
||||
newsletterCustomer(['billing_email' => 'kontakt@ kaputt.de']);
|
||||
newsletterCustomer(['billing_email' => '#']);
|
||||
newsletterCustomer(['billing_email' => 'gut@example.com']);
|
||||
|
||||
$result = newsletterBuild(['source' => 'customers']);
|
||||
|
||||
expect($result->rows->pluck('email')->all())->toBe(['gut@example.com'])
|
||||
->and($result->stats['invalid'])->toBe(2);
|
||||
});
|
||||
|
||||
it('filtert nach Sprache und behandelt fehlende Sprache wie Deutsch', function () {
|
||||
newsletterCustomer(['billing_email' => 'de@example.com', 'language' => 'de']);
|
||||
newsletterCustomer(['billing_email' => 'null@example.com', 'language' => null]);
|
||||
newsletterCustomer(['billing_email' => 'es@example.com', 'language' => 'es']);
|
||||
newsletterConsultant(['email' => 'berater-es@example.com', 'lang' => 'es']);
|
||||
|
||||
expect(newsletterBuild(['source' => 'both', 'language' => 'de'])->rows->pluck('email')->all())
|
||||
->toEqualCanonicalizing(['de@example.com', 'null@example.com'])
|
||||
->and(newsletterBuild(['source' => 'both', 'language' => 'es'])->rows->pluck('email')->all())
|
||||
->toEqualCanonicalizing(['es@example.com', 'berater-es@example.com'])
|
||||
->and(newsletterBuild(['source' => 'both', 'language' => ''])->rows)->toHaveCount(4);
|
||||
});
|
||||
|
||||
it('filtert nach Land bei Kunden und Beratern', function () {
|
||||
$at = newsletterCountry('AT');
|
||||
newsletterCustomer(['billing_email' => 'de@example.com']);
|
||||
newsletterCustomer(['billing_email' => 'at@example.com', 'billing_country_id' => $at->id]);
|
||||
newsletterConsultant(['email' => 'berater-at@example.com'], ['country_id' => $at->id]);
|
||||
newsletterConsultant(['email' => 'berater-de@example.com']);
|
||||
|
||||
$emails = newsletterBuild(['source' => 'both', 'country_ids' => [$at->id]])->rows->pluck('email')->all();
|
||||
|
||||
expect($emails)->toEqualCanonicalizing(['at@example.com', 'berater-at@example.com']);
|
||||
});
|
||||
|
||||
it('filtert nach Opt-in', function () {
|
||||
newsletterCustomer(['billing_email' => 'ja@example.com', 'subscribed' => 1]);
|
||||
newsletterCustomer(['billing_email' => 'nein@example.com', 'subscribed' => 0]);
|
||||
|
||||
expect(newsletterBuild(['optin' => 'subscribed'])->rows->pluck('email')->all())->toBe(['ja@example.com'])
|
||||
->and(newsletterBuild(['optin' => 'unsubscribed'])->rows->pluck('email')->all())->toBe(['nein@example.com'])
|
||||
->and(newsletterBuild(['optin' => 'all'])->rows)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('filtert nach Kaufstatus inklusive Zeitraum und aktivem Abo', function () {
|
||||
$country = newsletterCountry();
|
||||
$never = newsletterCustomer(['billing_email' => 'nie@example.com', 'has_buyed' => 0]);
|
||||
$recent = newsletterCustomer(['billing_email' => 'kuerzlich@example.com']);
|
||||
$old = newsletterCustomer(['billing_email' => 'alt@example.com']);
|
||||
$abo = newsletterCustomer(['billing_email' => 'abo@example.com']);
|
||||
|
||||
foreach ([[$recent, now()->subMonths(2)], [$old, now()->subMonths(4)]] as [$shoppingUser, $createdAt]) {
|
||||
ShoppingOrder::forceCreate([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => 0,
|
||||
'country_id' => $country->id,
|
||||
'language' => 'de',
|
||||
'user_shop_id' => 1,
|
||||
'payment_for' => 6,
|
||||
'total' => 50,
|
||||
'subtotal' => 50,
|
||||
'points' => 25,
|
||||
'paid' => true,
|
||||
'shipped' => 0,
|
||||
'mode' => 'live',
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
]);
|
||||
}
|
||||
|
||||
UserAbo::create([
|
||||
'user_id' => 1,
|
||||
'shopping_user_id' => $abo->id,
|
||||
'is_for' => 'customer',
|
||||
'email' => $abo->billing_email,
|
||||
'payone_userid' => 1,
|
||||
'clearingtype' => 'cc',
|
||||
'active' => true,
|
||||
'status' => 2,
|
||||
'abo_interval' => 5,
|
||||
'next_date' => now()->addDays(3),
|
||||
]);
|
||||
UserAbo::create([
|
||||
'user_id' => 1,
|
||||
'shopping_user_id' => $never->id,
|
||||
'is_for' => 'customer',
|
||||
'email' => $never->billing_email,
|
||||
'payone_userid' => 2,
|
||||
'clearingtype' => 'cc',
|
||||
'active' => false,
|
||||
'status' => 4,
|
||||
'abo_interval' => 5,
|
||||
'next_date' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
$emails = fn (string $purchase) => newsletterBuild(['purchase' => $purchase])->rows->pluck('email')->all();
|
||||
|
||||
expect($emails('has_buyed'))->toEqualCanonicalizing(['kuerzlich@example.com', 'alt@example.com', 'abo@example.com'])
|
||||
->and($emails('none'))->toBe(['nie@example.com'])
|
||||
->and($emails('months_3'))->toBe(['kuerzlich@example.com'])
|
||||
->and($emails('months_6'))->toEqualCanonicalizing(['kuerzlich@example.com', 'alt@example.com'])
|
||||
->and($emails('active_abo'))->toBe(['abo@example.com']);
|
||||
});
|
||||
|
||||
it('filtert nach zugeordnetem Berater bzw. Sponsor', function () {
|
||||
$sponsor = newsletterConsultant(['email' => 'sponsor@example.com']);
|
||||
newsletterCustomer(['billing_email' => 'zugeordnet@example.com', 'member_id' => $sponsor->id]);
|
||||
newsletterCustomer(['billing_email' => 'ohne@example.com', 'member_id' => null]);
|
||||
newsletterConsultant(['email' => 'firstline@example.com', 'm_sponsor' => $sponsor->id]);
|
||||
|
||||
expect(newsletterBuild(['source' => 'both', 'member_id' => (string) $sponsor->id])->rows->pluck('email')->all())
|
||||
->toEqualCanonicalizing(['zugeordnet@example.com', 'firstline@example.com'])
|
||||
->and(newsletterBuild(['source' => 'customers', 'member_id' => 'none'])->rows->pluck('email')->all())
|
||||
->toBe(['ohne@example.com']);
|
||||
});
|
||||
|
||||
it('filtert nach Anlagedatum und Firma', function () {
|
||||
newsletterCustomer(['billing_email' => 'alt@example.com', 'billing_company' => 'Firma GmbH', 'created_at' => '2025-01-15 10:00:00']);
|
||||
newsletterCustomer(['billing_email' => 'neu@example.com', 'billing_company' => '', 'created_at' => '2026-03-01 10:00:00']);
|
||||
newsletterConsultant(['email' => 'berater-firma@example.com'], ['company' => 'Beratung UG']);
|
||||
newsletterConsultant(['email' => 'berater-privat@example.com'], ['company' => null]);
|
||||
|
||||
expect(newsletterBuild(['created_from' => '2026-01-01', 'created_to' => '2026-12-31'])->rows->pluck('email')->all())
|
||||
->toBe(['neu@example.com'])
|
||||
->and(newsletterBuild(['source' => 'both', 'company' => 'with'])->rows->pluck('email')->all())
|
||||
->toEqualCanonicalizing(['alt@example.com', 'berater-firma@example.com'])
|
||||
->and(newsletterBuild(['source' => 'both', 'company' => 'without'])->rows->pluck('email')->all())
|
||||
->toEqualCanonicalizing(['neu@example.com', 'berater-privat@example.com']);
|
||||
});
|
||||
|
||||
it('filtert Berater nach Status und Level und gibt den Status in der Zeile aus', function () {
|
||||
$level = UserLevel::forceCreate(['name' => 'Starter', 'pos' => 1]);
|
||||
newsletterConsultant(['email' => 'aktiv@example.com', 'm_level' => $level->id]);
|
||||
newsletterConsultant(['email' => 'inaktiv@example.com', 'active' => 0]);
|
||||
newsletterConsultant(['email' => 'anderes-level@example.com']);
|
||||
|
||||
$active = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'active']);
|
||||
$inactive = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'inactive']);
|
||||
$all = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'all']);
|
||||
$byLevel = newsletterBuild(['source' => 'consultants', 'consultant_status' => 'all', 'level_ids' => [$level->id]]);
|
||||
|
||||
expect($active->rows->pluck('email')->all())->toEqualCanonicalizing(['aktiv@example.com', 'anderes-level@example.com'])
|
||||
->and($inactive->rows->pluck('email')->all())->toBe(['inaktiv@example.com'])
|
||||
->and($inactive->rows->first()['status'])->toBe('inactive')
|
||||
->and($inactive->rows->first()['optin'])->toBe('contract')
|
||||
->and($all->rows)->toHaveCount(3)
|
||||
->and($byLevel->rows->pluck('email')->all())->toBe(['aktiv@example.com'])
|
||||
->and($byLevel->rows->first()['level'])->toBe('Starter');
|
||||
});
|
||||
|
||||
it('liefert Export-Kopfzeile und Zeilen in Vorlagen-Reihenfolge, optional nur die neun Vorlage-Spalten', function () {
|
||||
$consultant = newsletterConsultant([], ['first_name' => 'Anna', 'last_name' => 'Sponsor']);
|
||||
newsletterCustomer([
|
||||
'billing_email' => 'export@example.com',
|
||||
'billing_firstname' => 'Erika',
|
||||
'billing_lastname' => 'Muster',
|
||||
'billing_company' => 'Muster GmbH',
|
||||
'member_id' => $consultant->id,
|
||||
]);
|
||||
$service = app(NewsletterListService::class);
|
||||
$rows = newsletterBuild()->rows;
|
||||
|
||||
$fullHeaders = $service->exportHeaders(false);
|
||||
$templateHeaders = $service->exportHeaders(true);
|
||||
$templateRows = $service->exportRows($rows, true);
|
||||
$fullRows = $service->exportRows($rows, false);
|
||||
|
||||
expect($templateHeaders)->toBe(['Emailadresse', 'Vorname', 'Nachname', 'Anrede', 'Straße', 'PLZ', 'Wohnort', 'Firma', 'Berater'])
|
||||
->and($fullHeaders)->toHaveCount(17)
|
||||
->and($fullHeaders[9])->toBe('Land')
|
||||
->and($templateRows[0])->toBe(['export@example.com', 'Erika', 'Muster', 'Frau', 'Musterweg 1', '12345', 'Musterstadt', 'Muster GmbH', 'Anna Sponsor'])
|
||||
->and($fullRows[0])->toHaveCount(17)
|
||||
->and($fullRows[0][9])->toBe('DE')
|
||||
->and($fullRows[0][11])->toBe('Kunde')
|
||||
->and($fullRows[0][14])->toBe('ja');
|
||||
});
|
||||
|
||||
it('baut den Filter mit Defaults und akzeptiert nur erlaubte Werte', function () {
|
||||
$default = NewsletterListFilter::fromArray([]);
|
||||
$custom = NewsletterListFilter::fromArray([
|
||||
'source' => 'unsinn',
|
||||
'language' => '',
|
||||
'country_ids' => ['3', '', '3', 'x'],
|
||||
'purchase' => 'months_6',
|
||||
'member_id' => '12',
|
||||
'created_from' => '2026-01-01',
|
||||
'created_to' => 'kein datum',
|
||||
'include_admins' => '1',
|
||||
]);
|
||||
|
||||
expect($default->source)->toBe('customers')
|
||||
->and($default->language)->toBe('de')
|
||||
->and($default->optin)->toBe('all')
|
||||
->and($default->consultantStatus)->toBe('active')
|
||||
->and($custom->source)->toBe('customers')
|
||||
->and($custom->language)->toBeNull()
|
||||
->and($custom->countryIds)->toBe([3, 0])
|
||||
->and($custom->purchaseMonths())->toBe(6)
|
||||
->and($custom->memberIdValue())->toBe(12)
|
||||
->and($custom->createdFrom?->format('Y-m-d H:i:s'))->toBe('2026-01-01 00:00:00')
|
||||
->and($custom->createdTo)->toBeNull()
|
||||
->and($custom->includeAdmins)->toBeTrue()
|
||||
->and($default->cacheKey())->not->toBe($custom->cacheKey());
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue