Kundenhoheit: E-Mail-Aenderung mit Bestaetigung, Zuordnungsfehler behoben, Admin-Uebersicht
- E-Mail-Aenderung durch Berater wird erst nach Bestaetigung durch den Kunden aktiv (Link an die neue Adresse, Portal kunde/email-bestaetigen); danach ziehen alle Eintraege des Kunden und seine Abos mit. Admins aendern direkt. - Kundenhoheit: E-Mail eines Beraterkontos oder eines Kunden eines anderen Beraters wird abgelehnt (beim Speichern und bei der Bestaetigung). - Fix CustomerPriority::entryExists: aelteste gepruefte Zuordnung gilt, Eintraege in Pruefung (is_like) dienen nicht mehr als Vorlage. - "Berater zuweisen" voreingestellt auf alle Bestellungen des Kunden. - Neue Admin-Seite Kundenhoheit: offene Pruefungen, Kunden bei mehreren Beratern, abgelehnte/offene E-Mail-Aenderungen; Zaehler im Menue. - Neue Tabelle shopping_user_email_changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ae59679150
commit
9ad69a3218
32 changed files with 2246 additions and 119 deletions
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();
|
||||
|
||||
|
|
|
|||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -326,6 +326,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
|
||||
|
|
@ -344,6 +344,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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue