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
303
app/Http/Controllers/Admin/NewsletterListController.php
Normal file
303
app/Http/Controllers/Admin/NewsletterListController.php
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Exports\xExport;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Newsletter\NewsletterListFilterRequest;
|
||||
use App\Http\Requests\Newsletter\StoreNewsletterListRequest;
|
||||
use App\Models\Country;
|
||||
use App\Models\NewsletterList;
|
||||
use App\Models\NewsletterListExport;
|
||||
use App\Models\UserLevel;
|
||||
use App\Services\Newsletter\NewsletterListFilter;
|
||||
use App\Services\Newsletter\NewsletterListResult;
|
||||
use App\Services\Newsletter\NewsletterListService;
|
||||
use App\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Yajra\DataTables\Facades\DataTables;
|
||||
|
||||
class NewsletterListController extends Controller
|
||||
{
|
||||
private const FILTER_SETTING_KEY = 'newsletter_list_filter';
|
||||
|
||||
public function __construct(private readonly NewsletterListService $service)
|
||||
{
|
||||
$this->middleware('admin');
|
||||
}
|
||||
|
||||
public function index(NewsletterListFilterRequest $request): View|RedirectResponse
|
||||
{
|
||||
if ($request->get('reset') === 'filter') {
|
||||
set_user_attr(self::FILTER_SETTING_KEY, null);
|
||||
|
||||
return redirect(route('admin_newsletter_lists'));
|
||||
}
|
||||
|
||||
$filter = $request->query() === []
|
||||
? NewsletterListFilter::fromArray((array) (get_user_attr(self::FILTER_SETTING_KEY) ?: []))
|
||||
: $request->toFilter();
|
||||
|
||||
return $this->renderIndex($filter, $request->listId() ? NewsletterList::find($request->listId()) : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine gespeicherte Liste in die Filter-Seite.
|
||||
*/
|
||||
public function load(NewsletterList $list): View
|
||||
{
|
||||
set_user_attr(self::FILTER_SETTING_KEY, $list->toFilter()->toArray());
|
||||
|
||||
return $this->renderIndex($list->toFilter(), $list);
|
||||
}
|
||||
|
||||
public function datatable(NewsletterListFilterRequest $request): JsonResponse
|
||||
{
|
||||
$filter = $request->toFilter();
|
||||
set_user_attr(self::FILTER_SETTING_KEY, $filter->toArray());
|
||||
|
||||
$result = $this->service->cached($filter, (int) $request->user()->id, $request->boolean('refresh'));
|
||||
|
||||
return DataTables::of($result->rows)
|
||||
->editColumn('email', fn (array $row) => e($row['email']))
|
||||
->addColumn('name', fn (array $row) => e(trim($row['firstname'].' '.$row['lastname'])))
|
||||
->editColumn('optin_label', fn (array $row) => $this->optinBadge($row))
|
||||
->editColumn('status_label', fn (array $row) => $this->statusBadge($row))
|
||||
->rawColumns(['optin_label', 'status_label'])
|
||||
->with('stats', $result->stats)
|
||||
->toJson();
|
||||
}
|
||||
|
||||
public function export(NewsletterListFilterRequest $request, string $format): BinaryFileResponse|StreamedResponse
|
||||
{
|
||||
$filter = $request->toFilter();
|
||||
set_user_attr(self::FILTER_SETTING_KEY, $filter->toArray());
|
||||
|
||||
$list = $request->listId() ? NewsletterList::find($request->listId()) : null;
|
||||
|
||||
return $this->download($request->user(), $filter, $format, $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export direkt aus der Übersicht der gespeicherten Listen mit den dort hinterlegten Filtern.
|
||||
*/
|
||||
public function exportSaved(Request $request, NewsletterList $list, string $format): BinaryFileResponse|StreamedResponse
|
||||
{
|
||||
return $this->download($request->user(), $list->toFilter(), $format, $list);
|
||||
}
|
||||
|
||||
public function store(StoreNewsletterListRequest $request): RedirectResponse
|
||||
{
|
||||
$list = new NewsletterList([
|
||||
'name' => $request->listName(),
|
||||
'created_by' => $request->user()->id,
|
||||
]);
|
||||
$list->applyFilter($request->toFilter());
|
||||
$list->save();
|
||||
|
||||
return redirect(route('admin_newsletter_lists_load', [$list->id]))->with('alert-success', __('newsletter.saved.stored'));
|
||||
}
|
||||
|
||||
public function update(StoreNewsletterListRequest $request, NewsletterList $list): RedirectResponse
|
||||
{
|
||||
$list->name = $request->listName();
|
||||
$list->applyFilter($request->toFilter());
|
||||
$list->save();
|
||||
|
||||
return redirect(route('admin_newsletter_lists_load', [$list->id]))->with('alert-success', __('newsletter.saved.updated'));
|
||||
}
|
||||
|
||||
public function destroy(NewsletterList $list): RedirectResponse
|
||||
{
|
||||
$list->exports()->update(['newsletter_list_id' => null]);
|
||||
$list->delete();
|
||||
|
||||
return redirect(route('admin_newsletter_lists_saved'))->with('alert-success', __('newsletter.saved.deleted'));
|
||||
}
|
||||
|
||||
public function saved(): View
|
||||
{
|
||||
return view('admin.newsletter.saved');
|
||||
}
|
||||
|
||||
public function savedDatatable(): JsonResponse
|
||||
{
|
||||
$query = NewsletterList::query()->with('creator.account')->select('newsletter_lists.*');
|
||||
|
||||
return DataTables::eloquent($query)
|
||||
->editColumn('name', fn (NewsletterList $list) => '<a href="'.route('admin_newsletter_lists_load', [$list->id]).'">'.e($list->name).'</a>')
|
||||
->editColumn('source', fn (NewsletterList $list) => __('newsletter.source_option.'.$list->source))
|
||||
->addColumn('summary', fn (NewsletterList $list) => e($this->service->describe($list->toFilter())))
|
||||
->editColumn('last_exported_at', fn (NewsletterList $list) => $list->last_exported_at?->format('d.m.Y H:i') ?? '–')
|
||||
->editColumn('last_row_count', fn (NewsletterList $list) => $list->last_row_count === null ? '–' : (string) $list->last_row_count)
|
||||
->addColumn('creator', fn (NewsletterList $list) => e($list->creator?->getFullName(false) ?? ''))
|
||||
->editColumn('created_at', fn (NewsletterList $list) => $list->created_at?->format('d.m.Y') ?? '')
|
||||
->addColumn('actions', fn (NewsletterList $list) => $this->savedActions($list))
|
||||
->orderColumn('name', 'name $1')
|
||||
->orderColumn('last_exported_at', 'last_exported_at $1')
|
||||
->orderColumn('last_row_count', 'last_row_count $1')
|
||||
->orderColumn('created_at', 'created_at $1')
|
||||
->rawColumns(['name', 'actions'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
public function exports(): View
|
||||
{
|
||||
return view('admin.newsletter.exports');
|
||||
}
|
||||
|
||||
public function exportsDatatable(): JsonResponse
|
||||
{
|
||||
$query = NewsletterListExport::query()->with(['list', 'user.account'])->select('newsletter_list_exports.*');
|
||||
|
||||
return DataTables::eloquent($query)
|
||||
->editColumn('created_at', fn (NewsletterListExport $export) => $export->created_at?->format('d.m.Y H:i') ?? '')
|
||||
->addColumn('user', fn (NewsletterListExport $export) => e($export->user?->getFullName(false) ?? ''))
|
||||
->addColumn('list', function (NewsletterListExport $export) {
|
||||
if ($export->list) {
|
||||
return '<a href="'.route('admin_newsletter_lists_load', [$export->list->id]).'">'.e($export->list->name).'</a>';
|
||||
}
|
||||
|
||||
return '<span class="text-muted">'.e(__('newsletter.exports.adhoc')).'</span>';
|
||||
})
|
||||
->addColumn('summary', fn (NewsletterListExport $export) => e($this->service->describe(NewsletterListFilter::fromArray((array) $export->filters))))
|
||||
->editColumn('format', fn (NewsletterListExport $export) => strtoupper($export->format))
|
||||
->orderColumn('created_at', 'created_at $1')
|
||||
->orderColumn('row_count', 'row_count $1')
|
||||
->orderColumn('format', 'format $1')
|
||||
->rawColumns(['list'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
private function renderIndex(NewsletterListFilter $filter, ?NewsletterList $list): View
|
||||
{
|
||||
return view('admin.newsletter.index', [
|
||||
'filter' => $filter->toArray(),
|
||||
'list' => $list,
|
||||
'countries' => Country::query()->orderBy(app()->getLocale() === 'en' ? 'en' : 'de')->get(),
|
||||
'members' => User::query()
|
||||
->with('account')
|
||||
->whereNotNull('account_id')
|
||||
->where('admin', '<', 5)
|
||||
->get()
|
||||
->sortBy(fn (User $user) => mb_strtolower($user->getFullName(false)))
|
||||
->values(),
|
||||
'levels' => UserLevel::query()->orderBy('pos')->get(),
|
||||
'languages' => ['de', 'en', 'es', 'fr'],
|
||||
'sources' => NewsletterListFilter::sources(),
|
||||
'optin_options' => NewsletterListFilter::optinOptions(),
|
||||
'purchase_options' => NewsletterListFilter::purchaseOptions(),
|
||||
'company_options' => NewsletterListFilter::companyOptions(),
|
||||
'consultant_status_options' => NewsletterListFilter::consultantStatusOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den Download, protokolliert den Export und aktualisiert die gespeicherte Liste.
|
||||
*/
|
||||
private function download(User $user, NewsletterListFilter $filter, string $format, ?NewsletterList $list): BinaryFileResponse|StreamedResponse
|
||||
{
|
||||
$result = $this->service->build($filter);
|
||||
$headers = $this->service->exportHeaders($filter->templateOnly);
|
||||
$rows = $this->service->exportRows($result->rows, $filter->templateOnly);
|
||||
$filename = 'newsletter_'.$filter->source.'_'.($filter->language ?? 'alle').'_'.now()->format('Y-m-d').'.'.$format;
|
||||
|
||||
$this->logExport($user, $filter, $format, $filename, $result, $list);
|
||||
|
||||
if ($format === 'csv') {
|
||||
return $this->streamCsv($filename, $headers, $rows);
|
||||
}
|
||||
|
||||
return Excel::download(new xExport($rows, $headers), $filename);
|
||||
}
|
||||
|
||||
private function logExport(User $user, NewsletterListFilter $filter, string $format, string $filename, NewsletterListResult $result, ?NewsletterList $list): void
|
||||
{
|
||||
NewsletterListExport::create([
|
||||
'newsletter_list_id' => $list?->id,
|
||||
'user_id' => $user->id,
|
||||
'format' => $format,
|
||||
'row_count' => $result->stats['total'],
|
||||
'filename' => $filename,
|
||||
'filters' => $filter->toArray(),
|
||||
'stats' => $result->stats,
|
||||
]);
|
||||
|
||||
if ($list !== null) {
|
||||
$list->forceFill([
|
||||
'last_exported_at' => now(),
|
||||
'last_row_count' => $result->stats['total'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $headers
|
||||
* @param array<int, array<int, string>> $rows
|
||||
*/
|
||||
private function streamCsv(string $filename, array $headers, array $rows): StreamedResponse
|
||||
{
|
||||
return response()->streamDownload(function () use ($headers, $rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF");
|
||||
fputcsv($out, $headers, ';', '"', '\\');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, self::csvSafeRow($row), ';', '"', '\\');
|
||||
}
|
||||
fclose($out);
|
||||
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schützt vor CSV-Injection: Werte, die mit Formelzeichen beginnen, werden entschärft.
|
||||
*
|
||||
* @param array<int, string> $row
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function csvSafeRow(array $row): array
|
||||
{
|
||||
return array_map(static function (string $value) {
|
||||
return $value !== '' && str_contains("=+-@\t\r", $value[0]) ? "'".$value : $value;
|
||||
}, $row);
|
||||
}
|
||||
|
||||
private function savedActions(NewsletterList $list): string
|
||||
{
|
||||
return '<a href="'.route('admin_newsletter_lists_load', [$list->id]).'" class="btn btn-sm btn-primary" title="'.e(__('newsletter.saved.load')).'"><i class="fa fa-filter"></i></a> '
|
||||
.'<a href="'.route('admin_newsletter_lists_saved_export', [$list->id, 'xlsx']).'" class="btn btn-sm btn-success" title="XLSX"><i class="fa fa-file-excel"></i></a> '
|
||||
.'<a href="'.route('admin_newsletter_lists_saved_export', [$list->id, 'csv']).'" class="btn btn-sm btn-outline-success" title="CSV"><i class="fa fa-file-csv"></i></a> '
|
||||
.'<form method="post" action="'.route('admin_newsletter_lists_destroy', [$list->id]).'" class="d-inline" onsubmit="return confirm(\''.e(__('newsletter.saved.confirm_delete')).'\');">'
|
||||
.'<input type="hidden" name="_token" value="'.csrf_token().'">'
|
||||
.'<button type="submit" class="btn btn-sm btn-outline-danger" title="'.e(__('newsletter.saved.delete')).'"><i class="fa fa-trash-alt"></i></button></form>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function optinBadge(array $row): string
|
||||
{
|
||||
$class = match ($row['optin']) {
|
||||
NewsletterListService::OPTIN_YES => 'badge-success',
|
||||
NewsletterListService::OPTIN_CONTRACT => 'badge-info',
|
||||
default => 'badge-danger',
|
||||
};
|
||||
|
||||
return '<span class="badge badge-pill '.$class.'">'.e($row['optin_label']).'</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function statusBadge(array $row): string
|
||||
{
|
||||
$class = $row['status'] === NewsletterListService::STATUS_ACTIVE ? 'badge-success' : 'badge-secondary';
|
||||
|
||||
return '<span class="badge badge-pill '.$class.'">'.e($row['status_label']).'</span>';
|
||||
}
|
||||
}
|
||||
75
app/Http/Requests/Newsletter/NewsletterListFilterRequest.php
Normal file
75
app/Http/Requests/Newsletter/NewsletterListFilterRequest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Newsletter;
|
||||
|
||||
use App\Services\Newsletter\NewsletterListFilter;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class NewsletterListFilterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user()?->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'source' => ['nullable', Rule::in(NewsletterListFilter::sources())],
|
||||
'language' => ['nullable', 'string', 'max:2'],
|
||||
'country_ids' => ['nullable', 'array'],
|
||||
'country_ids.*' => ['integer'],
|
||||
'optin' => ['nullable', Rule::in(NewsletterListFilter::optinOptions())],
|
||||
'purchase' => ['nullable', Rule::in(NewsletterListFilter::purchaseOptions())],
|
||||
'member_id' => ['nullable', 'regex:/^(none|\d+)$/'],
|
||||
'created_from' => ['nullable', 'date'],
|
||||
'created_to' => ['nullable', 'date', 'after_or_equal:created_from'],
|
||||
'company' => ['nullable', Rule::in(NewsletterListFilter::companyOptions())],
|
||||
'consultant_status' => ['nullable', Rule::in(NewsletterListFilter::consultantStatusOptions())],
|
||||
'level_ids' => ['nullable', 'array'],
|
||||
'level_ids.*' => ['integer'],
|
||||
'include_admins' => ['nullable', 'boolean'],
|
||||
'template_only' => ['nullable', 'boolean'],
|
||||
'refresh' => ['nullable', 'boolean'],
|
||||
'list_id' => ['nullable', 'integer', 'exists:newsletter_lists,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'source.in' => __('newsletter.validation.source'),
|
||||
'optin.in' => __('newsletter.validation.option'),
|
||||
'purchase.in' => __('newsletter.validation.option'),
|
||||
'company.in' => __('newsletter.validation.option'),
|
||||
'consultant_status.in' => __('newsletter.validation.option'),
|
||||
'member_id.regex' => __('newsletter.validation.member'),
|
||||
'created_from.date' => __('newsletter.validation.date'),
|
||||
'created_to.date' => __('newsletter.validation.date'),
|
||||
'created_to.after_or_equal' => __('newsletter.validation.date_range'),
|
||||
'country_ids.*.integer' => __('newsletter.validation.option'),
|
||||
'level_ids.*.integer' => __('newsletter.validation.option'),
|
||||
'list_id.exists' => __('newsletter.validation.list'),
|
||||
'list_id.integer' => __('newsletter.validation.list'),
|
||||
];
|
||||
}
|
||||
|
||||
public function toFilter(): NewsletterListFilter
|
||||
{
|
||||
return NewsletterListFilter::fromArray($this->validated());
|
||||
}
|
||||
|
||||
public function listId(): ?int
|
||||
{
|
||||
$id = $this->validated('list_id');
|
||||
|
||||
return $id === null || $id === '' ? null : (int) $id;
|
||||
}
|
||||
}
|
||||
35
app/Http/Requests/Newsletter/StoreNewsletterListRequest.php
Normal file
35
app/Http/Requests/Newsletter/StoreNewsletterListRequest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Newsletter;
|
||||
|
||||
/**
|
||||
* Speichern einer Filterkombination unter einem Namen. Erbt die Filter-Regeln.
|
||||
*/
|
||||
class StoreNewsletterListRequest extends NewsletterListFilterRequest
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return parent::rules() + [
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return parent::messages() + [
|
||||
'name.required' => __('newsletter.validation.name'),
|
||||
'name.max' => __('newsletter.validation.name'),
|
||||
];
|
||||
}
|
||||
|
||||
public function listName(): string
|
||||
{
|
||||
return trim((string) $this->validated('name'));
|
||||
}
|
||||
}
|
||||
54
app/Models/NewsletterList.php
Normal file
54
app/Models/NewsletterList.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Newsletter\NewsletterListFilter;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Gespeicherte Filterkombination für den Newsletter-Export.
|
||||
*/
|
||||
class NewsletterList extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'source',
|
||||
'filters',
|
||||
'created_by',
|
||||
'last_exported_at',
|
||||
'last_row_count',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'filters' => 'array',
|
||||
'last_exported_at' => 'datetime',
|
||||
'last_row_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function exports(): HasMany
|
||||
{
|
||||
return $this->hasMany(NewsletterListExport::class, 'newsletter_list_id');
|
||||
}
|
||||
|
||||
public function toFilter(): NewsletterListFilter
|
||||
{
|
||||
return NewsletterListFilter::fromArray((array) $this->filters);
|
||||
}
|
||||
|
||||
public function applyFilter(NewsletterListFilter $filter): void
|
||||
{
|
||||
$this->source = $filter->source;
|
||||
$this->filters = $filter->toArray();
|
||||
}
|
||||
}
|
||||
45
app/Models/NewsletterListExport.php
Normal file
45
app/Models/NewsletterListExport.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Protokolleintrag eines Newsletter-Exports (wer, wann, welches Format, wie viele Adressen).
|
||||
*/
|
||||
class NewsletterListExport extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'newsletter_list_id',
|
||||
'user_id',
|
||||
'format',
|
||||
'row_count',
|
||||
'filename',
|
||||
'filters',
|
||||
'stats',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'filters' => 'array',
|
||||
'stats' => 'array',
|
||||
'row_count' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function list(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NewsletterList::class, 'newsletter_list_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
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, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Gespeicherte Newsletter-Listen (Filterkombinationen) und das Protokoll
|
||||
* aller Exporte, damit nachvollziehbar bleibt, wer wann welche Adressen
|
||||
* exportiert hat.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('newsletter_lists', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 120);
|
||||
$table->string('source', 20);
|
||||
$table->json('filters');
|
||||
$table->unsignedBigInteger('created_by')->nullable()->index();
|
||||
$table->timestamp('last_exported_at')->nullable();
|
||||
$table->unsignedInteger('last_row_count')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('newsletter_list_exports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('newsletter_list_id')->nullable()->constrained('newsletter_lists')->nullOnDelete();
|
||||
$table->unsignedBigInteger('user_id')->nullable()->index();
|
||||
$table->string('format', 10);
|
||||
$table->unsignedInteger('row_count');
|
||||
$table->string('filename', 190);
|
||||
$table->json('filters');
|
||||
$table->json('stats')->nullable();
|
||||
$table->timestamp('created_at')->nullable()->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('newsletter_list_exports');
|
||||
Schema::dropIfExists('newsletter_lists');
|
||||
}
|
||||
};
|
||||
394
dev/_tickets/entwicklungsplan-newsletter-listen.md
Normal file
394
dev/_tickets/entwicklungsplan-newsletter-listen.md
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
# Newsletter-Listen (Admin) – Entwicklungsplan
|
||||
|
||||
**Stand:** 11.09.2026 · **Status:** Phase 1 + 2 umgesetzt (siehe Umsetzungsstand)
|
||||
**Ticket:** Export von E-Mail-Listen (Kunden + Berater) für den Newsletter-Versand. Vorlage: `Kundenliste_Deutsch_Stand 02.07.2026.csv/.xlsx` in diesem Ordner.
|
||||
|
||||
---
|
||||
|
||||
## Umsetzungsstand (11.09.2026)
|
||||
|
||||
| Phase | Thema | Status | Anmerkungen |
|
||||
|-------|-------|--------|-------------|
|
||||
| 1 | Filter, Vorschau, XLSX/CSV, Navigation, Tests | ✅ Umgesetzt | 20 Pest-Tests grün (`tests/Feature/Newsletter/`) |
|
||||
| 2 | Gespeicherte Listen, Export-Protokoll | ✅ Umgesetzt | Migration `2026_09_11_120000_create_newsletter_lists_tables`, 9 weitere Tests |
|
||||
| 3 | Sperrliste, Abmelde-Import, `subscribed_at` | ⏳ Offen | `subscribed_at` laut Rückmeldung gewünscht |
|
||||
| 4 | Schnittstelle rapidmail | ⏳ Offen | Tool steht fest: **rapidmail** (Import-API vorhanden) |
|
||||
|
||||
### Getroffene Entscheidungen (Antworten auf Abschnitt 10)
|
||||
|
||||
1. **Opt-in-Default = „alle"**, Opt-in wird als Spalte „Newsletter" (ja / nein / Vertrag) ausgegeben; Sortierung passiert in rapidmail. Warnhinweis auf der Seite bleibt.
|
||||
2. **Berater** laufen über den Beratervertrag mit (Spalte „Newsletter" = Vertrag), kein neues Feld.
|
||||
3. **„Deutsch" = Sprache `de`**, Land frei wählbar. Datensätze ohne Sprache zählen als `de`.
|
||||
4. **Rechnungsadresse** (`billing_*`).
|
||||
5. **Inaktive Berater** sind über den Status-Filter wählbar, Spalte „Status" (aktiv / inaktiv) im Export.
|
||||
6. **Berater immer separat**, Kunden nur ohne Berater-Login (`auth_user_id` leer).
|
||||
7. **Kaufstatus**: hat gekauft / nie gekauft / 3 / 6 / 12 Monate / aktives Abo als eigene Option.
|
||||
8. Newsletter-Tool: **rapidmail**.
|
||||
9. `subscribed_at` → Phase 3.
|
||||
10. Homeparty-Kunden bleiben draußen.
|
||||
|
||||
### Umgesetzte Dateien (Phase 1)
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `app/Services/Newsletter/NewsletterListFilter.php` | Wertobjekt für alle Filter, Defaults, erlaubte Werte, Cache-Key |
|
||||
| `app/Services/Newsletter/NewsletterListService.php` | Queries (Joins statt Eager Loading), Pflichtausschlüsse, Dedupe in einem Durchlauf, Export-Spalten |
|
||||
| `app/Services/Newsletter/NewsletterListResult.php` | Zeilen + Kennzahlen |
|
||||
| `app/Http/Requests/Newsletter/NewsletterListFilterRequest.php` | Validierung, `authorize()` nur Admin |
|
||||
| `app/Http/Controllers/Admin/NewsletterListController.php` | `index`, `datatable` (Yajra auf Collection, 5-Minuten-Cache je Filter), `export` (xlsx via `xExport`, csv via Stream) |
|
||||
| `routes/domains/crm.php` | `admin_newsletter_lists`, `…_datatable`, `…_export/{xlsx|csv}` im `admin`-Block |
|
||||
| `resources/views/admin/newsletter/index.blade.php` | Filter-Card, Kennzahlen, Hinweise, DataTable, Export-Buttons |
|
||||
| `resources/views/layouts/includes/layout-sidenav.blade.php` | Menüpunkt „Newsletter-Listen" unter „Kunden" |
|
||||
| `resources/lang/{de,en,es,fr}/newsletter.php`, `navigation.php` | Labels |
|
||||
| `tests/Feature/Newsletter/NewsletterListServiceTest.php`, `NewsletterListControllerTest.php` | Ausschlüsse, Dedupe, alle Filter, Export-Format, Zugriff, Validierung |
|
||||
|
||||
### Umgesetzte Dateien (Phase 2)
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `database/migrations/2026_09_11_120000_create_newsletter_lists_tables.php` | Tabellen `newsletter_lists` (Name, Quelle, Filter-JSON, Ersteller, letzter Export) und `newsletter_list_exports` (Protokoll: Benutzer, Format, Anzahl, Dateiname, Filter-Snapshot, Kennzahlen) |
|
||||
| `app/Models/NewsletterList.php`, `app/Models/NewsletterListExport.php` | Models mit Casts und Relationen; `NewsletterList::toFilter()` / `applyFilter()` |
|
||||
| `app/Http/Requests/Newsletter/StoreNewsletterListRequest.php` | Filter-Regeln + Pflichtfeld `name` |
|
||||
| `NewsletterListController` | neu: `store`, `update`, `destroy`, `load`, `saved` + Datatable, `exports` + Datatable, `exportSaved`; jeder Download läuft über `download()` und wird protokolliert |
|
||||
| `NewsletterListService::describe()` | lesbare Filter-Kurzfassung für Übersicht und Protokoll |
|
||||
| `resources/views/admin/newsletter/saved.blade.php`, `exports.blade.php` | Übersichten (Yajra) |
|
||||
| `index.blade.php` | Banner „Geladene Liste", Formular „Als neue Liste speichern" / „Änderungen speichern" (kopiert die aktuellen Filterfelder per JS), `list_id` wandert in Vorschau und Export |
|
||||
| Sidenav | Untermenü „Newsletter-Listen": Liste erstellen / Gespeicherte Listen / Export-Protokoll |
|
||||
| `tests/Feature/Newsletter/NewsletterListSavedTest.php` | Speichern, Validierung, Aktualisieren, Laden, Protokoll (gespeichert + ad hoc), Löschen, Datatables, Filter-Beschreibung |
|
||||
|
||||
**Ablauf:** Filter einstellen → Name eingeben → „Als neue Liste speichern" → Liste ist geladen (Banner). Export aus der Filter-Seite oder direkt aus der Übersicht; beides protokolliert Benutzer, Zeitpunkt, Format, Filter und Adresszahl und aktualisiert „Letzter Export" der Liste. Ad-hoc-Exporte ohne Liste erscheinen im Protokoll als „ohne gespeicherte Liste". Beim Löschen einer Liste bleiben die Protokolleinträge erhalten (Verknüpfung wird auf NULL gesetzt).
|
||||
|
||||
### Messwerte auf der Live-Datenbank (11.09.2026)
|
||||
|
||||
| Liste | Rohdaten | Duplikate | Ungültig | Ergebnis | Dauer |
|
||||
|---|---|---|---|---|---|
|
||||
| Kunden, de, alle | 73.927 | 32.208 | 88 | 41.631 | 0,5 s |
|
||||
| Kunden, de, nur Opt-in | 7.070 | 897 | 33 | 6.140 | 0,3 s |
|
||||
| Berater aktiv, de | 580 | 0 | 0 | 580 | < 0,1 s |
|
||||
| Kunden mit aktivem Abo | 447 | 1 | 1 | 445 | < 0,1 s |
|
||||
|
||||
Die hohe Duplikatzahl kommt von Gastbestellungen: dieselbe E-Mail liegt oft als mehrere `shopping_users`-Zeilen vor. Es gewinnt die Zeile mit Opt-in, dann mit Kauf, dann die neueste.
|
||||
|
||||
### Abweichungen vom ursprünglichen Plan
|
||||
|
||||
- **Kein `Spatie\Html`** im Filterformular: keine einzige Admin-View nutzt das Paket, das Formular folgt dem Blade-/Bootstrap-Muster der Kundenliste (`admin/customer/index.blade.php`).
|
||||
- **Dedupe ohne `Collection::sortBy`**: die Mehrfach-Sortierung über 74k Zeilen kostete 7 s, der Ein-Pass-Vergleich (`outranks`) 0,3 s.
|
||||
- **Test-Schema vs. Produktion**: In der SQLite-Testdatenbank sind `faker_mail`, `subscribed` und `auth_user_id` NOT NULL, in Produktion stehen dort NULL-Werte (z. B. `faker_mail` bei 4.281 Abonnenten). Der Service behandelt NULL wie 0, die Tests decken den NULL-Fall deshalb nicht ab.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziel
|
||||
|
||||
Ein neuer Admin-Bereich **„Newsletter-Listen"**, in dem ein Admin per Filter eine Empfängerliste
|
||||
zusammenstellt, in der Vorschau prüft und als **XLSX** (Vorlage) oder **CSV** herunterlädt.
|
||||
|
||||
- Quellen: **Kunden** (`shopping_users`) und **Berater** (`users` + `user_accounts`)
|
||||
- Doppelte E-Mails fallen automatisch raus
|
||||
- Gesperrte, gelöschte, anonymisierte, Test- und Fake-Datensätze fallen automatisch raus
|
||||
- Später: gespeicherte Listen-Definitionen, Sperrliste, API-Schnittstelle für den Newsletter-Dienst
|
||||
|
||||
---
|
||||
|
||||
## 2. Analyse der Vorlage (CSV vom 02.07.2026)
|
||||
|
||||
| Kennzahl | Wert |
|
||||
|---|---|
|
||||
| Zeilen | 10.441 |
|
||||
| Eindeutige E-Mails (normalisiert) | 10.022 |
|
||||
| Doppelte E-Mails | 394 Adressen, 417 überzählige Zeilen (18 davon nur durch Groß-/Kleinschreibung verschieden) |
|
||||
| Ungültige Zeilen | 3 (`#`, Leerzeichen in Adresse, Fußzeile „07.02.26 Seite 6") |
|
||||
| Berater-Spalte leer oder `?` | 2.889 |
|
||||
| Firma gefüllt | 884 |
|
||||
| Nicht-DE-PLZ (AT, CH, FR, LU) | ca. 960 |
|
||||
|
||||
**Spalten der Vorlage:** `Emailadresse; Vorname; Nachname; Anrede; Straße; PLZ; Wohnort; Firma; Berater`
|
||||
Anrede ist Freitext („Frau", „frau", „Herr Dr.", „Herrr", „Frau/Herr") → wir liefern sie künftig normalisiert.
|
||||
|
||||
### Abgleich mit der Datenbank (Stand heute)
|
||||
|
||||
| Ergebnis | Anzahl |
|
||||
|---|---|
|
||||
| CSV-Adressen, die in `shopping_users` existieren | 9.995 von 10.022 |
|
||||
| … davon mit Newsletter-Opt-in (`subscribed = 1`) | **2.885** |
|
||||
| … davon mit mindestens einem Kauf (`has_buyed = 1`) | 3.688 |
|
||||
| CSV-Adressen, die ein Berater-Login sind (`users.email`) | 254 |
|
||||
| CSV-Adressen, die nirgends im System sind | 29 |
|
||||
| Opt-in-Adressen in der DB, die **nicht** in der CSV stehen | 3.493 |
|
||||
|
||||
**Wichtigste Erkenntnis:** Die aktuelle Liste wurde offenbar aus **allen** Kunden mit echter E-Mail gezogen,
|
||||
nicht aus den Newsletter-Abonnenten. Rund **7.100 Adressen haben keinen Opt-in im System**. Das muss
|
||||
vor der Umsetzung entschieden werden (siehe Offene Fragen, Punkt 1) – der Export-Filter kann beides,
|
||||
aber der Default sollte rechtssicher sein.
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenquellen im System
|
||||
|
||||
### 3.1 Kunden – `shopping_users` (`App\Models\ShoppingUser`)
|
||||
|
||||
| Export-Spalte | DB-Feld | Anmerkung |
|
||||
|---|---|---|
|
||||
| E-Mail | `billing_email` | normalisieren: `LOWER(TRIM())` |
|
||||
| Vorname / Nachname | `billing_firstname`, `billing_lastname` | |
|
||||
| Anrede | `billing_salutation` | `mr` / `ms` / `di` → „Herr" / „Frau" / „Divers" (über `HTMLHelper::getSalutationLang`) |
|
||||
| Straße | `billing_address` (+ `billing_address_2`) | |
|
||||
| PLZ / Ort | `billing_zipcode`, `billing_city` | |
|
||||
| Land | `billing_country_id` → `countries` | Ländername je Sprache (`Country::getLocated`) plus ISO-Code |
|
||||
| Firma | `billing_company` | |
|
||||
| Berater | `member_id` → `users` → `user_accounts` | Name des Beraters, zusätzlich Berater-Nr. |
|
||||
| Sprache | `language` (char 2) | de / es / en / fr |
|
||||
| Opt-in | `subscribed` (bool) | **einziges echtes Opt-in-Feld im System**; nur wenn `has_buyed = 1` erlaubt |
|
||||
| Kunde-Nr. | `number` | |
|
||||
|
||||
Zahlen heute: 81.089 Kunden ohne Berater-Login, davon 8.919 mit `subscribed = 1`,
|
||||
davon 6.376 eindeutige echte E-Mail-Adressen (nach Pflichtausschlüssen, siehe Abschnitt 4).
|
||||
|
||||
### 3.2 Berater – `users` + `user_accounts` (`App\User`, `App\Models\UserAccount`)
|
||||
|
||||
| Export-Spalte | DB-Feld |
|
||||
|---|---|
|
||||
| E-Mail | `users.email` |
|
||||
| Vorname / Nachname / Anrede | `user_accounts.first_name`, `last_name`, `salutation` |
|
||||
| Straße / PLZ / Ort / Land | `user_accounts.address`, `zipcode`, `city`, `country_id` |
|
||||
| Firma | `user_accounts.company` |
|
||||
| Sponsor (= „Berater"-Spalte) | `users.m_sponsor` → `users` → `user_accounts` |
|
||||
| Sprache | `users.lang` |
|
||||
| Level | `users.m_level` → `user_levels.name` |
|
||||
| Status | `users.active`, `blocked`, `confirmed`, `admin` |
|
||||
|
||||
Zahlen heute: 895 Berater (`admin < 5`), davon 609 aktiv und nicht gesperrt.
|
||||
**Berater haben kein Newsletter-Opt-in-Feld.** Rechtsgrundlage wäre der Beratervertrag
|
||||
(`user_accounts.accepted_contract`, `data_protection`). Siehe Offene Fragen, Punkt 2.
|
||||
|
||||
### 3.3 Weitere Tabellen (nicht als Quelle vorgesehen)
|
||||
|
||||
- `homeparty_users` – gleiche Struktur inkl. `subscribed`, aktuell 0 Abonnenten → vorerst ignorieren, Quelle optional in Phase 2
|
||||
- `customers` – nur Portal-Login (Magic Link), 1.237 Zeilen, kein Opt-in → nur zur Dedupe-Prüfung interessant
|
||||
- Leads existieren nicht als eigene Tabelle (`admin_leads` listet `users`)
|
||||
|
||||
---
|
||||
|
||||
## 4. Pflicht-Ausschlüsse (immer aktiv, nicht abwählbar)
|
||||
|
||||
**Kunden:**
|
||||
|
||||
1. `deleted_at IS NULL` und `user_deleted_at IS NULL`
|
||||
2. `auth_user_id IS NULL` (sonst ist es der Shop-Datensatz eines Beraters → kommt über die Berater-Quelle)
|
||||
3. `faker_mail != 1` – **Achtung:** Feld ist bei ~4.300 Abonnenten `NULL`, Filter muss `IS NULL OR = 0` sein
|
||||
4. E-Mail nicht `%@dsgvo.invalid` (DSGVO-Anonymisierung aus `GdprDeletionService`) und nicht `%faker@mivita.care`
|
||||
5. `mode IS NULL OR mode = 'live'` (keine `dev` / `prev`-Datensätze)
|
||||
6. E-Mail syntaktisch gültig (`filter_var(FILTER_VALIDATE_EMAIL)`), kein Leerzeichen
|
||||
|
||||
**Berater:**
|
||||
|
||||
1. `deleted_at IS NULL`, `pre_deleted_at IS NULL`
|
||||
2. `blocked = 0`
|
||||
3. `admin < 5` (keine System-Accounts), `test_mode = 0`
|
||||
4. E-Mail syntaktisch gültig
|
||||
|
||||
**Beide:** Adresse steht nicht auf der Sperrliste (Phase 3, siehe Abschnitt 9).
|
||||
|
||||
---
|
||||
|
||||
## 5. Aufbau der Admin-Seite
|
||||
|
||||
**Navigation:** Neuer Eintrag im ADMIN-Block der Sidenav (`layout-sidenav.blade.php`), direkt unter „Kunden":
|
||||
Icon `ion ion-md-mail`, Label `navigation.newsletter_lists` („Newsletter-Listen"), sichtbar für `isAdmin()`.
|
||||
|
||||
**Route:** `/admin/newsletter-lists` innerhalb der bestehenden `['middleware' => ['admin']]`-Gruppe in `routes/domains/crm.php`.
|
||||
|
||||
### Seitenlayout (`layouts.layout-2`, Bootstrap 4 / Appwork)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Newsletter-Listen │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ [Filter-Card] │
|
||||
│ Quelle: (•) Kunden ( ) Berater ( ) Beide │
|
||||
│ Sprache: [de ▾] Land: [alle ▾] Opt-in: [nur mit Opt-in ▾] │
|
||||
│ Kaufstatus: [alle ▾] Berater: [alle ▾] Angelegt: [von] [bis] │
|
||||
│ ── Berater-Filter (nur bei Quelle Berater/Beide) ── │
|
||||
│ Status: [aktiv ▾] Level: [alle ▾] │
|
||||
│ [Vorschau aktualisieren] │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ [Ergebnis-Card] │
|
||||
│ Treffer: 6.376 eindeutige Adressen (Kunden 6.243 · Berater 133 · │
|
||||
│ Duplikate entfernt 1.204 · ausgeschlossen 2.543) │
|
||||
│ [⬇ XLSX] [⬇ CSV] (Phase 2: [💾 Liste speichern]) │
|
||||
│ ┌ Yajra DataTable (serverside) ─────────────────────────────────┐ │
|
||||
│ │ E-Mail | Name | Anrede | Ort | Land | Sprache | Berater | Quelle│ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Die Vorschau zeigt genau die Zeilen, die im Export landen (inkl. Dedupe). Die Kennzahlen-Zeile macht
|
||||
transparent, warum die Zahl kleiner ist als „Kunden gesamt".
|
||||
|
||||
---
|
||||
|
||||
## 6. Filter (Vorschlag)
|
||||
|
||||
| Filter | Optionen | Default | Quelle |
|
||||
|---|---|---|---|
|
||||
| **Quelle** | Kunden / Berater / Beide | Kunden | – |
|
||||
| **Sprache** | alle / de / es / en / fr | de | `shopping_users.language`, `users.lang` |
|
||||
| **Land** | alle / Mehrfachauswahl (DE, AT, CH, ES, …) | alle | `billing_country_id`, `user_accounts.country_id` |
|
||||
| **Opt-in** | nur mit Opt-in / alle (mit Warnhinweis) | nur mit Opt-in | `subscribed` (bei Beratern: immer „Vertrag") |
|
||||
| **Kaufstatus** | alle / hat gekauft / gekauft in den letzten X Monaten / hat aktives Abo | alle | `has_buyed`, `shopping_orders`, `abos` |
|
||||
| **Berater (Zuordnung)** | alle / bestimmter Berater / ohne Berater | alle | `member_id` bzw. `m_sponsor` |
|
||||
| **Angelegt von–bis** | Datum | leer | `created_at` |
|
||||
| **Firma** | alle / nur mit Firma / nur ohne Firma | alle | `billing_company` |
|
||||
| Berater: **Status** | aktiv / inaktiv / alle | aktiv | `active`, `confirmed` |
|
||||
| Berater: **Level** | alle / Mehrfachauswahl | alle | `m_level` |
|
||||
| Berater: **Rolle** | Berater / inkl. Admins | Berater | `admin` |
|
||||
|
||||
Die Filter werden wie bei der Kundenliste über `set_user_attr()` / `get_user_attr()` gemerkt.
|
||||
|
||||
**Hinweis zu „Deutsch":** Die Vorlage heißt „Kundenliste Deutsch", enthält aber DE, AT, CH, FR, LU.
|
||||
Gemeint ist offenbar die **Sprache** (Newsletter auf Deutsch), nicht das Land. Deshalb ist Sprache ein
|
||||
eigener Filter mit Default `de`, das Land bleibt frei wählbar. Der Export bekommt beide Spalten.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dedupe-Regeln
|
||||
|
||||
1. Schlüssel: `LOWER(TRIM(email))`
|
||||
2. Priorität bei Kollision:
|
||||
1. **Berater-Datensatz** gewinnt vor Kunden-Datensatz (bessere Stammdaten, Vertragsbasis)
|
||||
2. Innerhalb der Kunden: Datensatz mit `subscribed = 1` vor ohne, dann `has_buyed = 1`, dann neueste `id` (Muster aus `User/CustomerController.php:259`)
|
||||
3. Die Spalte **„Quelle"** (`Kunde` / `Berater`) im Export zeigt, welcher Datensatz gewonnen hat
|
||||
4. Dedupe erfolgt in PHP nach dem Laden (Collection `->unique()` nach Priorität sortiert), nicht per SQL,
|
||||
damit beide Tabellen gemeinsam dedupliziert werden können. Bei ~10k Zeilen unkritisch; Chunking über `cursor()`.
|
||||
|
||||
Heute im System: 133 Berater-Adressen stehen zusätzlich als abonnierter Kunde drin, 1.636 Abonnenten haben Fake-Mails.
|
||||
|
||||
---
|
||||
|
||||
## 8. Export-Format
|
||||
|
||||
**Spalten (Reihenfolge wie Vorlage, plus Ergänzungen am Ende):**
|
||||
|
||||
| # | Spalte | Kommentar |
|
||||
|---|---|---|
|
||||
| 1 | Emailadresse | normalisiert (klein, getrimmt) |
|
||||
| 2 | Vorname | getrimmt |
|
||||
| 3 | Nachname | |
|
||||
| 4 | Anrede | normalisiert: Frau / Herr / Divers / leer |
|
||||
| 5 | Straße | Adresse + Zusatz |
|
||||
| 6 | PLZ | ohne Länderpräfix (Vorlage hatte „A-6600", „CH-9542") |
|
||||
| 7 | Wohnort | |
|
||||
| 8 | Firma | |
|
||||
| 9 | Berater | voller Name des zugeordneten Beraters/Sponsors, sonst leer (kein `?`) |
|
||||
| 10 | Land | ISO-Code (DE, AT, CH …) |
|
||||
| 11 | Sprache | de / es / … |
|
||||
| 12 | Quelle | Kunde / Berater |
|
||||
| 13 | Kunden-Nr. / Berater-Nr. | `number` bzw. `m_account` |
|
||||
| 14 | Berater-Nr. | `m_account` des zugeordneten Beraters |
|
||||
| 15 | Opt-in | ja / Vertrag |
|
||||
|
||||
Spalten 10–15 sind der Mehrwert gegenüber der Vorlage; falls der Newsletter-Dienst nur die 9 Spalten
|
||||
verträgt, gibt es ein Häkchen „Nur Vorlage-Spalten".
|
||||
|
||||
- **XLSX** über `Maatwebsite\Excel` mit der bestehenden `App\Exports\xExport`-Klasse (Muster `PaymentSalesController.php:145`)
|
||||
- **CSV** per `response()->streamDownload()`, Trennzeichen `;`, UTF-8 mit BOM (Excel-kompatibel), inkl. CSV-Injection-Schutz wie `BackofficeStatisticsController::csvSafeRow()`
|
||||
- Dateiname: `newsletter_{quelle}_{sprache}_{YYYY-MM-DD}.xlsx`
|
||||
|
||||
---
|
||||
|
||||
## 9. Technische Umsetzung
|
||||
|
||||
### Phase 1 – Filter, Vorschau, Export (MVP)
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `app/Services/Newsletter/NewsletterListService.php` | Baut die beiden Queries (Kunden, Berater), wendet Pflichtausschlüsse + Filter an, dedupliziert, liefert einheitliche Zeilen-DTOs und Kennzahlen |
|
||||
| `app/Services/Newsletter/NewsletterListFilter.php` | Wertobjekt für die Filter (aus Request gebaut, validiert) |
|
||||
| `app/Http/Requests/Newsletter/NewsletterListFilterRequest.php` | Validierung der Filterparameter (Sprache, Land-IDs, Datumsbereich …) |
|
||||
| `app/Http/Controllers/Admin/NewsletterListController.php` | `index()`, `datatable()` (Yajra, serverside auf die deduplizierte Collection), `export()` (xlsx/csv) |
|
||||
| `routes/domains/crm.php` | `admin.newsletter.index`, `admin.newsletter.datatable`, `admin.newsletter.export` im `admin`-Block |
|
||||
| `resources/views/admin/newsletter/index.blade.php` + `_filter.blade.php` | Filterformular mit `Spatie\Html`, Kennzahlen, DataTable |
|
||||
| `resources/views/layouts/includes/layout-sidenav.blade.php` | Menüpunkt |
|
||||
| `resources/lang/{de,en,es,fr}/navigation.php`, neue `newsletter.php` | Labels |
|
||||
| `app/Exports/NewsletterListExport.php` | optional: eigene Export-Klasse statt `xExport`, falls Spaltenformatierung (PLZ als Text) nötig |
|
||||
|
||||
**Datatable-Hinweis:** Weil die Dedupe über zwei Tabellen in PHP passiert, ist eine reine Eloquent-Datatable
|
||||
nicht möglich. Vorgehen: `DataTables::collection($service->rows($filter))` mit Cache der Ergebnis-Collection
|
||||
(Cache-Key aus Filter-Hash, TTL 5 Min), damit Blättern/Sortieren nicht jedes Mal neu rechnet.
|
||||
Alternative bei Performance-Problemen: UNION-Query mit Fensterfunktion (`ROW_NUMBER() OVER (PARTITION BY email …)`)
|
||||
in MySQL 8 – dann bleibt Yajra serverside. Entscheidung nach erstem Messwert.
|
||||
|
||||
### Phase 2 – Gespeicherte Listen & Export-Protokoll
|
||||
|
||||
- Tabelle `newsletter_lists`: `id, name, source, filters (json), created_by, last_exported_at, row_count`
|
||||
- Tabelle `newsletter_list_exports`: `id, newsletter_list_id, user_id, format, row_count, filename, created_at`
|
||||
- Admin kann eine Filterkombination unter Namen speichern („Newsletter DE Kunden", „Berater ES") und mit einem Klick erneut exportieren
|
||||
- Übersichts-Datatable aller gespeicherten Listen mit letztem Export und Trefferzahl
|
||||
- Grundlage für die spätere Schnittstelle: jede Liste hat eine feste ID
|
||||
|
||||
### Phase 3 – Sperrliste & Abmeldung
|
||||
|
||||
- Tabelle `newsletter_blocklist`: `email (unique, normalisiert), reason (unsubscribed / bounce / manual / gdpr), source, created_by, created_at`
|
||||
- Admin-Maske: manuell sperren, CSV-Import von Abmeldungen/Bounces aus dem Newsletter-Tool
|
||||
- Pflichtausschluss in `NewsletterListService`
|
||||
- `GdprDeletionService` trägt anonymisierte Adressen automatisch ein
|
||||
- Optional: Abmelde-Link-Handling im Kundenportal setzt `subscribed = 0` **und** Sperrliste
|
||||
|
||||
### Phase 4 – Schnittstelle (später)
|
||||
|
||||
- API-Endpunkt `GET /api/newsletter/lists/{id}/contacts` (Passport, Client Credentials, eigener Scope `newsletter:read`)
|
||||
- Liefert dieselben Zeilen wie der Export als JSON, paginiert, mit `updated_since`-Parameter
|
||||
- Alternativ Push per Artisan-Command (`newsletter:sync {list}`) im Scheduler, wenn das Newsletter-Tool eine Import-API hat
|
||||
- Erst festlegen, wenn das Newsletter-Tool bekannt ist (Brevo, Mailchimp, CleverReach … haben alle andere Feldnamen)
|
||||
|
||||
### Tests (Pest, `tests/Feature/Admin/NewsletterList*.php`)
|
||||
|
||||
- Zugriff: Admin ja, VIP/Berater/Gast nein
|
||||
- Pflichtausschlüsse: gelöscht, DSGVO-anonymisiert, Fake-Mail (`NULL` und `1`), `mode = dev`, gesperrter Berater, `auth_user_id` gesetzt
|
||||
- Dedupe: gleiche Mail in Kunden + Berater → Berater gewinnt; gleiche Mail zweimal als Kunde → Opt-in-Datensatz gewinnt; Groß-/Kleinschreibung
|
||||
- Filter: Sprache, Land, Opt-in, Kaufstatus, Berater-Zuordnung, Datumsbereich
|
||||
- Export: XLSX-Download liefert erwartete Kopfzeile und Zeilenzahl; CSV mit `;` und BOM; Injection-Schutz
|
||||
- Anrede-Mapping `mr/ms/di`
|
||||
|
||||
---
|
||||
|
||||
## 10. Offene Fragen (vor Umsetzung klären)
|
||||
|
||||
1. **Rechtsgrundlage Kunden ohne Opt-in.** Die aktuelle Liste enthält ~7.100 Adressen ohne `subscribed = 1`.
|
||||
Vorschlag: Default-Filter „nur mit Opt-in", Option „alle" nur mit Warnhinweis und Protokollierung (wer, wann, wie viele).
|
||||
Oder: Opt-in-Flag im System korrigieren, falls die Abonnenten im Newsletter-Tool die Wahrheit sind (einmaliger Import).
|
||||
Ich würde als die default alle machen und es in der Liste zusätzlich mit aufführen,
|
||||
Eine Sortierung findet wahrscheinlich im Newsletter Tool statt, und hier sind schon entsprechende Adressen gekennzeichnet.
|
||||
Ein späterer Abgleich ist geplant, aber zuerst die Export listen
|
||||
2. **Berater-Opt-in.** Berater haben kein Newsletter-Feld. Reicht der Beratervertrag (Info-Mails an aktive Berater)?
|
||||
Falls nein: neues Feld `users.newsletter` + Checkbox im Berater-Profil. Kleiner Zusatzaufwand, sauberer.
|
||||
Ja, ist im Beratervertrag aufgeführt und wird die nicht berücksichtigt, Es gibt die Möglichkeit, sich abzumelden.
|
||||
3. **Sprache vs. Land.** Annahme: „Deutsch" = Sprache `de`, Länder frei. Korrekt?
|
||||
ja
|
||||
4. **Rechnungs- oder Lieferadresse?** Vorlage nutzt Rechnungsadresse (`billing_*`). Vorschlag: bleibt so.
|
||||
ja
|
||||
5. **Inaktive Berater.** Default nur `active = 1`. Sollen ehemalige Berater (inaktiv, nicht gesperrt) wählbar sein?
|
||||
Ja wählbar und auch zusätzlich in der Liste mit ausgeben
|
||||
6. **Berater-Datensätze als Kunden.** 133 Berater-Mails stehen zusätzlich als abonnierter Kunde in `shopping_users`.
|
||||
Bei Quelle „nur Kunden" fallen die raus (Regel `auth_user_id IS NULL`) – gewünscht, oder sollen sie als Kunde mitlaufen?
|
||||
Nein, Berater immer separat Kunden immer nur die keine Berater sind.
|
||||
7. **Kaufstatus-Filter.** Welche Zeiträume sind sinnvoll (3 / 6 / 12 Monate)? Aktives Abo als eigener Filter?
|
||||
Ja wie in deinem Vorschlag auch Abo als eigener Filter
|
||||
8. **Newsletter-Tool.** Welches Tool wird genutzt? Bestimmt Spaltennamen und die Schnittstelle in Phase 4.
|
||||
der Kunde nutzt derzeit rapidmail
|
||||
9. **Opt-in-Zeitpunkt.** `subscribed` hat keinen Timestamp. Für Nachweispflicht ggf. `subscribed_at` ergänzen (Phase 3).
|
||||
ja
|
||||
10. **Homeparty-Kunden** (`homeparty_users`, heute 0 Abonnenten) als Quelle vorsehen?
|
||||
Nein, das sind Letztendlich Kunden und Berater diese würde ich hier nicht mit reinnehmen
|
||||
|
||||
---
|
||||
|
||||
## 11. Aufwandsschätzung
|
||||
|
||||
| Phase | Inhalt | Aufwand |
|
||||
|---|---|---|
|
||||
| 1 | Service, Filter, Vorschau, XLSX/CSV, Navigation, Tests | 2–3 Tage |
|
||||
| 2 | Gespeicherte Listen, Export-Protokoll | 1 Tag |
|
||||
| 3 | Sperrliste, Import von Abmeldungen, DSGVO-Kopplung | 1 Tag |
|
||||
| 4 | API / Sync-Command | 1–2 Tage (abhängig vom Tool) |
|
||||
|
||||
Phase 1 ist unabhängig lauffähig und ersetzt die manuelle Listenerstellung sofort.
|
||||
|
|
@ -94,4 +94,8 @@ return [
|
|||
'team_customers' => 'Team Kunden',
|
||||
'payment_monitor' => 'Payment Monitor',
|
||||
'payment_monitor_management' => 'Payment Monitor GF',
|
||||
'newsletter_lists' => 'Newsletter-Listen',
|
||||
'newsletter_create' => 'Liste erstellen',
|
||||
'newsletter_saved' => 'Gespeicherte Listen',
|
||||
'newsletter_exports' => 'Export-Protokoll',
|
||||
];
|
||||
|
|
|
|||
158
resources/lang/de/newsletter.php
Normal file
158
resources/lang/de/newsletter.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Newsletter-Listen',
|
||||
'intro' => 'Empfängerliste aus Kunden und Beratern zusammenstellen, prüfen und für rapidmail exportieren. Gelöschte, anonymisierte, Fake- und Test-Datensätze werden immer ausgeschlossen, doppelte E-Mail-Adressen automatisch entfernt.',
|
||||
'filter' => [
|
||||
'source' => 'Quelle',
|
||||
'language' => 'Sprache',
|
||||
'country' => 'Land',
|
||||
'optin' => 'Newsletter-Opt-in',
|
||||
'purchase' => 'Kaufstatus',
|
||||
'member' => 'Zugeordneter Berater / Sponsor',
|
||||
'created_from' => 'Angelegt von',
|
||||
'created_to' => 'Angelegt bis',
|
||||
'company' => 'Firma',
|
||||
'consultant_status' => 'Berater-Status',
|
||||
'level' => 'Karriere-Level',
|
||||
'include_admins' => 'Admin-Accounts einbeziehen',
|
||||
'all' => 'alle',
|
||||
'none_member' => 'ohne Zuordnung',
|
||||
'consultant_section' => 'Berater-Filter',
|
||||
'refresh' => 'Vorschau aktualisieren',
|
||||
'reset' => 'Filter zurücksetzen',
|
||||
],
|
||||
'source_option' => [
|
||||
'customers' => 'Kunden',
|
||||
'consultants' => 'Berater',
|
||||
'both' => 'Kunden und Berater',
|
||||
],
|
||||
'source' => [
|
||||
'customer' => 'Kunde',
|
||||
'consultant' => 'Berater',
|
||||
],
|
||||
'optin_option' => [
|
||||
'all' => 'alle (Opt-in wird als Spalte ausgegeben)',
|
||||
'subscribed' => 'nur mit Opt-in',
|
||||
'unsubscribed' => 'nur ohne Opt-in',
|
||||
],
|
||||
'optin_value' => [
|
||||
'yes' => 'ja',
|
||||
'no' => 'nein',
|
||||
'contract' => 'Vertrag',
|
||||
],
|
||||
'purchase_option' => [
|
||||
'all' => 'alle',
|
||||
'has_buyed' => 'hat gekauft',
|
||||
'none' => 'noch nie gekauft',
|
||||
'months_3' => 'gekauft in den letzten 3 Monaten',
|
||||
'months_6' => 'gekauft in den letzten 6 Monaten',
|
||||
'months_12' => 'gekauft in den letzten 12 Monaten',
|
||||
'active_abo' => 'hat aktives Abo',
|
||||
],
|
||||
'company_option' => [
|
||||
'all' => 'alle',
|
||||
'with' => 'nur mit Firma',
|
||||
'without' => 'nur ohne Firma',
|
||||
],
|
||||
'consultant_status_option' => [
|
||||
'active' => 'nur aktive',
|
||||
'inactive' => 'nur inaktive',
|
||||
'all' => 'alle',
|
||||
],
|
||||
'status_value' => [
|
||||
'active' => 'aktiv',
|
||||
'inactive' => 'inaktiv',
|
||||
],
|
||||
'stats' => [
|
||||
'total' => 'eindeutige Adressen',
|
||||
'customers' => 'Kunden',
|
||||
'consultants' => 'Berater',
|
||||
'duplicates' => 'Duplikate entfernt',
|
||||
'invalid' => 'ungültige Adressen',
|
||||
'loading' => 'Liste wird berechnet …',
|
||||
],
|
||||
'hint' => [
|
||||
'optin_all' => 'Die Liste enthält auch Kunden ohne Newsletter-Opt-in. Die Spalte „Newsletter" kennzeichnet den Status je Adresse.',
|
||||
'consultants_contract' => 'Berater haben kein eigenes Opt-in; Grundlage ist der Beratervertrag (Spalte „Newsletter" = Vertrag).',
|
||||
],
|
||||
'export' => [
|
||||
'email' => 'Emailadresse',
|
||||
'firstname' => 'Vorname',
|
||||
'lastname' => 'Nachname',
|
||||
'salutation_label' => 'Anrede',
|
||||
'street' => 'Straße',
|
||||
'zipcode' => 'PLZ',
|
||||
'city' => 'Wohnort',
|
||||
'company' => 'Firma',
|
||||
'consultant' => 'Berater',
|
||||
'country' => 'Land',
|
||||
'language' => 'Sprache',
|
||||
'source_label' => 'Quelle',
|
||||
'number' => 'Nummer',
|
||||
'consultant_number' => 'Berater-Nr.',
|
||||
'optin_label' => 'Newsletter',
|
||||
'status_label' => 'Status',
|
||||
'created_at' => 'Angelegt',
|
||||
'button_xlsx' => 'Export XLSX',
|
||||
'button_csv' => 'Export CSV',
|
||||
'template_only' => 'Nur die 9 Spalten der Vorlage exportieren',
|
||||
],
|
||||
'table' => [
|
||||
'email' => 'E-Mail',
|
||||
'name' => 'Name',
|
||||
'salutation' => 'Anrede',
|
||||
'zipcode' => 'PLZ',
|
||||
'city' => 'Ort',
|
||||
'country' => 'Land',
|
||||
'language' => 'Sprache',
|
||||
'consultant' => 'Berater / Sponsor',
|
||||
'source' => 'Quelle',
|
||||
'optin' => 'Newsletter',
|
||||
'status' => 'Status',
|
||||
'created' => 'Angelegt',
|
||||
],
|
||||
'saved' => [
|
||||
'title' => 'Gespeicherte Listen',
|
||||
'current' => 'Geladene Liste',
|
||||
'name' => 'Name',
|
||||
'name_placeholder' => 'z. B. Newsletter DE Kunden',
|
||||
'save_new' => 'Als neue Liste speichern',
|
||||
'save_changes' => 'Änderungen in dieser Liste speichern',
|
||||
'summary' => 'Filter',
|
||||
'source' => 'Quelle',
|
||||
'last_export' => 'Letzter Export',
|
||||
'last_count' => 'Adressen',
|
||||
'created_by' => 'Angelegt von',
|
||||
'created_at' => 'Angelegt',
|
||||
'actions' => 'Aktionen',
|
||||
'load' => 'Filter laden',
|
||||
'delete' => 'Löschen',
|
||||
'confirm_delete' => 'Liste wirklich löschen? Das Export-Protokoll bleibt erhalten.',
|
||||
'stored' => 'Liste gespeichert.',
|
||||
'updated' => 'Liste aktualisiert.',
|
||||
'deleted' => 'Liste gelöscht.',
|
||||
'intro' => 'Gespeicherte Filterkombinationen. Über „Filter laden" wird die Liste in die Vorschau übernommen, der Export nutzt die hinterlegten Filter.',
|
||||
],
|
||||
'exports' => [
|
||||
'title' => 'Export-Protokoll',
|
||||
'intro' => 'Jeder Download wird mit Benutzer, Zeitpunkt, Filter und Anzahl der Adressen festgehalten.',
|
||||
'date' => 'Datum',
|
||||
'user' => 'Benutzer',
|
||||
'list' => 'Liste',
|
||||
'adhoc' => 'ohne gespeicherte Liste',
|
||||
'summary' => 'Filter',
|
||||
'format' => 'Format',
|
||||
'rows' => 'Adressen',
|
||||
'filename' => 'Datei',
|
||||
],
|
||||
'validation' => [
|
||||
'source' => 'Ungültige Quelle.',
|
||||
'option' => 'Ungültiger Filterwert.',
|
||||
'member' => 'Ungültige Berater-Auswahl.',
|
||||
'date' => 'Ungültiges Datum.',
|
||||
'name' => 'Bitte einen Namen angeben (max. 120 Zeichen).',
|
||||
'list' => 'Liste nicht gefunden.',
|
||||
'date_range' => 'Das Enddatum darf nicht vor dem Startdatum liegen.',
|
||||
],
|
||||
];
|
||||
|
|
@ -94,4 +94,8 @@ return [
|
|||
'team_customers' => 'Team Customers',
|
||||
'payment_monitor' => 'Payment Monitor',
|
||||
'payment_monitor_management' => 'Payment Monitor (Mgmt)',
|
||||
'newsletter_lists' => 'Newsletter lists',
|
||||
'newsletter_create' => 'Create list',
|
||||
'newsletter_saved' => 'Saved lists',
|
||||
'newsletter_exports' => 'Export log',
|
||||
];
|
||||
|
|
|
|||
158
resources/lang/en/newsletter.php
Normal file
158
resources/lang/en/newsletter.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Newsletter lists',
|
||||
'intro' => 'Build a recipient list from customers and consultants, review it and export it for rapidmail. Deleted, anonymised, fake and test records are always excluded; duplicate e-mail addresses are removed automatically.',
|
||||
'filter' => [
|
||||
'source' => 'Source',
|
||||
'language' => 'Language',
|
||||
'country' => 'Country',
|
||||
'optin' => 'Newsletter opt-in',
|
||||
'purchase' => 'Purchase status',
|
||||
'member' => 'Assigned consultant / sponsor',
|
||||
'created_from' => 'Created from',
|
||||
'created_to' => 'Created until',
|
||||
'company' => 'Company',
|
||||
'consultant_status' => 'Consultant status',
|
||||
'level' => 'Career level',
|
||||
'include_admins' => 'Include admin accounts',
|
||||
'all' => 'all',
|
||||
'none_member' => 'without assignment',
|
||||
'consultant_section' => 'Consultant filters',
|
||||
'refresh' => 'Refresh preview',
|
||||
'reset' => 'Reset filters',
|
||||
],
|
||||
'source_option' => [
|
||||
'customers' => 'Customers',
|
||||
'consultants' => 'Consultants',
|
||||
'both' => 'Customers and consultants',
|
||||
],
|
||||
'source' => [
|
||||
'customer' => 'Customer',
|
||||
'consultant' => 'Consultant',
|
||||
],
|
||||
'optin_option' => [
|
||||
'all' => 'all (opt-in is exported as a column)',
|
||||
'subscribed' => 'only with opt-in',
|
||||
'unsubscribed' => 'only without opt-in',
|
||||
],
|
||||
'optin_value' => [
|
||||
'yes' => 'yes',
|
||||
'no' => 'no',
|
||||
'contract' => 'contract',
|
||||
],
|
||||
'purchase_option' => [
|
||||
'all' => 'all',
|
||||
'has_buyed' => 'has purchased',
|
||||
'none' => 'never purchased',
|
||||
'months_3' => 'purchased in the last 3 months',
|
||||
'months_6' => 'purchased in the last 6 months',
|
||||
'months_12' => 'purchased in the last 12 months',
|
||||
'active_abo' => 'has an active subscription',
|
||||
],
|
||||
'company_option' => [
|
||||
'all' => 'all',
|
||||
'with' => 'only with company',
|
||||
'without' => 'only without company',
|
||||
],
|
||||
'consultant_status_option' => [
|
||||
'active' => 'active only',
|
||||
'inactive' => 'inactive only',
|
||||
'all' => 'all',
|
||||
],
|
||||
'status_value' => [
|
||||
'active' => 'active',
|
||||
'inactive' => 'inactive',
|
||||
],
|
||||
'stats' => [
|
||||
'total' => 'unique addresses',
|
||||
'customers' => 'customers',
|
||||
'consultants' => 'consultants',
|
||||
'duplicates' => 'duplicates removed',
|
||||
'invalid' => 'invalid addresses',
|
||||
'loading' => 'Calculating list …',
|
||||
],
|
||||
'hint' => [
|
||||
'optin_all' => 'The list also contains customers without newsletter opt-in. The "Newsletter" column marks the status per address.',
|
||||
'consultants_contract' => 'Consultants have no separate opt-in; the legal basis is the consultant contract ("Newsletter" column = contract).',
|
||||
],
|
||||
'export' => [
|
||||
'email' => 'Email address',
|
||||
'firstname' => 'First name',
|
||||
'lastname' => 'Last name',
|
||||
'salutation_label' => 'Salutation',
|
||||
'street' => 'Street',
|
||||
'zipcode' => 'Postcode',
|
||||
'city' => 'City',
|
||||
'company' => 'Company',
|
||||
'consultant' => 'Consultant',
|
||||
'country' => 'Country',
|
||||
'language' => 'Language',
|
||||
'source_label' => 'Source',
|
||||
'number' => 'Number',
|
||||
'consultant_number' => 'Consultant no.',
|
||||
'optin_label' => 'Newsletter',
|
||||
'status_label' => 'Status',
|
||||
'created_at' => 'Created',
|
||||
'button_xlsx' => 'Export XLSX',
|
||||
'button_csv' => 'Export CSV',
|
||||
'template_only' => 'Export only the 9 template columns',
|
||||
],
|
||||
'table' => [
|
||||
'email' => 'E-mail',
|
||||
'name' => 'Name',
|
||||
'salutation' => 'Salutation',
|
||||
'zipcode' => 'Postcode',
|
||||
'city' => 'City',
|
||||
'country' => 'Country',
|
||||
'language' => 'Language',
|
||||
'consultant' => 'Consultant / sponsor',
|
||||
'source' => 'Source',
|
||||
'optin' => 'Newsletter',
|
||||
'status' => 'Status',
|
||||
'created' => 'Created',
|
||||
],
|
||||
'saved' => [
|
||||
'title' => 'Saved lists',
|
||||
'current' => 'Loaded list',
|
||||
'name' => 'Name',
|
||||
'name_placeholder' => 'e.g. Newsletter DE customers',
|
||||
'save_new' => 'Save as new list',
|
||||
'save_changes' => 'Save changes to this list',
|
||||
'summary' => 'Filters',
|
||||
'source' => 'Source',
|
||||
'last_export' => 'Last export',
|
||||
'last_count' => 'Addresses',
|
||||
'created_by' => 'Created by',
|
||||
'created_at' => 'Created',
|
||||
'actions' => 'Actions',
|
||||
'load' => 'Load filters',
|
||||
'delete' => 'Delete',
|
||||
'confirm_delete' => 'Really delete this list? The export log is kept.',
|
||||
'stored' => 'List saved.',
|
||||
'updated' => 'List updated.',
|
||||
'deleted' => 'List deleted.',
|
||||
'intro' => 'Saved filter combinations. "Load filters" puts the list into the preview; the export uses the stored filters.',
|
||||
],
|
||||
'exports' => [
|
||||
'title' => 'Export log',
|
||||
'intro' => 'Every download is recorded with user, time, filters and number of addresses.',
|
||||
'date' => 'Date',
|
||||
'user' => 'User',
|
||||
'list' => 'List',
|
||||
'adhoc' => 'without saved list',
|
||||
'summary' => 'Filters',
|
||||
'format' => 'Format',
|
||||
'rows' => 'Addresses',
|
||||
'filename' => 'File',
|
||||
],
|
||||
'validation' => [
|
||||
'source' => 'Invalid source.',
|
||||
'option' => 'Invalid filter value.',
|
||||
'member' => 'Invalid consultant selection.',
|
||||
'date' => 'Invalid date.',
|
||||
'name' => 'Please enter a name (max. 120 characters).',
|
||||
'list' => 'List not found.',
|
||||
'date_range' => 'The end date must not be before the start date.',
|
||||
],
|
||||
];
|
||||
|
|
@ -95,4 +95,8 @@ return [
|
|||
'my_statistics' => 'Mi estadística',
|
||||
'team_statistics' => 'Estadística del equipo',
|
||||
'withdrawals' => 'Desistimientos',
|
||||
'newsletter_lists' => 'Listas de newsletter',
|
||||
'newsletter_create' => 'Crear lista',
|
||||
'newsletter_saved' => 'Listas guardadas',
|
||||
'newsletter_exports' => 'Registro de exportaciones',
|
||||
];
|
||||
|
|
|
|||
158
resources/lang/es/newsletter.php
Normal file
158
resources/lang/es/newsletter.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Listas de newsletter',
|
||||
'intro' => 'Crea una lista de destinatarios a partir de clientes y tutores, revísala y expórtala para rapidmail. Los registros eliminados, anonimizados, falsos y de prueba se excluyen siempre; las direcciones duplicadas se eliminan automáticamente.',
|
||||
'filter' => [
|
||||
'source' => 'Fuente',
|
||||
'language' => 'Idioma',
|
||||
'country' => 'País',
|
||||
'optin' => 'Opt-in newsletter',
|
||||
'purchase' => 'Estado de compra',
|
||||
'member' => 'Tutor / patrocinador asignado',
|
||||
'created_from' => 'Creado desde',
|
||||
'created_to' => 'Creado hasta',
|
||||
'company' => 'Empresa',
|
||||
'consultant_status' => 'Estado del tutor',
|
||||
'level' => 'Nivel de carrera',
|
||||
'include_admins' => 'Incluir cuentas de administrador',
|
||||
'all' => 'todos',
|
||||
'none_member' => 'sin asignación',
|
||||
'consultant_section' => 'Filtros de tutores',
|
||||
'refresh' => 'Actualizar vista previa',
|
||||
'reset' => 'Restablecer filtros',
|
||||
],
|
||||
'source_option' => [
|
||||
'customers' => 'Clientes',
|
||||
'consultants' => 'Tutores',
|
||||
'both' => 'Clientes y tutores',
|
||||
],
|
||||
'source' => [
|
||||
'customer' => 'Cliente',
|
||||
'consultant' => 'Tutor',
|
||||
],
|
||||
'optin_option' => [
|
||||
'all' => 'todos (el opt-in se exporta como columna)',
|
||||
'subscribed' => 'solo con opt-in',
|
||||
'unsubscribed' => 'solo sin opt-in',
|
||||
],
|
||||
'optin_value' => [
|
||||
'yes' => 'sí',
|
||||
'no' => 'no',
|
||||
'contract' => 'contrato',
|
||||
],
|
||||
'purchase_option' => [
|
||||
'all' => 'todos',
|
||||
'has_buyed' => 'ha comprado',
|
||||
'none' => 'nunca ha comprado',
|
||||
'months_3' => 'compró en los últimos 3 meses',
|
||||
'months_6' => 'compró en los últimos 6 meses',
|
||||
'months_12' => 'compró en los últimos 12 meses',
|
||||
'active_abo' => 'tiene una suscripción activa',
|
||||
],
|
||||
'company_option' => [
|
||||
'all' => 'todos',
|
||||
'with' => 'solo con empresa',
|
||||
'without' => 'solo sin empresa',
|
||||
],
|
||||
'consultant_status_option' => [
|
||||
'active' => 'solo activos',
|
||||
'inactive' => 'solo inactivos',
|
||||
'all' => 'todos',
|
||||
],
|
||||
'status_value' => [
|
||||
'active' => 'activo',
|
||||
'inactive' => 'inactivo',
|
||||
],
|
||||
'stats' => [
|
||||
'total' => 'direcciones únicas',
|
||||
'customers' => 'clientes',
|
||||
'consultants' => 'tutores',
|
||||
'duplicates' => 'duplicados eliminados',
|
||||
'invalid' => 'direcciones no válidas',
|
||||
'loading' => 'Calculando la lista …',
|
||||
],
|
||||
'hint' => [
|
||||
'optin_all' => 'La lista también contiene clientes sin opt-in de newsletter. La columna «Newsletter» indica el estado de cada dirección.',
|
||||
'consultants_contract' => 'Los tutores no tienen opt-in propio; la base es el contrato de tutor (columna «Newsletter» = contrato).',
|
||||
],
|
||||
'export' => [
|
||||
'email' => 'Correo electrónico',
|
||||
'firstname' => 'Nombre',
|
||||
'lastname' => 'Apellidos',
|
||||
'salutation_label' => 'Tratamiento',
|
||||
'street' => 'Calle',
|
||||
'zipcode' => 'Código postal',
|
||||
'city' => 'Ciudad',
|
||||
'company' => 'Empresa',
|
||||
'consultant' => 'Tutor',
|
||||
'country' => 'País',
|
||||
'language' => 'Idioma',
|
||||
'source_label' => 'Fuente',
|
||||
'number' => 'Número',
|
||||
'consultant_number' => 'N.º de tutor',
|
||||
'optin_label' => 'Newsletter',
|
||||
'status_label' => 'Estado',
|
||||
'created_at' => 'Creado',
|
||||
'button_xlsx' => 'Exportar XLSX',
|
||||
'button_csv' => 'Exportar CSV',
|
||||
'template_only' => 'Exportar solo las 9 columnas de la plantilla',
|
||||
],
|
||||
'table' => [
|
||||
'email' => 'Correo',
|
||||
'name' => 'Nombre',
|
||||
'salutation' => 'Tratamiento',
|
||||
'zipcode' => 'CP',
|
||||
'city' => 'Ciudad',
|
||||
'country' => 'País',
|
||||
'language' => 'Idioma',
|
||||
'consultant' => 'Tutor / patrocinador',
|
||||
'source' => 'Fuente',
|
||||
'optin' => 'Newsletter',
|
||||
'status' => 'Estado',
|
||||
'created' => 'Creado',
|
||||
],
|
||||
'saved' => [
|
||||
'title' => 'Listas guardadas',
|
||||
'current' => 'Lista cargada',
|
||||
'name' => 'Nombre',
|
||||
'name_placeholder' => 'p. ej. Newsletter DE clientes',
|
||||
'save_new' => 'Guardar como lista nueva',
|
||||
'save_changes' => 'Guardar cambios en esta lista',
|
||||
'summary' => 'Filtros',
|
||||
'source' => 'Fuente',
|
||||
'last_export' => 'Última exportación',
|
||||
'last_count' => 'Direcciones',
|
||||
'created_by' => 'Creado por',
|
||||
'created_at' => 'Creado',
|
||||
'actions' => 'Acciones',
|
||||
'load' => 'Cargar filtros',
|
||||
'delete' => 'Eliminar',
|
||||
'confirm_delete' => '¿Eliminar esta lista? El registro de exportaciones se conserva.',
|
||||
'stored' => 'Lista guardada.',
|
||||
'updated' => 'Lista actualizada.',
|
||||
'deleted' => 'Lista eliminada.',
|
||||
'intro' => 'Combinaciones de filtros guardadas. «Cargar filtros» lleva la lista a la vista previa; la exportación usa los filtros guardados.',
|
||||
],
|
||||
'exports' => [
|
||||
'title' => 'Registro de exportaciones',
|
||||
'intro' => 'Cada descarga se registra con usuario, fecha, filtros y número de direcciones.',
|
||||
'date' => 'Fecha',
|
||||
'user' => 'Usuario',
|
||||
'list' => 'Lista',
|
||||
'adhoc' => 'sin lista guardada',
|
||||
'summary' => 'Filtros',
|
||||
'format' => 'Formato',
|
||||
'rows' => 'Direcciones',
|
||||
'filename' => 'Archivo',
|
||||
],
|
||||
'validation' => [
|
||||
'source' => 'Fuente no válida.',
|
||||
'option' => 'Valor de filtro no válido.',
|
||||
'member' => 'Selección de tutor no válida.',
|
||||
'date' => 'Fecha no válida.',
|
||||
'name' => 'Indique un nombre (máx. 120 caracteres).',
|
||||
'list' => 'Lista no encontrada.',
|
||||
'date_range' => 'La fecha final no puede ser anterior a la inicial.',
|
||||
],
|
||||
];
|
||||
|
|
@ -95,4 +95,8 @@ return [
|
|||
'my_statistics' => 'Ma statistique',
|
||||
'team_statistics' => 'Statistiques de l\'équipe',
|
||||
'withdrawals' => 'Rétractations',
|
||||
'newsletter_lists' => 'Listes newsletter',
|
||||
'newsletter_create' => 'Créer une liste',
|
||||
'newsletter_saved' => 'Listes enregistrées',
|
||||
'newsletter_exports' => 'Journal des exports',
|
||||
];
|
||||
|
|
|
|||
158
resources/lang/fr/newsletter.php
Normal file
158
resources/lang/fr/newsletter.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Listes newsletter',
|
||||
'intro' => 'Composez une liste de destinataires à partir des clients et des conseillers, vérifiez-la et exportez-la pour rapidmail. Les enregistrements supprimés, anonymisés, fictifs et de test sont toujours exclus ; les adresses en double sont retirées automatiquement.',
|
||||
'filter' => [
|
||||
'source' => 'Source',
|
||||
'language' => 'Langue',
|
||||
'country' => 'Pays',
|
||||
'optin' => 'Opt-in newsletter',
|
||||
'purchase' => 'Statut d’achat',
|
||||
'member' => 'Conseiller / parrain attribué',
|
||||
'created_from' => 'Créé à partir du',
|
||||
'created_to' => 'Créé jusqu’au',
|
||||
'company' => 'Société',
|
||||
'consultant_status' => 'Statut du conseiller',
|
||||
'level' => 'Niveau de carrière',
|
||||
'include_admins' => 'Inclure les comptes administrateurs',
|
||||
'all' => 'tous',
|
||||
'none_member' => 'sans attribution',
|
||||
'consultant_section' => 'Filtres conseillers',
|
||||
'refresh' => 'Actualiser l’aperçu',
|
||||
'reset' => 'Réinitialiser les filtres',
|
||||
],
|
||||
'source_option' => [
|
||||
'customers' => 'Clients',
|
||||
'consultants' => 'Conseillers',
|
||||
'both' => 'Clients et conseillers',
|
||||
],
|
||||
'source' => [
|
||||
'customer' => 'Client',
|
||||
'consultant' => 'Conseiller',
|
||||
],
|
||||
'optin_option' => [
|
||||
'all' => 'tous (l’opt-in est exporté comme colonne)',
|
||||
'subscribed' => 'uniquement avec opt-in',
|
||||
'unsubscribed' => 'uniquement sans opt-in',
|
||||
],
|
||||
'optin_value' => [
|
||||
'yes' => 'oui',
|
||||
'no' => 'non',
|
||||
'contract' => 'contrat',
|
||||
],
|
||||
'purchase_option' => [
|
||||
'all' => 'tous',
|
||||
'has_buyed' => 'a acheté',
|
||||
'none' => 'n’a jamais acheté',
|
||||
'months_3' => 'a acheté au cours des 3 derniers mois',
|
||||
'months_6' => 'a acheté au cours des 6 derniers mois',
|
||||
'months_12' => 'a acheté au cours des 12 derniers mois',
|
||||
'active_abo' => 'a un abonnement actif',
|
||||
],
|
||||
'company_option' => [
|
||||
'all' => 'tous',
|
||||
'with' => 'uniquement avec société',
|
||||
'without' => 'uniquement sans société',
|
||||
],
|
||||
'consultant_status_option' => [
|
||||
'active' => 'actifs uniquement',
|
||||
'inactive' => 'inactifs uniquement',
|
||||
'all' => 'tous',
|
||||
],
|
||||
'status_value' => [
|
||||
'active' => 'actif',
|
||||
'inactive' => 'inactif',
|
||||
],
|
||||
'stats' => [
|
||||
'total' => 'adresses uniques',
|
||||
'customers' => 'clients',
|
||||
'consultants' => 'conseillers',
|
||||
'duplicates' => 'doublons retirés',
|
||||
'invalid' => 'adresses invalides',
|
||||
'loading' => 'Calcul de la liste …',
|
||||
],
|
||||
'hint' => [
|
||||
'optin_all' => 'La liste contient aussi des clients sans opt-in newsletter. La colonne « Newsletter » indique le statut de chaque adresse.',
|
||||
'consultants_contract' => 'Les conseillers n’ont pas d’opt-in propre ; la base est le contrat de conseiller (colonne « Newsletter » = contrat).',
|
||||
],
|
||||
'export' => [
|
||||
'email' => 'Adresse e-mail',
|
||||
'firstname' => 'Prénom',
|
||||
'lastname' => 'Nom',
|
||||
'salutation_label' => 'Civilité',
|
||||
'street' => 'Rue',
|
||||
'zipcode' => 'Code postal',
|
||||
'city' => 'Ville',
|
||||
'company' => 'Société',
|
||||
'consultant' => 'Conseiller',
|
||||
'country' => 'Pays',
|
||||
'language' => 'Langue',
|
||||
'source_label' => 'Source',
|
||||
'number' => 'Numéro',
|
||||
'consultant_number' => 'N° conseiller',
|
||||
'optin_label' => 'Newsletter',
|
||||
'status_label' => 'Statut',
|
||||
'created_at' => 'Créé le',
|
||||
'button_xlsx' => 'Exporter XLSX',
|
||||
'button_csv' => 'Exporter CSV',
|
||||
'template_only' => 'Exporter uniquement les 9 colonnes du modèle',
|
||||
],
|
||||
'table' => [
|
||||
'email' => 'E-mail',
|
||||
'name' => 'Nom',
|
||||
'salutation' => 'Civilité',
|
||||
'zipcode' => 'CP',
|
||||
'city' => 'Ville',
|
||||
'country' => 'Pays',
|
||||
'language' => 'Langue',
|
||||
'consultant' => 'Conseiller / parrain',
|
||||
'source' => 'Source',
|
||||
'optin' => 'Newsletter',
|
||||
'status' => 'Statut',
|
||||
'created' => 'Créé le',
|
||||
],
|
||||
'saved' => [
|
||||
'title' => 'Listes enregistrées',
|
||||
'current' => 'Liste chargée',
|
||||
'name' => 'Nom',
|
||||
'name_placeholder' => 'p. ex. Newsletter DE clients',
|
||||
'save_new' => 'Enregistrer comme nouvelle liste',
|
||||
'save_changes' => 'Enregistrer les modifications dans cette liste',
|
||||
'summary' => 'Filtres',
|
||||
'source' => 'Source',
|
||||
'last_export' => 'Dernier export',
|
||||
'last_count' => 'Adresses',
|
||||
'created_by' => 'Créé par',
|
||||
'created_at' => 'Créé le',
|
||||
'actions' => 'Actions',
|
||||
'load' => 'Charger les filtres',
|
||||
'delete' => 'Supprimer',
|
||||
'confirm_delete' => 'Supprimer vraiment cette liste ? Le journal des exports est conservé.',
|
||||
'stored' => 'Liste enregistrée.',
|
||||
'updated' => 'Liste mise à jour.',
|
||||
'deleted' => 'Liste supprimée.',
|
||||
'intro' => 'Combinaisons de filtres enregistrées. « Charger les filtres » reprend la liste dans l’aperçu ; l’export utilise les filtres enregistrés.',
|
||||
],
|
||||
'exports' => [
|
||||
'title' => 'Journal des exports',
|
||||
'intro' => 'Chaque téléchargement est consigné avec l’utilisateur, l’heure, les filtres et le nombre d’adresses.',
|
||||
'date' => 'Date',
|
||||
'user' => 'Utilisateur',
|
||||
'list' => 'Liste',
|
||||
'adhoc' => 'sans liste enregistrée',
|
||||
'summary' => 'Filtres',
|
||||
'format' => 'Format',
|
||||
'rows' => 'Adresses',
|
||||
'filename' => 'Fichier',
|
||||
],
|
||||
'validation' => [
|
||||
'source' => 'Source invalide.',
|
||||
'option' => 'Valeur de filtre invalide.',
|
||||
'member' => 'Sélection de conseiller invalide.',
|
||||
'date' => 'Date invalide.',
|
||||
'name' => 'Veuillez saisir un nom (120 caractères max.).',
|
||||
'list' => 'Liste introuvable.',
|
||||
'date_range' => 'La date de fin ne peut pas précéder la date de début.',
|
||||
],
|
||||
];
|
||||
51
resources/views/admin/newsletter/exports.blade.php
Normal file
51
resources/views/admin/newsletter/exports.blade.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
|
||||
<h4 class="font-weight-bold py-2 mb-2">
|
||||
{{ __('newsletter.exports.title') }}
|
||||
</h4>
|
||||
<p class="text-muted">{{ __('newsletter.exports.intro') }}</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-datatable table-responsive">
|
||||
<table class="table table-striped table-bordered" id="datatables-newsletter-exports">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('newsletter.exports.date') }}</th>
|
||||
<th>{{ __('newsletter.exports.user') }}</th>
|
||||
<th>{{ __('newsletter.exports.list') }}</th>
|
||||
<th>{{ __('newsletter.exports.summary') }}</th>
|
||||
<th>{{ __('newsletter.exports.format') }}</th>
|
||||
<th>{{ __('newsletter.exports.rows') }}</th>
|
||||
<th>{{ __('newsletter.exports.filename') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$('#datatables-newsletter-exports').DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
ajax: { url: '{!! route('admin_newsletter_lists_exports_datatable') !!}' },
|
||||
order: [[0, 'desc']],
|
||||
columns: [
|
||||
{ data: 'created_at', name: 'created_at', searchable: false },
|
||||
{ data: 'user', name: 'user', orderable: false, searchable: false },
|
||||
{ data: 'list', name: 'list', orderable: false, searchable: false },
|
||||
{ data: 'summary', name: 'summary', orderable: false, searchable: false },
|
||||
{ data: 'format', name: 'format' },
|
||||
{ data: 'row_count', name: 'row_count', searchable: false },
|
||||
{ data: 'filename', name: 'filename' }
|
||||
],
|
||||
bLengthChange: false,
|
||||
iDisplayLength: 50,
|
||||
language: { url: '/js/datatables-{{ \App::getLocale() }}.json' }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
304
resources/views/admin/newsletter/index.blade.php
Normal file
304
resources/views/admin/newsletter/index.blade.php
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
|
||||
<h4 class="font-weight-bold py-2 mb-2">
|
||||
{{ __('newsletter.title') }}
|
||||
</h4>
|
||||
<p class="text-muted">{{ __('newsletter.intro') }}</p>
|
||||
|
||||
@if($list)
|
||||
<div class="alert alert-secondary py-2 d-flex align-items-center justify-content-between flex-wrap">
|
||||
<div>
|
||||
<strong>{{ __('newsletter.saved.current') }}:</strong> {{ $list->name }}
|
||||
<span class="text-muted small ml-2">
|
||||
@if($list->last_exported_at)
|
||||
{{ __('newsletter.saved.last_export') }}: {{ $list->last_exported_at->format('d.m.Y H:i') }} ({{ $list->last_row_count }} {{ __('newsletter.saved.last_count') }})
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
<a href="{{ route('admin_newsletter_lists_saved') }}" class="btn btn-sm btn-outline-secondary">{{ __('newsletter.saved.title') }}</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<form id="newsletter-filter" method="get" action="{{ route('admin_newsletter_lists') }}" onsubmit="return false;">
|
||||
<input type="hidden" name="refresh" id="filter_refresh" value="0">
|
||||
<input type="hidden" name="list_id" value="{{ $list?->id }}">
|
||||
<div class="form-row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label class="form-label d-block">{{ __('newsletter.filter.source') }}</label>
|
||||
@foreach($sources as $source)
|
||||
<div class="custom-control custom-radio custom-control-inline">
|
||||
<input type="radio" class="custom-control-input" id="source_{{ $source }}" name="source" value="{{ $source }}" @if($filter['source'] === $source) checked @endif>
|
||||
<label class="custom-control-label" for="source_{{ $source }}">{{ __('newsletter.source_option.'.$source) }}</label>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="filter_language">{{ __('newsletter.filter.language') }}</label>
|
||||
<select class="custom-select" name="language" id="filter_language">
|
||||
<option value="" @if($filter['language'] === '') selected @endif>{{ __('newsletter.filter.all') }}</option>
|
||||
@foreach($languages as $language)
|
||||
<option value="{{ $language }}" @if($filter['language'] === $language) selected @endif>{{ strtoupper($language) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="filter_country_ids">{{ __('newsletter.filter.country') }}</label>
|
||||
<select class="selectpicker form-control" data-style="btn-default" data-live-search="true" data-actions-box="true" multiple name="country_ids[]" id="filter_country_ids" title="{{ __('newsletter.filter.all') }}">
|
||||
@foreach($countries as $country)
|
||||
<option value="{{ $country->id }}" @if(in_array($country->id, $filter['country_ids'], true)) selected @endif>{{ $country->code }} – {{ $country->getLocated() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="filter_optin">{{ __('newsletter.filter.optin') }}</label>
|
||||
<select class="custom-select" name="optin" id="filter_optin">
|
||||
@foreach($optin_options as $option)
|
||||
<option value="{{ $option }}" @if($filter['optin'] === $option) selected @endif>{{ __('newsletter.optin_option.'.$option) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="filter_purchase">{{ __('newsletter.filter.purchase') }}</label>
|
||||
<select class="custom-select" name="purchase" id="filter_purchase">
|
||||
@foreach($purchase_options as $option)
|
||||
<option value="{{ $option }}" @if($filter['purchase'] === $option) selected @endif>{{ __('newsletter.purchase_option.'.$option) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="filter_member_id">{{ __('newsletter.filter.member') }}</label>
|
||||
<select class="selectpicker form-control" data-style="btn-default" data-live-search="true" name="member_id" id="filter_member_id">
|
||||
<option value="" @if($filter['member_id'] === '') selected @endif>{{ __('newsletter.filter.all') }}</option>
|
||||
<option value="none" @if($filter['member_id'] === 'none') selected @endif>{{ __('newsletter.filter.none_member') }}</option>
|
||||
@foreach($members as $member)
|
||||
<option value="{{ $member->id }}" @if((string) $filter['member_id'] === (string) $member->id) selected @endif>{{ $member->getFullName() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="filter_created_from">{{ __('newsletter.filter.created_from') }}</label>
|
||||
<input type="date" class="form-control" name="created_from" id="filter_created_from" value="{{ $filter['created_from'] }}">
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<label class="form-label" for="filter_created_to">{{ __('newsletter.filter.created_to') }}</label>
|
||||
<input type="date" class="form-control" name="created_to" id="filter_created_to" value="{{ $filter['created_to'] }}">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="filter_company">{{ __('newsletter.filter.company') }}</label>
|
||||
<select class="custom-select" name="company" id="filter_company">
|
||||
@foreach($company_options as $option)
|
||||
<option value="{{ $option }}" @if($filter['company'] === $option) selected @endif>{{ __('newsletter.company_option.'.$option) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="consultant-filters" class="border-top pt-3 mt-1" @if($filter['source'] === 'customers') style="display:none" @endif>
|
||||
<div class="small font-weight-semibold text-muted mb-2">{{ __('newsletter.filter.consultant_section') }}</div>
|
||||
<div class="form-row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="filter_consultant_status">{{ __('newsletter.filter.consultant_status') }}</label>
|
||||
<select class="custom-select" name="consultant_status" id="filter_consultant_status">
|
||||
@foreach($consultant_status_options as $option)
|
||||
<option value="{{ $option }}" @if($filter['consultant_status'] === $option) selected @endif>{{ __('newsletter.consultant_status_option.'.$option) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="filter_level_ids">{{ __('newsletter.filter.level') }}</label>
|
||||
<select class="selectpicker form-control" data-style="btn-default" data-actions-box="true" multiple name="level_ids[]" id="filter_level_ids" title="{{ __('newsletter.filter.all') }}">
|
||||
@foreach($levels as $level)
|
||||
<option value="{{ $level->id }}" @if(in_array($level->id, $filter['level_ids'], true)) selected @endif>{{ $level->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3 pt-4">
|
||||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input" name="include_admins" id="filter_include_admins" value="1" @if($filter['include_admins']) checked @endif>
|
||||
<label class="custom-control-label" for="filter_include_admins">{{ __('newsletter.filter.include_admins') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row align-items-center border-top pt-3">
|
||||
<div class="col-md-8 mb-2">
|
||||
<button type="button" class="btn btn-primary" id="filter_apply"><span class="fa fa-sync"></span> {{ __('newsletter.filter.refresh') }}</button>
|
||||
<a href="{{ route('admin_newsletter_lists') }}?reset=filter" class="btn btn-outline-dark">{{ __('newsletter.filter.reset') }}</a>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2 text-right">
|
||||
<div class="custom-control custom-checkbox d-inline-block mr-3">
|
||||
<input type="checkbox" class="custom-control-input" name="template_only" id="filter_template_only" value="1" @if($filter['template_only']) checked @endif>
|
||||
<label class="custom-control-label" for="filter_template_only">{{ __('newsletter.export.template_only') }}</label>
|
||||
</div>
|
||||
<a href="#" class="btn btn-success export-link" data-format="xlsx"><span class="fa fa-file-excel"></span> {{ __('newsletter.export.button_xlsx') }}</a>
|
||||
<a href="#" class="btn btn-outline-success export-link" data-format="csv"><span class="fa fa-file-csv"></span> {{ __('newsletter.export.button_csv') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="newsletter-save-form" method="post" action="{{ route('admin_newsletter_lists_store') }}" class="form-row align-items-center border-top pt-3 mt-2">
|
||||
@csrf
|
||||
<div id="newsletter-save-fields"></div>
|
||||
<div class="col-md-5 mb-2">
|
||||
<input type="text" class="form-control" name="name" maxlength="120" required placeholder="{{ __('newsletter.saved.name_placeholder') }}" value="{{ old('name', $list?->name) }}">
|
||||
@error('name')<div class="text-danger small">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
<div class="col-md-7 mb-2">
|
||||
@if($list)
|
||||
<button type="submit" class="btn btn-outline-primary" data-action="{{ route('admin_newsletter_lists_update', [$list->id]) }}"><span class="fa fa-save"></span> {{ __('newsletter.saved.save_changes') }}</button>
|
||||
@endif
|
||||
<button type="submit" class="btn btn-outline-primary" data-action="{{ route('admin_newsletter_lists_store') }}"><span class="fa fa-plus"></span> {{ __('newsletter.saved.save_new') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-body py-2">
|
||||
<div class="alert alert-warning py-2 mb-2" id="hint_optin_all" @if($filter['optin'] !== 'all') style="display:none" @endif>{{ __('newsletter.hint.optin_all') }}</div>
|
||||
<div class="alert alert-info py-2 mb-2" id="hint_consultants" @if($filter['source'] === 'customers') style="display:none" @endif>{{ __('newsletter.hint.consultants_contract') }}</div>
|
||||
<div id="newsletter-stats" class="text-muted small">{{ __('newsletter.stats.loading') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card-datatable table-responsive">
|
||||
<table class="table table-striped table-bordered" id="datatables-newsletter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('newsletter.table.email') }}</th>
|
||||
<th>{{ __('newsletter.table.name') }}</th>
|
||||
<th>{{ __('newsletter.table.salutation') }}</th>
|
||||
<th>{{ __('newsletter.table.zipcode') }}</th>
|
||||
<th>{{ __('newsletter.table.city') }}</th>
|
||||
<th>{{ __('newsletter.table.country') }}</th>
|
||||
<th>{{ __('newsletter.table.language') }}</th>
|
||||
<th>{{ __('newsletter.table.consultant') }}</th>
|
||||
<th>{{ __('newsletter.table.source') }}</th>
|
||||
<th>{{ __('newsletter.table.optin') }}</th>
|
||||
<th>{{ __('newsletter.table.status') }}</th>
|
||||
<th>{{ __('newsletter.table.created') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
var form = $('#newsletter-filter');
|
||||
var statsLabels = {
|
||||
total: @json(__('newsletter.stats.total')),
|
||||
customers: @json(__('newsletter.stats.customers')),
|
||||
consultants: @json(__('newsletter.stats.consultants')),
|
||||
duplicates: @json(__('newsletter.stats.duplicates')),
|
||||
invalid: @json(__('newsletter.stats.invalid'))
|
||||
};
|
||||
|
||||
function filterParams(d) {
|
||||
d = d || {};
|
||||
form.serializeArray().forEach(function (field) {
|
||||
if (field.name.slice(-2) === '[]') {
|
||||
var key = field.name.slice(0, -2);
|
||||
(d[key] = d[key] || []).push(field.value);
|
||||
} else {
|
||||
d[field.name] = field.value;
|
||||
}
|
||||
});
|
||||
return d;
|
||||
}
|
||||
|
||||
var oTable = $('#datatables-newsletter').DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
ajax: {
|
||||
url: '{!! route('admin_newsletter_lists_datatable') !!}',
|
||||
data: function (d) {
|
||||
filterParams(d);
|
||||
}
|
||||
},
|
||||
order: [[0, 'asc']],
|
||||
columns: [
|
||||
{ data: 'email', name: 'email' },
|
||||
{ data: 'name', name: 'name', orderable: false },
|
||||
{ data: 'salutation_label', name: 'salutation_label' },
|
||||
{ data: 'zipcode', name: 'zipcode' },
|
||||
{ data: 'city', name: 'city' },
|
||||
{ data: 'country', name: 'country' },
|
||||
{ data: 'language', name: 'language' },
|
||||
{ data: 'consultant', name: 'consultant' },
|
||||
{ data: 'source_label', name: 'source_label' },
|
||||
{ data: 'optin_label', name: 'optin_label' },
|
||||
{ data: 'status_label', name: 'status_label' },
|
||||
{ data: 'created_at', name: 'created_at' }
|
||||
],
|
||||
bLengthChange: false,
|
||||
iDisplayLength: 50,
|
||||
language: {
|
||||
url: '/js/datatables-{{ \App::getLocale() }}.json'
|
||||
}
|
||||
});
|
||||
|
||||
$('#datatables-newsletter').on('xhr.dt', function (e, settings, json) {
|
||||
$('#filter_refresh').val('0');
|
||||
if (!json || !json.stats) {
|
||||
return;
|
||||
}
|
||||
var s = json.stats;
|
||||
$('#newsletter-stats').html(
|
||||
'<strong>' + s.total + '</strong> ' + statsLabels.total +
|
||||
' · ' + statsLabels.customers + ': ' + s.customers +
|
||||
' · ' + statsLabels.consultants + ': ' + s.consultants +
|
||||
' · ' + statsLabels.duplicates + ': ' + s.duplicates +
|
||||
' · ' + statsLabels.invalid + ': ' + s.invalid
|
||||
);
|
||||
});
|
||||
|
||||
function syncHints() {
|
||||
var source = form.find('input[name=source]:checked').val();
|
||||
$('#consultant-filters').toggle(source !== 'customers');
|
||||
$('#hint_consultants').toggle(source !== 'customers');
|
||||
$('#hint_optin_all').toggle($('#filter_optin').val() === 'all' && source !== 'consultants');
|
||||
}
|
||||
|
||||
$('#filter_apply').on('click', function () {
|
||||
$('#filter_refresh').val('1');
|
||||
syncHints();
|
||||
oTable.draw();
|
||||
});
|
||||
|
||||
form.find('select, input[type=date], input[type=checkbox], input[type=radio]').on('change', function () {
|
||||
syncHints();
|
||||
oTable.draw();
|
||||
});
|
||||
|
||||
$('#newsletter-save-form button[type=submit]').on('click', function () {
|
||||
$('#newsletter-save-form').attr('action', $(this).data('action'));
|
||||
});
|
||||
|
||||
$('#newsletter-save-form').on('submit', function () {
|
||||
var fields = $('#newsletter-save-fields').empty();
|
||||
form.serializeArray().forEach(function (field) {
|
||||
if (field.name === 'refresh' || field.name === 'list_id') {
|
||||
return;
|
||||
}
|
||||
fields.append($('<input>', { type: 'hidden', name: field.name, value: field.value }));
|
||||
});
|
||||
});
|
||||
|
||||
$('.export-link').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
var base = '{!! route('admin_newsletter_lists_export', ['format' => '__FORMAT__']) !!}'.replace('__FORMAT__', $(this).data('format'));
|
||||
window.location.href = base + '?' + $.param(filterParams({}));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
56
resources/views/admin/newsletter/saved.blade.php
Normal file
56
resources/views/admin/newsletter/saved.blade.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
|
||||
<h4 class="font-weight-bold py-2 mb-2">
|
||||
{{ __('newsletter.saved.title') }}
|
||||
</h4>
|
||||
<p class="text-muted">{{ __('newsletter.saved.intro') }}</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<a href="{{ route('admin_newsletter_lists') }}" class="btn btn-sm btn-primary"><span class="fa fa-plus"></span> {{ __('navigation.newsletter_create') }}</a>
|
||||
</div>
|
||||
<div class="card-datatable table-responsive">
|
||||
<table class="table table-striped table-bordered" id="datatables-newsletter-saved">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('newsletter.saved.name') }}</th>
|
||||
<th>{{ __('newsletter.saved.source') }}</th>
|
||||
<th>{{ __('newsletter.saved.summary') }}</th>
|
||||
<th>{{ __('newsletter.saved.last_export') }}</th>
|
||||
<th>{{ __('newsletter.saved.last_count') }}</th>
|
||||
<th>{{ __('newsletter.saved.created_by') }}</th>
|
||||
<th>{{ __('newsletter.saved.created_at') }}</th>
|
||||
<th>{{ __('newsletter.saved.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$('#datatables-newsletter-saved').DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
ajax: { url: '{!! route('admin_newsletter_lists_saved_datatable') !!}' },
|
||||
order: [[0, 'asc']],
|
||||
columns: [
|
||||
{ data: 'name', name: 'name' },
|
||||
{ data: 'source', name: 'source' },
|
||||
{ data: 'summary', name: 'summary', orderable: false, searchable: false },
|
||||
{ data: 'last_exported_at', name: 'last_exported_at', searchable: false },
|
||||
{ data: 'last_row_count', name: 'last_row_count', searchable: false },
|
||||
{ data: 'creator', name: 'creator', orderable: false, searchable: false },
|
||||
{ data: 'created_at', name: 'created_at', searchable: false },
|
||||
{ data: 'actions', name: 'actions', orderable: false, searchable: false }
|
||||
],
|
||||
bLengthChange: false,
|
||||
iDisplayLength: 50,
|
||||
language: { url: '/js/datatables-{{ \App::getLocale() }}.json' }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
|
@ -326,6 +326,32 @@
|
|||
<div>{{ __('navigation.clients') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item @if (Request::is('admin/newsletter-lists*')) open @endif">
|
||||
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
|
||||
<i class="sidenav-icon ion ion-md-mail"></i>
|
||||
<div>{{ __('navigation.newsletter_lists') }}</div>
|
||||
</a>
|
||||
<ul class="sidenav-menu">
|
||||
<li class="sidenav-item{{ Request::is('admin/newsletter-lists', 'admin/newsletter-lists/load/*') ? ' active' : '' }}">
|
||||
<a href="{{ route('admin_newsletter_lists') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-md-funnel"></i>
|
||||
<div>{{ __('navigation.newsletter_create') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item{{ Request::is('admin/newsletter-lists/saved*') ? ' active' : '' }}">
|
||||
<a href="{{ route('admin_newsletter_lists_saved') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-md-bookmarks"></i>
|
||||
<div>{{ __('navigation.newsletter_saved') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item{{ Request::is('admin/newsletter-lists/exports*') ? ' active' : '' }}">
|
||||
<a href="{{ route('admin_newsletter_lists_exports') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-md-list-box"></i>
|
||||
<div>{{ __('navigation.newsletter_exports') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="sidenav-item @if (Request::is('admin/sales/*', 'admin/abos', 'admin/abos/*', 'admin/withdrawals', 'admin/withdrawals/*')) open @endif">
|
||||
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
|
||||
<i class="sidenav-icon ion ion-ios-cart"></i>
|
||||
|
|
|
|||
|
|
@ -343,6 +343,20 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
|
|||
Route::post('/admin/customer/edit/{id}', 'CustomerController@store')->name('admin_customer_edit');
|
||||
Route::get('/admin/customer/datatable', 'CustomerController@getCustomers')->name('admin_customer_datatable');
|
||||
|
||||
// newsletter lists
|
||||
Route::get('/admin/newsletter-lists', 'Admin\NewsletterListController@index')->name('admin_newsletter_lists');
|
||||
Route::get('/admin/newsletter-lists/datatable', 'Admin\NewsletterListController@datatable')->name('admin_newsletter_lists_datatable');
|
||||
Route::get('/admin/newsletter-lists/export/{format}', 'Admin\NewsletterListController@export')->name('admin_newsletter_lists_export')->where('format', 'xlsx|csv');
|
||||
Route::get('/admin/newsletter-lists/saved', 'Admin\NewsletterListController@saved')->name('admin_newsletter_lists_saved');
|
||||
Route::get('/admin/newsletter-lists/saved/datatable', 'Admin\NewsletterListController@savedDatatable')->name('admin_newsletter_lists_saved_datatable');
|
||||
Route::get('/admin/newsletter-lists/saved/{list}/export/{format}', 'Admin\NewsletterListController@exportSaved')->name('admin_newsletter_lists_saved_export')->where('format', 'xlsx|csv');
|
||||
Route::get('/admin/newsletter-lists/load/{list}', 'Admin\NewsletterListController@load')->name('admin_newsletter_lists_load');
|
||||
Route::post('/admin/newsletter-lists/save', 'Admin\NewsletterListController@store')->name('admin_newsletter_lists_store');
|
||||
Route::post('/admin/newsletter-lists/update/{list}', 'Admin\NewsletterListController@update')->name('admin_newsletter_lists_update');
|
||||
Route::post('/admin/newsletter-lists/delete/{list}', 'Admin\NewsletterListController@destroy')->name('admin_newsletter_lists_destroy');
|
||||
Route::get('/admin/newsletter-lists/exports', 'Admin\NewsletterListController@exports')->name('admin_newsletter_lists_exports');
|
||||
Route::get('/admin/newsletter-lists/exports/datatable', 'Admin\NewsletterListController@exportsDatatable')->name('admin_newsletter_lists_exports_datatable');
|
||||
|
||||
Route::get('/admin/lead/change_mail/{id}', 'UserUpdateEmailController@adminChangeMail')->name('admin_lead_change_mail');
|
||||
Route::post('/admin/lead/change_mail/{id}', 'UserUpdateEmailController@adminUpdateMail')->name('admin_lead_change_mail');
|
||||
Route::get('/admin/lead/new_mail/verified/{id}', 'LeadController@newMailVerified')->name('admin_lead_new_mail_verified');
|
||||
|
|
|
|||
153
tests/Feature/Newsletter/NewsletterListControllerTest.php
Normal file
153
tests/Feature/Newsletter/NewsletterListControllerTest.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
use App\Exports\xExport;
|
||||
use App\Http\Controllers\Admin\NewsletterListController;
|
||||
use App\Http\Requests\Newsletter\NewsletterListFilterRequest;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\Newsletter\NewsletterListService;
|
||||
use App\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function newsletterAdmin(int $admin = 2): User
|
||||
{
|
||||
return User::forceCreate([
|
||||
'email' => 'admin-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'admin' => $admin,
|
||||
'lang' => 'de',
|
||||
'active' => 1,
|
||||
'blocked' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
function newsletterRequest(User $user, array $params = []): NewsletterListFilterRequest
|
||||
{
|
||||
$request = NewsletterListFilterRequest::create('/admin/newsletter-lists/datatable', 'GET', $params);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
$request->setContainer(app())->setRedirector(app(Redirector::class));
|
||||
$request->validateResolved();
|
||||
app()->instance('request', $request);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
function newsletterController(): NewsletterListController
|
||||
{
|
||||
return new NewsletterListController(app(NewsletterListService::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function newsletterListCustomer(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' => 1,
|
||||
'shipping_country_id' => 1,
|
||||
'language' => 'de',
|
||||
'subscribed' => 1,
|
||||
'has_buyed' => 1,
|
||||
'faker_mail' => 0,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('verweigert Nicht-Admins den Zugriff auf die Filter-Anfrage', function () {
|
||||
$this->actingAs(newsletterAdmin(0));
|
||||
|
||||
newsletterRequest(auth()->user(), ['source' => 'customers']);
|
||||
})->throws(AuthorizationException::class);
|
||||
|
||||
it('lehnt ungültige Filterwerte ab', function () {
|
||||
$this->actingAs(newsletterAdmin());
|
||||
|
||||
newsletterRequest(auth()->user(), ['source' => 'alle', 'created_from' => '2026-02-01', 'created_to' => '2026-01-01']);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
it('liefert die Vorschau als Datatable-JSON mit Kennzahlen', function () {
|
||||
$admin = newsletterAdmin();
|
||||
$this->actingAs($admin);
|
||||
newsletterListCustomer(['billing_email' => 'a@example.com']);
|
||||
newsletterListCustomer(['billing_email' => 'A@example.com']);
|
||||
newsletterListCustomer(['billing_email' => 'b@example.com', 'subscribed' => 0]);
|
||||
|
||||
$request = newsletterRequest($admin, ['source' => 'customers', 'draw' => 1, 'start' => 0, 'length' => 50]);
|
||||
$json = newsletterController()->datatable($request)->getData(true);
|
||||
|
||||
expect($json['recordsTotal'])->toBe(2)
|
||||
->and($json['stats']['total'])->toBe(2)
|
||||
->and($json['stats']['duplicates'])->toBe(1)
|
||||
->and(collect($json['data'])->pluck('email')->all())->toBe(['a@example.com', 'b@example.com'])
|
||||
->and($json['data'][0]['name'])->toBe('Erika Muster')
|
||||
->and($json['data'][0]['optin_label'])->toContain('badge-success')
|
||||
->and($json['data'][1]['optin_label'])->toContain('badge-danger')
|
||||
->and(get_user_attr('newsletter_list_filter')['source'])->toBe('customers');
|
||||
});
|
||||
|
||||
it('exportiert die Liste als CSV mit BOM, Semikolon und Injection-Schutz', function () {
|
||||
$admin = newsletterAdmin();
|
||||
$this->actingAs($admin);
|
||||
newsletterListCustomer(['billing_email' => 'csv@example.com', 'billing_firstname' => '=SUMME(A1)']);
|
||||
|
||||
$response = newsletterController()->export(newsletterRequest($admin, ['source' => 'customers', 'template_only' => 1]), 'csv');
|
||||
|
||||
expect($response)->toBeInstanceOf(StreamedResponse::class);
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$content = (string) ob_get_clean();
|
||||
$lines = explode("\n", trim($content));
|
||||
|
||||
expect($content)->toStartWith("\xEF\xBB\xBF")
|
||||
->and($lines[0])->toBe("\xEF\xBB\xBF".'Emailadresse;Vorname;Nachname;Anrede;Straße;PLZ;Wohnort;Firma;Berater')
|
||||
->and($lines[1])->toBe("csv@example.com;'=SUMME(A1);Muster;Frau;\"Musterweg 1\";12345;Musterstadt;;")
|
||||
->and($response->headers->get('content-disposition'))->toContain('newsletter_customers_de_'.now()->format('Y-m-d').'.csv');
|
||||
});
|
||||
|
||||
it('exportiert die Liste als XLSX über die Export-Klasse', function () {
|
||||
Excel::fake();
|
||||
$admin = newsletterAdmin();
|
||||
$this->actingAs($admin);
|
||||
newsletterListCustomer(['billing_email' => 'xlsx@example.com']);
|
||||
|
||||
newsletterController()->export(newsletterRequest($admin, ['source' => 'customers', 'language' => '']), 'xlsx');
|
||||
|
||||
Excel::assertDownloaded('newsletter_customers_alle_'.now()->format('Y-m-d').'.xlsx', function (xExport $export) {
|
||||
$rows = $export->collection();
|
||||
|
||||
return $export->headings()[0][0] === 'Emailadresse'
|
||||
&& count($export->headings()[0]) === 17
|
||||
&& $rows->count() === 1
|
||||
&& $rows->first()[0] === 'xlsx@example.com';
|
||||
});
|
||||
});
|
||||
|
||||
it('zeigt die Index-Seite mit gemerktem Filter', function () {
|
||||
$admin = newsletterAdmin();
|
||||
$this->actingAs($admin);
|
||||
set_user_attr('newsletter_list_filter', ['source' => 'both', 'language' => 'es', 'optin' => 'subscribed']);
|
||||
|
||||
$view = newsletterController()->index(newsletterRequest($admin));
|
||||
|
||||
expect($view->getName())->toBe('admin.newsletter.index')
|
||||
->and($view->getData()['filter']['source'])->toBe('both')
|
||||
->and($view->getData()['filter']['language'])->toBe('es')
|
||||
->and($view->getData()['filter']['optin'])->toBe('subscribed');
|
||||
});
|
||||
256
tests/Feature/Newsletter/NewsletterListSavedTest.php
Normal file
256
tests/Feature/Newsletter/NewsletterListSavedTest.php
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\NewsletterListController;
|
||||
use App\Http\Requests\Newsletter\NewsletterListFilterRequest;
|
||||
use App\Http\Requests\Newsletter\StoreNewsletterListRequest;
|
||||
use App\Models\NewsletterList;
|
||||
use App\Models\NewsletterListExport;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\Newsletter\NewsletterListFilter;
|
||||
use App\Services\Newsletter\NewsletterListService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Domain-Routen werden im Test-Kontext nicht geladen (Host ist nicht die CRM-Domain).
|
||||
* Für die Weiterleitungen und Links des Controllers registrieren wir die CRM-Routen hier.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
if (! app('router')->has('admin_newsletter_lists_load')) {
|
||||
Route::group([], base_path('routes/domains/crm.php'));
|
||||
app('router')->getRoutes()->refreshNameLookups();
|
||||
}
|
||||
});
|
||||
|
||||
function newsletterSavedAdmin(): User
|
||||
{
|
||||
return User::forceCreate([
|
||||
'email' => 'admin-saved-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'admin' => 2,
|
||||
'lang' => 'de',
|
||||
'active' => 1,
|
||||
'blocked' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
function newsletterStoreRequest(User $user, array $params = []): StoreNewsletterListRequest
|
||||
{
|
||||
$request = StoreNewsletterListRequest::create('/admin/newsletter-lists/save', 'POST', $params);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
$request->setContainer(app())->setRedirector(app(Redirector::class));
|
||||
$request->validateResolved();
|
||||
app()->instance('request', $request);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
function newsletterSavedList(User $user, array $filters = [], string $name = 'Testliste'): NewsletterList
|
||||
{
|
||||
$list = new NewsletterList(['name' => $name, 'created_by' => $user->id]);
|
||||
$list->applyFilter(NewsletterListFilter::fromArray($filters));
|
||||
$list->save();
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
function newsletterSavedCustomer(string $email): ShoppingUser
|
||||
{
|
||||
return ShoppingUser::forceCreate([
|
||||
'auth_user_id' => 0,
|
||||
'billing_email' => $email,
|
||||
'billing_firstname' => 'Erika',
|
||||
'billing_lastname' => 'Muster',
|
||||
'billing_salutation' => 'ms',
|
||||
'billing_country_id' => 1,
|
||||
'shipping_country_id' => 1,
|
||||
'language' => 'de',
|
||||
'subscribed' => 1,
|
||||
'has_buyed' => 1,
|
||||
'faker_mail' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
it('speichert die aktuelle Filterkombination unter einem Namen', function () {
|
||||
$admin = newsletterSavedAdmin();
|
||||
$this->actingAs($admin);
|
||||
|
||||
$request = newsletterStoreRequest($admin, ['name' => ' Newsletter DE Kunden ', 'source' => 'both', 'language' => 'de', 'optin' => 'subscribed', 'level_ids' => ['2']]);
|
||||
$response = (new NewsletterListController(app(NewsletterListService::class)))->store($request);
|
||||
|
||||
$list = NewsletterList::first();
|
||||
expect($list->name)->toBe('Newsletter DE Kunden')
|
||||
->and($list->source)->toBe('both')
|
||||
->and($list->created_by)->toBe($admin->id)
|
||||
->and($list->filters['optin'])->toBe('subscribed')
|
||||
->and($list->filters['level_ids'])->toBe([2])
|
||||
->and($list->toFilter()->optin)->toBe('subscribed')
|
||||
->and($response->getTargetUrl())->toContain('/admin/newsletter-lists/load/'.$list->id);
|
||||
});
|
||||
|
||||
it('verlangt beim Speichern einen Namen', function () {
|
||||
$admin = newsletterSavedAdmin();
|
||||
$this->actingAs($admin);
|
||||
|
||||
newsletterStoreRequest($admin, ['source' => 'customers']);
|
||||
})->throws(ValidationException::class);
|
||||
|
||||
it('aktualisiert Name und Filter einer gespeicherten Liste', function () {
|
||||
$admin = newsletterSavedAdmin();
|
||||
$this->actingAs($admin);
|
||||
$list = newsletterSavedList($admin, ['source' => 'customers']);
|
||||
|
||||
$request = newsletterStoreRequest($admin, ['name' => 'Berater ES', 'source' => 'consultants', 'language' => 'es', 'consultant_status' => 'all']);
|
||||
(new NewsletterListController(app(NewsletterListService::class)))->update($request, $list);
|
||||
|
||||
$list->refresh();
|
||||
expect($list->name)->toBe('Berater ES')
|
||||
->and($list->source)->toBe('consultants')
|
||||
->and($list->filters['language'])->toBe('es')
|
||||
->and($list->filters['consultant_status'])->toBe('all');
|
||||
});
|
||||
|
||||
it('lädt eine gespeicherte Liste in die Filter-Seite und merkt den Filter', function () {
|
||||
$admin = newsletterSavedAdmin();
|
||||
$this->actingAs($admin);
|
||||
$list = newsletterSavedList($admin, ['source' => 'consultants', 'language' => '']);
|
||||
|
||||
$view = (new NewsletterListController(app(NewsletterListService::class)))->load($list);
|
||||
|
||||
expect($view->getName())->toBe('admin.newsletter.index')
|
||||
->and($view->getData()['list']->id)->toBe($list->id)
|
||||
->and($view->getData()['filter']['source'])->toBe('consultants')
|
||||
->and($view->getData()['filter']['language'])->toBe('')
|
||||
->and(get_user_attr('newsletter_list_filter')['source'])->toBe('consultants');
|
||||
});
|
||||
|
||||
it('protokolliert Exporte und aktualisiert die gespeicherte Liste', function () {
|
||||
Excel::fake();
|
||||
$admin = newsletterSavedAdmin();
|
||||
$this->actingAs($admin);
|
||||
newsletterSavedCustomer('eins@example.com');
|
||||
newsletterSavedCustomer('zwei@example.com');
|
||||
$list = newsletterSavedList($admin, ['source' => 'customers', 'language' => 'de']);
|
||||
|
||||
$request = NewsletterListFilterRequest::create('/x', 'GET', []);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
(new NewsletterListController(app(NewsletterListService::class)))->exportSaved($request, $list, 'xlsx');
|
||||
|
||||
$export = NewsletterListExport::first();
|
||||
$list->refresh();
|
||||
expect($export->newsletter_list_id)->toBe($list->id)
|
||||
->and($export->user_id)->toBe($admin->id)
|
||||
->and($export->format)->toBe('xlsx')
|
||||
->and($export->row_count)->toBe(2)
|
||||
->and($export->filename)->toBe('newsletter_customers_de_'.now()->format('Y-m-d').'.xlsx')
|
||||
->and($export->filters['source'])->toBe('customers')
|
||||
->and($export->stats['total'])->toBe(2)
|
||||
->and($list->last_row_count)->toBe(2)
|
||||
->and($list->last_exported_at)->not->toBeNull();
|
||||
Excel::assertDownloaded($export->filename);
|
||||
});
|
||||
|
||||
it('protokolliert Ad-hoc-Exporte ohne Liste und ordnet sie bei list_id der Liste zu', function () {
|
||||
$admin = newsletterSavedAdmin();
|
||||
$this->actingAs($admin);
|
||||
newsletterSavedCustomer('csv@example.com');
|
||||
$list = newsletterSavedList($admin);
|
||||
$controller = new NewsletterListController(app(NewsletterListService::class));
|
||||
|
||||
$adhoc = NewsletterListFilterRequest::create('/x', 'GET', ['source' => 'customers']);
|
||||
$adhoc->setUserResolver(fn () => $admin);
|
||||
$adhoc->setContainer(app())->setRedirector(app(Redirector::class));
|
||||
$adhoc->validateResolved();
|
||||
$controller->export($adhoc, 'csv');
|
||||
|
||||
$linked = NewsletterListFilterRequest::create('/x', 'GET', ['source' => 'customers', 'list_id' => (string) $list->id]);
|
||||
$linked->setUserResolver(fn () => $admin);
|
||||
$linked->setContainer(app())->setRedirector(app(Redirector::class));
|
||||
$linked->validateResolved();
|
||||
$controller->export($linked, 'csv');
|
||||
|
||||
$exports = NewsletterListExport::orderBy('id')->get();
|
||||
expect($exports)->toHaveCount(2)
|
||||
->and($exports[0]->newsletter_list_id)->toBeNull()
|
||||
->and($exports[0]->format)->toBe('csv')
|
||||
->and($exports[1]->newsletter_list_id)->toBe($list->id)
|
||||
->and($list->fresh()->last_row_count)->toBe(1);
|
||||
});
|
||||
|
||||
it('löscht eine Liste und behält das Protokoll', function () {
|
||||
$admin = newsletterSavedAdmin();
|
||||
$this->actingAs($admin);
|
||||
$list = newsletterSavedList($admin);
|
||||
NewsletterListExport::create([
|
||||
'newsletter_list_id' => $list->id,
|
||||
'user_id' => $admin->id,
|
||||
'format' => 'csv',
|
||||
'row_count' => 3,
|
||||
'filename' => 'x.csv',
|
||||
'filters' => $list->filters,
|
||||
]);
|
||||
|
||||
$response = (new NewsletterListController(app(NewsletterListService::class)))->destroy($list);
|
||||
|
||||
expect(NewsletterList::count())->toBe(0)
|
||||
->and(NewsletterListExport::first()->newsletter_list_id)->toBeNull()
|
||||
->and($response->getTargetUrl())->toContain('/admin/newsletter-lists/saved');
|
||||
});
|
||||
|
||||
it('liefert gespeicherte Listen und Protokoll als Datatable-JSON', function () {
|
||||
$admin = newsletterSavedAdmin();
|
||||
$this->actingAs($admin);
|
||||
$list = newsletterSavedList($admin, ['source' => 'customers', 'optin' => 'subscribed', 'purchase' => 'months_6'], 'Kunden Opt-in');
|
||||
NewsletterListExport::create([
|
||||
'newsletter_list_id' => $list->id,
|
||||
'user_id' => $admin->id,
|
||||
'format' => 'xlsx',
|
||||
'row_count' => 7,
|
||||
'filename' => 'newsletter_customers_de.xlsx',
|
||||
'filters' => $list->filters,
|
||||
'stats' => ['total' => 7],
|
||||
]);
|
||||
$request = Request::create('/x', 'GET', ['draw' => 1, 'start' => 0, 'length' => 50]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
app()->instance('request', $request);
|
||||
$controller = new NewsletterListController(app(NewsletterListService::class));
|
||||
|
||||
$saved = $controller->savedDatatable()->getData(true);
|
||||
$exports = $controller->exportsDatatable()->getData(true);
|
||||
|
||||
expect($saved['recordsTotal'])->toBe(1)
|
||||
->and($saved['data'][0]['name'])->toContain('Kunden Opt-in')
|
||||
->and($saved['data'][0]['source'])->toBe('Kunden')
|
||||
->and($saved['data'][0]['summary'])->toContain('nur mit Opt-in')
|
||||
->and($saved['data'][0]['summary'])->toContain('6 Monaten')
|
||||
->and($saved['data'][0]['actions'])->toContain('/admin/newsletter-lists/saved/'.$list->id.'/export/xlsx')
|
||||
->and($exports['recordsTotal'])->toBe(1)
|
||||
->and($exports['data'][0]['format'])->toBe('XLSX')
|
||||
->and($exports['data'][0]['row_count'])->toBe(7)
|
||||
->and($exports['data'][0]['list'])->toContain('Kunden Opt-in');
|
||||
});
|
||||
|
||||
it('beschreibt einen Filter lesbar', function () {
|
||||
$service = app(NewsletterListService::class);
|
||||
|
||||
$text = $service->describe(NewsletterListFilter::fromArray([
|
||||
'source' => 'both', 'language' => 'de', 'optin' => 'unsubscribed', 'member_id' => 'none',
|
||||
'created_from' => '2026-01-01', 'company' => 'with', 'consultant_status' => 'all', 'include_admins' => 1,
|
||||
]));
|
||||
|
||||
expect($text)->toBe('Kunden und Berater · Sprache: DE · Newsletter-Opt-in: nur ohne Opt-in · Zugeordneter Berater / Sponsor: ohne Zuordnung · Angelegt von: 01.01.2026 – … · Firma: nur mit Firma · Berater-Status: alle · Admin-Accounts einbeziehen');
|
||||
});
|
||||
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