Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6780565586 | |||
| 7bc551ca1f | |||
| 9ad69a3218 | |||
| ae59679150 |
57 changed files with 3892 additions and 132 deletions
72
app/Console/Commands/RecognitionsFreeze.php
Normal file
72
app/Console/Commands/RecognitionsFreeze.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Team\RecognitionService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RecognitionsFreeze extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'recognitions:freeze
|
||||
{--month= : Nur diesen Monat einfrieren (mit --year)}
|
||||
{--year= : Nur dieses Jahr einfrieren (mit --month)}
|
||||
{--force : Bereits eingefrorene Monate neu berechnen}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Friert die Anerkennungen abgeschlossener Monate ein (ab dem 3. des Folgemonats)';
|
||||
|
||||
/**
|
||||
* Ohne Optionen werden alle noch offenen Monate seit dem ersten
|
||||
* Anerkennungsmonat nachgezogen — der tägliche Lauf holt damit auch einen
|
||||
* verpassten Stichtag nach. Ein neu gebauter Snapshot rechnet mit dem Stand
|
||||
* des Ausführungstags; `--force` deshalb nur bewusst einsetzen.
|
||||
*/
|
||||
public function handle(RecognitionService $recognitions): int
|
||||
{
|
||||
$months = $this->monthsToFreeze();
|
||||
$force = (bool) $this->option('force');
|
||||
$stored = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($months as ['month' => $month, 'year' => $year]) {
|
||||
if (! RecognitionService::isFreezable($month, $year)) {
|
||||
$this->line(sprintf('%02d/%d: noch nicht festschreibbar (ab %s)', $month, $year, RecognitionService::freezeDate($month, $year)->format('d.m.Y')));
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($recognitions->freeze($month, $year, $force)) {
|
||||
$this->info(sprintf('%02d/%d: eingefroren', $month, $year));
|
||||
$stored++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Fertig. Eingefroren: {$stored}, uebersprungen: {$skipped}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{month: int, year: int}>
|
||||
*/
|
||||
private function monthsToFreeze(): array
|
||||
{
|
||||
if ($this->option('month') && $this->option('year')) {
|
||||
return [['month' => max(1, min(12, (int) $this->option('month'))), 'year' => (int) $this->option('year')]];
|
||||
}
|
||||
|
||||
return RecognitionService::closedMonths();
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +98,10 @@ class Kernel extends ConsoleKernel
|
|||
// Incentive: Punkteberechnung täglich nach business:store-optimized
|
||||
$schedule->command('incentive:calculate')->dailyAt('05:00');
|
||||
|
||||
// Anerkennungen: abgeschlossene Monate ab dem 3. des Folgemonats einfrieren
|
||||
// (idempotent; nach Provisionslauf, Repairs und Incentive)
|
||||
$schedule->command('recognitions:freeze')->dailyAt('06:15');
|
||||
|
||||
// Cleanup old log files weekly (keeps logs for 30 days)
|
||||
$schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00');
|
||||
|
||||
|
|
|
|||
181
app/Http/Controllers/Admin/CustomerPriorityController.php
Normal file
181
app/Http/Controllers/Admin/CustomerPriorityController.php
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserEmailChange;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\CustomerPriorityOverviewService;
|
||||
use App\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
/**
|
||||
* Admin overview "Kundenhoheit": everything where the customer sovereignty still needs a
|
||||
* decision, so the work does not depend on info mails that get lost.
|
||||
*/
|
||||
class CustomerPriorityController extends Controller
|
||||
{
|
||||
public const TABS = ['reviews', 'conflicts', 'email-changes'];
|
||||
|
||||
public function __construct(private CustomerPriorityOverviewService $overview)
|
||||
{
|
||||
$this->middleware('admin');
|
||||
}
|
||||
|
||||
public function index(string $tab = 'reviews'): View
|
||||
{
|
||||
abort_unless(in_array($tab, self::TABS, true), 404);
|
||||
|
||||
return view('admin.customer_priority.index', [
|
||||
'tab' => $tab,
|
||||
'counts' => $this->overview->counts(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reviewsDatatable(): JsonResponse
|
||||
{
|
||||
$back = route('admin_customer_priority', ['reviews']);
|
||||
|
||||
return \DataTables::eloquent($this->overview->reviewsQuery())
|
||||
->addColumn('created', fn (ShoppingUser $customer): string => $customer->created_at->format('d.m.Y H:i')
|
||||
.'<br><small class="text-muted">'.$customer->created_at->diffForHumans().'</small>')
|
||||
->addColumn('customer', fn (ShoppingUser $customer): string => $this->customerCell($customer))
|
||||
->addColumn('member', fn (ShoppingUser $customer): string => $this->memberCell($customer->member))
|
||||
->addColumn('shop', fn (ShoppingUser $customer): string => $this->memberCell($customer->shopping_order?->user_shop?->user))
|
||||
->addColumn('matches', fn (ShoppingUser $customer): string => (string) count($customer->getNotice('like') ?: []))
|
||||
->addColumn('action', function (ShoppingUser $customer) use ($back): string {
|
||||
return '<button type="button" class="btn btn-sm btn-primary mb-1" data-toggle="modal" data-target="#modals-load-content"'
|
||||
.' data-id="'.$customer->id.'" data-action="shopping-user-is-like-member" data-back="'.e($back).'"'
|
||||
.' data-modal="modal-xl" data-route="'.route('modal_load').'"><i class="fa fa-edit"></i> Prüfen & zuordnen</button> '
|
||||
.'<form method="POST" action="'.route('admin_customer_priority_keep', [$customer->id]).'" class="d-inline"'
|
||||
.' onsubmit="return confirm(\'Prüffall schließen? Berater und Kundennummer bleiben unverändert.\')">'
|
||||
.csrf_field()
|
||||
.'<button type="submit" class="btn btn-sm btn-outline-secondary mb-1">Zuordnung beibehalten</button></form>';
|
||||
})
|
||||
->filterColumn('customer', function ($query, $keyword): void {
|
||||
$query->where(function ($query) use ($keyword) {
|
||||
$query->where('billing_email', 'like', '%'.$keyword.'%')
|
||||
->orWhere('billing_lastname', 'like', '%'.$keyword.'%')
|
||||
->orWhere('billing_firstname', 'like', '%'.$keyword.'%')
|
||||
->orWhere('billing_zipcode', 'like', '%'.$keyword.'%');
|
||||
});
|
||||
})
|
||||
->orderColumn('created', 'shopping_users.created_at $1')
|
||||
->rawColumns(['created', 'customer', 'member', 'shop', 'action'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
public function conflictsDatatable(): JsonResponse
|
||||
{
|
||||
$back = route('admin_customer_priority', ['conflicts']);
|
||||
|
||||
return \DataTables::of($this->overview->conflicts())
|
||||
->addColumn('customer', fn (array $row): string => '<strong>'.e($row['name']).'</strong><br>'.e($row['email']))
|
||||
->addColumn('groups', function (array $row) use ($back): string {
|
||||
$html = '';
|
||||
foreach ($row['groups'] as $group) {
|
||||
$html .= '<div class="mb-2">'
|
||||
.($group['oldest'] ? '<span class="badge badge-success">älteste Zuordnung</span> ' : '<span class="badge badge-warning">weitere Zuordnung</span> ')
|
||||
.$this->memberCell($group['member'])
|
||||
.' <span class="text-muted">· Kunden-Nr. '.e($group['number']).' · '.$group['entries'].' Einträge · zuletzt '.e(date('d.m.Y', strtotime($group['last_at']))).'</span> '
|
||||
.'<a href="'.route('admin_customer_detail', [$group['latest_id']]).'" class="btn btn-xs btn-outline-primary"><i class="fa fa-eye"></i></a> '
|
||||
.'<button type="button" class="btn btn-xs btn-outline-secondary" data-toggle="modal" data-target="#modals-load-content"'
|
||||
.' data-id="'.$group['latest_id'].'" data-action="shopping-user-change-member" data-view="customer" data-back="'.e($back).'"'
|
||||
.' data-route="'.route('modal_load').'"><i class="fa fa-edit"></i> Berater zuweisen</button>'
|
||||
.'</div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
})
|
||||
->addColumn('last', fn (array $row): string => date('d.m.Y', strtotime($row['last_at'])))
|
||||
->filter(function ($instance): void {
|
||||
$keyword = mb_strtolower((string) request('search.value'));
|
||||
if ($keyword !== '') {
|
||||
$instance->collection = $instance->collection->filter(
|
||||
fn (array $row) => str_contains($row['email'], $keyword) || str_contains(mb_strtolower($row['name']), $keyword)
|
||||
);
|
||||
}
|
||||
})
|
||||
->rawColumns(['customer', 'groups'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
public function emailChangesDatatable(): JsonResponse
|
||||
{
|
||||
return \DataTables::eloquent($this->overview->emailChangesQuery())
|
||||
->addColumn('created', fn (ShoppingUserEmailChange $change): string => $change->created_at->format('d.m.Y H:i'))
|
||||
->addColumn('customer', fn (ShoppingUserEmailChange $change): string => $change->shopping_user
|
||||
? $this->customerCell($change->shopping_user)
|
||||
: '<span class="text-muted">Kunde #'.$change->shopping_user_id.' gelöscht</span>')
|
||||
->addColumn('change', fn (ShoppingUserEmailChange $change): string => e($change->old_email).' <i class="fa fa-arrow-right"></i> <strong>'.e($change->new_email).'</strong>')
|
||||
->addColumn('requested_by', fn (ShoppingUserEmailChange $change): string => $this->memberCell($change->requested_by))
|
||||
->addColumn('status', function (ShoppingUserEmailChange $change): string {
|
||||
if ($change->blocked_at) {
|
||||
return '<span class="badge badge-danger">abgelehnt (Kundenhoheit)</span><br><small class="text-muted">Adresse gehört einem anderen Berater oder Beraterkonto</small>';
|
||||
}
|
||||
|
||||
return '<span class="badge badge-info">wartet auf Bestätigung</span><br><small class="text-muted">Link gültig bis '.$change->expires_at->format('d.m.Y H:i').'</small>';
|
||||
})
|
||||
->addColumn('action', function (ShoppingUserEmailChange $change): string {
|
||||
if ($change->blocked_at) {
|
||||
return '<form method="POST" action="'.route('admin_customer_priority_email_resolve', [$change->id]).'" class="d-inline">'
|
||||
.csrf_field().'<button type="submit" class="btn btn-sm btn-outline-success"><i class="fa fa-check"></i> Erledigt</button></form>';
|
||||
}
|
||||
|
||||
return '<form method="POST" action="'.route('admin_customer_priority_email_discard', [$change->id]).'" class="d-inline"'
|
||||
.' onsubmit="return confirm(\'Anfrage verwerfen? Der Link in der Bestätigungsmail funktioniert dann nicht mehr.\')">'
|
||||
.csrf_field().'<button type="submit" class="btn btn-sm btn-outline-danger">Verwerfen</button></form>';
|
||||
})
|
||||
->orderColumn('created', 'shopping_user_email_changes.created_at $1')
|
||||
->rawColumns(['customer', 'change', 'requested_by', 'status', 'action'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
public function keepAssignment(int $id): RedirectResponse
|
||||
{
|
||||
$customer = ShoppingUser::where('is_like', true)->findOrFail($id);
|
||||
CustomerPriority::keepAssignment($customer);
|
||||
\Session()->flash('alert-success', 'Prüffall geschlossen, Zuordnung beibehalten.');
|
||||
|
||||
return redirect(route('admin_customer_priority', ['reviews']));
|
||||
}
|
||||
|
||||
public function resolveEmailChange(int $id, CustomerEmailChangeService $service): RedirectResponse
|
||||
{
|
||||
$service->resolveBlocked(ShoppingUserEmailChange::blockedOpen()->findOrFail($id), \Auth::id());
|
||||
\Session()->flash('alert-success', 'Als erledigt markiert.');
|
||||
|
||||
return redirect(route('admin_customer_priority', ['email-changes']));
|
||||
}
|
||||
|
||||
public function discardEmailChange(int $id, CustomerEmailChangeService $service): RedirectResponse
|
||||
{
|
||||
$service->discard(ShoppingUserEmailChange::pending()->findOrFail($id));
|
||||
\Session()->flash('alert-success', 'Anfrage verworfen.');
|
||||
|
||||
return redirect(route('admin_customer_priority', ['email-changes']));
|
||||
}
|
||||
|
||||
private function customerCell(ShoppingUser $customer): string
|
||||
{
|
||||
return '<a href="'.route('admin_customer_detail', [$customer->id]).'"><strong>'
|
||||
.e(trim($customer->billing_firstname.' '.$customer->billing_lastname)).'</strong></a>'
|
||||
.($customer->number ? ' <span class="text-muted">#'.e($customer->number).'</span>' : '')
|
||||
.'<br>'.e($customer->billing_email)
|
||||
.'<br><small class="text-muted">'.e($customer->billing_zipcode.' '.$customer->billing_city).'</small>';
|
||||
}
|
||||
|
||||
private function memberCell(?User $member): string
|
||||
{
|
||||
if (! $member) {
|
||||
return '<span class="text-muted">–</span>';
|
||||
}
|
||||
|
||||
return '<a href="'.route('admin_lead_edit', [$member->id]).'">'.e($member->getFullName(false) ?: $member->email).'</a>'
|
||||
.' <span class="text-muted">#'.e($member->number).'</span>';
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||
use App\Exceptions\GdprDeletionException;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Repositories\CustomerRepository;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\GdprDeletionService;
|
||||
use App\Services\HTMLHelper;
|
||||
|
|
@ -146,6 +147,12 @@ class CustomerController extends Controller
|
|||
CustomerPriority::newMemberForCustomer($shopping_user, $data['change_member_id'], $data['customer_set_member_for']);
|
||||
\Session()->flash('alert-save', true);
|
||||
|
||||
// Aus der Übersicht Kundenhoheit aufgerufen: dorthin zurück.
|
||||
$back = $data['back'] ?? null;
|
||||
if ($back && str_starts_with($back, route('admin_customer_priority'))) {
|
||||
return redirect($back);
|
||||
}
|
||||
|
||||
return redirect(route('admin_customer_detail', [$shopping_user->id]));
|
||||
}
|
||||
if ($data['action'] === 'shopping-user-store') {
|
||||
|
|
@ -194,6 +201,14 @@ class CustomerController extends Controller
|
|||
$data['same_as_billing'] = isset($data['same_as_billing']) ? true : false;
|
||||
$data['shipping_country_id'] = isset($data['shipping_country_id']) ? $data['shipping_country_id'] : $data['billing_country_id'];
|
||||
CustomerPriority::checkChangeOne($shopping_user, $data, true);
|
||||
|
||||
// Admin ändert direkt (Support); alle Einträge des Kunden und seine Abos ziehen mit.
|
||||
$emailChangeService = app(CustomerEmailChangeService::class);
|
||||
if ($emailChangeService->changesAddress($shopping_user, $data['billing_email'] ?? null)) {
|
||||
$emailChangeService->changeDirectly($shopping_user, $data['billing_email'], Auth::id());
|
||||
unset($data['billing_email']);
|
||||
}
|
||||
|
||||
$shopping_user->fill($data);
|
||||
$shopping_user->save();
|
||||
Shop::newUserOrder($shopping_user->number);
|
||||
|
|
|
|||
|
|
@ -48,9 +48,12 @@ class ModalController extends Controller
|
|||
$current = ShoppingUser::find($data['id']); // current user form order
|
||||
$possibles = [];
|
||||
if ($current->is_like) {
|
||||
$likes = $current->getNotice('like');
|
||||
// Treffer können inzwischen gelöscht sein (alte Prüffälle).
|
||||
$likes = $current->getNotice('like') ?: [];
|
||||
foreach ($likes as $like_id => $number) {
|
||||
$possibles[] = ShoppingUser::find($like_id);
|
||||
if ($possible = ShoppingUser::find($like_id)) {
|
||||
$possibles[] = $possible;
|
||||
}
|
||||
}
|
||||
}
|
||||
$ret = view('admin.modal.is_like_member', compact('current', 'possibles', 'data'))->render();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
|||
use App\Models\ShoppingUser;
|
||||
use App\Repositories\CustomerRepository;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\HTMLHelper;
|
||||
use App\Services\Shop;
|
||||
|
|
@ -229,7 +230,24 @@ class CustomerController extends Controller
|
|||
if ($shopping_user->isGdprDeleted()) {
|
||||
return $this->redirectGdprLocked($shopping_user);
|
||||
}
|
||||
// Neue E-Mail wird erst nach Bestätigung durch den Kunden aktiv (Login + Abo) und nur,
|
||||
// wenn sie keinem Beraterkonto oder Kunden eines anderen Beraters gehört (Kundenhoheit).
|
||||
$emailChangeService = app(CustomerEmailChangeService::class);
|
||||
if ($emailChangeService->requiresConfirmation($shopping_user, $data['billing_email'] ?? null)) {
|
||||
$newEmail = trim($data['billing_email']);
|
||||
unset($data['billing_email']);
|
||||
if ($emailChangeService->ownershipConflict($shopping_user, $newEmail)) {
|
||||
$emailChangeService->reportConflict($shopping_user, $newEmail, \Auth::id());
|
||||
\Session()->flash('custom-error', __('customer.email_change_blocked', ['email' => $newEmail]));
|
||||
} else {
|
||||
$emailChangeService->request($shopping_user, $newEmail, \Auth::id());
|
||||
\Session()->flash('custom-error', __('customer.email_change_requested', ['email' => $newEmail]));
|
||||
}
|
||||
}
|
||||
|
||||
// Adresse (Nachname + PLZ) läuft weiter über die bestehende Kundenhoheit-Prüfung.
|
||||
CustomerPriority::checkChangeOne($shopping_user, $data, true);
|
||||
|
||||
$shopping_user->fill($data);
|
||||
$shopping_user->save();
|
||||
|
||||
|
|
|
|||
53
app/Http/Controllers/User/RecognitionController.php
Normal file
53
app/Http/Controllers/User/RecognitionController.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Recognition\RecognitionFilterRequest;
|
||||
use App\Services\Team\RecognitionService;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Anerkennungen im Salescenter: oben die Ranglisten des gewählten
|
||||
* abgeschlossenen Monats (Standard: Vormonat), darunter der Live-Stand des
|
||||
* laufenden Monats. Sichtbar für alle Berater mit aktivem Konto.
|
||||
*/
|
||||
class RecognitionController extends Controller
|
||||
{
|
||||
public function __construct(private RecognitionService $recognitions)
|
||||
{
|
||||
$this->middleware('active.account');
|
||||
}
|
||||
|
||||
public function index(RecognitionFilterRequest $request): View
|
||||
{
|
||||
$closedMonths = RecognitionService::closedMonths();
|
||||
$selected = $this->selectedMonth((string) $request->validated('period'), $closedMonths);
|
||||
|
||||
return view('user.recognitions.index', [
|
||||
'periods' => $closedMonths,
|
||||
'selected' => $selected,
|
||||
'recognitions' => $selected
|
||||
? $this->recognitions->recognitions($selected['month'], $selected['year'])
|
||||
: null,
|
||||
'live' => $this->recognitions->liveRankings((int) now()->month, (int) now()->year),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur abgeschlossene Monate sind wählbar; alles andere fällt auf den Vormonat zurück.
|
||||
*
|
||||
* @param array<int, array{month: int, year: int}> $closedMonths
|
||||
* @return array{month: int, year: int}|null
|
||||
*/
|
||||
private function selectedMonth(string $period, array $closedMonths): ?array
|
||||
{
|
||||
foreach ($closedMonths as $closedMonth) {
|
||||
if ($period === sprintf('%d-%02d', $closedMonth['year'], $closedMonth['month'])) {
|
||||
return $closedMonth;
|
||||
}
|
||||
}
|
||||
|
||||
return $closedMonths[0] ?? null;
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/Web/CustomerEmailChangeController.php
Normal file
43
app/Http/Controllers/Web/CustomerEmailChangeController.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
/**
|
||||
* Login-free confirmation of a new customer e-mail address via the tokenized link in the
|
||||
* confirmation mail. GET only shows the page; the change is applied on POST, so mail
|
||||
* scanners that open links cannot confirm on the customer's behalf.
|
||||
*/
|
||||
class CustomerEmailChangeController extends Controller
|
||||
{
|
||||
public function show(string $token, CustomerEmailChangeService $service): View
|
||||
{
|
||||
$change = $service->findConfirmable($token);
|
||||
if (! $change) {
|
||||
return view('portal.customer.email_change_done', ['status' => CustomerEmailChangeService::STATUS_INVALID]);
|
||||
}
|
||||
|
||||
if ($change->shopping_user && $service->ownershipConflict($change->shopping_user, $change->new_email)) {
|
||||
return view('portal.customer.email_change_done', ['status' => CustomerEmailChangeService::STATUS_CONFLICT]);
|
||||
}
|
||||
|
||||
return view('portal.customer.email_change_confirm', [
|
||||
'change' => $change,
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(string $token, CustomerEmailChangeService $service): View
|
||||
{
|
||||
$change = $service->findConfirmable($token);
|
||||
$status = $change ? $service->confirm($change) : CustomerEmailChangeService::STATUS_INVALID;
|
||||
|
||||
return view('portal.customer.email_change_done', [
|
||||
'status' => $status,
|
||||
'email' => $status === CustomerEmailChangeService::STATUS_CONFIRMED ? $change->new_email : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
app/Http/Requests/Recognition/RecognitionFilterRequest.php
Normal file
33
app/Http/Requests/Recognition/RecognitionFilterRequest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Recognition;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RecognitionFilterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'period' => ['nullable', 'date_format:Y-m'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'period.date_format' => __('recognitions.period_invalid'),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Mail/CustomerEmailChangeBlockedMail.php
Normal file
51
app/Mail/CustomerEmailChangeBlockedMail.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\Util;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Info to the customer sovereignty inbox: a consultant tried to change a customer's e-mail
|
||||
* to an address that already belongs to a consultant account or another consultant's customer.
|
||||
*/
|
||||
class CustomerEmailChangeBlockedMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param list<string> $owners E-mail addresses of the consultants the address belongs to
|
||||
*/
|
||||
public function __construct(
|
||||
public ShoppingUser $shoppingUser,
|
||||
public string $newEmail,
|
||||
public ?string $requestedBy = null,
|
||||
public array $owners = [],
|
||||
) {}
|
||||
|
||||
public function build(): self
|
||||
{
|
||||
$customer = $this->shoppingUser;
|
||||
|
||||
$content = 'Kunde: '.$customer->billing_firstname.' '.$customer->billing_lastname.' (Kunden-Nr. '.$customer->number.")\n";
|
||||
$content .= 'Bisherige E-Mail: '.$customer->billing_email."\n";
|
||||
$content .= 'Gewünschte E-Mail: '.$this->newEmail."\n";
|
||||
$content .= 'Betreuender Berater: '.($customer->member?->email ?? '-')."\n";
|
||||
$content .= 'Änderung angefragt von: '.($this->requestedBy ?? '-')."\n";
|
||||
$content .= 'E-Mail bereits zugeordnet bei: '.($this->owners ? implode(', ', $this->owners) : 'Beraterkonto')."\n";
|
||||
|
||||
return $this->subject('E-Mail-Änderung abgelehnt - Kundenhoheit')
|
||||
->view('emails.info')
|
||||
->with([
|
||||
'url' => Util::getMyMivitaUrl().'/admin/customer-priority/email-changes',
|
||||
'title' => 'Eine E-Mail-Änderung wurde nicht übernommen, weil die Adresse bereits einem anderen Berater bzw. einem Beraterkonto zugeordnet ist.',
|
||||
'button' => 'zur Übersicht Kundenhoheit',
|
||||
'copy1line' => "Der Fall steht in der Übersicht Kundenhoheit, bis er als erledigt markiert ist:\n",
|
||||
'copy2line' => __('email.copy2line'),
|
||||
'content' => $content,
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
app/Mail/CustomerEmailChangeMail.php
Normal file
36
app/Mail/CustomerEmailChangeMail.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\Util;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CustomerEmailChangeMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public ShoppingUser $shoppingUser, public string $token) {}
|
||||
|
||||
public function build(): self
|
||||
{
|
||||
$firstname = $this->shoppingUser->billing_firstname;
|
||||
$salutation = $firstname ? __('email.hello').' '.$firstname : __('email.hello');
|
||||
|
||||
// Link zeigt immer auf das Kundenportal (in.mivita.care), unabhängig vom Versand-Kontext.
|
||||
$link = rtrim(Util::getMyMivitaPortalUrl(), '/').'/kunde/email-bestaetigen/'.$this->token;
|
||||
$subject = __('customer.email_change_mail_subject');
|
||||
|
||||
return $this->subject($subject)
|
||||
->view('emails.customer_email_change')
|
||||
->with([
|
||||
'salutation' => $salutation,
|
||||
'link' => $link,
|
||||
'mailTitle' => $subject,
|
||||
'greetings' => __('email.greetings'),
|
||||
'sender' => __('email.sender'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
32
app/Models/RecognitionSnapshot.php
Normal file
32
app/Models/RecognitionSnapshot.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Team\RecognitionService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Eingefrorene Anerkennungen eines abgeschlossenen Monats
|
||||
* ({@see RecognitionService::freeze()}).
|
||||
*/
|
||||
class RecognitionSnapshot extends Model
|
||||
{
|
||||
protected $table = 'recognition_snapshots';
|
||||
|
||||
protected $fillable = [
|
||||
'year',
|
||||
'month',
|
||||
'payload',
|
||||
'frozen_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'year' => 'int',
|
||||
'month' => 'int',
|
||||
'payload' => 'array',
|
||||
'frozen_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Models/ShoppingUserEmailChange.php
Normal file
108
app/Models/ShoppingUserEmailChange.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Requested change of a customer's e-mail address. A consultant's change stays pending
|
||||
* until the customer confirms it via the tokenized link sent to the new address; only
|
||||
* then the address is used (login, abo mails). The plaintext token lives only in the
|
||||
* e-mail link, the database stores its SHA-256 hash (see {@see AboCardUpdateLink}).
|
||||
*
|
||||
* @property int $shopping_user_id
|
||||
* @property int|null $number
|
||||
* @property string $old_email
|
||||
* @property string $new_email
|
||||
* @property string|null $token_hash
|
||||
* @property Carbon|null $expires_at
|
||||
* @property Carbon|null $confirmed_at
|
||||
* @property Carbon|null $blocked_at
|
||||
* @property Carbon|null $resolved_at
|
||||
* @property int|null $requested_by_user_id
|
||||
* @property int|null $resolved_by_user_id
|
||||
*/
|
||||
class ShoppingUserEmailChange extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'shopping_user_id',
|
||||
'number',
|
||||
'old_email',
|
||||
'new_email',
|
||||
'token_hash',
|
||||
'expires_at',
|
||||
'confirmed_at',
|
||||
'blocked_at',
|
||||
'resolved_at',
|
||||
'requested_by_user_id',
|
||||
'resolved_by_user_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'confirmed_at' => 'datetime',
|
||||
'blocked_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function shopping_user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ShoppingUser::class, 'shopping_user_id');
|
||||
}
|
||||
|
||||
public function requested_by(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requested_by_user_id');
|
||||
}
|
||||
|
||||
public static function hashToken(string $token): string
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open requests that can still be confirmed.
|
||||
*
|
||||
* @param Builder<ShoppingUserEmailChange> $query
|
||||
*/
|
||||
public function scopePending(Builder $query): void
|
||||
{
|
||||
$query->whereNull('confirmed_at')->whereNotNull('token_hash')->where('expires_at', '>', now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes rejected because of customer sovereignty that an admin has not handled yet.
|
||||
*
|
||||
* @param Builder<ShoppingUserEmailChange> $query
|
||||
*/
|
||||
public function scopeBlockedOpen(Builder $query): void
|
||||
{
|
||||
$query->whereNotNull('blocked_at')->whereNull('resolved_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests belonging to the same customer (all entries share the customer number).
|
||||
*
|
||||
* @param Builder<ShoppingUserEmailChange> $query
|
||||
*/
|
||||
public function scopeForCustomer(Builder $query, ShoppingUser $shoppingUser): void
|
||||
{
|
||||
if ($shoppingUser->number > 0) {
|
||||
$query->where('number', $shoppingUser->number);
|
||||
} else {
|
||||
$query->where('shopping_user_id', $shoppingUser->id);
|
||||
}
|
||||
}
|
||||
|
||||
public function isConfirmable(): bool
|
||||
{
|
||||
return $this->confirmed_at === null && $this->token_hash !== null && $this->expires_at?->isFuture();
|
||||
}
|
||||
}
|
||||
291
app/Services/CustomerEmailChangeService.php
Normal file
291
app/Services/CustomerEmailChangeService.php
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\CustomerEmailChangeBlockedMail;
|
||||
use App\Mail\CustomerEmailChangeMail;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserEmailChange;
|
||||
use App\Models\UserAbo;
|
||||
use App\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Changing a customer's e-mail address. The address is the customer's portal login and
|
||||
* the key the portal uses to find their abo, so a consultant may not switch it silently:
|
||||
* the new address only becomes active after the customer confirmed it via the link sent
|
||||
* to it. On activation all entries of the customer with the old address and their abos
|
||||
* move to the new address together.
|
||||
*/
|
||||
class CustomerEmailChangeService
|
||||
{
|
||||
public const TTL_HOURS = 168;
|
||||
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
|
||||
public const STATUS_INVALID = 'invalid';
|
||||
|
||||
public const STATUS_CONFLICT = 'conflict';
|
||||
|
||||
public const CONFLICT_CONSULTANT_ACCOUNT = 'consultant_account';
|
||||
|
||||
public const CONFLICT_OTHER_CONSULTANT = 'other_consultant';
|
||||
|
||||
public function changesAddress(ShoppingUser $shoppingUser, ?string $newEmail): bool
|
||||
{
|
||||
return filled($newEmail) && ! $this->sameEmail($shoppingUser->billing_email, $newEmail);
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer without a real address yet (placeholder) has no login to lose, so only
|
||||
* replacing a real address needs the customer's confirmation.
|
||||
*/
|
||||
public function requiresConfirmation(ShoppingUser $shoppingUser, ?string $newEmail): bool
|
||||
{
|
||||
return ! $this->hasPlaceholderEmail($shoppingUser) && $this->changesAddress($shoppingUser, $newEmail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer sovereignty: the e-mail is the main indicator who a customer belongs to. A
|
||||
* consultant may not take over an address that already belongs to an active consultant
|
||||
* account or to a customer of another consultant.
|
||||
*
|
||||
* @return self::CONFLICT_*|null
|
||||
*/
|
||||
public function ownershipConflict(ShoppingUser $shoppingUser, string $newEmail): ?string
|
||||
{
|
||||
$newEmail = trim($newEmail);
|
||||
|
||||
if (User::where('email', $newEmail)->where('active', 1)->exists()) {
|
||||
return self::CONFLICT_CONSULTANT_ACCOUNT;
|
||||
}
|
||||
|
||||
return $this->customersOfOtherConsultants($shoppingUser, $newEmail)->isNotEmpty()
|
||||
? self::CONFLICT_OTHER_CONSULTANT
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a change rejected because of customer sovereignty, so it stays visible in the
|
||||
* admin overview until handled, and informs the customer sovereignty inbox.
|
||||
*/
|
||||
public function reportConflict(ShoppingUser $shoppingUser, string $newEmail, ?int $requestedBy = null, ?ShoppingUserEmailChange $change = null): ShoppingUserEmailChange
|
||||
{
|
||||
$newEmail = trim($newEmail);
|
||||
$owners = $this->customersOfOtherConsultants($shoppingUser, $newEmail)
|
||||
->map(fn (ShoppingUser $customer) => $customer->member?->email)
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$attributes = ['token_hash' => null, 'blocked_at' => now()];
|
||||
if ($change) {
|
||||
$change->forceFill($attributes)->save();
|
||||
} else {
|
||||
$change = ShoppingUserEmailChange::create($attributes + [
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'old_email' => $shoppingUser->billing_email,
|
||||
'new_email' => $newEmail,
|
||||
'requested_by_user_id' => $requestedBy,
|
||||
]);
|
||||
}
|
||||
|
||||
Log::warning('CustomerEmailChange: Änderung wegen Kundenhoheit abgelehnt', [
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'new_email' => $newEmail,
|
||||
'requested_by_user_id' => $requestedBy,
|
||||
'owners' => $owners,
|
||||
]);
|
||||
|
||||
Mail::to(config('app.priority_mail'))->send(new CustomerEmailChangeBlockedMail(
|
||||
$shoppingUser,
|
||||
$newEmail,
|
||||
$requestedBy ? User::find($requestedBy)?->email : null,
|
||||
$owners,
|
||||
));
|
||||
|
||||
return $change;
|
||||
}
|
||||
|
||||
public function resolveBlocked(ShoppingUserEmailChange $change, ?int $resolvedBy = null): void
|
||||
{
|
||||
$change->forceFill(['resolved_at' => now(), 'resolved_by_user_id' => $resolvedBy])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraws an open request; the link in the mail stops working.
|
||||
*/
|
||||
public function discard(ShoppingUserEmailChange $change): void
|
||||
{
|
||||
$change->forceFill(['token_hash' => null, 'expires_at' => now()])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the confirmation for a new address and sends the link to it. Older open
|
||||
* requests of the same customer are replaced.
|
||||
*/
|
||||
public function request(ShoppingUser $shoppingUser, string $newEmail, ?int $requestedBy = null): ShoppingUserEmailChange
|
||||
{
|
||||
$newEmail = trim($newEmail);
|
||||
$token = Str::random(48);
|
||||
|
||||
ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->delete();
|
||||
|
||||
$change = ShoppingUserEmailChange::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'old_email' => $shoppingUser->billing_email,
|
||||
'new_email' => $newEmail,
|
||||
'token_hash' => ShoppingUserEmailChange::hashToken($token),
|
||||
'expires_at' => now()->addHours(self::TTL_HOURS),
|
||||
'requested_by_user_id' => $requestedBy,
|
||||
]);
|
||||
|
||||
Mail::to($newEmail)
|
||||
->locale($shoppingUser->language ?: 'de')
|
||||
->send(new CustomerEmailChangeMail($shoppingUser, $token));
|
||||
|
||||
return $change;
|
||||
}
|
||||
|
||||
public function findConfirmable(string $token): ?ShoppingUserEmailChange
|
||||
{
|
||||
$change = ShoppingUserEmailChange::where('token_hash', ShoppingUserEmailChange::hashToken($token))->first();
|
||||
|
||||
return $change && $change->isConfirmable() ? $change : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the requested address. Fails when the customer's address was changed in
|
||||
* the meantime (an outdated link must not overwrite a newer address) or when the new
|
||||
* address has meanwhile been assigned to another consultant (customer sovereignty).
|
||||
*
|
||||
* @return self::STATUS_*
|
||||
*/
|
||||
public function confirm(ShoppingUserEmailChange $change): string
|
||||
{
|
||||
$shoppingUser = $change->shopping_user;
|
||||
if (! $change->isConfirmable() || ! $shoppingUser || ! $this->sameEmail($shoppingUser->billing_email, $change->old_email)) {
|
||||
return self::STATUS_INVALID;
|
||||
}
|
||||
|
||||
if ($this->ownershipConflict($shoppingUser, $change->new_email)) {
|
||||
$this->reportConflict($shoppingUser, $change->new_email, $change->requested_by_user_id, $change);
|
||||
|
||||
return self::STATUS_CONFLICT;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($change, $shoppingUser) {
|
||||
$this->apply($shoppingUser, $change->old_email, $change->new_email);
|
||||
$change->forceFill(['confirmed_at' => now(), 'token_hash' => null])->save();
|
||||
});
|
||||
|
||||
return self::STATUS_CONFIRMED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct change without confirmation (admin). Logged like a confirmed request.
|
||||
*/
|
||||
public function changeDirectly(ShoppingUser $shoppingUser, string $newEmail, ?int $changedBy = null): void
|
||||
{
|
||||
$oldEmail = $shoppingUser->billing_email;
|
||||
$newEmail = trim($newEmail);
|
||||
|
||||
DB::transaction(function () use ($shoppingUser, $oldEmail, $newEmail, $changedBy) {
|
||||
$this->apply($shoppingUser, $oldEmail, $newEmail);
|
||||
ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->delete();
|
||||
ShoppingUserEmailChange::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'old_email' => $oldEmail,
|
||||
'new_email' => $newEmail,
|
||||
'confirmed_at' => now(),
|
||||
'requested_by_user_id' => $changedBy,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function pendingFor(ShoppingUser $shoppingUser): ?ShoppingUserEmailChange
|
||||
{
|
||||
return ShoppingUserEmailChange::forCustomer($shoppingUser)->pending()->latest('id')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves all entries of the customer that still carry the old address, plus their
|
||||
* customer abos, to the new address.
|
||||
*
|
||||
* @return array{shopping_users: int, abos: int}
|
||||
*/
|
||||
private function apply(ShoppingUser $shoppingUser, string $oldEmail, string $newEmail): array
|
||||
{
|
||||
$shoppingUserIds = ShoppingUser::query()
|
||||
->where('billing_email', $oldEmail)
|
||||
->when(
|
||||
$shoppingUser->number > 0,
|
||||
fn ($query) => $query->where('number', $shoppingUser->number),
|
||||
fn ($query) => $query->whereKey($shoppingUser->id),
|
||||
)
|
||||
->pluck('id')
|
||||
->push($shoppingUser->id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$updatedShoppingUsers = ShoppingUser::whereIn('id', $shoppingUserIds)->update(['billing_email' => $newEmail]);
|
||||
|
||||
$updatedAbos = UserAbo::query()
|
||||
->whereIn('shopping_user_id', $shoppingUserIds)
|
||||
->where('is_for', 'ot')
|
||||
->where('email', $oldEmail)
|
||||
->update(['email' => $newEmail]);
|
||||
|
||||
$shoppingUser->billing_email = $newEmail;
|
||||
|
||||
Log::info('CustomerEmailChange: E-Mail geändert', [
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'number' => $shoppingUser->number,
|
||||
'old_email' => $oldEmail,
|
||||
'new_email' => $newEmail,
|
||||
'shopping_users' => $updatedShoppingUsers,
|
||||
'abos' => $updatedAbos,
|
||||
]);
|
||||
|
||||
return ['shopping_users' => $updatedShoppingUsers, 'abos' => $updatedAbos];
|
||||
}
|
||||
|
||||
/**
|
||||
* Customers (with a customer number) of other consultants that use the address.
|
||||
*
|
||||
* @return Collection<int, ShoppingUser>
|
||||
*/
|
||||
private function customersOfOtherConsultants(ShoppingUser $shoppingUser, string $email): Collection
|
||||
{
|
||||
return ShoppingUser::query()
|
||||
->with('member')
|
||||
->whereNull('auth_user_id')
|
||||
->whereNotNull('number')
|
||||
->whereNotNull('member_id')
|
||||
->when($shoppingUser->member_id, fn ($query) => $query->where('member_id', '!=', $shoppingUser->member_id))
|
||||
->where('billing_email', $email)
|
||||
->withoutGdprDeleted()
|
||||
->get();
|
||||
}
|
||||
|
||||
private function hasPlaceholderEmail(ShoppingUser $shoppingUser): bool
|
||||
{
|
||||
return (bool) $shoppingUser->faker_mail
|
||||
|| blank($shoppingUser->billing_email)
|
||||
|| str_ends_with((string) $shoppingUser->billing_email, '-faker@mivita.care');
|
||||
}
|
||||
|
||||
private function sameEmail(?string $a, ?string $b): bool
|
||||
{
|
||||
return mb_strtolower(trim((string) $a)) === mb_strtolower(trim((string) $b));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +1,90 @@
|
|||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\MailCheckout;
|
||||
use App\Mail\MailInfo;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\Services\Shop;
|
||||
use App\User;
|
||||
use Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
|
||||
|
||||
class CustomerPriority
|
||||
{
|
||||
|
||||
protected static $start_number = 1000;
|
||||
|
||||
protected static $user_notice_key = 'like';
|
||||
|
||||
public static function checkForAll(){
|
||||
//only extern no members with no numbers
|
||||
$shopping_users = ShoppingUser::where('auth_user_id', '=', NULL)->where('number', '=', NULL)->orderBy('created_at', 'ASC')->get();
|
||||
foreach ($shopping_users as $shopping_user){
|
||||
if($shopping_user->shopping_order && $shopping_user->shopping_order->user_shop){
|
||||
public static function checkForAll()
|
||||
{
|
||||
// only extern no members with no numbers
|
||||
$shopping_users = ShoppingUser::where('auth_user_id', '=', null)->where('number', '=', null)->orderBy('created_at', 'ASC')->get();
|
||||
foreach ($shopping_users as $shopping_user) {
|
||||
if ($shopping_user->shopping_order && $shopping_user->shopping_order->user_shop) {
|
||||
self::checkOne($shopping_user);
|
||||
}
|
||||
}
|
||||
|
||||
return $shopping_users;
|
||||
}
|
||||
|
||||
public static function checkOne($shopping_user, $mail=false, $newCustomer = true, $entryExistsLike = false){
|
||||
//ist die Mail ein Berater, dann sich selbst zuweisen.
|
||||
if(self::entryIsMember($shopping_user)){
|
||||
public static function checkOne($shopping_user, $mail = false, $newCustomer = true, $entryExistsLike = false)
|
||||
{
|
||||
// ist die Mail ein Berater, dann sich selbst zuweisen.
|
||||
if (self::entryIsMember($shopping_user)) {
|
||||
return 'self';
|
||||
}
|
||||
//look for entry
|
||||
if($entryExistsLike){
|
||||
if(self::entryExistsLike($shopping_user)){ //existiert die Email bei einem anderem member?
|
||||
if($mail){ //send mail
|
||||
// look for entry
|
||||
if ($entryExistsLike) {
|
||||
if (self::entryExistsLike($shopping_user)) { // existiert die Email bei einem anderem member?
|
||||
if ($mail) { // send mail
|
||||
Mail::to(config('app.priority_mail'))->send(new MailInfo($shopping_user, 'check_is_like_customer'));
|
||||
}
|
||||
|
||||
return 'exists';
|
||||
}
|
||||
}else{
|
||||
if(self::entryExists($shopping_user)){ //Berater zuweisen, wenn die Mail existiert
|
||||
} else {
|
||||
if (self::entryExists($shopping_user)) { // Berater zuweisen, wenn die Mail existiert
|
||||
return 'exists';
|
||||
}
|
||||
}
|
||||
if(self::entryLike($shopping_user)){ //existiert die Adresse bei einem anderem member?
|
||||
if($mail){ //send mail
|
||||
if (self::entryLike($shopping_user)) { // existiert die Adresse bei einem anderem member?
|
||||
if ($mail) { // send mail
|
||||
Mail::to(config('app.priority_mail'))->send(new MailInfo($shopping_user, 'check_is_like_customer'));
|
||||
}
|
||||
|
||||
return 'like'.$shopping_user->member_id;
|
||||
}
|
||||
if($newCustomer){
|
||||
if ($newCustomer) {
|
||||
self::newCustomer($shopping_user);
|
||||
}
|
||||
|
||||
return 'new';
|
||||
|
||||
}
|
||||
|
||||
public static function setIsLike($shopping_user, $set_like_shopping_user, $send_member_mail, $change_shopping_user=false)
|
||||
public static function setIsLike($shopping_user, $set_like_shopping_user, $send_member_mail, $change_shopping_user = false)
|
||||
{
|
||||
if ($shopping_user->id === $set_like_shopping_user->id) {
|
||||
//set new customer for shopping_user
|
||||
if($change_shopping_user){
|
||||
// set new customer for shopping_user
|
||||
if ($change_shopping_user) {
|
||||
self::newCustomerNumber($shopping_user);
|
||||
$send_member_mail = false;
|
||||
}else{
|
||||
} else {
|
||||
self::newCustomer($shopping_user);
|
||||
}
|
||||
} else {
|
||||
//set existing customer for shopping_user
|
||||
// set existing customer for shopping_user
|
||||
self::existingCustomer($shopping_user, $set_like_shopping_user);
|
||||
}
|
||||
self::clearIsLike($shopping_user);
|
||||
//SEND MAIL TO MEMBER
|
||||
if ($send_member_mail){
|
||||
// SEND MAIL TO MEMBER
|
||||
if ($send_member_mail) {
|
||||
if ($shopping_user->shopping_order && $shopping_user->shopping_order->shopping_payments) {
|
||||
Mail::to($shopping_user->member->email)->send(new MailCheckout($shopping_user->shopping_order->txaction, $shopping_user->shopping_order, $shopping_user->shopping_order->shopping_payments->last(), false, $shopping_user->shopping_order->mode));
|
||||
}
|
||||
}
|
||||
//set Points and Volume
|
||||
// set Points and Volume
|
||||
if ($shopping_user->shopping_order && $user_sales_volume = $shopping_user->shopping_order->user_sales_volume_no_userid()) {
|
||||
SalesPointsVolume::setToUserAndReCalculate($user_sales_volume, $shopping_user->member_id);
|
||||
}
|
||||
|
|
@ -90,19 +92,20 @@ class CustomerPriority
|
|||
return true;
|
||||
}
|
||||
|
||||
public static function newMemberForOrder($shopping_order, $member_id, Bool $all = false){
|
||||
if($all){
|
||||
if($shopping_order->shopping_user && $shopping_order->shopping_user->number){
|
||||
public static function newMemberForOrder($shopping_order, $member_id, bool $all = false)
|
||||
{
|
||||
if ($all) {
|
||||
if ($shopping_order->shopping_user && $shopping_order->shopping_user->number) {
|
||||
$shopping_users = ShoppingUser::where('number', '=', $shopping_order->shopping_user->number)->get();
|
||||
$new_number = self::nextNumber();
|
||||
foreach ($shopping_users as $shopping_user) {
|
||||
self::changeCustomer($shopping_user, $member_id, $new_number);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
} else {
|
||||
$shopping_order->member_id = $member_id;
|
||||
$shopping_order->save();
|
||||
if($shopping_order->shopping_user){
|
||||
if ($shopping_order->shopping_user) {
|
||||
$shopping_order->shopping_user->member_id = $member_id;
|
||||
$shopping_order->shopping_user->number = self::nextNumber();
|
||||
$shopping_order->shopping_user->save();
|
||||
|
|
@ -110,60 +113,62 @@ class CustomerPriority
|
|||
}
|
||||
}
|
||||
|
||||
public static function newMemberForCustomer($shopping_user, $member_id, Bool $all = false){
|
||||
if($all){
|
||||
if($shopping_user && $shopping_user->number){
|
||||
public static function newMemberForCustomer($shopping_user, $member_id, bool $all = false)
|
||||
{
|
||||
if ($all) {
|
||||
if ($shopping_user && $shopping_user->number) {
|
||||
$shopping_users = ShoppingUser::where('number', '=', $shopping_user->number)->get();
|
||||
$new_number = self::nextNumber();
|
||||
foreach ($shopping_users as $s_user) {
|
||||
self::changeCustomer($s_user, $member_id, $new_number);
|
||||
}
|
||||
}
|
||||
}else{
|
||||
} else {
|
||||
$shopping_user->member_id = $member_id;
|
||||
$shopping_user->number = self::nextNumber();
|
||||
$shopping_user->save();
|
||||
if($shopping_user->shopping_order){
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function checkChangeOne($shopping_user, $data, $mail=false){
|
||||
public static function checkChangeOne($shopping_user, $data, $mail = false)
|
||||
{
|
||||
|
||||
$matches = [];
|
||||
$change = [];
|
||||
$ret = 'update';
|
||||
|
||||
//email geändert
|
||||
if(isset($data['billing_email']) && $shopping_user->billing_email != $data['billing_email']){
|
||||
$found = ShoppingUser::where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
// email geändert
|
||||
if (isset($data['billing_email']) && $shopping_user->billing_email != $data['billing_email']) {
|
||||
$found = ShoppingUser::where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('billing_email', '=', $data['billing_email'])
|
||||
->where('member_id', '!=', $shopping_user->member_id)
|
||||
->get()->pluck('number', 'id')->unique()->toArray();
|
||||
if($found && count($found)){
|
||||
foreach ($found as $key=>$val){
|
||||
if ($found && count($found)) {
|
||||
foreach ($found as $key => $val) {
|
||||
$matches[$key] = $val;
|
||||
}
|
||||
$ret = 'exists';
|
||||
$change['billing_email'] = $data['billing_email'];
|
||||
}
|
||||
}
|
||||
//Anschrift geändert
|
||||
if(isset($data['billing_lastname']) && isset($data['billing_zipcode']) && ($shopping_user->billing_lastname != $data['billing_lastname'] || $shopping_user->billing_zipcode != $data['billing_zipcode'])){
|
||||
// Anschrift geändert
|
||||
if (isset($data['billing_lastname']) && isset($data['billing_zipcode']) && ($shopping_user->billing_lastname != $data['billing_lastname'] || $shopping_user->billing_zipcode != $data['billing_zipcode'])) {
|
||||
$found = ShoppingUser::select('*')
|
||||
->where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
->where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('billing_lastname', '=', $data['billing_lastname'])
|
||||
->where('billing_zipcode', '=', $data['billing_zipcode'])
|
||||
->where('member_id', '!=', $shopping_user->member_id)
|
||||
->get()->pluck('number', 'id')->unique()->toArray();
|
||||
if($found && count($found)){
|
||||
foreach ($found as $key=>$val){
|
||||
if ($found && count($found)) {
|
||||
foreach ($found as $key => $val) {
|
||||
$matches[$key] = $val;
|
||||
}
|
||||
$ret = 'like';
|
||||
|
|
@ -171,13 +176,13 @@ class CustomerPriority
|
|||
$change['billing_zipcode'] = $data['billing_zipcode'];
|
||||
}
|
||||
}
|
||||
if($matches){
|
||||
if ($matches) {
|
||||
$shopping_user->is_like = true;
|
||||
$shopping_user->setNotice(self::$user_notice_key, $matches);
|
||||
$shopping_user->save();
|
||||
}
|
||||
//look for entry
|
||||
if($matches && $mail){ //send mail
|
||||
// look for entry
|
||||
if ($matches && $mail) { // send mail
|
||||
Mail::to(config('app.priority_mail'))->send(new MailInfo($shopping_user, 'change_is_like_customer', $change));
|
||||
}
|
||||
|
||||
|
|
@ -185,96 +190,116 @@ class CustomerPriority
|
|||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static function checkNewOne($shopping_user, $mail=false){
|
||||
if(self::entryLike($shopping_user)){
|
||||
if($mail){ //send mail
|
||||
public static function checkNewOne($shopping_user, $mail = false)
|
||||
{
|
||||
if (self::entryLike($shopping_user)) {
|
||||
if ($mail) { // send mail
|
||||
Mail::to(config('app.priority_mail'))->send(new MailInfo($shopping_user, 'check_is_like_customer'));
|
||||
}
|
||||
//return 'like';
|
||||
// return 'like';
|
||||
}
|
||||
$shopping_user->number = self::nextNumber();
|
||||
$shopping_user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function entryIsMember($shopping_user){
|
||||
if($user = User::whereEmail($shopping_user->billing_email)->whereActive(1)->first()){
|
||||
public static function entryIsMember($shopping_user)
|
||||
{
|
||||
if ($user = User::whereEmail($shopping_user->billing_email)->whereActive(1)->first()) {
|
||||
$member_id = $user->id;
|
||||
$shopping_user->member_id = $member_id;
|
||||
$shopping_user->number = self::nextNumber();
|
||||
$shopping_user->save();
|
||||
if($shopping_user->shopping_order){
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
return true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function entryExists($shopping_user){
|
||||
//check same email
|
||||
$matches = ShoppingUser::where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
private static function entryExists($shopping_user)
|
||||
{
|
||||
// check same email: die älteste geprüfte Zuordnung gilt (Kundenhoheit). Einträge, die noch
|
||||
// in Prüfung sind (is_like), dürfen nicht als Vorlage dienen, sonst erbt der Kunde eine
|
||||
// ungeprüfte Zuordnung und bleibt nach der Prüfung beim falschen Berater hängen.
|
||||
$match = ShoppingUser::where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->whereNotNull('member_id')
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('billing_email', '=', $shopping_user->billing_email)->get();
|
||||
->where('billing_email', '=', $shopping_user->billing_email)
|
||||
->where(function ($query) {
|
||||
$query->where('is_like', false)->orWhereNull('is_like');
|
||||
})
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if($matches && count($matches)){
|
||||
$match = $matches->last();
|
||||
if ($match) {
|
||||
$shopping_user->member_id = $match->member_id;
|
||||
$shopping_user->number = $match->number;
|
||||
$shopping_user->save();
|
||||
//has order
|
||||
if($shopping_user->shopping_order){
|
||||
// has order
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $match->member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function entryExistsLike($shopping_user)
|
||||
{
|
||||
//existiert die Email bei einem anderem member?
|
||||
$matches = ShoppingUser::where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
// existiert die Email bei einem anderem member?
|
||||
$matches = ShoppingUser::where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('member_id', '!=', $shopping_user->member_id)
|
||||
->where('billing_email', '=', $shopping_user->billing_email)
|
||||
->where('billing_email', '=', $shopping_user->billing_email)
|
||||
->get()->pluck('number', 'id')->unique()->toArray();
|
||||
|
||||
if($matches && count($matches)){
|
||||
$shopping_user->is_like = true;
|
||||
$shopping_user->setNotice(self::$user_notice_key, $matches);
|
||||
$shopping_user->save();
|
||||
return true;
|
||||
}
|
||||
if ($matches && count($matches)) {
|
||||
$shopping_user->is_like = true;
|
||||
$shopping_user->setNotice(self::$user_notice_key, $matches);
|
||||
$shopping_user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
private static function entryLike($shopping_user){
|
||||
//check same last name und PLZ
|
||||
|
||||
private static function entryLike($shopping_user)
|
||||
{
|
||||
// check same last name und PLZ
|
||||
$matches = ShoppingUser::select('*')
|
||||
->where('auth_user_id', '=', NULL)
|
||||
->where('number', '!=', NULL) //has number
|
||||
->where('auth_user_id', '=', null)
|
||||
->where('number', '!=', null) // has number
|
||||
->where('id', '!=', $shopping_user->id)
|
||||
->where('member_id', '!=', $shopping_user->member_id)
|
||||
->where('billing_lastname', '=', $shopping_user->billing_lastname)
|
||||
->where('billing_zipcode', '=', $shopping_user->billing_zipcode)
|
||||
->get()->pluck('number', 'id')->unique()->toArray();
|
||||
if($matches && count($matches)){
|
||||
if ($matches && count($matches)) {
|
||||
$shopping_user->is_like = true;
|
||||
$shopping_user->setNotice(self::$user_notice_key, $matches);
|
||||
$shopping_user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function newCustomer($shopping_user){
|
||||
if($shopping_user->shopping_order && $shopping_user->shopping_order->user_shop) {
|
||||
private static function newCustomer($shopping_user)
|
||||
{
|
||||
if ($shopping_user->shopping_order && $shopping_user->shopping_order->user_shop) {
|
||||
$member_id = $shopping_user->shopping_order->user_shop->user_id;
|
||||
$shopping_user->member_id = $member_id;
|
||||
$shopping_user->number = self::nextNumber();
|
||||
|
|
@ -286,51 +311,63 @@ class CustomerPriority
|
|||
|
||||
private static function newCustomerNumber($shopping_user)
|
||||
{
|
||||
\App\Services\Shop::newUserOrder($shopping_user->number);
|
||||
Shop::newUserOrder($shopping_user->number);
|
||||
$shopping_user->number = self::nextNumber();
|
||||
$shopping_user->save();
|
||||
\App\Services\Shop::newUserOrder($shopping_user->number);
|
||||
Shop::newUserOrder($shopping_user->number);
|
||||
|
||||
}
|
||||
|
||||
private static function changeCustomer($shopping_user, $member_id, $number){
|
||||
private static function changeCustomer($shopping_user, $member_id, $number)
|
||||
{
|
||||
$old_number = $shopping_user->number;
|
||||
$shopping_user->member_id = $member_id;
|
||||
$shopping_user->number = $number;
|
||||
$shopping_user->save();
|
||||
if($shopping_user->shopping_order) {
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
\App\Services\Shop::newUserOrder($old_number);
|
||||
\App\Services\Shop::newUserOrder($number);
|
||||
Shop::newUserOrder($old_number);
|
||||
Shop::newUserOrder($number);
|
||||
}
|
||||
|
||||
private static function existingCustomer($shopping_user, $set_like_shopping_user){
|
||||
private static function existingCustomer($shopping_user, $set_like_shopping_user)
|
||||
{
|
||||
$old_number = $shopping_user->number;
|
||||
$shopping_user->member_id = $set_like_shopping_user->member_id;
|
||||
$shopping_user->number = $set_like_shopping_user->number;
|
||||
$shopping_user->save();
|
||||
if($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $set_like_shopping_user->member_id;
|
||||
if ($shopping_user->shopping_order) {
|
||||
$shopping_user->shopping_order->member_id = $set_like_shopping_user->member_id;
|
||||
$shopping_user->shopping_order->save();
|
||||
}
|
||||
\App\Services\Shop::newUserOrder($old_number);
|
||||
\App\Services\Shop::newUserOrder($set_like_shopping_user->number);
|
||||
Shop::newUserOrder($old_number);
|
||||
Shop::newUserOrder($set_like_shopping_user->number);
|
||||
}
|
||||
|
||||
private static function clearIsLike($shopping_user){
|
||||
/**
|
||||
* Prüffall abschließen, ohne Berater oder Kundennummer zu ändern (Übersicht Kundenhoheit).
|
||||
*/
|
||||
public static function keepAssignment(ShoppingUser $shopping_user): void
|
||||
{
|
||||
self::clearIsLike($shopping_user);
|
||||
}
|
||||
|
||||
private static function clearIsLike($shopping_user)
|
||||
{
|
||||
$shopping_user->is_like = false;
|
||||
$shopping_user->removeNotice(self::$user_notice_key);
|
||||
$shopping_user->save();
|
||||
|
||||
}
|
||||
|
||||
private static function nextNumber (){
|
||||
if(!$number = ShoppingUser::max('number')){
|
||||
private static function nextNumber()
|
||||
{
|
||||
if (! $number = ShoppingUser::max('number')) {
|
||||
$number = self::$start_number;
|
||||
}
|
||||
return $number+1;
|
||||
}
|
||||
|
||||
return $number + 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
app/Services/CustomerPriorityOverviewService.php
Normal file
140
app/Services/CustomerPriorityOverviewService.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserEmailChange;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Open work of the customer sovereignty (Kundenhoheit) for the admin overview: customers
|
||||
* waiting for review, customers that ended up with several consultants, and e-mail changes
|
||||
* that were rejected or are waiting for the customer's confirmation.
|
||||
*/
|
||||
class CustomerPriorityOverviewService
|
||||
{
|
||||
/**
|
||||
* @return Builder<ShoppingUser>
|
||||
*/
|
||||
public function reviewsQuery(): Builder
|
||||
{
|
||||
return ShoppingUser::query()
|
||||
->select('shopping_users.*')
|
||||
->with(['member.account', 'shopping_order.user_shop.user.account'])
|
||||
->where('is_like', true)
|
||||
->withoutGdprDeleted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Customers (by e-mail) whose entries belong to more than one consultant. Customers still
|
||||
* under review are listed there; addresses of consultant accounts are left out, because a
|
||||
* customer who became a consultant gets their own orders assigned on purpose.
|
||||
*
|
||||
* @return Collection<int, array{email: string, name: string, last_at: string, groups: list<array{member_id: int, member: User|null, number: int, entries: int, latest_id: int, last_at: string, oldest: bool}>}>
|
||||
*/
|
||||
public function conflicts(): Collection
|
||||
{
|
||||
$emailKeys = $this->customerEntries()
|
||||
->selectRaw('LOWER(billing_email) as email_key')
|
||||
->groupBy(DB::raw('LOWER(billing_email)'))
|
||||
->havingRaw('COUNT(DISTINCT member_id) > 1')
|
||||
->havingRaw('MAX(CASE WHEN is_like = 1 THEN 1 ELSE 0 END) = 0')
|
||||
->pluck('email_key');
|
||||
|
||||
if ($emailKeys->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$consultantEmails = User::query()
|
||||
->whereIn(DB::raw('LOWER(email)'), $emailKeys)
|
||||
->pluck('email')
|
||||
->map(fn (string $email) => mb_strtolower($email));
|
||||
$emailKeys = $emailKeys->diff($consultantEmails);
|
||||
|
||||
return $this->customerEntries()
|
||||
->with('member.account')
|
||||
->whereIn(DB::raw('LOWER(billing_email)'), $emailKeys)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->groupBy(fn (ShoppingUser $entry) => mb_strtolower($entry->billing_email))
|
||||
->map(function (Collection $entries, string $email) {
|
||||
$groups = $entries
|
||||
->groupBy(fn (ShoppingUser $entry) => $entry->member_id.'-'.$entry->number)
|
||||
->values()
|
||||
->map(fn (Collection $group, int $index) => [
|
||||
'member_id' => $group->first()->member_id,
|
||||
'member' => $group->first()->member,
|
||||
'number' => $group->first()->number,
|
||||
'entries' => $group->count(),
|
||||
'latest_id' => $group->last()->id,
|
||||
'last_at' => $group->last()->created_at->format('Y-m-d H:i'),
|
||||
'oldest' => $index === 0,
|
||||
])
|
||||
->all();
|
||||
$latest = $entries->last();
|
||||
|
||||
return [
|
||||
'email' => $email,
|
||||
'name' => trim($latest->billing_firstname.' '.$latest->billing_lastname),
|
||||
'last_at' => $latest->created_at->format('Y-m-d H:i'),
|
||||
'groups' => $groups,
|
||||
];
|
||||
})
|
||||
->sortByDesc('last_at')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<ShoppingUserEmailChange>
|
||||
*/
|
||||
public function emailChangesQuery(): Builder
|
||||
{
|
||||
return ShoppingUserEmailChange::query()
|
||||
->select('shopping_user_email_changes.*')
|
||||
->with(['shopping_user.member', 'requested_by'])
|
||||
->where(function (Builder $query) {
|
||||
$query->blockedOpen()->orWhere(fn (Builder $pending) => $pending->pending());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts for the menu badge (cheap queries only).
|
||||
*/
|
||||
public function openCount(): int
|
||||
{
|
||||
return $this->reviewsQuery()->count() + ShoppingUserEmailChange::blockedOpen()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{reviews: int, conflicts: int, email_changes: int}
|
||||
*/
|
||||
public function counts(): array
|
||||
{
|
||||
return [
|
||||
'reviews' => $this->reviewsQuery()->count(),
|
||||
'conflicts' => $this->conflicts()->count(),
|
||||
'email_changes' => $this->emailChangesQuery()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer entries that take part in the customer sovereignty.
|
||||
*
|
||||
* @return Builder<ShoppingUser>
|
||||
*/
|
||||
private function customerEntries(): Builder
|
||||
{
|
||||
return ShoppingUser::query()
|
||||
->whereNull('auth_user_id')
|
||||
->whereNotNull('number')
|
||||
->whereNotNull('member_id')
|
||||
->whereNotNull('billing_email')
|
||||
->where('billing_email', '!=', '')
|
||||
->where('billing_email', 'not like', '%-faker@mivita.care')
|
||||
->where(fn (Builder $query) => $query->where('faker_mail', false)->orWhereNull('faker_mail'))
|
||||
->withoutGdprDeleted();
|
||||
}
|
||||
}
|
||||
402
app/Services/Team/RecognitionService.php
Normal file
402
app/Services/Team/RecognitionService.php
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Team;
|
||||
|
||||
use App\Models\RecognitionSnapshot;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Abo\TeamAboOverviewService;
|
||||
use App\Services\Backoffice\SalesVolumePoints;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Anerkennungen des Monats: firmenweite Ranglisten in fünf Kategorien, wie sie
|
||||
* bisher monatlich per Mail verschickt wurden (dev/2026-08-12-intensiv-tickets).
|
||||
*
|
||||
* Die Kennzahlen folgen denselben Regeln wie die teaminterne Top-3-Karte
|
||||
* ({@see TopResultsService}) und die VIP-Statistik, nur über alle Berater.
|
||||
* Ein abgeschlossener Monat wird am {@see self::FREEZE_DAY}. des Folgemonats
|
||||
* eingefroren ({@see self::freeze()}); bis dahin ist er vorläufig, damit
|
||||
* Nachbuchungen und Nacht-Repairs noch einfließen.
|
||||
*/
|
||||
class RecognitionService
|
||||
{
|
||||
public const CATEGORIES = [
|
||||
'recruiters',
|
||||
'new_customer_abos',
|
||||
'customer_abo_points',
|
||||
'customer_abos_total',
|
||||
'total_points',
|
||||
];
|
||||
|
||||
public const FIRST_YEAR = 2026;
|
||||
|
||||
public const FIRST_MONTH = 1;
|
||||
|
||||
public const FREEZE_DAY = 3;
|
||||
|
||||
public const LIVE_LIMIT = 10;
|
||||
|
||||
private const DEFAULT_PODIUM_PLACES = 3;
|
||||
|
||||
/**
|
||||
* Abweichende Platzzahl je Kategorie — die Mail führte beim
|
||||
* 1.000-Punkte-Shop immer einen Platz 4 mit.
|
||||
*/
|
||||
private const PODIUM_PLACES = [
|
||||
'total_points' => 4,
|
||||
];
|
||||
|
||||
/**
|
||||
* Mindestwert für einen Platz: bei den Recruitern teilen sich sonst oft
|
||||
* zehn und mehr Partner mit je einem Neupartner einen Platz.
|
||||
*/
|
||||
private const MIN_VALUES = [
|
||||
'recruiters' => 2,
|
||||
];
|
||||
|
||||
private const CACHE_MINUTES = 15;
|
||||
|
||||
public function __construct(private TeamAboOverviewService $teamAboOverview) {}
|
||||
|
||||
/**
|
||||
* Anerkennungen eines abgeschlossenen Monats: eingefroren, falls vorhanden,
|
||||
* sonst vorläufig berechnet.
|
||||
*
|
||||
* @return array{month: int, year: int, is_final: bool, frozen_at: Carbon|null, freeze_date: Carbon, categories: array<string, array<int, array{place: int, value: float|int, members: array<int, array<string, mixed>>}>>}
|
||||
*/
|
||||
public function recognitions(int $month, int $year): array
|
||||
{
|
||||
$snapshot = RecognitionSnapshot::query()
|
||||
->where('year', $year)
|
||||
->where('month', $month)
|
||||
->first();
|
||||
|
||||
$categories = $snapshot
|
||||
? $snapshot->payload['categories']
|
||||
: Cache::remember(
|
||||
self::cacheKey('podium', $month, $year),
|
||||
now()->addMinutes(self::CACHE_MINUTES),
|
||||
fn () => $this->calculate($month, $year)['categories']
|
||||
);
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'is_final' => $snapshot !== null,
|
||||
'frozen_at' => $snapshot?->frozen_at,
|
||||
'freeze_date' => self::freezeDate($month, $year),
|
||||
'categories' => $this->withProfileImages($categories, 'members'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-Ranglisten (Top {@see self::LIVE_LIMIT}) je Kategorie, 15 Minuten gecacht.
|
||||
*
|
||||
* @return array{month: int, year: int, calculated_at: Carbon, categories: array<string, array<int, array<string, mixed>>>}
|
||||
*/
|
||||
public function liveRankings(int $month, int $year): array
|
||||
{
|
||||
$cached = Cache::remember(
|
||||
self::cacheKey('live', $month, $year),
|
||||
now()->addMinutes(self::CACHE_MINUTES),
|
||||
fn () => [
|
||||
'calculated_at' => now()->toIso8601String(),
|
||||
'categories' => $this->calculate($month, $year)['rankings'],
|
||||
]
|
||||
);
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'year' => $year,
|
||||
'calculated_at' => Carbon::parse($cached['calculated_at']),
|
||||
'categories' => $this->withProfileImages($cached['categories']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Friert einen abgeschlossenen Monat ein. Ein vorhandener Snapshot bleibt
|
||||
* ohne $force unangetastet.
|
||||
*/
|
||||
public function freeze(int $month, int $year, bool $force = false): bool
|
||||
{
|
||||
$exists = RecognitionSnapshot::query()->where('year', $year)->where('month', $month)->exists();
|
||||
|
||||
if ($exists && ! $force) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RecognitionSnapshot::query()->updateOrCreate(
|
||||
['year' => $year, 'month' => $month],
|
||||
['payload' => $this->calculate($month, $year), 'frozen_at' => now()]
|
||||
);
|
||||
|
||||
Cache::forget(self::cacheKey('podium', $month, $year));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ab diesem Zeitpunkt gilt ein Monat als festgeschrieben.
|
||||
*/
|
||||
public static function freezeDate(int $month, int $year): Carbon
|
||||
{
|
||||
return Carbon::create($year, $month, 1)->addMonthNoOverflow()->day(self::FREEZE_DAY)->startOfDay();
|
||||
}
|
||||
|
||||
public static function isFreezable(int $month, int $year): bool
|
||||
{
|
||||
return now()->gte(self::freezeDate($month, $year));
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgeschlossene Monate von heute rückwärts bis zum ersten Anerkennungsmonat.
|
||||
*
|
||||
* @return array<int, array{month: int, year: int}>
|
||||
*/
|
||||
public static function closedMonths(): array
|
||||
{
|
||||
$months = [];
|
||||
$cursor = now()->startOfMonth()->subMonthNoOverflow();
|
||||
$first = Carbon::create(self::FIRST_YEAR, self::FIRST_MONTH, 1);
|
||||
|
||||
while ($cursor->gte($first)) {
|
||||
$months[] = ['month' => (int) $cursor->month, 'year' => (int) $cursor->year];
|
||||
$cursor->subMonthNoOverflow();
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{categories: array<string, array<int, array<string, mixed>>>, rankings: array<string, array<int, array<string, mixed>>>}
|
||||
*/
|
||||
private function calculate(int $month, int $year): array
|
||||
{
|
||||
$userIds = $this->eligibleUserIds();
|
||||
$result = ['categories' => [], 'rankings' => []];
|
||||
|
||||
foreach (self::CATEGORIES as $category) {
|
||||
$entries = $this->sortedEntries($this->categoryValues($category, $userIds, $month, $year));
|
||||
|
||||
$result['categories'][$category] = self::podium(
|
||||
$entries,
|
||||
self::PODIUM_PLACES[$category] ?? self::DEFAULT_PODIUM_PLACES,
|
||||
self::MIN_VALUES[$category] ?? 0
|
||||
);
|
||||
$result['rankings'][$category] = self::ranking($entries, self::LIVE_LIMIT);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Berater außer den nie aufgeführten Konten (config mivita.recognition_excluded_user_ids).
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function eligibleUserIds(): array
|
||||
{
|
||||
return User::query()
|
||||
->whereNotNull('m_level')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotIn('id', config('mivita.recognition_excluded_user_ids', []))
|
||||
->whereHas('account')
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $userIds
|
||||
* @return array<int, float|int> user_id => Wert (> 0)
|
||||
*/
|
||||
private function categoryValues(string $category, array $userIds, int $month, int $year): array
|
||||
{
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = match ($category) {
|
||||
'recruiters' => User::query()
|
||||
->firstlinesOf($userIds)
|
||||
->newPartnerIn($month, $year)
|
||||
->groupBy('m_sponsor')
|
||||
->selectRaw('m_sponsor, COUNT(*) as value')
|
||||
->pluck('value', 'm_sponsor')
|
||||
->map(fn ($value) => (int) $value),
|
||||
'new_customer_abos' => $this->teamAboOverview
|
||||
->aboStockQuery($userIds, 'member_id', 'ot', $month, $year)
|
||||
->whereBetween('start_date', [
|
||||
Carbon::create($year, $month, 1)->startOfMonth(),
|
||||
Carbon::create($year, $month, 1)->endOfMonth(),
|
||||
])
|
||||
->groupBy('member_id')
|
||||
->selectRaw('member_id, COUNT(*) as value')
|
||||
->pluck('value', 'member_id')
|
||||
->map(fn ($value) => (int) $value),
|
||||
'customer_abos_total' => $this->teamAboOverview
|
||||
->aboStockQuery($userIds, 'member_id', 'ot', $month, $year)
|
||||
->groupBy('member_id')
|
||||
->selectRaw('member_id, COUNT(*) as value')
|
||||
->pluck('value', 'member_id')
|
||||
->map(fn ($value) => (int) $value),
|
||||
'customer_abo_points' => UserSalesVolume::query()
|
||||
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
|
||||
->whereIn('user_sales_volumes.user_id', $userIds)
|
||||
->where('user_sales_volumes.month', $month)
|
||||
->where('user_sales_volumes.year', $year)
|
||||
->groupBy('user_sales_volumes.user_id')
|
||||
->selectRaw('user_sales_volumes.user_id, '.SalesVolumePoints::customerAboPoints().' as value')
|
||||
->pluck('value', 'user_id')
|
||||
->map(fn ($value) => round((float) $value, 2)),
|
||||
'total_points' => SalesVolumePoints::totalPointsByUser($userIds, $month, $year)
|
||||
->map(fn (float $points) => round($points, 2)),
|
||||
};
|
||||
|
||||
return $values->filter(fn ($value) => $value > 0)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wert absteigend, bei Gleichstand nach Name und zuletzt nach user_id, damit
|
||||
* die Reihenfolge reproduzierbar bleibt.
|
||||
*
|
||||
* @param array<int, float|int> $valuesByUserId
|
||||
* @return array<int, array{user_id: int, first_name: string, last_name: string, value: float|int}>
|
||||
*/
|
||||
private function sortedEntries(array $valuesByUserId): array
|
||||
{
|
||||
if ($valuesByUserId === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$users = User::query()
|
||||
->with('account')
|
||||
->whereIn('id', array_keys($valuesByUserId))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$entries = [];
|
||||
foreach ($valuesByUserId as $userId => $value) {
|
||||
$account = $users->get($userId)?->account;
|
||||
if (! $account) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = [
|
||||
'user_id' => (int) $userId,
|
||||
'first_name' => trim((string) $account->first_name),
|
||||
'last_name' => trim((string) $account->last_name),
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
usort($entries, fn (array $a, array $b) => [$b['value'], $a['first_name'], $a['last_name'], $a['user_id']]
|
||||
<=> [$a['value'], $b['first_name'], $b['last_name'], $b['user_id']]);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plätze wie in der Mail: Gleichstand teilt sich einen Platz, der nächste
|
||||
* Wert bekommt den nächsten Platz (1, 2, 2, 3 → Platz 1, 2, 3).
|
||||
*
|
||||
* @param array<int, array{user_id: int, first_name: string, last_name: string, value: float|int}> $entries
|
||||
* @return array<int, array{place: int, value: float|int, members: array<int, array{user_id: int, first_name: string, last_name: string}>}>
|
||||
*/
|
||||
private static function podium(array $entries, int $places, float|int $minValue): array
|
||||
{
|
||||
$podium = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry['value'] < $minValue) {
|
||||
break;
|
||||
}
|
||||
|
||||
$current = array_key_last($podium);
|
||||
if ($current === null || $podium[$current]['value'] != $entry['value']) {
|
||||
if (count($podium) >= $places) {
|
||||
break;
|
||||
}
|
||||
|
||||
$podium[] = ['place' => count($podium) + 1, 'value' => $entry['value'], 'members' => []];
|
||||
$current = array_key_last($podium);
|
||||
}
|
||||
|
||||
$podium[$current]['members'][] = [
|
||||
'user_id' => $entry['user_id'],
|
||||
'first_name' => $entry['first_name'],
|
||||
'last_name' => $entry['last_name'],
|
||||
];
|
||||
}
|
||||
|
||||
return $podium;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rangliste mit Wettkampf-Rang (1, 2, 2, 4), auf $limit Zeilen gekürzt.
|
||||
*
|
||||
* @param array<int, array{user_id: int, first_name: string, last_name: string, value: float|int}> $entries
|
||||
* @return array<int, array{rank: int, user_id: int, first_name: string, last_name: string, value: float|int}>
|
||||
*/
|
||||
private static function ranking(array $entries, int $limit): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach (array_slice($entries, 0, $limit) as $index => $entry) {
|
||||
$previous = $rows[$index - 1] ?? null;
|
||||
$rank = $previous !== null && $previous['value'] == $entry['value'] ? $previous['rank'] : $index + 1;
|
||||
|
||||
$rows[] = ['rank' => $rank] + $entry;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profilfotos erst beim Lesen ergänzen — nicht mit einfrieren oder cachen,
|
||||
* der Dateiname ändert sich bei jedem Upload ({@see TopResultsService}).
|
||||
*
|
||||
* @param array<string, array<int, array<string, mixed>>> $categories
|
||||
* @return array<string, array<int, array<string, mixed>>>
|
||||
*/
|
||||
private function withProfileImages(array $categories, ?string $nestedKey = null): array
|
||||
{
|
||||
$userIds = collect($categories)->flatten(1)
|
||||
->flatMap(fn (array $row) => $nestedKey ? $row[$nestedKey] : [$row])
|
||||
->pluck('user_id')
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($userIds === []) {
|
||||
return $categories;
|
||||
}
|
||||
|
||||
$imageUrls = User::query()
|
||||
->with('account')
|
||||
->whereIn('id', $userIds)
|
||||
->get()
|
||||
->mapWithKeys(fn (User $member) => [(int) $member->id => $member->account?->getProfileImageUrl()]);
|
||||
|
||||
$addImage = fn (array $row) => $row + ['profile_image_url' => $imageUrls->get((int) $row['user_id'])];
|
||||
|
||||
foreach ($categories as $category => $rows) {
|
||||
foreach ($rows as $index => $row) {
|
||||
if ($nestedKey) {
|
||||
$categories[$category][$index][$nestedKey] = array_map($addImage, $row[$nestedKey]);
|
||||
} else {
|
||||
$categories[$category][$index] = $addImage($row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
private static function cacheKey(string $type, int $month, int $year): string
|
||||
{
|
||||
return "recognitions_{$type}_{$year}_{$month}";
|
||||
}
|
||||
}
|
||||
|
|
@ -19,4 +19,12 @@ return [
|
|||
// Berater landen, sind User-Fehler über die Hauptwebseite — VP-Punkte
|
||||
// stehen immer dem Berater zu (Kundenentscheid 2026-08-19).
|
||||
'company_user_id' => (int) env('MIVITA_COMPANY_USER_ID', 6),
|
||||
|
||||
// Anerkennungen (Monats-Ranglisten im Salescenter): diese Konten werden nie
|
||||
// aufgeführt — Firmenkonto (6) und Großkundin Ruth Krummel (3), Kundenentscheid 2026-10-07;
|
||||
// Go4 Values (486), Kundenentscheid 2026-10-09.
|
||||
'recognition_excluded_user_ids' => array_values(array_filter(array_map(
|
||||
'intval',
|
||||
explode(',', (string) env('MIVITA_RECOGNITION_EXCLUDED_USER_IDS', '3,6,486'))
|
||||
))),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* E-Mail-Änderungen an Kunden (shopping_users): Eine vom Berater eingetragene neue
|
||||
* Adresse wird erst aktiv, wenn der Kunde sie über den Link in der Bestätigungsmail
|
||||
* bestätigt. Direkte Änderungen durch Admins werden mit confirmed_at protokolliert.
|
||||
* Wegen Kundenhoheit abgelehnte Änderungen (blocked_at) bleiben offen, bis ein Admin sie
|
||||
* in der Übersicht „Kundenhoheit“ als erledigt markiert (resolved_at).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('shopping_user_email_changes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedInteger('shopping_user_id')->index();
|
||||
$table->unsignedInteger('number')->nullable()->index();
|
||||
$table->string('old_email');
|
||||
$table->string('new_email');
|
||||
$table->string('token_hash', 64)->nullable()->unique();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('confirmed_at')->nullable();
|
||||
$table->timestamp('blocked_at')->nullable()->index();
|
||||
$table->timestamp('resolved_at')->nullable();
|
||||
$table->unsignedInteger('requested_by_user_id')->nullable();
|
||||
$table->unsignedInteger('resolved_by_user_id')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('shopping_user_email_changes');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('recognition_snapshots', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedSmallInteger('year');
|
||||
$table->unsignedTinyInteger('month');
|
||||
$table->json('payload');
|
||||
$table->timestamp('frozen_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['year', 'month'], 'recognition_snapshot_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('recognition_snapshots');
|
||||
}
|
||||
};
|
||||
46
dev/2026-08-12-intensiv-tickets/september-anerkennungen.md
Normal file
46
dev/2026-08-12-intensiv-tickets/september-anerkennungen.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
Herzlichen Glückwunsch zu euren großartigen Erfolgen! 🥳💙
|
||||
|
||||
|
||||
🏆 Top-Recruiter – September
|
||||
🥇 Gabi Ramstötter (2)
|
||||
🥇 Tanja Longerich (2)
|
||||
🥇 Claudia Lattner (2)
|
||||
|
||||
🌟 Die meisten neuen Kunden-Abos im September
|
||||
🥇 Platz 1: Verena Zöllinger (7)
|
||||
🥈 Platz 2: Nicola Maier (6)
|
||||
🥉 Platz 3: Rosa Maria Assenbaum (4), Carmen Graf (4)
|
||||
|
||||
💙 Höchster Umsatz mit Kunden-Abos
|
||||
🥇 Platz 1: Cornelia Böhm-Sodan – 1.096 Punkte
|
||||
🥈 Platz 2: Annette Harsch – 1.042 Punkte
|
||||
🥉 Platz 3: Tanja Longerich – 930 Punkte
|
||||
|
||||
⭐ Die meisten Kunden-Abos insgesamt
|
||||
🥇 Platz 1: Annette Harsch (21)
|
||||
🥈 Platz 2: Rosa Maria Assenbaum (19)
|
||||
🥉 Platz 3: Nina Fleschhut (16), Tanja Longerich (16)
|
||||
|
||||
🚀 Top 3 durch Kundenumsatz & Recruiting – 1.000-Punkte-Shop
|
||||
🥇 Platz 1: Claudia Lattner (5.808 P)
|
||||
🥈 Platz 2: Daniel Biallowons (4.202 P)
|
||||
🥉 Platz 3: Tanja Longerich (3.279 P)
|
||||
Platz 4: Claudia Zürn (3.001 P)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Prüfung firmenweit (07.10.2026, Live-Stand) – nicht mitschicken
|
||||
|
||||
Nachgerechnet über die gesamte Firma statt nur Claudias Team, mit denselben Regeln wie
|
||||
Dashboard „Die besten Ergebnisse“ und VIP-Statistik. Grundsätzlich ohne das
|
||||
Firmenkonto (mivita care gmbh, User 6) und ohne Ruth Krummel (User 3, Großkunde) –
|
||||
beide werden in den Anerkennungen nie aufgeführt (bestätigt von Kevin, 07.10.2026).
|
||||
|
||||
| Kategorie | Ergebnis |
|
||||
|---|---|
|
||||
| Top-Recruiter | ✅ stimmt (je 2; dahinter rund 10 Partner mit je 1) |
|
||||
| Neue Kunden-Abos | ✅ stimmt; Name korrigiert: Rosa Maria Assenbaum |
|
||||
| Umsatz Kunden-Abos | ✅ stimmt (Böhm-Sodan genau 1.096,5 P) |
|
||||
| Kunden-Abos insgesamt | ✅ stimmt; Name korrigiert: Rosa Maria Assenbaum (Klammer fehlte) |
|
||||
| 1.000-Punkte-Shop | ❌ **falsch**: Daniel Biallowons (4.202,5 P) und Claudia Zürn (3.001,5 P) sind nicht in Claudias Team und fehlten. Jasmin Neuhauser (2.637 P) rutscht auf Platz 5 |
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Spam über das Kontaktformular – Entwicklungsplan & Protokoll
|
||||
|
||||
Stand: 09.10.2026 · Status: **Kontaktformular + Registrierung umgesetzt, als `v1.6.0` getaggt** (Deploy siehe Abschnitt 7)
|
||||
Stand: 09.10.2026 · Status: **Kontaktformular + Registrierung umgesetzt, als `v1.6.0` getaggt, Datenschutzerklärung in `v1.6.1`** (Deploy siehe Abschnitt 7)
|
||||
|
||||
Beispiel-Mail: [spam-mail.md](spam-mail.md)
|
||||
|
||||
|
|
@ -208,8 +208,8 @@ Hinweis Routing: Auf der Hauptdomain überschreibt `routes/domains/main.php` die
|
|||
TURNSTILE_SECRET_KEY=...
|
||||
```
|
||||
✅ Widget angelegt am 09.10.2026. **Ohne diese Schlüssel lehnen Kontaktformular und Registrierung jede Anfrage ab.** Lokal sind die Cloudflare-Testschlüssel eingetragen, die immer bestehen.
|
||||
3. **Datenschutzerklärung** anpassen: Google reCAPTCHA raus, Cloudflare Turnstile (Kontakt + Registrierung) rein.
|
||||
4. Deploy wie üblich per Tag (`dev/betrieb/README.md`), keine Migration: `bash dev/betrieb/deploy.sh v1.6.0`
|
||||
3. ✅ **Datenschutzerklärung** angepasst (09.10.2026): neuer Absatz „Schutz unserer Formulare vor Missbrauch (Cloudflare Turnstile)“ am Ende von Abschnitt V in `resources/lang/{de,en,es,fr}/dataprotect.php`, inkl. Rate-Limit und 30-Tage-Protokoll; „Stand“ auf 09.10.2026. reCAPTCHA war dort nie erwähnt. Die Erklärung wird ohnehin komplett neu gefasst – dabei Abschnitt XI prüfen (verweist noch auf das ungültige „Privacy Shield“).
|
||||
4. Deploy wie üblich per Tag (`dev/betrieb/README.md`), keine Migration: `bash dev/betrieb/deploy.sh v1.6.1` (enthält v1.6.0)
|
||||
5. Danach den alten reCAPTCHA-Schlüssel in der Google-Konsole löschen (das Secret liegt in der Git-Historie).
|
||||
6. SQL-Abfrage aus Abschnitt 5 (Benutzer ohne Account) auf Live ausführen.
|
||||
7. Nach ein paar Tagen das Log auswerten (Befehl oben) und hier im Protokoll eintragen.
|
||||
|
|
@ -231,3 +231,4 @@ Hinweis Routing: Auf der Hauptdomain überschreibt `routes/domains/main.php` die
|
|||
| 09.10.2026 | Passwort-Mindestlänge systemweit auf 8 Zeichen (zentral über `Password::defaults()`), Turnstile-Widget bei Cloudflare angelegt | noch nicht deployt |
|
||||
| 09.10.2026 | Echte Turnstile-Schlüssel lokal geprüft: Secret von Cloudflare akzeptiert, Site-Key auf Hauptseite und Berater-Shop ausgeliefert, gefälschtes Token wird abgelehnt und protokolliert | noch nicht deployt |
|
||||
| 09.10.2026 | Auf dem Testsystem im Browser geprüft (läuft), Commit + Tag für den Deploy | v1.6.0 |
|
||||
| 09.10.2026 | Datenschutzerklärung (DE/EN/ES/FR) um Cloudflare Turnstile und Missbrauchsprotokoll ergänzt | v1.6.1 |
|
||||
|
|
|
|||
|
|
@ -35,4 +35,21 @@ return [
|
|||
'under_review' => 'in Prüfung',
|
||||
'select' => 'wählen',
|
||||
'language_hint' => 'Die Rechnungen und Dokumente werden in dieser Sprache erstellt.',
|
||||
'email_change_requested' => 'Die neue E-Mail-Adresse :email ist noch nicht aktiv. Wir haben dem Kunden einen Bestätigungslink an diese Adresse geschickt. Erst nach seiner Bestätigung gilt sie für Login und Abo.',
|
||||
'email_change_pending' => 'Neue E-Mail-Adresse :email wartet auf Bestätigung durch den Kunden (angefragt am :date, Link gültig bis :until). Bis dahin gilt die bisherige Adresse.',
|
||||
'email_change_edit_hint' => 'Eine geänderte E-Mail-Adresse wird erst aktiv, wenn der Kunde sie über den Link in der Bestätigungsmail bestätigt.',
|
||||
'email_change_mail_subject' => 'Bitte bestätige deine neue E-Mail-Adresse',
|
||||
'email_change_mail_intro' => 'für dein Kundenkonto bei mivita.care wurde diese E-Mail-Adresse als neue Adresse eingetragen. Bitte bestätige sie mit dem Button. Danach meldest du dich im Kundenportal mit dieser Adresse an, und alle Nachrichten zu deinem Abo kommen hierher.',
|
||||
'email_change_mail_button' => 'E-Mail-Adresse bestätigen',
|
||||
'email_change_mail_hint' => 'Der Link ist :days Tage gültig. Wenn du die Änderung nicht veranlasst hast, ignoriere diese E-Mail einfach – dann bleibt alles wie bisher. Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:',
|
||||
'email_change_confirm_title' => 'Neue E-Mail-Adresse bestätigen',
|
||||
'email_change_confirm_copy' => 'Bitte bestätige, dass du künftig diese E-Mail-Adresse für dein Kundenkonto und dein Abo nutzen möchtest:',
|
||||
'email_change_confirm_button' => 'Ja, E-Mail-Adresse bestätigen',
|
||||
'email_change_done_title' => 'E-Mail-Adresse bestätigt',
|
||||
'email_change_done_copy' => 'Ab sofort meldest du dich im Kundenportal mit :email an.',
|
||||
'email_change_invalid_title' => 'Link ungültig',
|
||||
'email_change_invalid_copy' => 'Der Link ist abgelaufen, wurde schon verwendet oder durch eine neuere Änderung ersetzt. Bitte wende dich an deinen Berater.',
|
||||
'email_change_blocked' => 'Die E-Mail-Adresse :email wurde nicht übernommen: Sie ist bereits einem Beraterkonto oder einem Kunden eines anderen Beraters zugeordnet (Kundenhoheit). Der Support wurde informiert.',
|
||||
'email_change_conflict_title' => 'E-Mail-Adresse bereits vergeben',
|
||||
'email_change_conflict_copy' => 'Diese E-Mail-Adresse ist bereits einem anderen Kundenkonto zugeordnet und kann deshalb nicht übernommen werden. Bitte wende dich an deinen Berater.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -82,6 +82,14 @@ return [
|
|||
Aufgrund der Regelungen des Telemediengesetzes (TMG) sind wir zur Angabe einer allgemeinen Mailadresse im Impressum unserer Website verpflichtet, über die Sie uns per Mail kontaktieren können. Weiterhin haben Sie die Möglichkeit, über Kontaktformulare auf unserer Website Kontakt mit uns aufzunehmen. Über die Kontaktformulare können Sie u.a. Anfragen zu den Produkten oder unserem Geschäft stellen bzw. sich als Berater bei uns bewerben. Die von Ihnen per Mail oder unsere Kontaktformulare an uns übermittelten personenbezogenen Daten werden automatisch für Zwecke der Bearbeitung oder der Kontaktaufnahme zu Ihnen gespeichert.
|
||||
<br><br>
|
||||
Mit Absenden Ihrer Nachricht willigen Sie in die Verarbeitung der übermittelten Daten ein. Die Verarbeitung erfolgt auf Grundlage des Art. 6 Abs. 1 lit. a DSGVO mit Ihrer Einwilligung. Sie können Ihre Einwilligung jederzeit durch Mitteilung an uns widerrufen, ohne dass die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung berührt wird. Ihre E-Mail-Adresse nutzen wir nur zur Bearbeitung Ihrer Anfrage. Ihre Daten werden anschließend gelöscht, sofern Sie der weitergehenden Verarbeitung und Nutzung nicht zugestimmt haben.
|
||||
<br><br>
|
||||
<strong>Schutz unserer Formulare vor Missbrauch (Cloudflare Turnstile)</strong>
|
||||
<br>
|
||||
Zum Schutz unseres Kontaktformulars und unserer Registrierung vor Spam und automatisierten Zugriffen (Bots) setzen wir den Dienst „Turnstile“ der Cloudflare, Inc., 101 Townsend St., San Francisco, CA 94107, USA, ein. Turnstile prüft beim Aufruf dieser Formulare im Hintergrund, ob die Eingabe von einem Menschen stammt. Dabei werden insbesondere Ihre IP-Adresse sowie technische Merkmale Ihres Browsers und Geräts (z. B. Browserkennung/User-Agent und Merkmale der verschlüsselten Verbindung) an Cloudflare übermittelt und dort ausgewertet. Cloudflare verarbeitet diese Daten in unserem Auftrag zur Erkennung von Bots und nach eigenen Angaben außerdem zur Verbesserung der Bot-Erkennung.
|
||||
<br><br>
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt darin, unsere Formulare vor missbräuchlicher Nutzung zu schützen, insbesondere davor, dass über unsere Website Spam-Nachrichten an Dritte verschickt werden. Dabei kann eine Übermittlung in die USA erfolgen. Cloudflare ist unter dem EU-US Data Privacy Framework zertifiziert; ergänzend gelten die EU-Standardvertragsklauseln. Weitere Informationen: https://www.cloudflare.com/turnstile-privacy-policy/ und https://www.cloudflare.com/privacypolicy/
|
||||
<br><br>
|
||||
Zusätzlich begrenzen wir die Anzahl der Anfragen je IP-Adresse und protokollieren abgewiesene Anfragen (Zeitpunkt, IP-Adresse, aufgerufene Seite, Domain-Teil der angegebenen E-Mail-Adresse und Grund der Abweisung) für 30 Tage, ebenfalls auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Der Inhalt Ihrer Nachricht und Passwörter werden dabei nicht protokolliert.
|
||||
<br><br>',
|
||||
'data_protect_copy6' => '<strong>VI. Rechtsgrundlagen der Verarbeitung</strong>
|
||||
<br>
|
||||
|
|
@ -260,8 +268,8 @@ Finden Sie weitere Datenschutzinformationen von Google unter: https://www.googl
|
|||
Es werden jedoch keine Informationen herausgegeben, mit denen sich Nutzer persönlich identifizieren lassen.<br>
|
||||
Wenn Sie nicht an dem Tracking-Verfahren teilnehmen möchten, können Sie auch das hierfür erforderliche Setzen eines Cookies ablehnen – etwa per Browser-Einstellung, die das automatische Setzen von Cookies generell deaktiviert. Sie können Cookies für Conversion-Tracking auch deaktivieren, indem Sie Ihren Browser so einstellen, dass Cookies von der Domain „www.googleadservices.com“ blockiert werden.<br>
|
||||
Googles Datenschutzbelehrung zum Conversion-Tracking finden Sie hier ( https://services.google.com/sitestats/de.html )',
|
||||
'data_protect_stand_shop' => 'Stand: 18.02.2022',
|
||||
'data_protect_stand_site' => 'Stand: 05.10.2018',
|
||||
'data_protect_stand_shop' => 'Stand: 09.10.2026',
|
||||
'data_protect_stand_site' => 'Stand: 09.10.2026',
|
||||
'data_protect' => 'Datenschutz',
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ return [
|
|||
'tools' => 'Tools',
|
||||
'system_status' => 'Systemstatus',
|
||||
'news_archive' => 'News Archiv',
|
||||
'recognitions' => 'Anerkennungen',
|
||||
'incentive' => 'Incentive',
|
||||
'incentives' => 'Incentives',
|
||||
'create' => 'Neu anlegen',
|
||||
|
|
|
|||
52
resources/lang/de/recognitions.php
Normal file
52
resources/lang/de/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Anerkennungen',
|
||||
'hero_kicker' => 'Anerkennungen des Monats',
|
||||
'hero_title' => 'Unsere Erfolge im :month',
|
||||
'hero_text' => 'Herzlichen Glückwunsch zu euren großartigen Erfolgen! Hinter jedem Platz stehen Begeisterung, Ausdauer und ganz viel Einsatz – wir sind sehr stolz auf euch.',
|
||||
'period_label' => 'Monat',
|
||||
'period_invalid' => 'Bitte einen gültigen Monat wählen.',
|
||||
'show' => 'Anzeigen',
|
||||
'status_final' => 'Endgültig · festgeschrieben am :date',
|
||||
'status_preliminary' => 'Vorläufig · wird am :date festgeschrieben',
|
||||
'place' => 'Platz :place',
|
||||
'empty' => 'In diesem Monat gibt es hier keine Platzierungen.',
|
||||
'no_months' => 'Es gibt noch keinen abgeschlossenen Monat.',
|
||||
'recruiters_min_hint' => 'Platzierung ab 2 Neupartnern im Monat.',
|
||||
'live_title' => 'Live: So entwickelt sich der :month',
|
||||
'live_text' => 'Die aktuellen Top :limit je Kategorie – alles ist noch offen!',
|
||||
'live_updated' => 'Stand: :time Uhr · wird alle 15 Minuten aktualisiert',
|
||||
'live_rank' => 'Platz',
|
||||
'live_name' => 'Name',
|
||||
'live_value' => 'Wert',
|
||||
'live_empty' => 'Noch keine Einträge in diesem Monat.',
|
||||
'self' => 'ich',
|
||||
'categories' => [
|
||||
'recruiters' => [
|
||||
'title' => 'Top-Recruiter',
|
||||
'description' => 'Die meisten neuen Partner im Monat (direkt gesponsert, Starterpaket bezahlt).',
|
||||
'unit' => 'Neupartner',
|
||||
],
|
||||
'new_customer_abos' => [
|
||||
'title' => 'Die meisten neuen Kunden-Abos',
|
||||
'description' => 'Neu abgeschlossene Kunden-Abos im Monat.',
|
||||
'unit' => 'neue Abos',
|
||||
],
|
||||
'customer_abo_points' => [
|
||||
'title' => 'Höchster Umsatz mit Kunden-Abos',
|
||||
'description' => 'Punkte aus Kunden-Abo-Bestellungen im Monat.',
|
||||
'unit' => 'Punkte',
|
||||
],
|
||||
'customer_abos_total' => [
|
||||
'title' => 'Die meisten Kunden-Abos insgesamt',
|
||||
'description' => 'Aktive Kunden-Abos zum Monatsende (im laufenden Monat: heute).',
|
||||
'unit' => 'Abos',
|
||||
],
|
||||
'total_points' => [
|
||||
'title' => 'Kundenumsatz & Recruiting – 1.000-Punkte-Shop',
|
||||
'description' => 'Höchste Punktzahl aus Kundenumsatz, Eigenumsatz und Recruiting.',
|
||||
'unit' => 'Punkte',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -35,4 +35,21 @@ return [
|
|||
'delete_customer' => 'Delete customer',
|
||||
'deleted_successfully' => 'Customer was deleted successfully',
|
||||
'delete_customer_confirm' => 'Are you sure you want to delete this customer?',
|
||||
'email_change_requested' => 'The new e-mail address :email is not active yet. We sent the customer a confirmation link to this address. It will be used for login and subscription only after the customer confirms it.',
|
||||
'email_change_pending' => 'New e-mail address :email is awaiting confirmation by the customer (requested on :date, link valid until :until). Until then the previous address remains in use.',
|
||||
'email_change_edit_hint' => 'A changed e-mail address only becomes active once the customer confirms it via the link in the confirmation e-mail.',
|
||||
'email_change_mail_subject' => 'Please confirm your new e-mail address',
|
||||
'email_change_mail_intro' => 'this e-mail address has been entered as the new address of your customer account at mivita.care. Please confirm it with the button. Afterwards you log in to the customer portal with this address and all messages about your subscription will be sent here.',
|
||||
'email_change_mail_button' => 'Confirm e-mail address',
|
||||
'email_change_mail_hint' => 'The link is valid for :days days. If you did not request this change, simply ignore this e-mail – nothing will change. If the button does not work, copy this link into your browser:',
|
||||
'email_change_confirm_title' => 'Confirm new e-mail address',
|
||||
'email_change_confirm_copy' => 'Please confirm that you want to use this e-mail address for your customer account and subscription from now on:',
|
||||
'email_change_confirm_button' => 'Yes, confirm e-mail address',
|
||||
'email_change_done_title' => 'E-mail address confirmed',
|
||||
'email_change_done_copy' => 'From now on you log in to the customer portal with :email.',
|
||||
'email_change_invalid_title' => 'Invalid link',
|
||||
'email_change_invalid_copy' => 'The link has expired, has already been used or was replaced by a newer change. Please contact your consultant.',
|
||||
'email_change_blocked' => 'The e-mail address :email was not applied: it already belongs to a consultant account or to a customer of another consultant (customer sovereignty). Support has been informed.',
|
||||
'email_change_conflict_title' => 'E-mail address already in use',
|
||||
'email_change_conflict_copy' => 'This e-mail address is already assigned to another customer account and therefore cannot be applied. Please contact your consultant.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -10,12 +10,20 @@ return [
|
|||
'data_protect_copy2' => '<strong>I. Definitions</strong> <br> To make our data protection declaration easier to understand, we would first like to explain to you some of the terms used. For the purposes of this privacy policy, the term: <br><br> 1) “personal data” means any information relating to an identified or identifiable natural person (hereinafter “data subject”); An identifiable natural person is a natural person who can be identified directly or indirectly, in particular by reference to an identifier such as a name, an identification number, location data, an online identifier or to one or more special features that express the physical , physiological, genetic, psychological, economic, cultural or social identity of that natural person; <br><br> 2) "Processing" means any operation or series of operations carried out with or without the aid of automated procedures in connection with personal data, such as the collection, recording, organization, structuring, storage, adaptation or modification, reading, retrieving, using, disclosing by transmission, dissemination or any other form of provision, matching or combination, restriction, deletion or destruction; <br><br> 3) "Restriction of processing" means the marking of stored personal data with the aim of restricting their future processing; <br><br> 4) "Profiling" means any type of automated processing of personal data, which consists in using these personal data to evaluate certain personal aspects relating to a natural person, in particular aspects relating to analyze or predict that natural person\'s work performance, economic situation, health, personal preferences, interests, reliability, behavior, location or movements; <br><br> 5) "Pseudonymization" means the processing of personal data in such a way that the personal data can no longer be attributed to a specific data subject without the use of additional information, provided that this additional information is kept separately and is subject to technical and organizational measures that ensure that the personal data is not assigned to an identified or identifiable natural person; <br><br> 6) “Controller” means the natural or legal person, public authority, institution or other body which, alone or jointly with others, decides on the purposes and means of processing personal data; If the purposes and means of this processing are determined by Union or Member State law, the controller or the specific criteria for its nomination may be provided for by Union or Member State law <br><br> 7) “Processor” means a natural or legal person, public authority, agency or other body that processes personal data on behalf of the controller; <br><br> 8) “Recipient” means a natural or legal person, public authority, agency or other body to which personal data is disclosed, regardless of whether it is a third party or not. However, public authorities which may receive personal data in the context of a specific investigative task under Union or Member State law shall not be deemed to be recipients; the processing of these data by the said authorities will be carried out in accordance with the applicable data protection rules in accordance with the purposes of the processing; <br><br> 9) “Third party” means a natural or legal person, public authority, agency or other body, other than the data subject, the controller, the processor and the persons authorized under the direct responsibility of the controller or the processor to process personal data; <br><br> 10) “Consent” of the data subject means any voluntary, informed and unambiguous expression of will in the specific case in the form of a statement or other clear confirmatory act by which the data subject indicates that he or she agrees to the processing of personal data concerning them; <br><br> 11) “personal data breach” means a breach of security that, whether accidental or unlawful, results in the destruction, loss, alteration, or unauthorized disclosure of or access to personal data, that have been transmitted, stored or otherwise processed. <br><br>',
|
||||
'data_protect_copy3' => '<strong>II. Responsible person</strong> <br> The person responsible within the meaning of the General Data Protection Regulation as well as the data protection laws applicable in the member states of the European Union and other data protection regulations is: <br><br> <strong>mivita care gmbh</strong><br> Mr Alois Ried<br> Leinfeld 2<br> 87755 Kirchhaslach<br> Telephone: +49 (0) 8333 94 61 767<br> Mail: info@mivita.care<br> <br><br>',
|
||||
'data_protect_copy4' => '<strong>III. Cookies</strong>',
|
||||
'data_protect_copy5' => '<br> Our website uses cookies. Cookies are small text files that are stored and stored on a computer system via an Internet browser. <br> Many websites and servers use cookies. Many cookies contain a so-called cookie ID. A cookie ID is a unique identifier of the cookie. It consists of a string through which websites and servers can be assigned to the specific Internet browser in which the cookie was stored. This allows the websites and servers visited to distinguish the individual browser of the data subject from other Internet browsers that contain other cookies. A specific Internet browser can be recognized and identified via the unique cookie ID. <br> By using cookies, we can provide you with more user-friendly services that would not be possible without the cookie setting. <br> Using a cookie, the information and offers on our website can be optimized for the user. As already mentioned, cookies enable us to recognize the users of our website. The purpose of this recognition is to make it easier for you to use our websites. For example, the user of a website that uses cookies does not have to re-enter their access data each time they visit the website because this is done by the website and the cookie stored on the user\'s computer system. <br> The data processed by cookies is necessary for the purposes mentioned to protect our legitimate interests in accordance with Article 6 Paragraph 1 Sentence 1 Letter f of the GDPR. <br> You can prevent the setting of cookies through our website at any time by adjusting the appropriate settings on the Internet browser used and thus permanently object to the setting of cookies. Furthermore, cookies that have already been set can be deleted at any time via an internet browser or other software programs. This is possible in all common internet browsers. If you deactivate the setting of cookies in the Internet browser you use, not all functions of our website may be fully usable. <br><br> IV. Data and information that is stored when you visit our website <br> Every time you visit our website, general data and information is recorded that is stored in the server\'s log files. <br><br> <strong>The following data/information may be collected:</strong> <br> - the browser types and versions used - the operating system used, - the website from which you access our website - the sub-sites, which are accessed on our website - the date and time of access to our website, - your IP address - your Internet service provider - other similar data and information that serves to avert danger in the event of attacks on our IT systems . <br><br> We need this information in order to be able to accurately provide the content of our website. to ensure the long-term functionality of our IT system and to be able to provide the authorities with the information necessary for criminal prosecution in the event of a cyber attack. This is also our legitimate interest within the legal basis for processing this data in accordance with Article 6 Paragraph 1 Letter f of the GDPR. When using this general data and information, we do not draw any conclusions about the data subject. We store the anonymous data from the server log files separately from all personal data. V. Contacting us electronically <br><br> Due to the regulations of the Telemedia Act (TMG), we are obliged to provide a general email address in the imprint of our website, which you can use to contact us by email. You also have the option of contacting us using the contact forms on our website. You can use the contact forms to make inquiries about the products or our business or apply to us as a consultant. The personal data you send to us via email or our contact forms will be automatically stored for the purposes of processing or contacting you. <br><br> By sending your message, you consent to the processing of the transmitted data. Processing is carried out on the basis of Article 6 Paragraph 1 Letter a GDPR with your consent. You can revoke your consent at any time by notifying us, without this affecting the lawfulness of the processing carried out based on your consent before its revocation. We only use your email address to process your request. Your data will then be deleted unless you have consented to further processing and use. <br><br>',
|
||||
'data_protect_copy5' => '<br> Our website uses cookies. Cookies are small text files that are stored and stored on a computer system via an Internet browser. <br> Many websites and servers use cookies. Many cookies contain a so-called cookie ID. A cookie ID is a unique identifier of the cookie. It consists of a string through which websites and servers can be assigned to the specific Internet browser in which the cookie was stored. This allows the websites and servers visited to distinguish the individual browser of the data subject from other Internet browsers that contain other cookies. A specific Internet browser can be recognized and identified via the unique cookie ID. <br> By using cookies, we can provide you with more user-friendly services that would not be possible without the cookie setting. <br> Using a cookie, the information and offers on our website can be optimized for the user. As already mentioned, cookies enable us to recognize the users of our website. The purpose of this recognition is to make it easier for you to use our websites. For example, the user of a website that uses cookies does not have to re-enter their access data each time they visit the website because this is done by the website and the cookie stored on the user\'s computer system. <br> The data processed by cookies is necessary for the purposes mentioned to protect our legitimate interests in accordance with Article 6 Paragraph 1 Sentence 1 Letter f of the GDPR. <br> You can prevent the setting of cookies through our website at any time by adjusting the appropriate settings on the Internet browser used and thus permanently object to the setting of cookies. Furthermore, cookies that have already been set can be deleted at any time via an internet browser or other software programs. This is possible in all common internet browsers. If you deactivate the setting of cookies in the Internet browser you use, not all functions of our website may be fully usable. <br><br> IV. Data and information that is stored when you visit our website <br> Every time you visit our website, general data and information is recorded that is stored in the server\'s log files. <br><br> <strong>The following data/information may be collected:</strong> <br> - the browser types and versions used - the operating system used, - the website from which you access our website - the sub-sites, which are accessed on our website - the date and time of access to our website, - your IP address - your Internet service provider - other similar data and information that serves to avert danger in the event of attacks on our IT systems . <br><br> We need this information in order to be able to accurately provide the content of our website. to ensure the long-term functionality of our IT system and to be able to provide the authorities with the information necessary for criminal prosecution in the event of a cyber attack. This is also our legitimate interest within the legal basis for processing this data in accordance with Article 6 Paragraph 1 Letter f of the GDPR. When using this general data and information, we do not draw any conclusions about the data subject. We store the anonymous data from the server log files separately from all personal data. V. Contacting us electronically <br><br> Due to the regulations of the Telemedia Act (TMG), we are obliged to provide a general email address in the imprint of our website, which you can use to contact us by email. You also have the option of contacting us using the contact forms on our website. You can use the contact forms to make inquiries about the products or our business or apply to us as a consultant. The personal data you send to us via email or our contact forms will be automatically stored for the purposes of processing or contacting you. <br><br> By sending your message, you consent to the processing of the transmitted data. Processing is carried out on the basis of Article 6 Paragraph 1 Letter a GDPR with your consent. You can revoke your consent at any time by notifying us, without this affecting the lawfulness of the processing carried out based on your consent before its revocation. We only use your email address to process your request. Your data will then be deleted unless you have consented to further processing and use. <br><br>
|
||||
<strong>Protection of our forms against misuse (Cloudflare Turnstile)</strong>
|
||||
<br>
|
||||
To protect our contact form and our registration against spam and automated access (bots), we use the “Turnstile” service of Cloudflare, Inc., 101 Townsend St., San Francisco, CA 94107, USA. When these forms are loaded, Turnstile checks in the background whether the input comes from a human. In particular, your IP address and technical characteristics of your browser and device (e.g. browser identifier/user agent and characteristics of the encrypted connection) are transmitted to Cloudflare and evaluated there. Cloudflare processes this data on our behalf to detect bots and, according to its own information, also to improve its bot detection.
|
||||
<br><br>
|
||||
The legal basis is Art. 6 (1) (f) GDPR. Our legitimate interest lies in protecting our forms against misuse, in particular against our website being used to send spam messages to third parties. Data may be transferred to the USA in this context. Cloudflare is certified under the EU-US Data Privacy Framework; the EU Standard Contractual Clauses apply in addition. Further information: https://www.cloudflare.com/turnstile-privacy-policy/ and https://www.cloudflare.com/privacypolicy/
|
||||
<br><br>
|
||||
In addition, we limit the number of requests per IP address and log rejected requests (time, IP address, page accessed, domain part of the email address provided and reason for rejection) for 30 days, likewise on the basis of Art. 6 (1) (f) GDPR. The content of your message and passwords are not logged.
|
||||
<br><br>',
|
||||
'data_protect_copy6' => '<strong>VI. Legal basis for processing</strong> <br> If we obtain your consent for a specific processing purpose, Art. 6 I lit. a GDPR serves as the legal basis for the processing operation. <br><br> If the processing of personal data is necessary to fulfill a contract, the processing is based on Art. 6 I lit. b GDPR. This also applies to processing operations that are necessary to carry out pre-contractual measures, such as inquiries about our products or other services. <br><br> If we are subject to a legal obligation that requires the processing of personal data, such as to fulfill tax obligations, the legal basis for the processing is Art. 6 I lit. c GDPR. <br><br> If the processing of personal data is necessary to protect the vital interests of the data subject or another natural person, the processing would be based on Art. 6 I lit. d GDPR. <br><br> Ultimately, Art. 6 I lit. f GDPR can be the legal basis for a processing operation. This is the case if the processing operation is not covered by any of the aforementioned legal bases and the processing is necessary to protect a legitimate interest of our company or a third party, provided that the interests, fundamental rights and freedoms of the data subject do not prevail. If the processing of personal data is based on Article 6 I lit. f GDPR, our legitimate interest is to carry out our business activities for the benefit of the well-being of our employees/shareholders. <br><br>',
|
||||
'data_protect_copy7' => '<strong>VII. Legal or contractual regulations for the provision of personal data</strong> <br> The provision of personal data is partly required by law (e.g. tax regulations) and can also result from contractual regulations (e.g. information about the contractual partner). If you want to conclude a contract with us, it is necessary that you provide us with personal data, which will subsequently be processed by us. Failure to provide personal data would mean that we would not be able to conclude a contract with you. <br><br>',
|
||||
'data_protect_copy8' => '<strong>VIII. Routine deletion/blocking of personal data</strong> <br> We only process and store personal data for the period necessary to achieve the storage purpose or if this is required by the European legislator or another legislator in laws or regulations was provided. If the storage purpose no longer applies or if a storage period prescribed by the European legislator or another responsible legislator expires, the personal data will be blocked or deleted routinely and in accordance with legal regulations. <br><br> After the respective statutory retention period has expired, the relevant data will be routinely deleted unless it is no longer required to fulfill the contract or initiate a contract. According to the legal requirements in Germany, storage takes place in particular for 10 years in accordance with Sections 147 Paragraph 1 AO, 257 Paragraph 1 Nos. 1 and 4, Paragraph 4 HGB (books, records, management reports, accounting documents, trading books, more relevant for taxation). documents, etc.) and 6 years in accordance with Section 257 Paragraph 1 Nos. 2 and 3, Paragraph 4 HGB (commercial letters). <br><br>',
|
||||
'data_protect_copy9' => '<strong>IX. Your rights</strong> <br> <strong>1) Right to confirmation and information</strong> <br> You have the right to request confirmation from us as to whether the relevant personal data is being processed. If you would like to exercise legal rights, you can contact us at any time. <br><br> You also have the right to receive free information from us about the personal data stored about you and a copy of this information. You are also entitled to information about the following information: <br> - the processing purposes - the categories of personal data that are processed - the recipients or categories of recipients to whom the personal data have been disclosed or are still being disclosed, in particular recipients in Third countries or international organizations - if possible, the planned period for which the personal data will be stored or, if this is not possible, the criteria for determining that period - the existence of a right to rectification or deletion of the personal data concerning them or to restriction of processing by the controller or a right to object to this processing - the existence of a right to lodge a complaint with a supervisory authority - if the personal data are not collected from the data subject: all available information about the origin of the data - the existence of automated decision-making including profiling in accordance with Article 22 Para. 1 and 4 GDPR and - at least in these cases - meaningful information about the logic involved as well as the scope and intended effects of such processing for the data subject <br><br> You also have the right to information about: whether personal data has been transferred to a third country or to an international organization. If this is the case, you also have the right to receive information about the appropriate guarantees in connection with the transmission. <br><br> If you would like to exercise this right to information, you can contact us at any time. <br><br> <strong>2) Right to rectification</strong> <br> You have the right to request the immediate correction of incorrect personal data concerning you. You also have the right, taking into account the purposes of the processing, to request the completion of incomplete personal data - including by means of a supplementary declaration. <br><br> If you would like to exercise this right to correction, you can contact us at any time. <br><br> <strong>3) Right to deletion (“Right to be forgotten”)</strong> <br> You have the right to request that the personal data concerning you be deleted immediately, if one of the following reasons applies and to the extent that the processing is not necessary: <br> - The personal data were collected or otherwise processed for purposes for which they are no longer necessary; - You revoke your consent on which the processing was based in accordance with Article 6 Paragraph 1 Letter a GDPR or Article 9 Paragraph 2 Letter a GDPR and there is no other legal basis for the processing; - You object to the processing in accordance with Art. 21 Para. 1 GDPR and there are no overriding legitimate reasons for the processing, or you object to the processing in accordance with Art. 21 Para. 2 GDPR; - The personal data was processed unlawfully; - The deletion of personal data is necessary to comply with a legal obligation under Union or Member State law to which we are subject. - The personal data was collected in relation to information society services offered in accordance with Art. 8 Para. 1 GDPR. <br><br>',
|
||||
'data_protect_stand_shop' => 'As of: February 18, 2022',
|
||||
'data_protect_stand_site' => 'As of: October 5, 2018',
|
||||
'data_protect_stand_shop' => 'As of: October 9, 2026',
|
||||
'data_protect_stand_site' => 'As of: October 9, 2026',
|
||||
'data_protect' => 'Privacy Policy',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ return [
|
|||
'tools' => 'Tools',
|
||||
'system_status' => 'System status',
|
||||
'news_archive' => 'News Archive',
|
||||
'recognitions' => 'Recognitions',
|
||||
'incentive' => 'Incentive',
|
||||
'incentives' => 'Incentives',
|
||||
'create' => 'Create new',
|
||||
|
|
|
|||
52
resources/lang/en/recognitions.php
Normal file
52
resources/lang/en/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Recognitions',
|
||||
'hero_kicker' => 'Recognitions of the month',
|
||||
'hero_title' => 'Our successes in :month',
|
||||
'hero_text' => 'Congratulations on your great achievements! Every place stands for enthusiasm, perseverance and a lot of commitment – we are very proud of you.',
|
||||
'period_label' => 'Month',
|
||||
'period_invalid' => 'Please choose a valid month.',
|
||||
'show' => 'Show',
|
||||
'status_final' => 'Final · locked on :date',
|
||||
'status_preliminary' => 'Preliminary · will be locked on :date',
|
||||
'place' => 'Place :place',
|
||||
'empty' => 'No placements this month.',
|
||||
'no_months' => 'There is no completed month yet.',
|
||||
'recruiters_min_hint' => 'Placement from 2 new partners per month.',
|
||||
'live_title' => 'Live: how :month is shaping up',
|
||||
'live_text' => 'The current top :limit per category – everything is still open!',
|
||||
'live_updated' => 'As of :time · updated every 15 minutes',
|
||||
'live_rank' => 'Place',
|
||||
'live_name' => 'Name',
|
||||
'live_value' => 'Value',
|
||||
'live_empty' => 'No entries this month yet.',
|
||||
'self' => 'me',
|
||||
'categories' => [
|
||||
'recruiters' => [
|
||||
'title' => 'Top recruiters',
|
||||
'description' => 'Most new partners in the month (directly sponsored, starter package paid).',
|
||||
'unit' => 'new partners',
|
||||
],
|
||||
'new_customer_abos' => [
|
||||
'title' => 'Most new customer subscriptions',
|
||||
'description' => 'Newly started customer subscriptions in the month.',
|
||||
'unit' => 'new subscriptions',
|
||||
],
|
||||
'customer_abo_points' => [
|
||||
'title' => 'Highest revenue from customer subscriptions',
|
||||
'description' => 'Points from customer subscription orders in the month.',
|
||||
'unit' => 'points',
|
||||
],
|
||||
'customer_abos_total' => [
|
||||
'title' => 'Most customer subscriptions overall',
|
||||
'description' => 'Active customer subscriptions at month end (current month: today).',
|
||||
'unit' => 'subscriptions',
|
||||
],
|
||||
'total_points' => [
|
||||
'title' => 'Customer revenue & recruiting – 1,000 points shop',
|
||||
'description' => 'Highest points from customer revenue, own revenue and recruiting.',
|
||||
'unit' => 'points',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -35,4 +35,21 @@ return [
|
|||
'delete_customer' => 'Eliminar cliente',
|
||||
'deleted_successfully' => 'El cliente se eliminó correctamente',
|
||||
'delete_customer_confirm' => '¿Estás seguro de que quieres eliminar este cliente?',
|
||||
'email_change_requested' => 'La nueva dirección de correo :email todavía no está activa. Hemos enviado al cliente un enlace de confirmación a esta dirección. Solo se usará para el acceso y la suscripción cuando el cliente la confirme.',
|
||||
'email_change_pending' => 'La nueva dirección de correo :email está pendiente de confirmación por el cliente (solicitada el :date, enlace válido hasta :until). Hasta entonces se mantiene la dirección anterior.',
|
||||
'email_change_edit_hint' => 'Una dirección de correo modificada solo se activa cuando el cliente la confirma mediante el enlace del correo de confirmación.',
|
||||
'email_change_mail_subject' => 'Confirma tu nueva dirección de correo electrónico',
|
||||
'email_change_mail_intro' => 'esta dirección de correo se ha registrado como nueva dirección de tu cuenta de cliente en mivita.care. Confírmala con el botón. Después accederás al portal de clientes con esta dirección y todos los mensajes sobre tu suscripción llegarán aquí.',
|
||||
'email_change_mail_button' => 'Confirmar dirección de correo',
|
||||
'email_change_mail_hint' => 'El enlace es válido durante :days días. Si no has solicitado este cambio, ignora este correo y todo seguirá igual. Si el botón no funciona, copia este enlace en tu navegador:',
|
||||
'email_change_confirm_title' => 'Confirmar nueva dirección de correo',
|
||||
'email_change_confirm_copy' => 'Confirma que a partir de ahora quieres usar esta dirección de correo para tu cuenta de cliente y tu suscripción:',
|
||||
'email_change_confirm_button' => 'Sí, confirmar dirección de correo',
|
||||
'email_change_done_title' => 'Dirección de correo confirmada',
|
||||
'email_change_done_copy' => 'A partir de ahora accedes al portal de clientes con :email.',
|
||||
'email_change_invalid_title' => 'Enlace no válido',
|
||||
'email_change_invalid_copy' => 'El enlace ha caducado, ya se ha utilizado o ha sido sustituido por un cambio más reciente. Ponte en contacto con tu asesor.',
|
||||
'email_change_blocked' => 'La dirección de correo :email no se ha aplicado: ya está asignada a una cuenta de asesor o a un cliente de otro asesor (titularidad del cliente). Se ha informado al soporte.',
|
||||
'email_change_conflict_title' => 'Dirección de correo ya en uso',
|
||||
'email_change_conflict_copy' => 'Esta dirección de correo ya está asignada a otra cuenta de cliente y por eso no se puede aplicar. Ponte en contacto con tu asesor.',
|
||||
];
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -84,6 +84,7 @@ return [
|
|||
'tools' => 'Herramientas',
|
||||
'system_status' => 'Estado del sistema',
|
||||
'news_archive' => 'Archivo de Noticias',
|
||||
'recognitions' => 'Reconocimientos',
|
||||
'incentive' => 'Incentivo',
|
||||
'incentives' => 'Incentivos',
|
||||
'create' => 'Crear nuevo',
|
||||
|
|
|
|||
52
resources/lang/es/recognitions.php
Normal file
52
resources/lang/es/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Reconocimientos',
|
||||
'hero_kicker' => 'Reconocimientos del mes',
|
||||
'hero_title' => 'Nuestros éxitos en :month',
|
||||
'hero_text' => '¡Enhorabuena por vuestros grandes logros! Detrás de cada puesto hay entusiasmo, constancia y mucho esfuerzo: estamos muy orgullosos de vosotros.',
|
||||
'period_label' => 'Mes',
|
||||
'period_invalid' => 'Elige un mes válido.',
|
||||
'show' => 'Mostrar',
|
||||
'status_final' => 'Definitivo · cerrado el :date',
|
||||
'status_preliminary' => 'Provisional · se cerrará el :date',
|
||||
'place' => 'Puesto :place',
|
||||
'empty' => 'No hay clasificaciones este mes.',
|
||||
'no_months' => 'Todavía no hay ningún mes cerrado.',
|
||||
'recruiters_min_hint' => 'Clasificación a partir de 2 nuevos socios en el mes.',
|
||||
'live_title' => 'En directo: así evoluciona :month',
|
||||
'live_text' => 'El top :limit actual por categoría: ¡todo sigue abierto!',
|
||||
'live_updated' => 'Estado: :time · se actualiza cada 15 minutos',
|
||||
'live_rank' => 'Puesto',
|
||||
'live_name' => 'Nombre',
|
||||
'live_value' => 'Valor',
|
||||
'live_empty' => 'Todavía no hay entradas este mes.',
|
||||
'self' => 'yo',
|
||||
'categories' => [
|
||||
'recruiters' => [
|
||||
'title' => 'Mejores reclutadores',
|
||||
'description' => 'Más socios nuevos en el mes (patrocinio directo, paquete de inicio pagado).',
|
||||
'unit' => 'socios nuevos',
|
||||
],
|
||||
'new_customer_abos' => [
|
||||
'title' => 'Más suscripciones nuevas de clientes',
|
||||
'description' => 'Suscripciones de clientes iniciadas en el mes.',
|
||||
'unit' => 'suscripciones nuevas',
|
||||
],
|
||||
'customer_abo_points' => [
|
||||
'title' => 'Mayor facturación con suscripciones de clientes',
|
||||
'description' => 'Puntos de pedidos de suscripciones de clientes en el mes.',
|
||||
'unit' => 'puntos',
|
||||
],
|
||||
'customer_abos_total' => [
|
||||
'title' => 'Más suscripciones de clientes en total',
|
||||
'description' => 'Suscripciones de clientes activas a final de mes (mes en curso: hoy).',
|
||||
'unit' => 'suscripciones',
|
||||
],
|
||||
'total_points' => [
|
||||
'title' => 'Facturación de clientes y reclutamiento – tienda de 1.000 puntos',
|
||||
'description' => 'Mayor puntuación por facturación de clientes, facturación propia y reclutamiento.',
|
||||
'unit' => 'puntos',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -35,4 +35,21 @@ return [
|
|||
'under_review' => 'en vérification',
|
||||
'select' => 'sélectionner',
|
||||
'language_hint' => 'Les factures et documents sont créés dans cette langue.',
|
||||
'email_change_requested' => 'La nouvelle adresse e-mail :email n\'est pas encore active. Nous avons envoyé au client un lien de confirmation à cette adresse. Elle ne sera utilisée pour la connexion et l\'abonnement qu\'après sa confirmation.',
|
||||
'email_change_pending' => 'La nouvelle adresse e-mail :email attend la confirmation du client (demandée le :date, lien valable jusqu\'au :until). D\'ici là, l\'ancienne adresse reste valable.',
|
||||
'email_change_edit_hint' => 'Une adresse e-mail modifiée ne devient active qu\'après confirmation par le client via le lien de l\'e-mail de confirmation.',
|
||||
'email_change_mail_subject' => 'Merci de confirmer ta nouvelle adresse e-mail',
|
||||
'email_change_mail_intro' => 'cette adresse e-mail a été enregistrée comme nouvelle adresse de ton compte client chez mivita.care. Merci de la confirmer avec le bouton. Tu te connecteras ensuite au portail client avec cette adresse et tous les messages concernant ton abonnement arriveront ici.',
|
||||
'email_change_mail_button' => 'Confirmer l\'adresse e-mail',
|
||||
'email_change_mail_hint' => 'Le lien est valable :days jours. Si tu n\'as pas demandé ce changement, ignore simplement cet e-mail – rien ne changera. Si le bouton ne fonctionne pas, copie ce lien dans ton navigateur :',
|
||||
'email_change_confirm_title' => 'Confirmer la nouvelle adresse e-mail',
|
||||
'email_change_confirm_copy' => 'Merci de confirmer que tu souhaites désormais utiliser cette adresse e-mail pour ton compte client et ton abonnement :',
|
||||
'email_change_confirm_button' => 'Oui, confirmer l\'adresse e-mail',
|
||||
'email_change_done_title' => 'Adresse e-mail confirmée',
|
||||
'email_change_done_copy' => 'Désormais, tu te connectes au portail client avec :email.',
|
||||
'email_change_invalid_title' => 'Lien invalide',
|
||||
'email_change_invalid_copy' => 'Le lien a expiré, a déjà été utilisé ou a été remplacé par une modification plus récente. Merci de contacter ton conseiller.',
|
||||
'email_change_blocked' => 'L\'adresse e-mail :email n\'a pas été appliquée : elle est déjà attribuée à un compte conseiller ou à un client d\'un autre conseiller (priorité client). Le support a été informé.',
|
||||
'email_change_conflict_title' => 'Adresse e-mail déjà utilisée',
|
||||
'email_change_conflict_copy' => 'Cette adresse e-mail est déjà attribuée à un autre compte client et ne peut donc pas être appliquée. Merci de contacter ton conseiller.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -80,6 +80,14 @@ return [
|
|||
Vous pouvez nous contacter par e-mail via l’adresse indiquée dans les mentions légales ou par les formulaires de contact du site. Les données personnelles transmises par e-mail ou formulaire sont enregistrées automatiquement pour traiter votre demande et vous contacter.
|
||||
<br><br>
|
||||
En envoyant votre message, vous consentez au traitement des données transmises. Le traitement repose sur l’art. 6 al. 1 let. a RGPD. Vous pouvez révoquer ce consentement à tout moment. Votre adresse e-mail est utilisée uniquement pour traiter votre demande, puis supprimée sauf consentement à un traitement ultérieur.
|
||||
<br><br>
|
||||
<strong>Protection de nos formulaires contre les abus (Cloudflare Turnstile)</strong>
|
||||
<br>
|
||||
Afin de protéger notre formulaire de contact et notre inscription contre le spam et les accès automatisés (bots), nous utilisons le service « Turnstile » de Cloudflare, Inc., 101 Townsend St., San Francisco, CA 94107, États-Unis. Lors du chargement de ces formulaires, Turnstile vérifie en arrière-plan si la saisie provient d’un être humain. À cette fin, votre adresse IP ainsi que des caractéristiques techniques de votre navigateur et de votre appareil (p. ex. identifiant du navigateur/user agent et caractéristiques de la connexion chiffrée) sont notamment transmises à Cloudflare et y sont analysées. Cloudflare traite ces données pour notre compte afin de détecter les bots et, selon ses propres indications, également pour améliorer sa détection des bots.
|
||||
<br><br>
|
||||
La base juridique est l’art. 6 al. 1 let. f RGPD. Notre intérêt légitime consiste à protéger nos formulaires contre toute utilisation abusive, en particulier contre l’envoi de messages de spam à des tiers via notre site web. Un transfert de données vers les États-Unis peut avoir lieu dans ce cadre. Cloudflare est certifiée dans le cadre du Data Privacy Framework UE-États-Unis ; les clauses contractuelles types de l’UE s’appliquent en complément. Plus d’informations : https://www.cloudflare.com/turnstile-privacy-policy/ et https://www.cloudflare.com/privacypolicy/
|
||||
<br><br>
|
||||
En outre, nous limitons le nombre de demandes par adresse IP et enregistrons les demandes refusées (date et heure, adresse IP, page consultée, partie domaine de l’adresse e-mail indiquée et motif du refus) pendant 30 jours, également sur la base de l’art. 6 al. 1 let. f RGPD. Le contenu de votre message et les mots de passe ne sont pas enregistrés.
|
||||
<br><br>',
|
||||
'data_protect_copy6' => '<strong>VI. Bases juridiques du traitement</strong>
|
||||
<br>
|
||||
|
|
@ -186,7 +194,7 @@ return [
|
|||
Les informations collectées servent à établir des statistiques de conversion. Aucune information permettant d’identifier personnellement les utilisateurs n’est transmise.<br>
|
||||
Vous pouvez refuser le cookie nécessaire via les paramètres de votre navigateur ou bloquer les cookies du domaine « www.googleadservices.com ».<br>
|
||||
La politique de confidentialité Google relative au conversion tracking est disponible ici (https://services.google.com/sitestats/de.html)',
|
||||
'data_protect_stand_shop' => 'Version : 18.02.2022',
|
||||
'data_protect_stand_site' => 'Version : 05.10.2018',
|
||||
'data_protect_stand_shop' => 'Version : 09.10.2026',
|
||||
'data_protect_stand_site' => 'Version : 09.10.2026',
|
||||
'data_protect' => 'Protection des données',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ return [
|
|||
'tools' => 'Outils',
|
||||
'system_status' => 'État du système',
|
||||
'news_archive' => 'Archive des actualités',
|
||||
'recognitions' => 'Distinctions',
|
||||
'incentive' => 'Incentive',
|
||||
'incentives' => 'Incentives',
|
||||
'create' => 'Créer',
|
||||
|
|
|
|||
52
resources/lang/fr/recognitions.php
Normal file
52
resources/lang/fr/recognitions.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Distinctions',
|
||||
'hero_kicker' => 'Distinctions du mois',
|
||||
'hero_title' => 'Nos succès en :month',
|
||||
'hero_text' => 'Félicitations pour vos magnifiques résultats ! Derrière chaque place, il y a de l’enthousiasme, de la persévérance et beaucoup d’engagement – nous sommes très fiers de vous.',
|
||||
'period_label' => 'Mois',
|
||||
'period_invalid' => 'Veuillez choisir un mois valide.',
|
||||
'show' => 'Afficher',
|
||||
'status_final' => 'Définitif · arrêté le :date',
|
||||
'status_preliminary' => 'Provisoire · sera arrêté le :date',
|
||||
'place' => 'Place :place',
|
||||
'empty' => 'Aucun classement ce mois-ci.',
|
||||
'no_months' => 'Il n’y a encore aucun mois clôturé.',
|
||||
'recruiters_min_hint' => 'Classement à partir de 2 nouveaux partenaires dans le mois.',
|
||||
'live_title' => 'En direct : l’évolution de :month',
|
||||
'live_text' => 'Le top :limit actuel par catégorie – tout reste possible !',
|
||||
'live_updated' => 'État : :time · mis à jour toutes les 15 minutes',
|
||||
'live_rank' => 'Place',
|
||||
'live_name' => 'Nom',
|
||||
'live_value' => 'Valeur',
|
||||
'live_empty' => 'Pas encore d’entrées ce mois-ci.',
|
||||
'self' => 'moi',
|
||||
'categories' => [
|
||||
'recruiters' => [
|
||||
'title' => 'Meilleurs recruteurs',
|
||||
'description' => 'Le plus de nouveaux partenaires dans le mois (parrainage direct, pack de démarrage payé).',
|
||||
'unit' => 'nouveaux partenaires',
|
||||
],
|
||||
'new_customer_abos' => [
|
||||
'title' => 'Le plus de nouveaux abonnements clients',
|
||||
'description' => 'Abonnements clients démarrés dans le mois.',
|
||||
'unit' => 'nouveaux abonnements',
|
||||
],
|
||||
'customer_abo_points' => [
|
||||
'title' => 'Plus gros chiffre d’affaires avec les abonnements clients',
|
||||
'description' => 'Points issus des commandes d’abonnements clients dans le mois.',
|
||||
'unit' => 'points',
|
||||
],
|
||||
'customer_abos_total' => [
|
||||
'title' => 'Le plus d’abonnements clients au total',
|
||||
'description' => 'Abonnements clients actifs en fin de mois (mois en cours : aujourd’hui).',
|
||||
'unit' => 'abonnements',
|
||||
],
|
||||
'total_points' => [
|
||||
'title' => 'CA clients & recrutement – boutique 1 000 points',
|
||||
'description' => 'Le plus de points grâce au CA clients, au CA personnel et au recrutement.',
|
||||
'unit' => 'points',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
Aufbewahrungspflicht an den Bestellungen erhalten. Der Eintrag kann nicht mehr bearbeitet werden.
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.customer._email_change_pending')
|
||||
@if($isAdmin)
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
|
|
|
|||
|
|
@ -351,6 +351,10 @@
|
|||
</div>
|
||||
@endif
|
||||
@else
|
||||
@include('admin.customer._email_change_pending')
|
||||
@if(!$isAdmin)
|
||||
<p class="text-muted small mb-2">{{ __('customer.email_change_edit_hint') }}</p>
|
||||
@endif
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="form-label" for="billing_email">{{ __('E-Mail Address') }}*</label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
@php($pendingEmailChange = isset($shopping_user->id) && is_numeric($shopping_user->id) ? app(\App\Services\CustomerEmailChangeService::class)->pendingFor($shopping_user) : null)
|
||||
@if($pendingEmailChange)
|
||||
<div class="alert alert-warning">
|
||||
{{ __('customer.email_change_pending', [
|
||||
'email' => $pendingEmailChange->new_email,
|
||||
'date' => $pendingEmailChange->created_at->format('d.m.Y H:i'),
|
||||
'until' => $pendingEmailChange->expires_at->format('d.m.Y H:i'),
|
||||
]) }}
|
||||
</div>
|
||||
@endif
|
||||
159
resources/views/admin/customer_priority/index.blade.php
Normal file
159
resources/views/admin/customer_priority/index.blade.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
<h4 class="font-weight-bold py-2 mb-2">Kundenhoheit</h4>
|
||||
<p class="text-muted mb-3">
|
||||
Jeder Kunde gehört genau einem Berater. Erkennungsmerkmal ist die E-Mail-Adresse, ohne E-Mail die Adresse
|
||||
(Nachname + PLZ). Hier steht alles, was noch eine Entscheidung braucht – unabhängig davon, ob die Info-Mail
|
||||
gelesen wurde. Ist eine Liste leer, ist nichts zu tun.
|
||||
</p>
|
||||
|
||||
@php($tabs = [
|
||||
'reviews' => ['label' => 'Offene Prüfungen', 'count' => $counts['reviews']],
|
||||
'conflicts' => ['label' => 'Kunde bei mehreren Beratern', 'count' => $counts['conflicts']],
|
||||
'email-changes' => ['label' => 'E-Mail-Änderungen', 'count' => $counts['email_changes']],
|
||||
])
|
||||
|
||||
<div class="nav-tabs-top mb-4">
|
||||
<ul class="nav nav-tabs">
|
||||
@foreach($tabs as $key => $item)
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ $tab === $key ? 'active' : '' }}" href="{{ route('admin_customer_priority', [$key]) }}">
|
||||
{{ $item['label'] }}
|
||||
<span class="badge badge-pill {{ $item['count'] ? 'badge-danger' : 'badge-default' }} ml-1">{{ $item['count'] }}</span>
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active">
|
||||
<div class="card-body pb-0">
|
||||
@if($tab === 'reviews')
|
||||
<p>
|
||||
<strong>Was ist das?</strong> Beim Anlegen oder Ändern eines Kunden wurde dieselbe E-Mail oder
|
||||
dieselbe Adresse bei einem anderen Berater gefunden. Bis zur Entscheidung ist der Kunde für den
|
||||
Berater gesperrt und kann bei zwei Beratern liegen.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Was tun?</strong> „Prüfen & zuordnen“ zeigt den Kunden neben den Treffern: Ist es
|
||||
derselbe Kunde, dem bestehenden Berater zuordnen, sonst als neuen Kunden bestätigen.
|
||||
„Zuordnung beibehalten“ schließt den Fall ohne Änderung (z. B. alte Fälle, die sich erledigt haben).
|
||||
</p>
|
||||
@elseif($tab === 'conflicts')
|
||||
<p>
|
||||
<strong>Was ist das?</strong> Diese Kunden liegen mit derselben E-Mail-Adresse bei mehreren
|
||||
Beratern, ohne dass eine Prüfung offen ist. Ursachen waren u. a. das Umhängen einzelner
|
||||
Bestellungen und ein inzwischen behobener Fehler bei der Zuordnung im Shop.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Was tun?</strong> Nach Kundenhoheit gilt die <span class="badge badge-success">älteste Zuordnung</span>.
|
||||
Bei jeder <span class="badge badge-warning">weiteren Zuordnung</span> über „Berater zuweisen“ →
|
||||
„Jeder Bestellung dieses Kunden“ den richtigen Berater wählen. Bereits abgerechnete Punkte
|
||||
ändern sich dadurch nicht automatisch. Kunden, die inzwischen selbst Berater sind, stehen nicht in
|
||||
dieser Liste – ihre eigenen Bestellungen laufen gewollt auf sie selbst.
|
||||
</p>
|
||||
@else
|
||||
<p>
|
||||
<strong>Was ist das?</strong> E-Mail-Änderungen durch Berater. Eine neue Adresse gilt erst, wenn
|
||||
der Kunde sie bestätigt hat. Abgelehnt wird sie, wenn sie schon einem anderen Berater oder einem
|
||||
Beraterkonto gehört.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Was tun?</strong> Bei <span class="badge badge-danger">abgelehnt</span> klären, ob der Kunde
|
||||
dem anderen Berater gehört (dann ggf. in „Kunde bei mehreren Beratern“ oder über die Kundenansicht
|
||||
umhängen) und danach „Erledigt“ klicken. <span class="badge badge-info">wartet auf Bestätigung</span>
|
||||
ist nur zur Info; mit „Verwerfen“ wird der Link ungültig.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="card-datatable table-responsive">
|
||||
<table class="table table-striped table-bordered" id="datatables-customer-priority">
|
||||
<thead>
|
||||
<tr>
|
||||
@if($tab === 'reviews')
|
||||
<th>Seit</th>
|
||||
<th>Kunde</th>
|
||||
<th>Zugeordnet</th>
|
||||
<th>Bestellt im Shop</th>
|
||||
<th>Treffer</th>
|
||||
<th>Aktion</th>
|
||||
@elseif($tab === 'conflicts')
|
||||
<th>Kunde</th>
|
||||
<th>Zuordnungen</th>
|
||||
<th>Letzter Eintrag</th>
|
||||
@else
|
||||
<th>Datum</th>
|
||||
<th>Kunde</th>
|
||||
<th>Änderung</th>
|
||||
<th>Angefragt von</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
@if($tab === 'reviews')
|
||||
var config = {
|
||||
url: '{!! route('admin_customer_priority_reviews_datatable') !!}',
|
||||
serverSide: true,
|
||||
order: [[0, 'asc']],
|
||||
columns: [
|
||||
{data: 'created', name: 'created'},
|
||||
{data: 'customer', name: 'customer'},
|
||||
{data: 'member', name: 'member', searchable: false, orderable: false},
|
||||
{data: 'shop', name: 'shop', searchable: false, orderable: false},
|
||||
{data: 'matches', name: 'matches', searchable: false, orderable: false},
|
||||
{data: 'action', name: 'action', searchable: false, orderable: false},
|
||||
]
|
||||
};
|
||||
@elseif($tab === 'conflicts')
|
||||
var config = {
|
||||
url: '{!! route('admin_customer_priority_conflicts_datatable') !!}',
|
||||
serverSide: true,
|
||||
order: [],
|
||||
columns: [
|
||||
{data: 'customer', name: 'customer', orderable: false},
|
||||
{data: 'groups', name: 'groups', searchable: false, orderable: false},
|
||||
{data: 'last', name: 'last', searchable: false, orderable: false},
|
||||
]
|
||||
};
|
||||
@else
|
||||
var config = {
|
||||
url: '{!! route('admin_customer_priority_email_changes_datatable') !!}',
|
||||
serverSide: true,
|
||||
order: [[0, 'desc']],
|
||||
columns: [
|
||||
{data: 'created', name: 'created'},
|
||||
{data: 'customer', name: 'customer', searchable: false, orderable: false},
|
||||
{data: 'change', name: 'change', searchable: false, orderable: false},
|
||||
{data: 'requested_by', name: 'requested_by', searchable: false, orderable: false},
|
||||
{data: 'status', name: 'status', searchable: false, orderable: false},
|
||||
{data: 'action', name: 'action', searchable: false, orderable: false},
|
||||
]
|
||||
};
|
||||
@endif
|
||||
|
||||
$('#datatables-customer-priority').DataTable({
|
||||
processing: true,
|
||||
serverSide: config.serverSide,
|
||||
ajax: {url: config.url},
|
||||
order: config.order,
|
||||
columns: config.columns,
|
||||
bLengthChange: false,
|
||||
iDisplayLength: 50,
|
||||
language: {
|
||||
url: '/js/datatables-{{ \App::getLocale() }}.json'
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
|
@ -140,6 +140,9 @@
|
|||
|
||||
<div class="col-sm-6">
|
||||
<p class="font-weight-bold">Ist das vielleicht dieser Kunde?</p>
|
||||
@if(empty($possibles))
|
||||
<div class="alert alert-secondary">Die damals gefundenen Treffer gibt es nicht mehr. Ordne den Kunden links zu oder schließe den Prüffall in der Übersicht Kundenhoheit mit „Zuordnung beibehalten“.</div>
|
||||
@endif
|
||||
@foreach($possibles as $possible)
|
||||
@if(isset($possible[0]))
|
||||
@php($possible = $possible[0])
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
<input type="hidden" name="action" value="{{$data['action']}}">
|
||||
<input type="hidden" name="id" value="{{$data['id']}}">
|
||||
<input type="hidden" name="view" value="{{$data['view']}}">
|
||||
@if(!empty($data['back']))
|
||||
<input type="hidden" name="back" value="{{ $data['back'] }}">
|
||||
@endif
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col">
|
||||
|
|
@ -31,12 +34,13 @@
|
|||
@if($value->member_id && $value->shopping_user && $value->shopping_user->number)
|
||||
<div class="custom-controls-stacked">
|
||||
<label class="custom-control custom-radio">
|
||||
<input name="customer_set_member_for" value="0" type="radio" class="custom-control-input" checked="">
|
||||
<span class="custom-control-label">Nur dieser Bestellung einen neuen Berater zuweisen</span>
|
||||
<input name="customer_set_member_for" value="1" type="radio" class="custom-control-input" checked="">
|
||||
<span class="custom-control-label">Jeder Bestellung vom Kunden #{{$value->shopping_user->number}} einen neuen Berater zuweisen</span>
|
||||
</label>
|
||||
<label class="custom-control custom-radio">
|
||||
<input name="customer_set_member_for" value="1" type="radio" class="custom-control-input">
|
||||
<span class="custom-control-label">Jeder Bestellung vom Kunden #{{$value->shopping_user->number}} einen neuen Berater zuweisen</span>
|
||||
<input name="customer_set_member_for" value="0" type="radio" class="custom-control-input">
|
||||
<span class="custom-control-label">Nur dieser Bestellung einen neuen Berater zuweisen
|
||||
<br><small class="text-warning">Achtung: Der Kunde liegt danach bei zwei Beratern (Kundenhoheit).</small></span>
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -45,12 +49,13 @@
|
|||
@if($value->member_id && $value->number)
|
||||
<div class="custom-controls-stacked">
|
||||
<label class="custom-control custom-radio">
|
||||
<input name="customer_set_member_for" value="0" type="radio" class="custom-control-input" checked="">
|
||||
<span class="custom-control-label">Nur diesen Kunden Bestellung einen neuen Berater zuweisen</span>
|
||||
<input name="customer_set_member_for" value="1" type="radio" class="custom-control-input" checked="">
|
||||
<span class="custom-control-label">Jeder Bestellung diesen Kunden #{{$value->number}} einen neuen Berater zuweisen</span>
|
||||
</label>
|
||||
<label class="custom-control custom-radio">
|
||||
<input name="customer_set_member_for" value="1" type="radio" class="custom-control-input">
|
||||
<span class="custom-control-label">Jeder Bestellung diesen Kunden #{{$value->number}} einen neuen Berater zuweisen</span>
|
||||
<input name="customer_set_member_for" value="0" type="radio" class="custom-control-input">
|
||||
<span class="custom-control-label">Nur diesen Kunden Bestellung einen neuen Berater zuweisen
|
||||
<br><small class="text-warning">Achtung: Der Kunde liegt danach bei zwei Beratern (Kundenhoheit).</small></span>
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
108
resources/views/emails/customer_email_change.blade.php
Normal file
108
resources/views/emails/customer_email_change.blade.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{{ $mailTitle }}</title>
|
||||
<style type="text/css">
|
||||
a { text-decoration: none; border: 0; outline: none; color: #919f7a; }
|
||||
a:hover { color: #b6b600; }
|
||||
td, h1, h2, h3 { font-family: Helvetica, Verdana, Arial, sans-serif; font-weight: 400; }
|
||||
td { text-align: left; }
|
||||
body { -webkit-font-smoothing: antialiased; width: 100%; height: 100%; color: #37302d; background: #ffffff; font-size: 15px; line-height: 26px; }
|
||||
table { border-collapse: separate !important; }
|
||||
.headline { color: #37302d; font-size: 18px; }
|
||||
.force-full-width { width: 100% !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="body" style="padding:0; margin:0; display:block; background:#f8f8f8; -webkit-text-size-adjust:none" bgcolor="#f8f8f8">
|
||||
<table align="left" cellpadding="0" cellspacing="0" width="100%" height="100%">
|
||||
<tr>
|
||||
<td align="left" valign="top" bgcolor="#f8f8f8" width="100%">
|
||||
<br>
|
||||
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="700" class="w320">
|
||||
<tr>
|
||||
<td align="left" valign="top">
|
||||
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align:center;">
|
||||
<center>
|
||||
<picture style="text-align: center">
|
||||
<img src="https://my.mivita.care/images/logo_mivita.png" alt="mivita.care" style="border:none" width="230">
|
||||
</picture>
|
||||
</center>
|
||||
<br>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table cellpadding="10" style="padding: 10px;" cellspacing="0" border="0" width="100%" bgcolor="#ffffff">
|
||||
<tr>
|
||||
<td class="headline">
|
||||
<b>{{ $mailTitle }}</b>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#37302d;line-height:1.6em;">
|
||||
{{ $salutation }},
|
||||
<br><br>
|
||||
{{ __('customer.email_change_mail_intro') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table style="background-color: #f6fdf5; border: 2px solid #b5c49b; border-radius: 8px; margin: 20px 0;" cellpadding="15" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="text-align: center; padding-top: 10px;">
|
||||
<a href="{{ $link }}" style="display: inline-block; background: #FFCC00; color: #37302d; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px;">
|
||||
{{ __('customer.email_change_mail_button') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#7B7B7E; font-size: 13px; line-height:1.6em;">
|
||||
{{ __('customer.email_change_mail_hint', ['days' => (int) (\App\Services\CustomerEmailChangeService::TTL_HOURS / 24)]) }}
|
||||
<br>
|
||||
<a href="{{ $link }}" style="color:#7B7B7E; text-decoration: underline; word-break: break-all;">{{ $link }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#37302d;line-height:1.6em;">
|
||||
<br>
|
||||
{{ $greetings }}<br>
|
||||
{{ $sender }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<center>
|
||||
<table cellpadding="0" cellspacing="0" class="force-full-width" bgcolor="#f8f8f8" style="margin: 0 auto; text-align: center;line-height:1.6em;">
|
||||
<tr>
|
||||
<td style="color:#7B7B7E; font-size:14px; text-align: center;line-height:1.6em;" align="center">
|
||||
<p>{{__('email.footer_copy1')}}</p>
|
||||
<br>
|
||||
<a href="https://www.mivita.care" style="color: #7B7B7E; text-decoration: underline;">www.mivita.care</a>
|
||||
<br>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#bbbbbb; font-size:12px; text-align: center;line-height:1.6em;" align="center">
|
||||
<p>{{__('email.footer_copy2')}}</p>
|
||||
<a href="https://www.mivita.care/datenschutz">{{ __('dataprotect.data_protect') }}</a> <br>
|
||||
<p>{{__('email.footer_copy3')}}</p>
|
||||
<br><br>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -55,6 +55,12 @@
|
|||
<div>{{ __('navigation.news_archive') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item{{ Request::is('user/recognitions') ? ' active' : '' }}">
|
||||
<a href="{{ route('user_recognitions') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-md-trophy"></i>
|
||||
<div>{{ __('navigation.recognitions') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
@if (Auth::user()->isVIP())
|
||||
<li class="sidenav-item @if (Request::is('user/backoffice/statistics*')) open @endif">
|
||||
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
|
||||
|
|
@ -326,6 +332,18 @@
|
|||
<div>{{ __('navigation.clients') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
@php $customerPriorityOpen = Cache::remember('customer_priority_open_count', 60, fn () => app(\App\Services\CustomerPriorityOverviewService::class)->openCount()); @endphp
|
||||
<li class="sidenav-item{{ Request::is('admin/customer-priority*') ? ' active' : '' }}">
|
||||
<a href="{{ route('admin_customer_priority') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-md-git-compare"></i>
|
||||
<div>Kundenhoheit</div>
|
||||
@if ($customerPriorityOpen)
|
||||
<div class="pl-1 ml-auto">
|
||||
<div class="badge badge-danger">{{ $customerPriorityOpen }}</div>
|
||||
</div>
|
||||
@endif
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
@extends('portal.layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="text-center" style="max-width: 640px; margin: 0 auto;">
|
||||
<i class="ion ion-md-mail text-primary" style="font-size: 3rem;"></i>
|
||||
<h4 class="font-weight-bold mt-3">{{ __('customer.email_change_confirm_title') }}</h4>
|
||||
<p class="text-muted">{{ __('customer.email_change_confirm_copy') }}</p>
|
||||
<p class="font-weight-bold">{{ $change->new_email }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('customer_email_change_store', [$token]) }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary">{{ __('customer.email_change_confirm_button') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@endsection
|
||||
23
resources/views/portal/customer/email_change_done.blade.php
Normal file
23
resources/views/portal/customer/email_change_done.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
@extends('portal.layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="text-center" style="max-width: 640px; margin: 0 auto;">
|
||||
@if ($status === \App\Services\CustomerEmailChangeService::STATUS_CONFIRMED)
|
||||
<i class="ion ion-md-checkmark-circle text-success" style="font-size: 3rem;"></i>
|
||||
<h4 class="font-weight-bold mt-3">{{ __('customer.email_change_done_title') }}</h4>
|
||||
<p class="text-muted">{{ __('customer.email_change_done_copy', ['email' => $email]) }}</p>
|
||||
@elseif ($status === \App\Services\CustomerEmailChangeService::STATUS_CONFLICT)
|
||||
<i class="ion ion-md-alert text-warning" style="font-size: 3rem;"></i>
|
||||
<h4 class="font-weight-bold mt-3">{{ __('customer.email_change_conflict_title') }}</h4>
|
||||
<p class="text-muted">{{ __('customer.email_change_conflict_copy') }}</p>
|
||||
@else
|
||||
<i class="ion ion-md-close-circle text-danger" style="font-size: 3rem;"></i>
|
||||
<h4 class="font-weight-bold mt-3">{{ __('customer.email_change_invalid_title') }}</h4>
|
||||
<p class="text-muted">{{ __('customer.email_change_invalid_copy') }}</p>
|
||||
@endif
|
||||
|
||||
<a href="{{ \App\Services\Util::getMyMivitaPortalUrl() }}" class="btn btn-outline-secondary btn-sm mt-2">
|
||||
<i class="ion ion-md-home mr-1"></i>{{ __('abo.card_update_to_portal') }}
|
||||
</a>
|
||||
</div>
|
||||
@endsection
|
||||
324
resources/views/user/recognitions/index.blade.php
Normal file
324
resources/views/user/recognitions/index.blade.php
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('styles')
|
||||
<style>
|
||||
.recognition-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: .75rem;
|
||||
background: linear-gradient(135deg, #6b7758 0%, #4f5940 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.recognition-hero .hero-trophy {
|
||||
position: absolute;
|
||||
right: -1rem;
|
||||
bottom: -2.5rem;
|
||||
font-size: 13rem;
|
||||
line-height: 1;
|
||||
color: rgba(255, 255, 255, .08);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recognition-hero .hero-kicker {
|
||||
letter-spacing: .08em;
|
||||
color: #f4d77a;
|
||||
}
|
||||
|
||||
.recognition-hero .badge-status {
|
||||
background: rgba(255, 255, 255, .18);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recognition-card .card-header {
|
||||
border-bottom: 0;
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
|
||||
.recognition-card .category-icon {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.recognition-place {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: .5rem;
|
||||
padding: .6rem .75rem;
|
||||
margin-bottom: .5rem;
|
||||
background: #f7f7f5;
|
||||
}
|
||||
|
||||
.recognition-place.place-1 {
|
||||
background: #fff7dc;
|
||||
box-shadow: inset 3px 0 0 #e0b537;
|
||||
}
|
||||
|
||||
.recognition-place.place-2 {
|
||||
background: #f1f3f5;
|
||||
box-shadow: inset 3px 0 0 #a9b0b8;
|
||||
}
|
||||
|
||||
.recognition-place.place-3 {
|
||||
background: #fbefe6;
|
||||
box-shadow: inset 3px 0 0 #c98a5a;
|
||||
}
|
||||
|
||||
.recognition-place .place-medal {
|
||||
flex: 0 0 2.25rem;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recognition-place .place-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border-radius: 50%;
|
||||
background: #dfe2d8;
|
||||
color: #4f5940;
|
||||
font-weight: 700;
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.recognition-place .place-members {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recognition-place .place-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recognition-place .place-member + .place-member {
|
||||
margin-top: .35rem;
|
||||
}
|
||||
|
||||
.recognition-place .place-value {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
padding-left: .75rem;
|
||||
}
|
||||
|
||||
.recognition-live .live-dot {
|
||||
display: inline-block;
|
||||
width: .6rem;
|
||||
height: .6rem;
|
||||
border-radius: 50%;
|
||||
background: #d9534f;
|
||||
margin-right: .4rem;
|
||||
animation: recognition-pulse 1.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes recognition-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(217, 83, 79, .6); }
|
||||
70% { box-shadow: 0 0 0 .5rem rgba(217, 83, 79, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(217, 83, 79, 0); }
|
||||
}
|
||||
|
||||
.recognition-live .nav-pills .nav-link {
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.recognition-live .nav-pills .nav-link.active,
|
||||
.recognition-live .nav-pills .nav-link.active:hover,
|
||||
.recognition-live .nav-pills .nav-link.active:focus {
|
||||
background-color: #d7d700;
|
||||
color: #565600;
|
||||
}
|
||||
|
||||
.recognition-live .table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$viewerId = (int) auth()->id();
|
||||
$medals = [1 => '🥇', 2 => '🥈', 3 => '🥉'];
|
||||
$categoryIcons = [
|
||||
'recruiters' => '🏆',
|
||||
'new_customer_abos' => '🌟',
|
||||
'customer_abo_points' => '💙',
|
||||
'customer_abos_total' => '⭐',
|
||||
'total_points' => '🚀',
|
||||
];
|
||||
$formatValue = fn ($value) => number_format((float) $value, floor((float) $value) == (float) $value ? 0 : 1, ',', '.');
|
||||
$periodOptions = collect($periods)->mapWithKeys(fn (array $period) => [
|
||||
sprintf('%d-%02d', $period['year'], $period['month']) => HTMLHelper::getMonth($period['month']).' '.$period['year'],
|
||||
])->all();
|
||||
$selectedPeriod = $selected ? sprintf('%d-%02d', $selected['year'], $selected['month']) : null;
|
||||
@endphp
|
||||
|
||||
<h4 class="font-weight-bold py-2 mb-2 d-flex flex-wrap justify-content-between align-items-center w-100">
|
||||
<div class="mb-2 mb-sm-0">
|
||||
<i class="ion ion-md-trophy text-warning mr-1"></i>{{ __('recognitions.title') }}
|
||||
</div>
|
||||
@if ($periodOptions !== [])
|
||||
<form method="GET" action="{{ route('user_recognitions') }}" class="form-inline">
|
||||
<label for="period" class="small text-muted font-weight-normal mr-2">{{ __('recognitions.period_label') }}</label>
|
||||
{{ html()->select('period', $periodOptions, $selectedPeriod)->id('period')->class('custom-select custom-select-sm mr-2')->attribute('onchange', 'this.form.submit()') }}
|
||||
<noscript><button type="submit" class="btn btn-sm btn-primary">{{ __('recognitions.show') }}</button></noscript>
|
||||
</form>
|
||||
@endif
|
||||
</h4>
|
||||
|
||||
@if ($recognitions === null)
|
||||
<div class="alert alert-info">{{ __('recognitions.no_months') }}</div>
|
||||
@else
|
||||
<div class="recognition-hero p-4 p-md-5 mb-4">
|
||||
<i class="ion ion-md-trophy hero-trophy"></i>
|
||||
<div class="position-relative">
|
||||
<div class="hero-kicker text-uppercase small font-weight-bold mb-2">{{ __('recognitions.hero_kicker') }}</div>
|
||||
<h2 class="font-weight-bold text-white mb-2">
|
||||
{{ __('recognitions.hero_title', ['month' => HTMLHelper::getMonth($recognitions['month']).' '.$recognitions['year']]) }}
|
||||
🥳💙
|
||||
</h2>
|
||||
<p class="mb-3" style="max-width: 46rem; opacity: .9;">{{ __('recognitions.hero_text') }}</p>
|
||||
@if ($recognitions['is_final'])
|
||||
<span class="badge badge-status px-2 py-1">
|
||||
<i class="ion ion-md-lock mr-1"></i>{{ __('recognitions.status_final', ['date' => $recognitions['frozen_at']?->format('d.m.Y')]) }}
|
||||
</span>
|
||||
@else
|
||||
<span class="badge badge-status px-2 py-1">
|
||||
<i class="ion ion-md-time mr-1"></i>{{ __('recognitions.status_preliminary', ['date' => $recognitions['freeze_date']->format('d.m.Y')]) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
|
||||
@php $podium = $recognitions['categories'][$category] ?? []; @endphp
|
||||
<div class="col-md-6 col-xl-4 d-flex align-items-stretch mb-4">
|
||||
<div class="card recognition-card w-100">
|
||||
<div class="card-header bg-transparent d-flex align-items-start">
|
||||
<span class="category-icon mr-2">{{ $categoryIcons[$category] }}</span>
|
||||
<div>
|
||||
<h6 class="font-weight-bold mb-1">{{ __('recognitions.categories.'.$category.'.title') }}</h6>
|
||||
<div class="text-muted small">{{ __('recognitions.categories.'.$category.'.description') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pt-2">
|
||||
@forelse ($podium as $place)
|
||||
<div class="recognition-place place-{{ $place['place'] }}">
|
||||
<div class="place-medal">
|
||||
@if (isset($medals[$place['place']]))
|
||||
<span title="{{ __('recognitions.place', ['place' => $place['place']]) }}">{{ $medals[$place['place']] }}</span>
|
||||
@else
|
||||
<span class="place-number" title="{{ __('recognitions.place', ['place' => $place['place']]) }}">{{ $place['place'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="place-members ml-2">
|
||||
@foreach ($place['members'] as $member)
|
||||
<div class="place-member">
|
||||
@include('components._avatar', [
|
||||
'imageUrl' => $member['profile_image_url'] ?? null,
|
||||
'firstName' => $member['first_name'],
|
||||
'lastName' => $member['last_name'],
|
||||
'size' => 36,
|
||||
])
|
||||
<span class="ml-2 font-weight-semibold text-truncate">
|
||||
{{ $member['first_name'] }} {{ $member['last_name'] }}
|
||||
@if ((int) $member['user_id'] === $viewerId)
|
||||
<span class="badge badge-pill badge-info ml-1">{{ __('recognitions.self') }}</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="place-value">
|
||||
<div class="font-weight-bold" style="font-size: 1.1rem;">{{ $formatValue($place['value']) }}</div>
|
||||
<div class="text-muted small">{{ __('recognitions.categories.'.$category.'.unit') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-muted small">{{ __('recognitions.empty') }}</div>
|
||||
@endforelse
|
||||
@if ($category === 'recruiters')
|
||||
<div class="text-muted small mt-2">{{ __('recognitions.recruiters_min_hint') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card recognition-live mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-1 d-flex align-items-center">
|
||||
<span class="live-dot"></span>
|
||||
{{ __('recognitions.live_title', ['month' => HTMLHelper::getMonth($live['month']).' '.$live['year']]) }}
|
||||
</h5>
|
||||
<div class="text-muted small">
|
||||
{{ __('recognitions.live_text', ['limit' => \App\Services\Team\RecognitionService::LIVE_LIMIT]) }}
|
||||
· {{ __('recognitions.live_updated', ['time' => $live['calculated_at']->format('d.m.Y H:i')]) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="nav nav-pills flex-wrap mb-3" role="tablist">
|
||||
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
|
||||
<li class="nav-item mb-1">
|
||||
<a class="nav-link {{ $loop->first ? 'active' : '' }}" data-toggle="tab" href="#live-{{ $category }}" role="tab">
|
||||
{{ $categoryIcons[$category] }} {{ __('recognitions.categories.'.$category.'.title') }}
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
@foreach (\App\Services\Team\RecognitionService::CATEGORIES as $category)
|
||||
<div class="tab-pane fade {{ $loop->first ? 'show active' : '' }}" id="live-{{ $category }}" role="tabpanel">
|
||||
@if (empty($live['categories'][$category]))
|
||||
<div class="text-muted small">{{ __('recognitions.live_empty') }}</div>
|
||||
@else
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5rem;">{{ __('recognitions.live_rank') }}</th>
|
||||
<th>{{ __('recognitions.live_name') }}</th>
|
||||
<th class="text-right">{{ __('recognitions.categories.'.$category.'.unit') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($live['categories'][$category] as $row)
|
||||
<tr class="{{ (int) $row['user_id'] === $viewerId ? 'table-info' : '' }}">
|
||||
<td class="font-weight-bold">
|
||||
{{ $medals[$row['rank']] ?? $row['rank'].'.' }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
@include('components._avatar', [
|
||||
'imageUrl' => $row['profile_image_url'] ?? null,
|
||||
'firstName' => $row['first_name'],
|
||||
'lastName' => $row['last_name'],
|
||||
'size' => 32,
|
||||
])
|
||||
<span class="ml-2">
|
||||
{{ $row['first_name'] }} {{ $row['last_name'] }}
|
||||
@if ((int) $row['user_id'] === $viewerId)
|
||||
<span class="badge badge-pill badge-info ml-1">{{ __('recognitions.self') }}</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right font-weight-bold">{{ $formatValue($row['value']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -134,6 +134,9 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
|
|||
Route::get('/user/backoffice/statistics/export', 'User\BackofficeStatisticsController@export')->name('user_backoffice_statistics_export');
|
||||
Route::get('/user/backoffice/statistics/export-overview', 'User\BackofficeStatisticsController@overviewExport')->name('user_backoffice_statistics_overview_export');
|
||||
|
||||
// user recognitions (Anerkennungen des Monats, firmenweit)
|
||||
Route::get('/user/recognitions', 'User\RecognitionController@index')->name('user_recognitions');
|
||||
|
||||
// user team
|
||||
Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member');
|
||||
|
||||
|
|
@ -344,6 +347,15 @@ 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');
|
||||
|
||||
// Kundenhoheit: offene Prüfungen, Kunden bei mehreren Beratern, E-Mail-Änderungen
|
||||
Route::get('/admin/customer-priority/datatable/reviews', 'Admin\CustomerPriorityController@reviewsDatatable')->name('admin_customer_priority_reviews_datatable');
|
||||
Route::get('/admin/customer-priority/datatable/conflicts', 'Admin\CustomerPriorityController@conflictsDatatable')->name('admin_customer_priority_conflicts_datatable');
|
||||
Route::get('/admin/customer-priority/datatable/email-changes', 'Admin\CustomerPriorityController@emailChangesDatatable')->name('admin_customer_priority_email_changes_datatable');
|
||||
Route::post('/admin/customer-priority/keep/{id}', 'Admin\CustomerPriorityController@keepAssignment')->name('admin_customer_priority_keep');
|
||||
Route::post('/admin/customer-priority/email-change/{id}/resolve', 'Admin\CustomerPriorityController@resolveEmailChange')->name('admin_customer_priority_email_resolve');
|
||||
Route::post('/admin/customer-priority/email-change/{id}/discard', 'Admin\CustomerPriorityController@discardEmailChange')->name('admin_customer_priority_email_discard');
|
||||
Route::get('/admin/customer-priority/{tab?}', 'Admin\CustomerPriorityController@index')->name('admin_customer_priority');
|
||||
|
||||
// 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');
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use App\Http\Controllers\Portal\CustomerController;
|
|||
use App\Http\Controllers\Portal\InController;
|
||||
use App\Http\Controllers\Portal\OrderController;
|
||||
use App\Http\Controllers\Web\AboCardUpdateController;
|
||||
use App\Http\Controllers\Web\CustomerEmailChangeController;
|
||||
|
||||
/* ROUTING FOR CRM my.mivita / CMS */
|
||||
|
||||
|
|
@ -41,6 +42,11 @@ Route::domain(config('app.pre_url_portal').config('app.domain').config('app.tld_
|
|||
Route::post('abo/karte/{token}/stripe', [AboCardUpdateController::class, 'startStripe'])->middleware('throttle:10,1')->name('abo_card_update_stripe');
|
||||
Route::get('abo/karte/{token}/return/{status}', [AboCardUpdateController::class, 'return'])->middleware('throttle:10,1')->name('abo_card_update_return');
|
||||
|
||||
// Login-freie Bestätigung einer neuen Kunden-E-Mail (vom Berater eingetragen). Erst nach
|
||||
// Bestätigung wird die Adresse für Login und Abo verwendet.
|
||||
Route::get('kunde/email-bestaetigen/{token}', [CustomerEmailChangeController::class, 'show'])->middleware('throttle:10,1')->name('customer_email_change_show');
|
||||
Route::post('kunde/email-bestaetigen/{token}', [CustomerEmailChangeController::class, 'store'])->middleware('throttle:10,1')->name('customer_email_change_store');
|
||||
|
||||
// Geschützte Kunden-Routen
|
||||
Route::middleware('auth:customers')->group(function () {
|
||||
|
||||
|
|
|
|||
372
tests/Feature/CustomerEmailChangeTest.php
Normal file
372
tests/Feature/CustomerEmailChangeTest.php
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\CustomerController as AdminCustomerController;
|
||||
use App\Http\Controllers\User\CustomerController as ConsultantCustomerController;
|
||||
use App\Http\Controllers\Web\CustomerEmailChangeController;
|
||||
use App\Mail\CustomerEmailChangeBlockedMail;
|
||||
use App\Mail\CustomerEmailChangeMail;
|
||||
use App\Models\Country;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\ShoppingUserEmailChange;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
URL::resolveMissingNamedRoutesUsing(
|
||||
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
|
||||
);
|
||||
|
||||
$this->country = Country::create([
|
||||
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
|
||||
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
|
||||
]);
|
||||
|
||||
$this->member = User::forceCreate([
|
||||
'email' => 'cec-member-'.uniqid('', true).'@example.com',
|
||||
'password' => Hash::make('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$this->service = app(CustomerEmailChangeService::class);
|
||||
});
|
||||
|
||||
function cecCustomer(array $attributes = []): ShoppingUser
|
||||
{
|
||||
return ShoppingUser::create(array_merge([
|
||||
'auth_user_id' => null,
|
||||
'member_id' => test()->member->id,
|
||||
'number' => 7100,
|
||||
'language' => 'de',
|
||||
'billing_salutation' => 'mr',
|
||||
'billing_firstname' => 'Sascha',
|
||||
'billing_lastname' => 'Frey',
|
||||
'billing_address' => 'Weg 1',
|
||||
'billing_zipcode' => '11111',
|
||||
'billing_city' => 'Stadt',
|
||||
'billing_email' => 'alt@example.com',
|
||||
'billing_country_id' => test()->country->id,
|
||||
'shipping_country_id' => test()->country->id,
|
||||
'same_as_billing' => true,
|
||||
'faker_mail' => false,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function cecAbo(ShoppingUser $customer, array $attributes = []): UserAbo
|
||||
{
|
||||
return UserAbo::create(array_merge([
|
||||
'member_id' => test()->member->id,
|
||||
'shopping_user_id' => $customer->id,
|
||||
'email' => $customer->billing_email,
|
||||
'is_for' => 'ot',
|
||||
'payone_userid' => 0,
|
||||
'clearingtype' => 'stripe',
|
||||
'abo_interval' => 5,
|
||||
'status' => 3,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a controller action that reads its input from the Request facade.
|
||||
*/
|
||||
function cecCallStore(object $controller, int $id, array $input): mixed
|
||||
{
|
||||
$request = Request::create('/customer/edit/'.$id, 'POST', $input);
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
app()->instance('request', $request);
|
||||
Facade::clearResolvedInstance('request');
|
||||
|
||||
return $controller->store($id);
|
||||
}
|
||||
|
||||
function cecFormData(ShoppingUser $customer, string $email): array
|
||||
{
|
||||
return [
|
||||
'action' => 'shopping-user-store',
|
||||
'billing_salutation' => $customer->billing_salutation,
|
||||
'billing_firstname' => $customer->billing_firstname,
|
||||
'billing_lastname' => $customer->billing_lastname,
|
||||
'billing_address' => $customer->billing_address,
|
||||
'billing_zipcode' => $customer->billing_zipcode,
|
||||
'billing_city' => 'Neustadt',
|
||||
'billing_country_id' => $customer->billing_country_id,
|
||||
'billing_email' => $email,
|
||||
'same_as_billing' => '1',
|
||||
];
|
||||
}
|
||||
|
||||
// --- Wann ist eine Bestätigung nötig? ---
|
||||
|
||||
it('verlangt eine Bestätigung nur beim Ersetzen einer echten Adresse', function () {
|
||||
$customer = cecCustomer();
|
||||
|
||||
expect($this->service->requiresConfirmation($customer, 'neu@example.com'))->toBeTrue()
|
||||
->and($this->service->requiresConfirmation($customer, ' ALT@example.com '))->toBeFalse()
|
||||
->and($this->service->requiresConfirmation($customer, ''))->toBeFalse()
|
||||
->and($this->service->requiresConfirmation($customer, null))->toBeFalse();
|
||||
|
||||
$placeholder = cecCustomer(['number' => 7101, 'faker_mail' => true, 'billing_email' => '123-faker@mivita.care']);
|
||||
expect($this->service->requiresConfirmation($placeholder, 'neu@example.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
// --- Anfrage ---
|
||||
|
||||
it('schickt den Bestätigungslink an die neue Adresse und lässt die alte aktiv', function () {
|
||||
$customer = cecCustomer();
|
||||
$abo = cecAbo($customer);
|
||||
|
||||
$change = $this->service->request($customer, ' neu@example.com ', $this->member->id);
|
||||
|
||||
expect($change->new_email)->toBe('neu@example.com')
|
||||
->and($change->old_email)->toBe('alt@example.com')
|
||||
->and($change->requested_by_user_id)->toBe($this->member->id)
|
||||
->and($change->isConfirmable())->toBeTrue()
|
||||
->and($customer->fresh()->billing_email)->toBe('alt@example.com')
|
||||
->and($abo->fresh()->email)->toBe('alt@example.com')
|
||||
->and($this->service->pendingFor($customer)?->id)->toBe($change->id);
|
||||
|
||||
Mail::assertSent(CustomerEmailChangeMail::class, function (CustomerEmailChangeMail $mail) use ($change) {
|
||||
return $mail->hasTo('neu@example.com')
|
||||
&& ShoppingUserEmailChange::hashToken($mail->token) === $change->token_hash;
|
||||
});
|
||||
});
|
||||
|
||||
it('enthält in der Mail den Bestätigungslink ins Kundenportal', function () {
|
||||
$html = (new CustomerEmailChangeMail(cecCustomer(), 'abc123'))->render();
|
||||
|
||||
expect($html)->toContain('/kunde/email-bestaetigen/abc123')
|
||||
->and($html)->toContain(__('customer.email_change_mail_button'))
|
||||
->and($html)->toContain('Sascha');
|
||||
});
|
||||
|
||||
it('ersetzt eine ältere offene Anfrage desselben Kunden', function () {
|
||||
$customer = cecCustomer();
|
||||
$first = $this->service->request($customer, 'erst@example.com');
|
||||
$second = $this->service->request($customer, 'zweit@example.com');
|
||||
|
||||
expect(ShoppingUserEmailChange::find($first->id))->toBeNull()
|
||||
->and($this->service->pendingFor($customer)->id)->toBe($second->id);
|
||||
});
|
||||
|
||||
// --- Bestätigung ---
|
||||
|
||||
it('übernimmt nach Bestätigung die Adresse für alle Einträge des Kunden und seine Abos', function () {
|
||||
$older = cecCustomer();
|
||||
$latest = cecCustomer();
|
||||
$abo = cecAbo($older);
|
||||
$endedAbo = cecAbo($older, ['status' => 5]);
|
||||
$olderAddress = cecCustomer(['billing_email' => 'ganz-alt@example.com']);
|
||||
$otherCustomer = cecCustomer(['number' => 7200]);
|
||||
$otherAbo = cecAbo($otherCustomer);
|
||||
|
||||
$change = $this->service->request($latest, 'neu@example.com');
|
||||
|
||||
expect($this->service->confirm($change))->toBe(CustomerEmailChangeService::STATUS_CONFIRMED);
|
||||
|
||||
expect($older->fresh()->billing_email)->toBe('neu@example.com')
|
||||
->and($latest->fresh()->billing_email)->toBe('neu@example.com')
|
||||
->and($abo->fresh()->email)->toBe('neu@example.com')
|
||||
->and($endedAbo->fresh()->email)->toBe('neu@example.com')
|
||||
->and($olderAddress->fresh()->billing_email)->toBe('ganz-alt@example.com')
|
||||
->and($otherCustomer->fresh()->billing_email)->toBe('alt@example.com')
|
||||
->and($otherAbo->fresh()->email)->toBe('alt@example.com');
|
||||
|
||||
$change->refresh();
|
||||
expect($change->confirmed_at)->not->toBeNull()
|
||||
->and($change->token_hash)->toBeNull()
|
||||
->and($this->service->confirm($change))->toBe(CustomerEmailChangeService::STATUS_INVALID);
|
||||
});
|
||||
|
||||
it('lehnt abgelaufene Links ab', function () {
|
||||
$customer = cecCustomer();
|
||||
$change = $this->service->request($customer, 'neu@example.com');
|
||||
$change->forceFill(['expires_at' => now()->subMinute()])->save();
|
||||
|
||||
expect($this->service->confirm($change->fresh()))->toBe(CustomerEmailChangeService::STATUS_INVALID)
|
||||
->and($customer->fresh()->billing_email)->toBe('alt@example.com');
|
||||
});
|
||||
|
||||
it('lehnt einen Link ab, wenn die Adresse inzwischen anders geändert wurde', function () {
|
||||
$customer = cecCustomer();
|
||||
$change = $this->service->request($customer, 'neu@example.com');
|
||||
$this->service->changeDirectly($customer, 'admin@example.com');
|
||||
|
||||
expect($this->service->confirm($change->fresh() ?? $change))->toBe(CustomerEmailChangeService::STATUS_INVALID)
|
||||
->and($customer->fresh()->billing_email)->toBe('admin@example.com');
|
||||
});
|
||||
|
||||
// --- Portal-Seite ---
|
||||
|
||||
it('bestätigt erst per POST und zeigt bei ungültigem Link eine Fehlerseite', function () {
|
||||
$customer = cecCustomer();
|
||||
$this->service->request($customer, 'neu@example.com');
|
||||
$token = null;
|
||||
Mail::assertSent(CustomerEmailChangeMail::class, function (CustomerEmailChangeMail $mail) use (&$token) {
|
||||
$token = $mail->token;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$controller = new CustomerEmailChangeController;
|
||||
|
||||
$invalid = $controller->show('gibt-es-nicht', $this->service);
|
||||
expect($invalid->name())->toBe('portal.customer.email_change_done')
|
||||
->and($invalid->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_INVALID);
|
||||
|
||||
$page = $controller->show($token, $this->service);
|
||||
expect($page->name())->toBe('portal.customer.email_change_confirm')
|
||||
->and($customer->fresh()->billing_email)->toBe('alt@example.com');
|
||||
|
||||
$done = $controller->store($token, $this->service);
|
||||
expect($done->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_CONFIRMED)
|
||||
->and($customer->fresh()->billing_email)->toBe('neu@example.com');
|
||||
|
||||
expect($controller->store($token, $this->service)->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_INVALID);
|
||||
});
|
||||
|
||||
// --- CRM ---
|
||||
|
||||
it('übernimmt eine vom Berater geänderte E-Mail erst nach Bestätigung, andere Felder sofort', function () {
|
||||
$customer = cecCustomer();
|
||||
$abo = cecAbo($customer);
|
||||
$this->actingAs($this->member);
|
||||
|
||||
cecCallStore(app(ConsultantCustomerController::class), $customer->id, cecFormData($customer, 'neu@example.com'));
|
||||
|
||||
$customer->refresh();
|
||||
expect($customer->billing_email)->toBe('alt@example.com')
|
||||
->and($customer->billing_city)->toBe('Neustadt')
|
||||
->and($abo->fresh()->email)->toBe('alt@example.com')
|
||||
->and($this->service->pendingFor($customer)?->new_email)->toBe('neu@example.com')
|
||||
->and(session('custom-error'))->toContain('neu@example.com');
|
||||
|
||||
Mail::assertSent(CustomerEmailChangeMail::class, fn ($mail) => $mail->hasTo('neu@example.com'));
|
||||
});
|
||||
|
||||
it('startet keine Bestätigung, wenn der Berater die E-Mail nicht ändert', function () {
|
||||
$customer = cecCustomer();
|
||||
$this->actingAs($this->member);
|
||||
|
||||
cecCallStore(app(ConsultantCustomerController::class), $customer->id, cecFormData($customer, 'alt@example.com'));
|
||||
|
||||
expect($this->service->pendingFor($customer))->toBeNull()
|
||||
->and($customer->fresh()->billing_city)->toBe('Neustadt');
|
||||
Mail::assertNotSent(CustomerEmailChangeMail::class);
|
||||
});
|
||||
|
||||
it('lässt den Admin die E-Mail direkt ändern und zieht die Abos mit', function () {
|
||||
$customer = cecCustomer();
|
||||
$abo = cecAbo($customer);
|
||||
$admin = User::forceCreate([
|
||||
'email' => 'cec-admin-'.uniqid('', true).'@example.com',
|
||||
'password' => Hash::make('secret'),
|
||||
'lang' => 'de',
|
||||
'admin' => 1,
|
||||
]);
|
||||
$this->actingAs($admin);
|
||||
|
||||
cecCallStore(app(AdminCustomerController::class), $customer->id, cecFormData($customer, 'neu@example.com'));
|
||||
|
||||
expect($customer->fresh()->billing_email)->toBe('neu@example.com')
|
||||
->and($abo->fresh()->email)->toBe('neu@example.com')
|
||||
->and(ShoppingUserEmailChange::where('shopping_user_id', $customer->id)->whereNotNull('confirmed_at')->value('requested_by_user_id'))->toBe($admin->id);
|
||||
Mail::assertNotSent(CustomerEmailChangeMail::class);
|
||||
});
|
||||
|
||||
// --- Kundenhoheit ---
|
||||
|
||||
function cecOtherConsultant(array $attributes = []): User
|
||||
{
|
||||
return User::forceCreate(array_merge([
|
||||
'email' => 'cec-other-'.uniqid('', true).'@example.com',
|
||||
'password' => Hash::make('secret'),
|
||||
'lang' => 'de',
|
||||
'active' => 0,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('erkennt, wenn die E-Mail einem Kunden eines anderen Beraters oder einem Beraterkonto gehört', function () {
|
||||
$customer = cecCustomer();
|
||||
$other = cecOtherConsultant();
|
||||
cecCustomer(['number' => 7300, 'member_id' => $other->id, 'billing_email' => 'fremd@example.com']);
|
||||
cecCustomer(['number' => 7301, 'billing_email' => 'eigener@example.com']);
|
||||
cecCustomer(['number' => 7302, 'member_id' => $other->id, 'billing_email' => 'geloescht@example.com'])
|
||||
->forceFill(['gdpr_deleted_at' => now()])->save();
|
||||
cecOtherConsultant(['email' => 'berater@example.com', 'active' => 1]);
|
||||
cecOtherConsultant(['email' => 'inaktiv@example.com', 'active' => 0]);
|
||||
|
||||
expect($this->service->ownershipConflict($customer, 'fremd@example.com'))->toBe(CustomerEmailChangeService::CONFLICT_OTHER_CONSULTANT)
|
||||
->and($this->service->ownershipConflict($customer, ' berater@example.com '))->toBe(CustomerEmailChangeService::CONFLICT_CONSULTANT_ACCOUNT)
|
||||
->and($this->service->ownershipConflict($customer, 'eigener@example.com'))->toBeNull()
|
||||
->and($this->service->ownershipConflict($customer, 'geloescht@example.com'))->toBeNull()
|
||||
->and($this->service->ownershipConflict($customer, 'inaktiv@example.com'))->toBeNull()
|
||||
->and($this->service->ownershipConflict($customer, 'unbekannt@example.com'))->toBeNull();
|
||||
});
|
||||
|
||||
it('lehnt die Änderung durch den Berater ab, wenn die E-Mail einem anderen Berater zugeordnet ist', function () {
|
||||
$customer = cecCustomer();
|
||||
$abo = cecAbo($customer);
|
||||
$other = cecOtherConsultant();
|
||||
cecCustomer(['number' => 7300, 'member_id' => $other->id, 'billing_email' => 'fremd@example.com']);
|
||||
$this->actingAs($this->member);
|
||||
|
||||
cecCallStore(app(ConsultantCustomerController::class), $customer->id, cecFormData($customer, 'fremd@example.com'));
|
||||
|
||||
$customer->refresh();
|
||||
expect($customer->billing_email)->toBe('alt@example.com')
|
||||
->and($customer->billing_city)->toBe('Neustadt')
|
||||
->and((bool) $customer->is_like)->toBeFalse()
|
||||
->and($abo->fresh()->email)->toBe('alt@example.com')
|
||||
->and(ShoppingUserEmailChange::count())->toBe(1)
|
||||
->and(ShoppingUserEmailChange::blockedOpen()->first()?->new_email)->toBe('fremd@example.com')
|
||||
->and(session('custom-error'))->toBe(__('customer.email_change_blocked', ['email' => 'fremd@example.com']));
|
||||
|
||||
Mail::assertNotSent(CustomerEmailChangeMail::class);
|
||||
Mail::assertSent(CustomerEmailChangeBlockedMail::class, function (CustomerEmailChangeBlockedMail $mail) use ($other) {
|
||||
return $mail->hasTo(config('app.priority_mail'))
|
||||
&& $mail->newEmail === 'fremd@example.com'
|
||||
&& $mail->owners === [$other->email]
|
||||
&& $mail->requestedBy === $this->member->email;
|
||||
});
|
||||
});
|
||||
|
||||
it('lehnt die Bestätigung ab, wenn die E-Mail inzwischen einem anderen Berater zugeordnet wurde', function () {
|
||||
$customer = cecCustomer();
|
||||
$abo = cecAbo($customer);
|
||||
$this->service->request($customer, 'neu@example.com', $this->member->id);
|
||||
$token = null;
|
||||
Mail::assertSent(CustomerEmailChangeMail::class, function (CustomerEmailChangeMail $mail) use (&$token) {
|
||||
$token = $mail->token;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
cecCustomer(['number' => 7300, 'member_id' => cecOtherConsultant()->id, 'billing_email' => 'neu@example.com']);
|
||||
$controller = new CustomerEmailChangeController;
|
||||
|
||||
expect($controller->show($token, $this->service)->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_CONFLICT)
|
||||
->and($controller->store($token, $this->service)->getData()['status'])->toBe(CustomerEmailChangeService::STATUS_CONFLICT)
|
||||
->and($customer->fresh()->billing_email)->toBe('alt@example.com')
|
||||
->and($abo->fresh()->email)->toBe('alt@example.com')
|
||||
->and($this->service->findConfirmable($token))->toBeNull();
|
||||
|
||||
Mail::assertSent(CustomerEmailChangeBlockedMail::class);
|
||||
});
|
||||
|
||||
it('rendert die Info-Mail an die Kundenhoheit', function () {
|
||||
$html = (new CustomerEmailChangeBlockedMail(cecCustomer(), 'fremd@example.com', 'berater@example.com', ['b2@example.com']))->render();
|
||||
|
||||
expect($html)->toContain('fremd@example.com')
|
||||
->and($html)->toContain('b2@example.com')
|
||||
->and($html)->toContain('alt@example.com');
|
||||
});
|
||||
85
tests/Feature/CustomerPriorityEntryExistsTest.php
Normal file
85
tests/Feature/CustomerPriorityEntryExistsTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
|
||||
$this->country = Country::create([
|
||||
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
|
||||
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
|
||||
]);
|
||||
|
||||
$this->owner = cpeConsultant();
|
||||
$this->other = cpeConsultant();
|
||||
});
|
||||
|
||||
function cpeConsultant(): User
|
||||
{
|
||||
return User::forceCreate([
|
||||
'email' => 'cpe-'.uniqid('', true).'@example.com',
|
||||
'password' => Hash::make('secret'),
|
||||
'lang' => 'de',
|
||||
'active' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
function cpeEntry(array $attributes = []): ShoppingUser
|
||||
{
|
||||
return ShoppingUser::create(array_merge([
|
||||
'auth_user_id' => null,
|
||||
'billing_firstname' => 'Karola',
|
||||
'billing_lastname' => 'Home',
|
||||
'billing_address' => 'Weg 1',
|
||||
'billing_zipcode' => '11111',
|
||||
'billing_city' => 'Stadt',
|
||||
'billing_email' => 'karola@example.com',
|
||||
'billing_country_id' => test()->country->id,
|
||||
'shipping_country_id' => test()->country->id,
|
||||
'is_from' => 'shopping',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('ordnet eine neue Bestellung der ältesten geprüften Zuordnung zu, nicht einem Eintrag in Prüfung', function () {
|
||||
$established = cpeEntry(['member_id' => $this->owner->id, 'number' => 100]);
|
||||
cpeEntry(['member_id' => $this->other->id, 'number' => 200, 'is_like' => true]);
|
||||
$new = cpeEntry();
|
||||
|
||||
expect(CustomerPriority::checkOne($new))->toBe('exists');
|
||||
|
||||
$new->refresh();
|
||||
expect($new->member_id)->toBe($this->owner->id)
|
||||
->and($new->number)->toBe($established->number);
|
||||
});
|
||||
|
||||
it('nimmt bei mehreren geprüften Zuordnungen die älteste', function () {
|
||||
cpeEntry(['member_id' => $this->owner->id, 'number' => 100]);
|
||||
cpeEntry(['member_id' => $this->other->id, 'number' => 200]);
|
||||
$new = cpeEntry();
|
||||
|
||||
CustomerPriority::checkOne($new);
|
||||
|
||||
expect($new->fresh()->member_id)->toBe($this->owner->id)
|
||||
->and($new->fresh()->number)->toBe(100);
|
||||
});
|
||||
|
||||
it('übernimmt keine Zuordnung von einem Eintrag in Prüfung, sondern prüft den Kunden selbst', function () {
|
||||
cpeEntry(['member_id' => $this->other->id, 'number' => 200, 'is_like' => true]);
|
||||
$new = cpeEntry();
|
||||
|
||||
expect(CustomerPriority::checkOne($new, false, false))->toStartWith('like');
|
||||
|
||||
$new->refresh();
|
||||
expect((bool) $new->is_like)->toBeTrue()
|
||||
->and($new->number)->toBeNull()
|
||||
->and($new->member_id)->toBeNull();
|
||||
});
|
||||
208
tests/Feature/CustomerPriorityOverviewTest.php
Normal file
208
tests/Feature/CustomerPriorityOverviewTest.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\CustomerPriorityController;
|
||||
use App\Http\Controllers\CustomerController as AdminCustomerController;
|
||||
use App\Models\Country;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\CustomerEmailChangeService;
|
||||
use App\Services\CustomerPriorityOverviewService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
URL::resolveMissingNamedRoutesUsing(
|
||||
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
|
||||
);
|
||||
|
||||
$this->country = Country::create([
|
||||
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
|
||||
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
|
||||
]);
|
||||
$this->first = cpoConsultant();
|
||||
$this->second = cpoConsultant();
|
||||
$this->overview = app(CustomerPriorityOverviewService::class);
|
||||
});
|
||||
|
||||
function cpoConsultant(array $attributes = []): User
|
||||
{
|
||||
return User::forceCreate(array_merge([
|
||||
'email' => 'cpo-'.uniqid('', true).'@example.com',
|
||||
'password' => Hash::make('secret'),
|
||||
'lang' => 'de',
|
||||
'active' => 1,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function cpoEntry(array $attributes = []): ShoppingUser
|
||||
{
|
||||
return ShoppingUser::create(array_merge([
|
||||
'auth_user_id' => null,
|
||||
'member_id' => test()->first->id,
|
||||
'number' => 100,
|
||||
'billing_firstname' => 'Karola',
|
||||
'billing_lastname' => 'Home',
|
||||
'billing_address' => 'Weg 1',
|
||||
'billing_zipcode' => '11111',
|
||||
'billing_city' => 'Stadt',
|
||||
'billing_email' => 'karola@example.com',
|
||||
'billing_country_id' => test()->country->id,
|
||||
'shipping_country_id' => test()->country->id,
|
||||
'faker_mail' => false,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Datatable endpoints read their parameters from the current request.
|
||||
*/
|
||||
function cpoDatatable(string $method): array
|
||||
{
|
||||
$request = Request::create('/datatable', 'GET', ['draw' => 1, 'start' => 0, 'length' => 50, 'search' => ['value' => '']]);
|
||||
app()->instance('request', $request);
|
||||
Facade::clearResolvedInstance('request');
|
||||
|
||||
return app(CustomerPriorityController::class)->{$method}()->getData(true);
|
||||
}
|
||||
|
||||
// --- Kunde bei mehreren Beratern ---
|
||||
|
||||
it('listet Kunden, deren E-Mail bei mehreren Beratern liegt, und markiert die älteste Zuordnung', function () {
|
||||
$oldest = cpoEntry();
|
||||
cpoEntry();
|
||||
$other = cpoEntry(['member_id' => $this->second->id, 'number' => 200]);
|
||||
cpoEntry(['billing_email' => 'eindeutig@example.com', 'number' => 300]);
|
||||
|
||||
$conflicts = $this->overview->conflicts();
|
||||
|
||||
expect($conflicts)->toHaveCount(1);
|
||||
$row = $conflicts->first();
|
||||
expect($row['email'])->toBe('karola@example.com')
|
||||
->and($row['groups'])->toHaveCount(2)
|
||||
->and($row['groups'][0]['member_id'])->toBe($this->first->id)
|
||||
->and($row['groups'][0]['oldest'])->toBeTrue()
|
||||
->and($row['groups'][0]['entries'])->toBe(2)
|
||||
->and($row['groups'][0]['latest_id'])->not->toBe($oldest->id)
|
||||
->and($row['groups'][1]['latest_id'])->toBe($other->id)
|
||||
->and($row['groups'][1]['oldest'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('lässt offene Prüfungen, Beraterkonten und gelöschte Einträge aus der Konfliktliste', function () {
|
||||
cpoEntry();
|
||||
cpoEntry(['member_id' => $this->second->id, 'number' => 200, 'is_like' => true]);
|
||||
|
||||
cpoEntry(['billing_email' => 'berater@example.com']);
|
||||
cpoEntry(['billing_email' => 'berater@example.com', 'member_id' => $this->second->id, 'number' => 201]);
|
||||
cpoConsultant(['email' => 'berater@example.com']);
|
||||
|
||||
cpoEntry(['billing_email' => 'weg@example.com']);
|
||||
cpoEntry(['billing_email' => 'weg@example.com', 'member_id' => $this->second->id, 'number' => 202])
|
||||
->forceFill(['gdpr_deleted_at' => now()])->save();
|
||||
|
||||
expect($this->overview->conflicts())->toBeEmpty();
|
||||
});
|
||||
|
||||
// --- Offene Prüfungen ---
|
||||
|
||||
it('zählt offene Prüfungen und abgelehnte E-Mail-Änderungen für das Menü', function () {
|
||||
$review = cpoEntry(['is_like' => true]);
|
||||
cpoEntry(['is_like' => true])->forceFill(['gdpr_deleted_at' => now()])->save();
|
||||
cpoEntry(['number' => 101]);
|
||||
app(CustomerEmailChangeService::class)->reportConflict($review, 'fremd@example.com');
|
||||
|
||||
expect($this->overview->reviewsQuery()->pluck('id')->all())->toBe([$review->id])
|
||||
->and($this->overview->openCount())->toBe(2)
|
||||
->and($this->overview->counts())->toBe(['reviews' => 1, 'conflicts' => 0, 'email_changes' => 1]);
|
||||
});
|
||||
|
||||
it('schließt einen Prüffall ohne Berater oder Nummer zu ändern', function () {
|
||||
$review = cpoEntry(['is_like' => true, 'member_id' => $this->second->id, 'number' => 555]);
|
||||
$review->setNotice('like', [99 => 100]);
|
||||
|
||||
app(CustomerPriorityController::class)->keepAssignment($review->id);
|
||||
|
||||
$review->refresh();
|
||||
expect($review->is_like)->toBeFalse()
|
||||
->and($review->getNotice('like'))->toBeFalse()
|
||||
->and($review->member_id)->toBe($this->second->id)
|
||||
->and($review->number)->toBe(555);
|
||||
});
|
||||
|
||||
// --- E-Mail-Änderungen ---
|
||||
|
||||
it('zeigt abgelehnte und wartende E-Mail-Änderungen, bis sie erledigt oder verworfen sind', function () {
|
||||
$service = app(CustomerEmailChangeService::class);
|
||||
$customer = cpoEntry();
|
||||
$blocked = $service->reportConflict($customer, 'fremd@example.com', $this->first->id);
|
||||
$pending = $service->request(cpoEntry(['number' => 101, 'billing_email' => 'b@example.com']), 'neu@example.com');
|
||||
$service->changeDirectly(cpoEntry(['number' => 102, 'billing_email' => 'c@example.com']), 'admin@example.com');
|
||||
|
||||
expect($blocked->blocked_at)->not->toBeNull()
|
||||
->and($this->overview->emailChangesQuery()->pluck('id')->sort()->values()->all())->toBe([$blocked->id, $pending->id]);
|
||||
|
||||
$this->actingAs($this->first);
|
||||
$controller = app(CustomerPriorityController::class);
|
||||
$controller->resolveEmailChange($blocked->id, $service);
|
||||
$controller->discardEmailChange($pending->id, $service);
|
||||
|
||||
expect($blocked->fresh()->resolved_by_user_id)->toBe($this->first->id)
|
||||
->and($service->findConfirmable('egal'))->toBeNull()
|
||||
->and($pending->fresh()->isConfirmable())->toBeFalse()
|
||||
->and($this->overview->emailChangesQuery()->count())->toBe(0);
|
||||
});
|
||||
|
||||
// --- Datatables ---
|
||||
|
||||
it('liefert die drei Listen als Datatable', function () {
|
||||
$review = cpoEntry(['is_like' => true, 'billing_email' => 'pruefung@example.com', 'number' => 400]);
|
||||
cpoEntry();
|
||||
cpoEntry(['member_id' => $this->second->id, 'number' => 200]);
|
||||
app(CustomerEmailChangeService::class)->reportConflict($review, 'fremd@example.com');
|
||||
|
||||
$reviews = cpoDatatable('reviewsDatatable');
|
||||
$conflicts = cpoDatatable('conflictsDatatable');
|
||||
$changes = cpoDatatable('emailChangesDatatable');
|
||||
|
||||
expect($reviews['recordsTotal'])->toBe(1)
|
||||
->and($reviews['data'][0]['customer'])->toContain('pruefung@example.com')
|
||||
->and($reviews['data'][0]['action'])->toContain('shopping-user-is-like-member')
|
||||
->and($conflicts['recordsTotal'])->toBe(1)
|
||||
->and($conflicts['data'][0]['groups'])->toContain('älteste Zuordnung')
|
||||
->and($conflicts['data'][0]['groups'])->toContain('shopping-user-change-member')
|
||||
->and($changes['recordsTotal'])->toBe(1)
|
||||
->and($changes['data'][0]['status'])->toContain('abgelehnt');
|
||||
});
|
||||
|
||||
// --- Berater zuweisen aus der Übersicht ---
|
||||
|
||||
it('führt nach "Berater zuweisen" zurück in die Übersicht und hängt alle Einträge des Kunden um', function () {
|
||||
cpoEntry();
|
||||
$moved = cpoEntry(['member_id' => $this->second->id, 'number' => 200]);
|
||||
cpoEntry(['member_id' => $this->second->id, 'number' => 200]);
|
||||
$back = route('admin_customer_priority', ['conflicts']);
|
||||
|
||||
$request = Request::create('/admin/customer/edit/'.$moved->id, 'POST', [
|
||||
'action' => 'shopping-user-change-member',
|
||||
'id' => $moved->id,
|
||||
'change_member_key' => config('mivita.edit_data_pass'),
|
||||
'change_member_id' => $this->first->id,
|
||||
'customer_set_member_for' => '1',
|
||||
'back' => $back,
|
||||
]);
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
app()->instance('request', $request);
|
||||
Facade::clearResolvedInstance('request');
|
||||
|
||||
$response = app(AdminCustomerController::class)->store($moved->id);
|
||||
|
||||
expect($response->getTargetUrl())->toEndWith('/admin_customer_priority/conflicts')
|
||||
->and(ShoppingUser::where('number', '!=', 100)->where('member_id', $this->second->id)->count())->toBe(0)
|
||||
->and($this->overview->conflicts())->toBeEmpty();
|
||||
});
|
||||
77
tests/Feature/CustomerPriorityPageTest.php
Normal file
77
tests/Feature/CustomerPriorityPageTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\User;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->previousHttpHost = $_SERVER['HTTP_HOST'] ?? null;
|
||||
Mail::fake();
|
||||
$_SERVER['HTTP_HOST'] = config('domains.domains.crm.host');
|
||||
$this->refreshApplication();
|
||||
$this->artisan('migrate');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Host wieder zurücksetzen, sonst booten nachfolgende Tests mit den CRM-Routen.
|
||||
if ($this->previousHttpHost === null) {
|
||||
unset($_SERVER['HTTP_HOST']);
|
||||
} else {
|
||||
$_SERVER['HTTP_HOST'] = $this->previousHttpHost;
|
||||
}
|
||||
});
|
||||
|
||||
function cppUser(array $attributes = []): User
|
||||
{
|
||||
return User::forceCreate(array_merge([
|
||||
'email' => 'cpp-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'active' => 1,
|
||||
'wizard' => 100,
|
||||
'payment_account' => now()->addYear(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('zeigt Admins die Übersicht Kundenhoheit mit allen Reitern und dem Zähler im Menü', function (string $tab, string $text) {
|
||||
$admin = cppUser(['admin' => 2]);
|
||||
$country = Country::first() ?? Country::create([
|
||||
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
|
||||
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
|
||||
]);
|
||||
ShoppingUser::create([
|
||||
'member_id' => $admin->id,
|
||||
'number' => 71001,
|
||||
'is_like' => true,
|
||||
'billing_firstname' => 'Paula',
|
||||
'billing_lastname' => 'Prüf',
|
||||
'billing_email' => 'paula@example.com',
|
||||
'billing_country_id' => $country->id,
|
||||
'shipping_country_id' => $country->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->get(route('admin_customer_priority', [$tab]))
|
||||
->assertOk()
|
||||
->assertSee('Kundenhoheit')
|
||||
->assertSee($text)
|
||||
->assertSee('id="datatables-customer-priority"', false)
|
||||
->assertSee('<div class="badge badge-danger">1</div>', false);
|
||||
})->with([
|
||||
'Prüfungen' => ['reviews', 'Prüfen & zuordnen'],
|
||||
'Konflikte' => ['conflicts', 'älteste Zuordnung'],
|
||||
'E-Mail' => ['email-changes', 'wartet auf Bestätigung'],
|
||||
]);
|
||||
|
||||
it('kennt nur die drei Reiter', function () {
|
||||
$this->actingAs(cppUser(['admin' => 2]))->get(route('admin_customer_priority', ['gibt-es-nicht']))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('sperrt die Übersicht für Berater ohne Admin-Rechte', function () {
|
||||
$this->actingAs(cppUser())->get(route('admin_customer_priority'))
|
||||
->assertRedirect();
|
||||
});
|
||||
115
tests/Feature/RecognitionPageTest.php
Normal file
115
tests/Feature/RecognitionPageTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
use App\Models\RecognitionSnapshot;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->previousHttpHost = $_SERVER['HTTP_HOST'] ?? null;
|
||||
$_SERVER['HTTP_HOST'] = config('domains.domains.crm.host');
|
||||
$this->refreshApplication();
|
||||
$this->artisan('migrate');
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-09 10:00:00'));
|
||||
Cache::flush();
|
||||
config(['mivita.recognition_excluded_user_ids' => []]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
|
||||
// Host wieder zurücksetzen, sonst booten nachfolgende Tests mit den CRM-Routen.
|
||||
if ($this->previousHttpHost === null) {
|
||||
unset($_SERVER['HTTP_HOST']);
|
||||
} else {
|
||||
$_SERVER['HTTP_HOST'] = $this->previousHttpHost;
|
||||
}
|
||||
});
|
||||
|
||||
function recPageUser(string $firstName, string $lastName, array $attributes = []): User
|
||||
{
|
||||
$account = UserAccount::forceCreate(['first_name' => $firstName, 'last_name' => $lastName]);
|
||||
|
||||
return User::forceCreate(array_merge([
|
||||
'email' => 'recp-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'active' => 1,
|
||||
'wizard' => 100,
|
||||
'm_level' => 1,
|
||||
'payment_account' => now()->addYear(),
|
||||
'account_id' => $account->id,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function recPageVolume(int $userId, int $points, int $month): void
|
||||
{
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $userId,
|
||||
'month' => $month,
|
||||
'year' => 2026,
|
||||
'points' => $points,
|
||||
'status' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
it('zeigt aktiven Beratern die Anerkennungen des Vormonats und die Live-Tabelle', function () {
|
||||
$viewer = recPageUser('Vera', 'Viewer');
|
||||
$september = recPageUser('Sina', 'September');
|
||||
$october = recPageUser('Otto', 'Oktober');
|
||||
recPageVolume($september->id, 5808, 9);
|
||||
recPageVolume($october->id, 1234, 10);
|
||||
recPageVolume($viewer->id, 800, 10);
|
||||
|
||||
$this->actingAs($viewer)->get(route('user_recognitions'))
|
||||
->assertOk()
|
||||
->assertSee('Unsere Erfolge im September 2026')
|
||||
->assertSee('Vorläufig · wird am 03.10.2026 festgeschrieben', false)
|
||||
->assertSee('Sina September')
|
||||
->assertSee('5.808')
|
||||
->assertSee('Live: So entwickelt sich der Oktober 2026')
|
||||
->assertSee('Otto Oktober')
|
||||
->assertSee('1.234')
|
||||
->assertSee('<tr class="table-info">', false)
|
||||
->assertSee('href="'.route('user_recognitions').'"', false);
|
||||
});
|
||||
|
||||
it('zeigt eingefrorene Monate als endgültig und wechselt per Filter den Monat', function () {
|
||||
$viewer = recPageUser('Vera', 'Viewer');
|
||||
$august = recPageUser('Augusta', 'August');
|
||||
recPageVolume($august->id, 4321, 8);
|
||||
$this->artisan('recognitions:freeze', ['--month' => 8, '--year' => 2026])->assertSuccessful();
|
||||
|
||||
expect(RecognitionSnapshot::query()->where('month', 8)->exists())->toBeTrue();
|
||||
|
||||
$this->actingAs($viewer)->get(route('user_recognitions', ['period' => '2026-08']))
|
||||
->assertOk()
|
||||
->assertSee('Unsere Erfolge im August 2026')
|
||||
->assertSee('Endgültig · festgeschrieben am 09.10.2026', false)
|
||||
->assertSee('Augusta August')
|
||||
->assertSee('<option value="2026-08" selected="selected">', false);
|
||||
});
|
||||
|
||||
it('faellt bei einem nicht abgeschlossenen Monat auf den Vormonat zurueck', function () {
|
||||
$this->actingAs(recPageUser('Vera', 'Viewer'))->get(route('user_recognitions', ['period' => '2026-10']))
|
||||
->assertOk()
|
||||
->assertSee('Unsere Erfolge im September 2026');
|
||||
});
|
||||
|
||||
it('lehnt ein ungueltiges Monatsformat ab', function () {
|
||||
$this->actingAs(recPageUser('Vera', 'Viewer'))->get(route('user_recognitions', ['period' => 'kaputt']))
|
||||
->assertRedirect()
|
||||
->assertSessionHasErrors('period');
|
||||
});
|
||||
|
||||
it('sperrt die Seite fuer Berater ohne aktives Konto', function () {
|
||||
$this->actingAs(recPageUser('Ina', 'Inaktiv', ['payment_account' => now()->subDay()]))
|
||||
->get(route('user_recognitions'))
|
||||
->assertRedirect('/home');
|
||||
});
|
||||
257
tests/Feature/RecognitionServiceTest.php
Normal file
257
tests/Feature/RecognitionServiceTest.php
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<?php
|
||||
|
||||
use App\Models\RecognitionSnapshot;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Team\RecognitionService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-09 10:00:00'));
|
||||
Cache::flush();
|
||||
config(['mivita.recognition_excluded_user_ids' => []]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
function recMakeUser(string $firstName, string $lastName = 'Partner', ?int $sponsorId = null, ?string $partnerPaidAt = null): User
|
||||
{
|
||||
$account = UserAccount::forceCreate([
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
]);
|
||||
|
||||
return User::forceCreate([
|
||||
'email' => 'rec-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'm_level' => 1,
|
||||
'm_sponsor' => $sponsorId,
|
||||
'payment_account' => now()->addYear(),
|
||||
'active_date' => $partnerPaidAt,
|
||||
'partner_paid_at' => $partnerPaidAt,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
}
|
||||
|
||||
function recAddVolume(int $userId, float $points, int $status, int $month = 9, ?int $shoppingOrderId = null): void
|
||||
{
|
||||
UserSalesVolume::forceCreate([
|
||||
'user_id' => $userId,
|
||||
'month' => $month,
|
||||
'year' => 2026,
|
||||
'points' => $points,
|
||||
'status' => $status,
|
||||
'shopping_order_id' => $shoppingOrderId,
|
||||
]);
|
||||
}
|
||||
|
||||
function recAddCustomerAboPoints(int $userId, float $points, int $month = 9): void
|
||||
{
|
||||
$order = ShoppingOrder::forceCreate([
|
||||
'shopping_user_id' => 0,
|
||||
'auth_user_id' => 0,
|
||||
'country_id' => 0,
|
||||
'user_shop_id' => 0,
|
||||
'member_id' => $userId,
|
||||
'is_abo' => true,
|
||||
]);
|
||||
|
||||
recAddVolume($userId, $points, 2, $month, $order->id);
|
||||
}
|
||||
|
||||
function recMakeCustomerAbo(int $consultantId, string $startDate): UserAbo
|
||||
{
|
||||
return UserAbo::create([
|
||||
'user_id' => 0,
|
||||
'member_id' => $consultantId,
|
||||
'shopping_user_id' => 0,
|
||||
'email' => 'rec-abo-'.uniqid('', true).'@example.com',
|
||||
'is_for' => 'ot',
|
||||
'payone_userid' => 0,
|
||||
'clearingtype' => 'cc',
|
||||
'amount' => 5000,
|
||||
'active' => true,
|
||||
'status' => 2,
|
||||
'abo_interval' => 5,
|
||||
'start_date' => $startDate,
|
||||
'next_date' => '2026-11-05',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $podium
|
||||
* @return array<int, array{place: int, value: float|int, names: array<int, string>}>
|
||||
*/
|
||||
function recPlaces(array $podium): array
|
||||
{
|
||||
return array_map(fn (array $place) => [
|
||||
'place' => $place['place'],
|
||||
'value' => $place['value'],
|
||||
'names' => array_map(fn (array $member) => $member['first_name'], $place['members']),
|
||||
], $podium);
|
||||
}
|
||||
|
||||
it('rankt alle fuenf Kategorien firmenweit, unabhaengig vom Team', function (): void {
|
||||
$anna = recMakeUser('Anna');
|
||||
$ben = recMakeUser('Ben');
|
||||
|
||||
recMakeUser('Neu1', 'Partner', $anna->id, '2026-09-10');
|
||||
recMakeUser('Neu2', 'Partner', $anna->id, '2026-09-20');
|
||||
recMakeUser('Alt', 'Partner', $anna->id, '2026-08-20');
|
||||
|
||||
recMakeCustomerAbo($ben->id, '2026-09-05');
|
||||
recMakeCustomerAbo($ben->id, '2026-09-15');
|
||||
recMakeCustomerAbo($ben->id, '2026-06-01');
|
||||
recMakeCustomerAbo($anna->id, '2026-09-02');
|
||||
|
||||
recAddCustomerAboPoints($ben->id, 120.5);
|
||||
recAddCustomerAboPoints($anna->id, 80);
|
||||
recAddVolume($anna->id, 900, 1);
|
||||
recAddVolume($ben->id, 50, 1, 8);
|
||||
|
||||
$categories = app(RecognitionService::class)->recognitions(9, 2026)['categories'];
|
||||
|
||||
expect(recPlaces($categories['recruiters']))->toBe([['place' => 1, 'value' => 2, 'names' => ['Anna']]])
|
||||
->and(recPlaces($categories['new_customer_abos']))->toBe([
|
||||
['place' => 1, 'value' => 2, 'names' => ['Ben']],
|
||||
['place' => 2, 'value' => 1, 'names' => ['Anna']],
|
||||
])
|
||||
->and(recPlaces($categories['customer_abos_total']))->toBe([
|
||||
['place' => 1, 'value' => 3, 'names' => ['Ben']],
|
||||
['place' => 2, 'value' => 1, 'names' => ['Anna']],
|
||||
])
|
||||
->and(recPlaces($categories['customer_abo_points']))->toBe([
|
||||
['place' => 1, 'value' => 120.5, 'names' => ['Ben']],
|
||||
['place' => 2, 'value' => 80.0, 'names' => ['Anna']],
|
||||
])
|
||||
->and(recPlaces($categories['total_points']))->toBe([
|
||||
['place' => 1, 'value' => 980.0, 'names' => ['Anna']],
|
||||
['place' => 2, 'value' => 120.5, 'names' => ['Ben']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('schliesst standardmaessig Firmenkonto, Ruth Krummel und Go4 Values aus', function (): void {
|
||||
expect((require config_path('mivita.php'))['recognition_excluded_user_ids'])->toBe([3, 6, 486]);
|
||||
});
|
||||
|
||||
it('fuehrt Firmenkonto und Grosskunden nie auf', function (): void {
|
||||
$company = recMakeUser('Firma', 'GmbH');
|
||||
$bigCustomer = recMakeUser('Ruth', 'Gross');
|
||||
$anna = recMakeUser('Anna');
|
||||
config(['mivita.recognition_excluded_user_ids' => [$company->id, $bigCustomer->id]]);
|
||||
|
||||
recAddVolume($company->id, 5000, 2);
|
||||
recAddVolume($bigCustomer->id, 7000, 1);
|
||||
recAddVolume($anna->id, 300, 1);
|
||||
|
||||
$service = app(RecognitionService::class);
|
||||
|
||||
expect(recPlaces($service->recognitions(9, 2026)['categories']['total_points']))
|
||||
->toBe([['place' => 1, 'value' => 300.0, 'names' => ['Anna']]])
|
||||
->and(array_column($service->liveRankings(10, 2026)['categories']['total_points'], 'user_id'))
|
||||
->not->toContain($company->id, $bigCustomer->id);
|
||||
});
|
||||
|
||||
it('teilt Gleichstaende einen Platz und vergibt die Plaetze lueckenlos', function (): void {
|
||||
foreach (['Carla' => 500, 'Anna' => 400, 'Ben' => 400, 'Dora' => 300, 'Emil' => 200, 'Fritz' => 100] as $name => $points) {
|
||||
recAddVolume(recMakeUser($name)->id, $points, 1);
|
||||
}
|
||||
|
||||
$categories = app(RecognitionService::class)->recognitions(9, 2026)['categories'];
|
||||
|
||||
// 1.000-Punkte-Shop mit Platz 4 (wie in der Mail), danach ist Schluss.
|
||||
expect(recPlaces($categories['total_points']))->toBe([
|
||||
['place' => 1, 'value' => 500.0, 'names' => ['Carla']],
|
||||
['place' => 2, 'value' => 400.0, 'names' => ['Anna', 'Ben']],
|
||||
['place' => 3, 'value' => 300.0, 'names' => ['Dora']],
|
||||
['place' => 4, 'value' => 200.0, 'names' => ['Emil']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('platziert Recruiter erst ab zwei Neupartnern', function (): void {
|
||||
$anna = recMakeUser('Anna');
|
||||
$ben = recMakeUser('Ben');
|
||||
recMakeUser('Neu1', 'Partner', $anna->id, '2026-09-10');
|
||||
recMakeUser('Neu2', 'Partner', $anna->id, '2026-09-11');
|
||||
recMakeUser('Neu3', 'Partner', $ben->id, '2026-09-12');
|
||||
|
||||
$service = app(RecognitionService::class);
|
||||
|
||||
expect(recPlaces($service->recognitions(9, 2026)['categories']['recruiters']))
|
||||
->toBe([['place' => 1, 'value' => 2, 'names' => ['Anna']]]);
|
||||
});
|
||||
|
||||
it('zeigt Live-Ranglisten mit Wettkampf-Rang und hoechstens zehn Zeilen', function (): void {
|
||||
foreach (range(1, 12) as $index) {
|
||||
recAddVolume(recMakeUser('User'.str_pad((string) $index, 2, '0', STR_PAD_LEFT))->id, $index === 2 ? 1200 : 100 * $index, 1, 10);
|
||||
}
|
||||
|
||||
$rows = app(RecognitionService::class)->liveRankings(10, 2026)['categories']['total_points'];
|
||||
|
||||
expect($rows)->toHaveCount(10)
|
||||
->and(array_column(array_slice($rows, 0, 3), 'rank'))->toBe([1, 1, 3])
|
||||
->and(array_column(array_slice($rows, 0, 3), 'first_name'))->toBe(['User02', 'User12', 'User11'])
|
||||
->and($rows[0])->toHaveKey('profile_image_url');
|
||||
});
|
||||
|
||||
it('liefert vor dem Stichtag den vorlaeufigen Stand und danach den eingefrorenen', function (): void {
|
||||
$anna = recMakeUser('Anna');
|
||||
recAddVolume($anna->id, 300, 1);
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-02 12:00:00'));
|
||||
$service = app(RecognitionService::class);
|
||||
$preliminary = $service->recognitions(9, 2026);
|
||||
|
||||
expect($preliminary['is_final'])->toBeFalse()
|
||||
->and($preliminary['freeze_date']->toDateString())->toBe('2026-10-03');
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-03 06:15:00'));
|
||||
$this->artisan('recognitions:freeze', ['--month' => 9, '--year' => 2026])->assertSuccessful();
|
||||
|
||||
// Spaetere Buchungen veraendern den festgeschriebenen Monat nicht mehr.
|
||||
recAddVolume(recMakeUser('Spaet')->id, 9999, 1);
|
||||
Cache::flush();
|
||||
|
||||
$final = $service->recognitions(9, 2026);
|
||||
|
||||
expect($final['is_final'])->toBeTrue()
|
||||
->and($final['frozen_at']->toDateString())->toBe('2026-10-03')
|
||||
->and(recPlaces($final['categories']['total_points']))->toEqual([['place' => 1, 'value' => 300, 'names' => ['Anna']]]);
|
||||
});
|
||||
|
||||
it('friert per Command nur festschreibbare Monate ein und ueberschreibt nichts ohne --force', function (): void {
|
||||
$anna = recMakeUser('Anna');
|
||||
recAddVolume($anna->id, 300, 1);
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-02 06:15:00'));
|
||||
$this->artisan('recognitions:freeze')->assertSuccessful();
|
||||
|
||||
expect(RecognitionSnapshot::query()->where('year', 2026)->where('month', 9)->exists())->toBeFalse()
|
||||
->and(RecognitionSnapshot::query()->where('year', 2026)->where('month', 8)->exists())->toBeTrue()
|
||||
->and(RecognitionSnapshot::query()->count())->toBe(8);
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-03 06:15:00'));
|
||||
$this->artisan('recognitions:freeze')->assertSuccessful();
|
||||
$frozenAt = RecognitionSnapshot::query()->where('month', 9)->value('frozen_at');
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-10-04 06:15:00'));
|
||||
$this->artisan('recognitions:freeze')->assertSuccessful();
|
||||
|
||||
expect(RecognitionSnapshot::query()->count())->toBe(9)
|
||||
->and(RecognitionSnapshot::query()->where('month', 9)->value('frozen_at'))->toEqual($frozenAt);
|
||||
|
||||
$this->artisan('recognitions:freeze', ['--month' => 9, '--year' => 2026, '--force' => true])->assertSuccessful();
|
||||
|
||||
expect(RecognitionSnapshot::query()->where('month', 9)->first()->frozen_at->toDateString())->toBe('2026-10-04');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue