mivita/app/Services/Newsletter/NewsletterListService.php
Kevin d21d94a060 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>
2026-09-23 15:44:09 +00:00

573 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Newsletter;
use App\Models\Country;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserLevel;
use App\Services\HTMLHelper;
use App\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* Stellt Newsletter-Empfängerlisten aus Kunden (shopping_users) und Beratern
* (users + user_accounts) zusammen. Pflichtausschlüsse (gelöscht, anonymisiert,
* Fake-Mails, Test-Datensätze) sind fest verdrahtet, alles andere kommt aus dem Filter.
* Doppelte E-Mails werden über beide Quellen hinweg entfernt.
*/
class NewsletterListService
{
public const SOURCE_CUSTOMER = 'customer';
public const SOURCE_CONSULTANT = 'consultant';
public const OPTIN_YES = 'yes';
public const OPTIN_NO = 'no';
public const OPTIN_CONTRACT = 'contract';
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
private const CACHE_TTL_SECONDS = 300;
private const CHUNK_SIZE = 1000;
/**
* @var array<string, array<string, string>>|null
*/
private ?array $labels = null;
private ?string $fallbackLocale = null;
/**
* Spaltenschlüssel in Export-Reihenfolge. Die ersten neun entsprechen der Vorlage
* "Kundenliste" (rapidmail), der Rest sind Zusatzspalten.
*
* @var string[]
*/
private const TEMPLATE_COLUMNS = [
'email', 'firstname', 'lastname', 'salutation_label', 'street', 'zipcode', 'city', 'company', 'consultant',
];
/**
* @var string[]
*/
private const EXTRA_COLUMNS = [
'country', 'language', 'source_label', 'number', 'consultant_number', 'optin_label', 'status_label', 'created_at',
];
public function build(NewsletterListFilter $filter): NewsletterListResult
{
$stats = [
'customers_raw' => 0,
'consultants_raw' => 0,
'invalid' => 0,
'duplicates' => 0,
'total' => 0,
'customers' => 0,
'consultants' => 0,
];
$valid = 0;
/** @var array<string, array<string, mixed>> $best */
$best = [];
$collect = function (array $row) use (&$best, &$valid, &$stats): void {
if (! self::isValidEmail($row['email'])) {
$stats['invalid']++;
return;
}
$valid++;
$email = $row['email'];
if (! isset($best[$email]) || self::outranks($row, $best[$email])) {
$best[$email] = $row;
}
};
if ($filter->includesConsultants()) {
foreach ($this->consultantQuery($filter)->toBase()->lazyById(self::CHUNK_SIZE, 'users.id', 'id') as $user) {
$stats['consultants_raw']++;
$collect($this->mapConsultant($user));
}
}
if ($filter->includesCustomers()) {
foreach ($this->customerQuery($filter)->toBase()->lazyById(self::CHUNK_SIZE, 'shopping_users.id', 'id') as $shoppingUser) {
$stats['customers_raw']++;
$collect($this->mapCustomer($shoppingUser));
}
}
ksort($best, SORT_NATURAL | SORT_FLAG_CASE);
$stats['duplicates'] = $valid - count($best);
$stats['total'] = count($best);
foreach ($best as $row) {
$row['source'] === self::SOURCE_CUSTOMER ? $stats['customers']++ : $stats['consultants']++;
}
return new NewsletterListResult(collect(array_values($best)), $stats);
}
/**
* Dedupe-Priorität: Berater vor Kunde, Opt-in vor ohne, Käufer vor Nicht-Käufer, dann neuester Datensatz.
*
* @param array<string, mixed> $candidate
* @param array<string, mixed> $current
*/
private static function outranks(array $candidate, array $current): bool
{
if ($candidate['priority'] !== $current['priority']) {
return $candidate['priority'] > $current['priority'];
}
return $candidate['id'] > $current['id'];
}
/**
* Zwischengespeicherte Variante für die Vorschau-Datatable, damit Blättern und
* Sortieren nicht jedes Mal die komplette Liste neu berechnen.
*/
public function cached(NewsletterListFilter $filter, int $userId, bool $refresh = false): NewsletterListResult
{
$key = 'newsletter_list:'.$userId.':'.$filter->cacheKey();
if ($refresh) {
Cache::forget($key);
}
return Cache::remember($key, self::CACHE_TTL_SECONDS, fn () => $this->build($filter));
}
/**
* @return Builder<ShoppingUser>
*/
public function customerQuery(NewsletterListFilter $filter): Builder
{
$countryName = 'bc.'.$this->countryNameColumn();
$query = ShoppingUser::query()
->select([
'shopping_users.id', 'shopping_users.billing_email', 'shopping_users.billing_firstname', 'shopping_users.billing_lastname',
'shopping_users.billing_salutation', 'shopping_users.billing_address', 'shopping_users.billing_address_2',
'shopping_users.billing_zipcode', 'shopping_users.billing_city', 'shopping_users.billing_company',
'shopping_users.language', 'shopping_users.number', 'shopping_users.subscribed', 'shopping_users.has_buyed',
'shopping_users.created_at',
'bc.code as country_code', $countryName.' as country_name',
'ma.first_name as consultant_firstname', 'ma.last_name as consultant_lastname', 'ma.m_account as consultant_number',
])
->leftJoin('countries as bc', 'bc.id', '=', 'shopping_users.billing_country_id')
->leftJoin('users as m', 'm.id', '=', 'shopping_users.member_id')
->leftJoin('user_accounts as ma', 'ma.id', '=', 'm.account_id')
->whereNull('shopping_users.user_deleted_at')
->where(fn ($q) => $q->whereNull('shopping_users.auth_user_id')->orWhere('shopping_users.auth_user_id', 0))
->where(fn ($q) => $q->whereNull('shopping_users.faker_mail')->orWhere('shopping_users.faker_mail', 0))
->whereNotNull('shopping_users.billing_email')
->where('shopping_users.billing_email', '!=', '')
->where('shopping_users.billing_email', 'not like', '%@dsgvo.invalid')
->where('shopping_users.billing_email', 'not like', '%faker@mivita.care')
->where(fn ($q) => $q->whereNull('shopping_users.mode')->orWhere('shopping_users.mode', 'live'));
$this->applyLanguage($query, 'shopping_users.language', $filter->language);
if ($filter->countryIds !== []) {
$query->whereIn('shopping_users.billing_country_id', $filter->countryIds);
}
if ($filter->optin === NewsletterListFilter::OPTIN_SUBSCRIBED) {
$query->where('shopping_users.subscribed', 1);
} elseif ($filter->optin === NewsletterListFilter::OPTIN_UNSUBSCRIBED) {
$query->where(fn ($q) => $q->whereNull('shopping_users.subscribed')->orWhere('shopping_users.subscribed', 0));
}
$this->applyPurchase($query, $filter);
if ($filter->memberIdIsNone()) {
$query->where(fn ($q) => $q->whereNull('shopping_users.member_id')->orWhere('shopping_users.member_id', 0));
} elseif ($filter->memberIdValue() !== null) {
$query->where('shopping_users.member_id', $filter->memberIdValue());
}
$this->applyCreatedRange($query, 'shopping_users.created_at', $filter);
$this->applyCompany($query, 'shopping_users.billing_company', $filter->company);
return $query;
}
/**
* @return Builder<User>
*/
public function consultantQuery(NewsletterListFilter $filter): Builder
{
$countryName = 'c.'.$this->countryNameColumn();
$query = User::query()
->select([
'users.id', 'users.email', 'users.lang', 'users.active', 'users.created_at',
'a.salutation', 'a.first_name', 'a.last_name', 'a.address', 'a.address_2', 'a.zipcode', 'a.city', 'a.company',
'a.m_account as number',
'c.code as country_code', $countryName.' as country_name',
'sa.first_name as consultant_firstname', 'sa.last_name as consultant_lastname', 'sa.m_account as consultant_number',
'l.name as level',
])
->leftJoin('user_accounts as a', 'a.id', '=', 'users.account_id')
->leftJoin('countries as c', 'c.id', '=', 'a.country_id')
->leftJoin('users as s', 's.id', '=', 'users.m_sponsor')
->leftJoin('user_accounts as sa', 'sa.id', '=', 's.account_id')
->leftJoin('user_levels as l', 'l.id', '=', 'users.m_level')
->whereNull('users.pre_deleted_at')
->where(fn ($q) => $q->whereNull('users.blocked')->orWhere('users.blocked', 0))
->where('users.admin', '<', $filter->includeAdmins ? 5 : 2)
->where(fn ($q) => $q->whereNull('users.test_mode')->orWhere('users.test_mode', 0))
->whereNotNull('users.email')
->where('users.email', '!=', '')
->where('users.email', 'not like', 'delete-%');
$this->applyLanguage($query, 'users.lang', $filter->language);
if ($filter->countryIds !== []) {
$query->whereIn('a.country_id', $filter->countryIds);
}
if ($filter->consultantStatus === NewsletterListFilter::CONSULTANT_STATUS_ACTIVE) {
$query->where('users.active', 1);
} elseif ($filter->consultantStatus === NewsletterListFilter::CONSULTANT_STATUS_INACTIVE) {
$query->where(fn ($q) => $q->whereNull('users.active')->orWhere('users.active', 0));
}
if ($filter->levelIds !== []) {
$query->whereIn('users.m_level', $filter->levelIds);
}
if ($filter->memberIdIsNone()) {
$query->where(fn ($q) => $q->whereNull('users.m_sponsor')->orWhere('users.m_sponsor', 0));
} elseif ($filter->memberIdValue() !== null) {
$query->where('users.m_sponsor', $filter->memberIdValue());
}
$this->applyCreatedRange($query, 'users.created_at', $filter);
$this->applyCompany($query, 'a.company', $filter->company);
return $query;
}
/**
* Kopfzeile für XLSX/CSV.
*
* @return string[]
*/
public function exportHeaders(bool $templateOnly): array
{
return array_map(
fn (string $column) => __('newsletter.export.'.$column),
$this->exportColumns($templateOnly)
);
}
/**
* Zeilen für XLSX/CSV in Kopfzeilen-Reihenfolge.
*
* @param Collection<int, array<string, mixed>> $rows
* @return array<int, array<int, string>>
*/
public function exportRows(Collection $rows, bool $templateOnly): array
{
$columns = $this->exportColumns($templateOnly);
return $rows
->map(fn (array $row) => array_map(fn (string $column) => (string) ($row[$column] ?? ''), $columns))
->values()
->all();
}
/**
* @return string[]
*/
public function exportColumns(bool $templateOnly): array
{
return $templateOnly ? self::TEMPLATE_COLUMNS : array_merge(self::TEMPLATE_COLUMNS, self::EXTRA_COLUMNS);
}
/**
* Lesbare Kurzfassung eines Filters für Listen-Übersicht und Protokoll,
* z. B. "Kunden · Sprache: DE · Land: AT, CH · Opt-in: nur mit Opt-in".
*/
public function describe(NewsletterListFilter $filter): string
{
$parts = [__('newsletter.source_option.'.$filter->source)];
$parts[] = __('newsletter.filter.language').': '.($filter->language === null ? __('newsletter.filter.all') : strtoupper($filter->language));
if ($filter->countryIds !== []) {
$codes = Country::query()->whereIn('id', $filter->countryIds)->orderBy('code')->pluck('code')->all();
$parts[] = __('newsletter.filter.country').': '.implode(', ', $codes);
}
if ($filter->includesCustomers()) {
if ($filter->optin !== NewsletterListFilter::OPTIN_ALL) {
$parts[] = __('newsletter.filter.optin').': '.__('newsletter.optin_option.'.$filter->optin);
}
if ($filter->purchase !== NewsletterListFilter::PURCHASE_ALL) {
$parts[] = __('newsletter.filter.purchase').': '.__('newsletter.purchase_option.'.$filter->purchase);
}
}
if ($filter->memberIdIsNone()) {
$parts[] = __('newsletter.filter.member').': '.__('newsletter.filter.none_member');
} elseif ($filter->memberIdValue() !== null) {
$account = UserAccount::query()
->join('users', 'users.account_id', '=', 'user_accounts.id')
->where('users.id', $filter->memberIdValue())
->first(['user_accounts.first_name', 'user_accounts.last_name']);
$parts[] = __('newsletter.filter.member').': '.($account ? self::fullName($account->first_name, $account->last_name) : '#'.$filter->memberIdValue());
}
if ($filter->createdFrom !== null || $filter->createdTo !== null) {
$parts[] = __('newsletter.filter.created_from').': '.($filter->createdFrom?->format('d.m.Y') ?? '…').' – '.($filter->createdTo?->format('d.m.Y') ?? '…');
}
if ($filter->company !== NewsletterListFilter::COMPANY_ALL) {
$parts[] = __('newsletter.filter.company').': '.__('newsletter.company_option.'.$filter->company);
}
if ($filter->includesConsultants()) {
$parts[] = __('newsletter.filter.consultant_status').': '.__('newsletter.consultant_status_option.'.$filter->consultantStatus);
if ($filter->levelIds !== []) {
$names = UserLevel::query()->whereIn('id', $filter->levelIds)->orderBy('pos')->pluck('name')->all();
$parts[] = __('newsletter.filter.level').': '.implode(', ', $names);
}
if ($filter->includeAdmins) {
$parts[] = __('newsletter.filter.include_admins');
}
}
return implode(' · ', $parts);
}
public static function normalizeEmail(?string $email): string
{
return mb_strtolower(trim((string) $email));
}
public static function isValidEmail(string $email): bool
{
return $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* @return array<string, mixed>
*/
private function mapCustomer(object $row): array
{
$subscribed = (bool) $row->subscribed;
$hasBuyed = (bool) $row->has_buyed;
return $this->row([
'id' => (int) $row->id,
'source' => self::SOURCE_CUSTOMER,
'email' => self::normalizeEmail($row->billing_email),
'firstname' => trim((string) $row->billing_firstname),
'lastname' => trim((string) $row->billing_lastname),
'salutation' => (string) $row->billing_salutation,
'street' => self::joinAddress($row->billing_address, $row->billing_address_2),
'zipcode' => trim((string) $row->billing_zipcode),
'city' => trim((string) $row->billing_city),
'company' => trim((string) $row->billing_company),
'consultant' => self::fullName($row->consultant_firstname, $row->consultant_lastname),
'consultant_number' => (string) ($row->consultant_number ?? ''),
'country' => (string) ($row->country_code ?? ''),
'country_name' => (string) ($row->country_name ?? ''),
'language' => $this->languageOrFallback($row->language),
'number' => (string) ($row->number ?? ''),
'optin' => $subscribed ? self::OPTIN_YES : self::OPTIN_NO,
'status' => self::STATUS_ACTIVE,
'created_at' => self::dateOnly($row->created_at),
'priority' => 100 + ($subscribed ? 10 : 0) + ($hasBuyed ? 1 : 0),
]);
}
/**
* @return array<string, mixed>
*/
private function mapConsultant(object $row): array
{
$active = (bool) $row->active;
return $this->row([
'id' => (int) $row->id,
'source' => self::SOURCE_CONSULTANT,
'email' => self::normalizeEmail($row->email),
'firstname' => trim((string) $row->first_name),
'lastname' => trim((string) $row->last_name),
'salutation' => (string) ($row->salutation ?? ''),
'street' => self::joinAddress($row->address, $row->address_2),
'zipcode' => trim((string) $row->zipcode),
'city' => trim((string) $row->city),
'company' => trim((string) $row->company),
'consultant' => self::fullName($row->consultant_firstname, $row->consultant_lastname),
'consultant_number' => (string) ($row->consultant_number ?? ''),
'country' => (string) ($row->country_code ?? ''),
'country_name' => (string) ($row->country_name ?? ''),
'language' => $this->languageOrFallback($row->lang),
'number' => (string) ($row->number ?? ''),
'optin' => self::OPTIN_CONTRACT,
'status' => $active ? self::STATUS_ACTIVE : self::STATUS_INACTIVE,
'level' => (string) ($row->level ?? ''),
'created_at' => self::dateOnly($row->created_at),
'priority' => 1000 + ($active ? 1 : 0),
]);
}
/**
* Ergänzt die sprachabhängigen Anzeige-Spalten, damit Vorschau und Export dieselben Werte zeigen.
*
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function row(array $row): array
{
$labels = $this->labels();
$row['salutation_label'] = $labels['salutation'][$row['salutation']] ?? '';
$row['source_label'] = $labels['source'][$row['source']] ?? '';
$row['optin_label'] = $labels['optin'][$row['optin']] ?? '';
$row['status_label'] = $labels['status'][$row['status']] ?? '';
$row['level'] ??= '';
return $row;
}
/**
* Übersetzungen einmal pro Lauf auflösen statt pro Zeile (bei 70k Zeilen spürbar).
*
* @return array<string, array<string, string>>
*/
private function labels(): array
{
return $this->labels ??= [
'salutation' => [
'mr' => HTMLHelper::getSalutationLang('mr'),
'ms' => HTMLHelper::getSalutationLang('ms'),
'di' => HTMLHelper::getSalutationLang('di'),
],
'source' => [
self::SOURCE_CUSTOMER => __('newsletter.source.customer'),
self::SOURCE_CONSULTANT => __('newsletter.source.consultant'),
],
'optin' => [
self::OPTIN_YES => __('newsletter.optin_value.yes'),
self::OPTIN_NO => __('newsletter.optin_value.no'),
self::OPTIN_CONTRACT => __('newsletter.optin_value.contract'),
],
'status' => [
self::STATUS_ACTIVE => __('newsletter.status_value.active'),
self::STATUS_INACTIVE => __('newsletter.status_value.inactive'),
],
];
}
private static function joinAddress(?string $address, ?string $address2): string
{
return trim(implode(', ', array_filter([trim((string) $address), trim((string) $address2)])));
}
private static function fullName(?string $firstname, ?string $lastname): string
{
return trim(trim((string) $firstname).' '.trim((string) $lastname));
}
private function languageOrFallback(?string $language): string
{
$language = trim((string) $language);
return $language !== '' ? $language : ($this->fallbackLocale ??= (string) config('app.fallback_locale', 'de'));
}
private static function dateOnly(mixed $value): string
{
if ($value === null || $value === '') {
return '';
}
return substr((string) $value, 0, 10);
}
/**
* Spalte der countries-Tabelle mit dem Ländernamen in der aktuellen Oberflächensprache.
*/
private function countryNameColumn(): string
{
$locale = (string) app()->getLocale();
return in_array($locale, ['de', 'en', 'es', 'fr', 'it', 'ru'], true) ? $locale : 'de';
}
/**
* Sprache "de" schließt Datensätze ohne gesetzte Sprache ein, weil das System
* dort auf die Fallback-Sprache zurückfällt.
*/
private function applyLanguage(Builder $query, string $column, ?string $language): void
{
if ($language === null) {
return;
}
if ($language === config('app.fallback_locale', 'de')) {
$query->where(fn ($q) => $q->where($column, $language)->orWhereNull($column)->orWhere($column, ''));
return;
}
$query->where($column, $language);
}
private function applyPurchase(Builder $query, NewsletterListFilter $filter): void
{
$months = $filter->purchaseMonths();
if ($months !== null) {
$query->whereHas('shopping_orders', fn ($q) => $q
->where('paid', 1)
->where('created_at', '>=', now()->subMonths($months)));
return;
}
match ($filter->purchase) {
NewsletterListFilter::PURCHASE_HAS_BUYED => $query->where('shopping_users.has_buyed', 1),
NewsletterListFilter::PURCHASE_NONE => $query->where(fn ($q) => $q->whereNull('shopping_users.has_buyed')->orWhere('shopping_users.has_buyed', 0)),
NewsletterListFilter::PURCHASE_ACTIVE_ABO => $query->whereIn(
'shopping_users.id',
UserAbo::query()->activeAbo()->whereNotNull('shopping_user_id')->select('shopping_user_id')
),
default => null,
};
}
private function applyCreatedRange(Builder $query, string $column, NewsletterListFilter $filter): void
{
if ($filter->createdFrom !== null) {
$query->where($column, '>=', $filter->createdFrom);
}
if ($filter->createdTo !== null) {
$query->where($column, '<=', $filter->createdTo);
}
}
private function applyCompany(Builder $query, string $column, string $company): void
{
if ($company === NewsletterListFilter::COMPANY_WITH) {
$query->whereNotNull($column)->where($column, '!=', '');
} elseif ($company === NewsletterListFilter::COMPANY_WITHOUT) {
$query->where(fn ($q) => $q->whereNull($column)->orWhere($column, ''));
}
}
}