>|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> $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 $candidate * @param array $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 */ 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 */ 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> $rows * @return array> */ 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 */ 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 */ 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 $row * @return array */ 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> */ 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, '')); } } }