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
247
app/Services/Newsletter/NewsletterListFilter.php
Normal file
247
app/Services/Newsletter/NewsletterListFilter.php
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Newsletter;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Wertobjekt für die Filter der Newsletter-Listen. Wird aus dem validierten
|
||||
* Request gebaut und ist die einzige Quelle für Defaults und erlaubte Werte.
|
||||
*/
|
||||
final class NewsletterListFilter
|
||||
{
|
||||
public const SOURCE_CUSTOMERS = 'customers';
|
||||
|
||||
public const SOURCE_CONSULTANTS = 'consultants';
|
||||
|
||||
public const SOURCE_BOTH = 'both';
|
||||
|
||||
public const OPTIN_ALL = 'all';
|
||||
|
||||
public const OPTIN_SUBSCRIBED = 'subscribed';
|
||||
|
||||
public const OPTIN_UNSUBSCRIBED = 'unsubscribed';
|
||||
|
||||
public const PURCHASE_ALL = 'all';
|
||||
|
||||
public const PURCHASE_HAS_BUYED = 'has_buyed';
|
||||
|
||||
public const PURCHASE_NONE = 'none';
|
||||
|
||||
public const PURCHASE_MONTHS_3 = 'months_3';
|
||||
|
||||
public const PURCHASE_MONTHS_6 = 'months_6';
|
||||
|
||||
public const PURCHASE_MONTHS_12 = 'months_12';
|
||||
|
||||
public const PURCHASE_ACTIVE_ABO = 'active_abo';
|
||||
|
||||
public const COMPANY_ALL = 'all';
|
||||
|
||||
public const COMPANY_WITH = 'with';
|
||||
|
||||
public const COMPANY_WITHOUT = 'without';
|
||||
|
||||
public const CONSULTANT_STATUS_ACTIVE = 'active';
|
||||
|
||||
public const CONSULTANT_STATUS_INACTIVE = 'inactive';
|
||||
|
||||
public const CONSULTANT_STATUS_ALL = 'all';
|
||||
|
||||
public const MEMBER_NONE = 'none';
|
||||
|
||||
/**
|
||||
* @param int[] $countryIds
|
||||
* @param int[] $levelIds
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $source = self::SOURCE_CUSTOMERS,
|
||||
public readonly ?string $language = 'de',
|
||||
public readonly array $countryIds = [],
|
||||
public readonly string $optin = self::OPTIN_ALL,
|
||||
public readonly string $purchase = self::PURCHASE_ALL,
|
||||
public readonly ?string $memberId = null,
|
||||
public readonly ?Carbon $createdFrom = null,
|
||||
public readonly ?Carbon $createdTo = null,
|
||||
public readonly string $company = self::COMPANY_ALL,
|
||||
public readonly string $consultantStatus = self::CONSULTANT_STATUS_ACTIVE,
|
||||
public readonly array $levelIds = [],
|
||||
public readonly bool $includeAdmins = false,
|
||||
public readonly bool $templateOnly = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function sources(): array
|
||||
{
|
||||
return [self::SOURCE_CUSTOMERS, self::SOURCE_CONSULTANTS, self::SOURCE_BOTH];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function optinOptions(): array
|
||||
{
|
||||
return [self::OPTIN_ALL, self::OPTIN_SUBSCRIBED, self::OPTIN_UNSUBSCRIBED];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function purchaseOptions(): array
|
||||
{
|
||||
return [
|
||||
self::PURCHASE_ALL,
|
||||
self::PURCHASE_HAS_BUYED,
|
||||
self::PURCHASE_NONE,
|
||||
self::PURCHASE_MONTHS_3,
|
||||
self::PURCHASE_MONTHS_6,
|
||||
self::PURCHASE_MONTHS_12,
|
||||
self::PURCHASE_ACTIVE_ABO,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function companyOptions(): array
|
||||
{
|
||||
return [self::COMPANY_ALL, self::COMPANY_WITH, self::COMPANY_WITHOUT];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function consultantStatusOptions(): array
|
||||
{
|
||||
return [self::CONSULTANT_STATUS_ACTIVE, self::CONSULTANT_STATUS_INACTIVE, self::CONSULTANT_STATUS_ALL];
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den Filter aus validierten Request-Daten. Fehlende Werte fallen auf die Defaults zurück;
|
||||
* ein leerer String bedeutet bewusst "kein Filter" (z. B. Sprache "alle").
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
*/
|
||||
public static function fromArray(array $input): self
|
||||
{
|
||||
$language = array_key_exists('language', $input) ? (string) $input['language'] : 'de';
|
||||
$memberId = isset($input['member_id']) && $input['member_id'] !== '' ? (string) $input['member_id'] : null;
|
||||
|
||||
return new self(
|
||||
source: self::pick($input, 'source', self::sources(), self::SOURCE_CUSTOMERS),
|
||||
language: $language === '' ? null : $language,
|
||||
countryIds: self::intList($input['country_ids'] ?? []),
|
||||
optin: self::pick($input, 'optin', self::optinOptions(), self::OPTIN_ALL),
|
||||
purchase: self::pick($input, 'purchase', self::purchaseOptions(), self::PURCHASE_ALL),
|
||||
memberId: $memberId,
|
||||
createdFrom: self::date($input['created_from'] ?? null)?->startOfDay(),
|
||||
createdTo: self::date($input['created_to'] ?? null)?->endOfDay(),
|
||||
company: self::pick($input, 'company', self::companyOptions(), self::COMPANY_ALL),
|
||||
consultantStatus: self::pick($input, 'consultant_status', self::consultantStatusOptions(), self::CONSULTANT_STATUS_ACTIVE),
|
||||
levelIds: self::intList($input['level_ids'] ?? []),
|
||||
includeAdmins: filter_var($input['include_admins'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
templateOnly: filter_var($input['template_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'source' => $this->source,
|
||||
'language' => $this->language ?? '',
|
||||
'country_ids' => $this->countryIds,
|
||||
'optin' => $this->optin,
|
||||
'purchase' => $this->purchase,
|
||||
'member_id' => $this->memberId ?? '',
|
||||
'created_from' => $this->createdFrom?->format('Y-m-d') ?? '',
|
||||
'created_to' => $this->createdTo?->format('Y-m-d') ?? '',
|
||||
'company' => $this->company,
|
||||
'consultant_status' => $this->consultantStatus,
|
||||
'level_ids' => $this->levelIds,
|
||||
'include_admins' => $this->includeAdmins,
|
||||
'template_only' => $this->templateOnly,
|
||||
];
|
||||
}
|
||||
|
||||
public function includesCustomers(): bool
|
||||
{
|
||||
return $this->source !== self::SOURCE_CONSULTANTS;
|
||||
}
|
||||
|
||||
public function includesConsultants(): bool
|
||||
{
|
||||
return $this->source !== self::SOURCE_CUSTOMERS;
|
||||
}
|
||||
|
||||
public function purchaseMonths(): ?int
|
||||
{
|
||||
return match ($this->purchase) {
|
||||
self::PURCHASE_MONTHS_3 => 3,
|
||||
self::PURCHASE_MONTHS_6 => 6,
|
||||
self::PURCHASE_MONTHS_12 => 12,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function memberIdIsNone(): bool
|
||||
{
|
||||
return $this->memberId === self::MEMBER_NONE;
|
||||
}
|
||||
|
||||
public function memberIdValue(): ?int
|
||||
{
|
||||
return $this->memberId !== null && ctype_digit($this->memberId) ? (int) $this->memberId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stabiler Schlüssel für das Zwischenspeichern der Ergebnisliste (Export-Option zählt nicht mit).
|
||||
*/
|
||||
public function cacheKey(): string
|
||||
{
|
||||
$data = $this->toArray();
|
||||
unset($data['template_only']);
|
||||
|
||||
return md5(json_encode($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @param string[] $allowed
|
||||
*/
|
||||
private static function pick(array $input, string $key, array $allowed, string $default): string
|
||||
{
|
||||
$value = (string) ($input[$key] ?? '');
|
||||
|
||||
return in_array($value, $allowed, true) ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private static function intList(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
$value = $value === null || $value === '' ? [] : [$value];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_map('intval', array_filter($value, fn ($v) => $v !== '' && $v !== null))));
|
||||
}
|
||||
|
||||
private static function date(mixed $value): ?Carbon
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse((string) $value);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/Services/Newsletter/NewsletterListResult.php
Normal file
21
app/Services/Newsletter/NewsletterListResult.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Newsletter;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Ergebnis einer Listen-Berechnung: deduplizierte Zeilen plus Kennzahlen,
|
||||
* die erklären, warum Datensätze weggefallen sind.
|
||||
*/
|
||||
final class NewsletterListResult
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $rows
|
||||
* @param array{customers_raw:int, consultants_raw:int, invalid:int, duplicates:int, total:int, customers:int, consultants:int} $stats
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Collection $rows,
|
||||
public readonly array $stats,
|
||||
) {}
|
||||
}
|
||||
573
app/Services/Newsletter/NewsletterListService.php
Normal file
573
app/Services/Newsletter/NewsletterListService.php
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
<?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, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue