Kunden loeschen: DSGVO-geloeschte Kunden ausblenden und sperren; Checkout-Herkunft freiwillig
Kunden loeschen (Rueckmeldung "es wird nichts geloescht"): - DSGVO-geloeschte Eintraege blieben mit Name in den Listen stehen; Kunde 61009 wurde deshalb achtmal geloescht, bei Eintrag 45013 wurde die E-Mail danach ueber ein offenes Bearbeiten-Formular zurueckgeschrieben - neue Spalte shopping_users.gdpr_deleted_at (Bestand per Migration markiert) - Admin-Kundenliste blendet geloeschte Kunden aus (Filter: ausblenden / mit anzeigen / nur geloeschte), Berater-Kundenliste immer - Bearbeiten/Speichern im Admin und beim Berater gesperrt; ShoppingUser verweigert das Zurueckschreiben von E-Mail und Telefon - Modal und Erfolgsmeldung (de/en/es/fr) sagen klar, dass Name und Rechnungsadresse wegen der Aufbewahrungspflicht bleiben - gdpr:repair-customers [--execute] zieht protokollierte Loeschungen nach - DSGVO-Loeschung prueft Superadmin serverseitig; Berater duerfen eine Kunden-E-Mail nur beim eigenen Kunden nachtragen - setFakerMail setzt faker_mail bei geloeschten Eintraegen nicht zurueck Checkout: "Wie bist du auf uns aufmerksam geworden?" ist kein Pflichtfeld mehr und wird farbig hervorgehoben. Deploy: php artisan migrate && php artisan gdpr:repair-customers --execute Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
083d643220
commit
3d0864713b
19 changed files with 635 additions and 90 deletions
50
app/Console/Commands/GdprRepairCustomers.php
Normal file
50
app/Console/Commands/GdprRepairCustomers.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\GdprDeletionService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GdprRepairCustomers extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'gdpr:repair-customers
|
||||
{--execute : Änderungen wirklich ausführen (ohne Option nur Vorschau)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Zieht protokollierte DSGVO-Löschungen von Endkunden nach: Einträge, deren Kontaktdaten nach der Löschung wieder befüllt wurden, werden erneut anonymisiert und gesperrt.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(GdprDeletionService $gdprDeletionService): int
|
||||
{
|
||||
$execute = (bool) $this->option('execute');
|
||||
$rows = $gdprDeletionService->repairLoggedShoppingUserDeletions(! $execute);
|
||||
|
||||
if ($rows === []) {
|
||||
$this->info('Keine Einträge zu reparieren.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->table(['ShoppingUser-ID', 'Kd-Nr.', 'Löschung protokolliert am'], array_map(
|
||||
fn (array $row) => [$row['id'], $row['number'] ?: '-', $row['deletion_logged_at']],
|
||||
$rows
|
||||
));
|
||||
|
||||
$this->info($execute
|
||||
? count($rows).' Eintrag/Einträge erneut anonymisiert und gesperrt.'
|
||||
: count($rows).' Eintrag/Einträge betroffen. Vorschau – mit --execute ausführen.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ use App\Services\GdprDeletionService;
|
|||
use App\Services\HTMLHelper;
|
||||
use App\Services\Shop;
|
||||
use Auth;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Request;
|
||||
use Validator;
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ class CustomerController extends Controller
|
|||
if (Request::get('reset') === 'filter') {
|
||||
set_user_attr('filter_member_id', null);
|
||||
set_user_attr('filter_customer_member', null);
|
||||
set_user_attr('filter_gdpr_deleted', null);
|
||||
|
||||
return redirect(route('admin_customers'));
|
||||
}
|
||||
|
|
@ -82,6 +84,10 @@ class CustomerController extends Controller
|
|||
{
|
||||
$shopping_user = ShoppingUser::withTrashed()->findOrFail($id);
|
||||
|
||||
if (! Auth::user()->isSuperAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! Request::get('gdpr_delete_customer')) {
|
||||
\Session()->flash('alert-error', __('msg.gdpr_confirm_checkbox_missing'));
|
||||
|
||||
|
|
@ -89,8 +95,8 @@ class CustomerController extends Controller
|
|||
}
|
||||
|
||||
try {
|
||||
app(GdprDeletionService::class)->deleteShoppingUser($shopping_user, Auth::id());
|
||||
\Session()->flash('alert-success', __('msg.gdpr_delete_customer_success'));
|
||||
$summary = app(GdprDeletionService::class)->deleteShoppingUser($shopping_user, Auth::id());
|
||||
\Session()->flash('alert-success', __('msg.gdpr_delete_customer_success', ['count' => $summary['shopping_users_anonymized']]));
|
||||
} catch (GdprDeletionException $e) {
|
||||
\Session()->flash('alert-error', $e->getMessage());
|
||||
|
||||
|
|
@ -107,6 +113,9 @@ class CustomerController extends Controller
|
|||
$shopping_user->id = 'new';
|
||||
} else {
|
||||
$shopping_user = ShoppingUser::findOrFail($id);
|
||||
if ($shopping_user->isGdprDeleted()) {
|
||||
return $this->redirectGdprLocked($shopping_user);
|
||||
}
|
||||
}
|
||||
$data = [
|
||||
'shopping_user' => $shopping_user,
|
||||
|
|
@ -168,6 +177,9 @@ class CustomerController extends Controller
|
|||
}
|
||||
|
||||
$shopping_user = ShoppingUser::findOrFail($id);
|
||||
if ($shopping_user->isGdprDeleted()) {
|
||||
return $this->redirectGdprLocked($shopping_user);
|
||||
}
|
||||
|
||||
$data['language'] = isset($data['language']) ? $data['language'] : \App::getLocale();
|
||||
$data['has_buyed'] = isset($data['has_buyed']) ? true : false;
|
||||
|
|
@ -196,6 +208,13 @@ class CustomerController extends Controller
|
|||
{
|
||||
$query = ShoppingUser::select('shopping_users.*')->where('auth_user_id', '=', null);
|
||||
|
||||
set_user_attr('filter_gdpr_deleted', Request::get('filter_gdpr_deleted'));
|
||||
if (Request::get('filter_gdpr_deleted') === 'only') {
|
||||
$query->whereNotNull('gdpr_deleted_at');
|
||||
} elseif (Request::get('filter_gdpr_deleted') !== 'all') {
|
||||
$query->withoutGdprDeleted();
|
||||
}
|
||||
|
||||
set_user_attr('filter_member_id', Request::get('filter_member_id'));
|
||||
if (Request::get('filter_member_id') != '') {
|
||||
$query->where('member_id', '=', Request::get('filter_member_id'));
|
||||
|
|
@ -224,7 +243,8 @@ class CustomerController extends Controller
|
|||
return $ShoppingUser->billing_country ? $ShoppingUser->billing_country->getLocated() : '';
|
||||
})
|
||||
->addColumn('isMember', function (ShoppingUser $ShoppingUser) {
|
||||
return get_active_badge($ShoppingUser->auth_user_id).($ShoppingUser->mode === 'dev' ? ' <span class="badge badge-warning">dev</span>' : '');
|
||||
return get_active_badge($ShoppingUser->auth_user_id).($ShoppingUser->mode === 'dev' ? ' <span class="badge badge-warning">dev</span>' : '')
|
||||
.($ShoppingUser->isGdprDeleted() ? ' <span class="badge badge-secondary">DSGVO-gelöscht</span>' : '');
|
||||
})
|
||||
->addColumn('member_id', function (ShoppingUser $ShoppingUser) {
|
||||
if ($ShoppingUser->is_like) {
|
||||
|
|
@ -263,4 +283,11 @@ class CustomerController extends Controller
|
|||
->rawColumns(['id', 'subscribed', 'isMember', 'member_id'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
protected function redirectGdprLocked(ShoppingUser $shopping_user): RedirectResponse
|
||||
{
|
||||
\Session()->flash('alert-error', __('msg.gdpr_customer_locked'));
|
||||
|
||||
return redirect(route('admin_customer_detail', [$shopping_user->id]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Services\HTMLHelper;
|
|||
use App\Services\Shop;
|
||||
use App\Services\ShoppingUserService;
|
||||
use App\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Request;
|
||||
use Validator;
|
||||
|
|
@ -61,6 +62,9 @@ class CustomerController extends Controller
|
|||
if ($shopping_user->member_id != \Auth::user()->id) {
|
||||
abort(404);
|
||||
}
|
||||
if ($shopping_user->isGdprDeleted()) {
|
||||
return $this->redirectGdprLocked($shopping_user);
|
||||
}
|
||||
$data = [
|
||||
'shopping_user' => $shopping_user,
|
||||
'isAdmin' => false,
|
||||
|
|
@ -81,6 +85,9 @@ class CustomerController extends Controller
|
|||
if ($shopping_user->member_id != \Auth::user()->id) {
|
||||
abort(404);
|
||||
}
|
||||
if ($shopping_user->isGdprDeleted()) {
|
||||
return $this->redirectGdprLocked($shopping_user);
|
||||
}
|
||||
}
|
||||
|
||||
$billing_email = null;
|
||||
|
|
@ -142,6 +149,12 @@ class CustomerController extends Controller
|
|||
|
||||
if ($action === 'save') {
|
||||
$shopping_user = ShoppingUser::findOrFail($id);
|
||||
if ($shopping_user->member_id != \Auth::user()->id) {
|
||||
abort(404);
|
||||
}
|
||||
if ($shopping_user->isGdprDeleted()) {
|
||||
return $this->redirectGdprLocked($shopping_user);
|
||||
}
|
||||
$shopping_user->faker_mail = false;
|
||||
$shopping_user->billing_email = Request::get($email);
|
||||
$shopping_user->save();
|
||||
|
|
@ -213,6 +226,9 @@ class CustomerController extends Controller
|
|||
if ($shopping_user->member_id != \Auth::user()->id) {
|
||||
abort(404);
|
||||
}
|
||||
if ($shopping_user->isGdprDeleted()) {
|
||||
return $this->redirectGdprLocked($shopping_user);
|
||||
}
|
||||
CustomerPriority::checkChangeOne($shopping_user, $data, true);
|
||||
$shopping_user->fill($data);
|
||||
$shopping_user->save();
|
||||
|
|
@ -261,7 +277,8 @@ class CustomerController extends Controller
|
|||
$query->select(DB::raw('MAX(id)'))
|
||||
->from('shopping_users')
|
||||
->groupBy('billing_email');
|
||||
})->where('shopping_users.member_id', '=', $user->id)->where('shopping_users.auth_user_id', '=', null);
|
||||
})->where('shopping_users.member_id', '=', $user->id)->where('shopping_users.auth_user_id', '=', null)
|
||||
->withoutGdprDeleted();
|
||||
|
||||
if (Request::get('isfor') === 'ot-member') { // Bestellung für Kunden
|
||||
}
|
||||
|
|
@ -327,4 +344,11 @@ class CustomerController extends Controller
|
|||
->rawColumns(['send_to', 'id', 'subscribed', 'extras', 'status'])
|
||||
->make(true);
|
||||
}
|
||||
|
||||
protected function redirectGdprLocked(ShoppingUser $shopping_user): RedirectResponse
|
||||
{
|
||||
\Session()->flash('alert-error', __('msg.gdpr_customer_locked'));
|
||||
|
||||
return redirect(route('user_customer_detail', [$shopping_user->id]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ class CheckoutController extends Controller
|
|||
];
|
||||
|
||||
if (Request::get('is_from') === 'shopping') {
|
||||
$rules['customer_order_source_comment'] = 'required|string|max:500';
|
||||
$rules['customer_order_source_comment'] = 'nullable|string|max:500';
|
||||
}
|
||||
|
||||
if (Request::get('same_as_billing')) {
|
||||
|
|
@ -287,9 +287,7 @@ class CheckoutController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
return Validator::make(Request::all(), $rules, [
|
||||
'customer_order_source_comment.required' => __('order.source_comment_required'),
|
||||
]);
|
||||
return Validator::make(Request::all(), $rules);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Exceptions\GdprDeletionException;
|
||||
use App\Services\ShoppingUserService;
|
||||
use App\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
|
@ -122,6 +124,7 @@ use Illuminate\Support\Carbon;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingUser whereMode($value)
|
||||
*
|
||||
* @property bool|null $faker_mail
|
||||
* @property Carbon|null $gdpr_deleted_at
|
||||
* @property string|null $shipping_email
|
||||
* @property string|null $is_for
|
||||
* @property string|null $is_from
|
||||
|
|
@ -215,8 +218,57 @@ class ShoppingUser extends Model
|
|||
'has_buyed' => 'bool',
|
||||
'subscribed' => 'bool',
|
||||
'wp_order_number' => 'int',
|
||||
'gdpr_deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Kontaktfelder, die nach einer DSGVO-Löschung nicht wieder befüllt werden dürfen.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const GDPR_LOCKED_CONTACT_FIELDS = ['billing_email', 'billing_phone', 'shipping_email', 'shipping_phone'];
|
||||
|
||||
/**
|
||||
* Schutz gegen das Zurückschreiben von Kontaktdaten in DSGVO-gelöschte Einträge,
|
||||
* z. B. durch ein noch offenes Bearbeiten-Formular (dev/_tickets/2026-10-06).
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(function (ShoppingUser $shoppingUser) {
|
||||
if ($shoppingUser->getOriginal('gdpr_deleted_at') === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (self::GDPR_LOCKED_CONTACT_FIELDS as $field) {
|
||||
if ($shoppingUser->isDirty($field) && ! self::isGdprPlaceholderValue($field, $shoppingUser->{$field})) {
|
||||
throw new GdprDeletionException(__('msg.gdpr_customer_locked'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected static function isGdprPlaceholderValue(string $field, mixed $value): bool
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $field === 'billing_email' && str_ends_with((string) $value, '@dsgvo.invalid');
|
||||
}
|
||||
|
||||
public function isGdprDeleted(): bool
|
||||
{
|
||||
return $this->gdpr_deleted_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<ShoppingUser> $query
|
||||
*/
|
||||
public function scopeWithoutGdprDeleted($query): void
|
||||
{
|
||||
$query->whereNull($query->qualifyColumn('gdpr_deleted_at'));
|
||||
}
|
||||
|
||||
// can null
|
||||
public function member()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Services;
|
|||
|
||||
use App\Exceptions\GdprDeletionException;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Logger;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
|
|
@ -13,6 +14,7 @@ use App\Models\UserShop;
|
|||
use App\Models\UserUpdateEmail;
|
||||
use App\Services\Payment\Stripe\StripeCustomerService;
|
||||
use App\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -220,7 +222,10 @@ class GdprDeletionService
|
|||
* orders_count: int,
|
||||
* orders_by_row: array<int, int>,
|
||||
* active_abos: int,
|
||||
* can_purge: bool
|
||||
* can_purge: bool,
|
||||
* gdpr_deleted_rows: int,
|
||||
* gdpr_deleted_at: Carbon|null,
|
||||
* all_gdpr_deleted: bool
|
||||
* }
|
||||
*/
|
||||
public function analyzeShoppingUser(ShoppingUser $shoppingUser): array
|
||||
|
|
@ -239,6 +244,7 @@ class GdprDeletionService
|
|||
$isMember = $rows->contains(fn (ShoppingUser $row) => (int) $row->auth_user_id > 0);
|
||||
$ordersCount = array_sum($ordersByRow);
|
||||
$activeAbos = UserAbo::whereIn('shopping_user_id', $rowIds)->where('active', true)->count();
|
||||
$gdprDeletedRows = $rows->filter(fn (ShoppingUser $row) => $row->isGdprDeleted());
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
|
|
@ -248,9 +254,76 @@ class GdprDeletionService
|
|||
'orders_by_row' => $ordersByRow,
|
||||
'active_abos' => $activeAbos,
|
||||
'can_purge' => ! $isMember && $ordersCount === 0 && $activeAbos === 0,
|
||||
'gdpr_deleted_rows' => $gdprDeletedRows->count(),
|
||||
'gdpr_deleted_at' => $gdprDeletedRows->max('gdpr_deleted_at'),
|
||||
'all_gdpr_deleted' => $rows->isNotEmpty() && $gdprDeletedRows->count() === $rows->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Protokollierte DSGVO-Löschungen von Endkunden nachziehen: Einträge, die vor
|
||||
* der Löschung angelegt waren und deren Kontaktdaten danach wieder befüllt
|
||||
* wurden (z. B. durch ein offenes Bearbeiten-Formular), werden erneut
|
||||
* anonymisiert und markiert. Später angelegte Einträge (neue Bestellung nach
|
||||
* der Löschung) bleiben unberührt.
|
||||
*
|
||||
* @return array<int, array{id: int, number: int|null, deletion_logged_at: string}>
|
||||
*/
|
||||
public function repairLoggedShoppingUserDeletions(bool $dryRun = true): array
|
||||
{
|
||||
$deletions = Logger::query()
|
||||
->whereIn('action', ['gdpr-delete-customer', 'gdpr-purge-customer'])
|
||||
->where('model', ShoppingUser::class)
|
||||
->selectRaw('model_id, MAX(created_at) as deleted_at')
|
||||
->groupBy('model_id')
|
||||
->get();
|
||||
|
||||
$repaired = [];
|
||||
foreach ($deletions as $deletion) {
|
||||
$target = ShoppingUser::withTrashed()->find($deletion->model_id);
|
||||
if (! $target || (int) $target->auth_user_id > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = ShoppingUser::withTrashed()
|
||||
->where(function ($query) use ($target) {
|
||||
$query->where('id', $target->id);
|
||||
if ($target->number > 0) {
|
||||
$query->orWhere('number', $target->number);
|
||||
}
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('auth_user_id')->orWhere('auth_user_id', 0);
|
||||
})
|
||||
->where('created_at', '<=', $deletion->deleted_at)
|
||||
->get()
|
||||
->filter(fn (ShoppingUser $row) => ! $row->isGdprDeleted() || ! str_ends_with((string) $row->billing_email, '@dsgvo.invalid'))
|
||||
->reject(fn (ShoppingUser $row) => isset($repaired[$row->id]));
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$repaired[$row->id] = [
|
||||
'id' => $row->id,
|
||||
'number' => $row->number,
|
||||
'deletion_logged_at' => (string) $deletion->deleted_at,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::transaction(fn () => $this->anonymizeShoppingUserRows($rows));
|
||||
$this->logDeletion('gdpr-repair-customer', $target->id, ShoppingUser::class, null, null, [
|
||||
'shopping_user_id' => $target->id,
|
||||
'shopping_users_anonymized' => $rows->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($repaired);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Datensätze eines Endkunden, inkl. soft-deleted: der Eintrag selbst,
|
||||
* alle Einträge mit derselben Kundennummer und alle Endkunden-Einträge mit
|
||||
|
|
@ -494,6 +567,7 @@ class GdprDeletionService
|
|||
$row->notice = [];
|
||||
$row->subscribed = false;
|
||||
$row->faker_mail = true;
|
||||
$row->gdpr_deleted_at ??= now();
|
||||
$row->save();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,78 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\User;
|
||||
use Yard;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShoppingUserService
|
||||
{
|
||||
|
||||
/**
|
||||
* Gibt alle Bestellungen eines Mitglieds zurück
|
||||
*
|
||||
* @param ShoppingUser $shopping_user Das Mitglied, dessen Bestellungen zurückgegeben werden sollen
|
||||
*
|
||||
* @param ShoppingUser $shopping_user Das Mitglied, dessen Bestellungen zurückgegeben werden sollen
|
||||
* @return Collection Die Bestellungen des Mitglieds
|
||||
*/
|
||||
public static function getAllOrdersByMember($shopping_user){
|
||||
public static function getAllOrdersByMember($shopping_user)
|
||||
{
|
||||
$users = ShoppingUser::where('billing_email', '=', $shopping_user->billing_email)->where('member_id', '=', $shopping_user->member_id)->get();
|
||||
return $users->flatMap(function($user) {
|
||||
|
||||
return $users->flatMap(function ($user) {
|
||||
return $user->shopping_orders;
|
||||
})->sortByDesc('created_at');
|
||||
})->sortByDesc('created_at');
|
||||
}
|
||||
|
||||
public static function syncOrdersByEmail($user)
|
||||
{
|
||||
$maxAttempts = 3;
|
||||
$attempt = 1;
|
||||
|
||||
|
||||
while ($attempt <= $maxAttempts) {
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
|
||||
$shopping_user = ShoppingUser::where('member_id', $user->id)
|
||||
->first();
|
||||
|
||||
if (!$shopping_user) {
|
||||
if (! $shopping_user) {
|
||||
DB::commit();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$shopping_users = ShoppingUser::where('billing_email', '=', $shopping_user->billing_email)
|
||||
->whereHas('shopping_order', function($q) {
|
||||
->whereHas('shopping_order', function ($q) {
|
||||
$q->where('txaction', 'paid')
|
||||
->orWhere('txaction', 'appointed')
|
||||
->orWhere('txaction', 'extern')
|
||||
->orWhere('txaction', 'invoice_open')
|
||||
->orWhere('txaction', 'invoice_paid');
|
||||
->orWhere('txaction', 'appointed')
|
||||
->orWhere('txaction', 'extern')
|
||||
->orWhere('txaction', 'invoice_open')
|
||||
->orWhere('txaction', 'invoice_paid');
|
||||
})
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
$order_count = $shopping_users->count();
|
||||
|
||||
foreach($shopping_users as $user) {
|
||||
foreach ($shopping_users as $user) {
|
||||
$user->orders = $order_count;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return; // Erfolgreicher Fall
|
||||
|
||||
} catch (QueryException $e) {
|
||||
DB::rollBack();
|
||||
|
||||
|
||||
// Wenn es ein Deadlock ist, versuchen wir es erneut
|
||||
if ($e->getCode() == 40001 && $attempt < $maxAttempts) {
|
||||
$sleepTime = pow(2, $attempt) * 100000; // Exponentielles Backoff in Mikrosekunden
|
||||
usleep($sleepTime);
|
||||
$attempt++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
throw $e; // Andere Fehler oder zu viele Versuche
|
||||
}
|
||||
}
|
||||
|
|
@ -91,52 +95,55 @@ class ShoppingUserService
|
|||
}*/
|
||||
/**
|
||||
* Synchronisiert die Bestellungen eines ShoppingOrders mit den Bestellungen des zugehörigen ShoppingUsers
|
||||
*
|
||||
* @param ShoppingOrder $shopping_order Der zu synchronisierende ShoppingOrder
|
||||
*/
|
||||
public static function snycOrdersByShoppingOrder(ShoppingOrder $shopping_order) {
|
||||
*
|
||||
* @param ShoppingOrder $shopping_order Der zu synchronisierende ShoppingOrder
|
||||
*/
|
||||
public static function snycOrdersByShoppingOrder(ShoppingOrder $shopping_order)
|
||||
{
|
||||
$shopping_user = $shopping_order->shopping_user ? $shopping_order->shopping_user : null;
|
||||
if($shopping_user){
|
||||
if ($shopping_user) {
|
||||
self::snycOrdersByShoppingUser($shopping_user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert die Bestellungen eines ShoppingUsers mit den Bestellungen des zugehörigen Benutzers
|
||||
*
|
||||
* @param ShoppingUser $shopping_user Der zu synchronisierende ShoppingUser
|
||||
*/
|
||||
public static function snycOrdersByShoppingUser(ShoppingUser $shopping_user) {
|
||||
*
|
||||
* @param ShoppingUser $shopping_user Der zu synchronisierende ShoppingUser
|
||||
*/
|
||||
public static function snycOrdersByShoppingUser(ShoppingUser $shopping_user)
|
||||
{
|
||||
|
||||
// Aktualisiere alle Benutzer mit einer einzigen Datenbankabfrage
|
||||
ShoppingUser::where('billing_email', $shopping_user->billing_email)
|
||||
->update([
|
||||
'orders' => ShoppingUser::where('billing_email', $shopping_user->billing_email)
|
||||
->whereHas('shopping_order', function($q) {
|
||||
$q->whereIn('txaction', ['paid', 'appointed', 'extern', 'invoice_open', 'invoice_paid']);
|
||||
})
|
||||
->count()
|
||||
]);
|
||||
->update([
|
||||
'orders' => ShoppingUser::where('billing_email', $shopping_user->billing_email)
|
||||
->whereHas('shopping_order', function ($q) {
|
||||
$q->whereIn('txaction', ['paid', 'appointed', 'extern', 'invoice_open', 'invoice_paid']);
|
||||
})
|
||||
->count(),
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
public static function snycOrdersByNumber($number){
|
||||
if($number <= 0) {
|
||||
|
||||
public static function snycOrdersByNumber($number)
|
||||
{
|
||||
if ($number <= 0) {
|
||||
return;
|
||||
}
|
||||
// Aktualisiere alle Benutzer mit einer einzigen Datenbankabfrage
|
||||
|
||||
ShoppingUser::where('number', '=', $number)
|
||||
->update([
|
||||
'orders' => ShoppingUser::where('number', '=', $number)
|
||||
->whereHas('shopping_order', function($q) {
|
||||
$q->whereIn('txaction', ['paid', 'appointed', 'extern', 'invoice_open', 'invoice_paid']);
|
||||
})
|
||||
->count()
|
||||
]);
|
||||
->update([
|
||||
'orders' => ShoppingUser::where('number', '=', $number)
|
||||
->whereHas('shopping_order', function ($q) {
|
||||
$q->whereIn('txaction', ['paid', 'appointed', 'extern', 'invoice_open', 'invoice_paid']);
|
||||
})
|
||||
->count(),
|
||||
]);
|
||||
// Setze orders auf NULL für alle anderen
|
||||
ShoppingUser::where('number', '=', $number)
|
||||
->whereDoesntHave('shopping_order', function($q) {
|
||||
->whereDoesntHave('shopping_order', function ($q) {
|
||||
$q->whereIn('txaction', ['paid', 'appointed', 'extern', 'extern_paid']);
|
||||
})
|
||||
->update(['orders' => null]);
|
||||
|
|
@ -144,22 +151,23 @@ class ShoppingUserService
|
|||
|
||||
/**
|
||||
* Synchronisiert die Nummern aller ShoppingUser eines Benutzers anhand der E-Mails
|
||||
*
|
||||
* @param User $user Der Benutzer, dessen ShoppingUser synchronisiert werden sollen
|
||||
*/
|
||||
public static function syncNumbersByEmail($user) {
|
||||
*
|
||||
* @param User $user Der Benutzer, dessen ShoppingUser synchronisiert werden sollen
|
||||
*/
|
||||
public static function syncNumbersByEmail($user)
|
||||
{
|
||||
// Hole alle ShoppingUser mit E-Mail gruppiert
|
||||
$emailGroups = ShoppingUser::where('member_id', $user->id)
|
||||
->whereNotNull('billing_email')
|
||||
->where('billing_email', '!=', '')
|
||||
->get()
|
||||
->groupBy('billing_email');
|
||||
|
||||
foreach($emailGroups as $email => $users) {
|
||||
|
||||
foreach ($emailGroups as $email => $users) {
|
||||
// Suche ob es bereits eine Nummer für diese E-Mail gibt
|
||||
$existingNumber = ($users->whereNotNull('number')->first()) ? $users->whereNotNull('number')->first()->number : null;
|
||||
|
||||
if($existingNumber) {
|
||||
|
||||
if ($existingNumber) {
|
||||
// Wenn eine Nummer existiert, nutze diese für alle User mit der E-Mail
|
||||
ShoppingUser::where('member_id', $user->id)
|
||||
->where('billing_email', $email)
|
||||
|
|
@ -169,20 +177,21 @@ class ShoppingUserService
|
|||
// Wenn keine Nummer existiert, generiere eine neue
|
||||
$maxNumber = ShoppingUser::max('number') ?: 1000;
|
||||
$newNumber = $maxNumber + 1;
|
||||
|
||||
|
||||
ShoppingUser::where('member_id', $user->id)
|
||||
->where('billing_email', $email)
|
||||
->update(['number' => $newNumber]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Setzt alle Faker-E-Mails für einen Benutzer auf 1
|
||||
*
|
||||
* @param User $user Der Benutzer, dessen Faker-E-Mails gesetzt werden sollen
|
||||
*/
|
||||
public static function setFakerMail($user){
|
||||
*
|
||||
* @param User $user Der Benutzer, dessen Faker-E-Mails gesetzt werden sollen
|
||||
*/
|
||||
public static function setFakerMail($user)
|
||||
{
|
||||
// Aktualisiere alle Faker-E-Mails für den Benutzer in einem einzigen Update
|
||||
// Schneller da nur ein einzelner SQL Query ausgeführt wird
|
||||
ShoppingUser::where('member_id', $user->id)
|
||||
|
|
@ -193,6 +202,7 @@ class ShoppingUserService
|
|||
// Setze alle anderen auf NULL zurück
|
||||
ShoppingUser::where('member_id', $user->id)
|
||||
->where('billing_email', 'NOT LIKE', '%faker@mivita.care')
|
||||
->whereNull('gdpr_deleted_at')
|
||||
->update(['faker_mail' => null]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Markierung für DSGVO-gelöschte Kundeneinträge: Einträge mit Bestellung bleiben
|
||||
* wegen der Aufbewahrungspflicht bestehen, werden aber ausgeblendet und gegen
|
||||
* erneutes Bearbeiten gesperrt.
|
||||
*
|
||||
* Bestand: Alle bereits anonymisierten Einträge (Platzhalter-E-Mail) werden mit
|
||||
* ihrem letzten Änderungsdatum markiert.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('shopping_users', function (Blueprint $table) {
|
||||
$table->timestamp('gdpr_deleted_at')->nullable()->index()->after('deleted_at');
|
||||
});
|
||||
|
||||
DB::table('shopping_users')
|
||||
->where('billing_email', 'like', 'geloescht\_%@dsgvo.invalid')
|
||||
->whereNull('gdpr_deleted_at')
|
||||
->update(['gdpr_deleted_at' => DB::raw('COALESCE(updated_at, CURRENT_TIMESTAMP)')]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('shopping_users', function (Blueprint $table) {
|
||||
$table->dropIndex(['gdpr_deleted_at']);
|
||||
$table->dropColumn('gdpr_deleted_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -42,7 +42,8 @@ return [
|
|||
'cart_product_not_allowed_for_order_type' => 'Der Warenkorb enthält Artikel, die für diese Bestellart nicht vorgesehen sind. Bitte leere den Warenkorb und wähle nur Produkte, die zu dieser Bestellung passen.',
|
||||
'cart_max_weight_reached' => 'Das maximale Versandgewicht ist erreicht. Es können keine weiteren Produkte hinzugefügt werden.',
|
||||
'gdpr_delete_success' => 'DSGVO-Löschung ausgeführt: Alle personenbezogenen Daten wurden unwiderruflich anonymisiert.',
|
||||
'gdpr_delete_customer_success' => 'DSGVO-Löschung ausgeführt: Alle personenbezogenen Daten des Kunden wurden unwiderruflich anonymisiert.',
|
||||
'gdpr_delete_customer_success' => 'DSGVO-Löschung ausgeführt: :count Kundeneintrag/-einträge bearbeitet. Kontaktdaten und Portal-Logins sind entfernt; Name und Rechnungsadresse bleiben wegen der Aufbewahrungspflicht an den Bestellungen. Die Einträge sind in der Kundenliste ausgeblendet und gesperrt.',
|
||||
'gdpr_customer_locked' => 'Dieser Kunde wurde DSGVO-gelöscht und kann nicht mehr bearbeitet werden.',
|
||||
'gdpr_delete_customer_button' => 'Kunde DSGVO-löschen',
|
||||
'gdpr_confirm_checkbox_missing' => 'Bitte bestätige die DSGVO-Löschung über die Checkbox.',
|
||||
'gdpr_error_is_admin' => 'DSGVO-Löschung nicht möglich: Der Account hat Admin-Rechte. Bitte entziehe zuerst die Admin-Rechte.',
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ return [
|
|||
'cart_product_not_allowed_for_order_type' => 'Your cart contains items that are not allowed for this order type. Please clear the cart and only add products that match this order.',
|
||||
'cart_max_weight_reached' => 'The maximum shipping weight has been reached. No further products can be added.',
|
||||
'gdpr_delete_success' => 'GDPR deletion executed: All personal data has been irreversibly anonymized.',
|
||||
'gdpr_delete_customer_success' => 'GDPR deletion executed: All personal data of the customer has been irreversibly anonymized.',
|
||||
'gdpr_delete_customer_success' => 'GDPR deletion executed: :count customer record(s) processed. Contact data and portal logins have been removed; name and billing address are kept on the orders due to the statutory retention obligation. The records are hidden in the customer list and locked.',
|
||||
'gdpr_customer_locked' => 'This customer has been deleted under GDPR and can no longer be edited.',
|
||||
'gdpr_delete_customer_button' => 'GDPR-delete customer',
|
||||
'gdpr_confirm_checkbox_missing' => 'Please confirm the GDPR deletion via the checkbox.',
|
||||
'gdpr_error_is_admin' => 'GDPR deletion not possible: The account has admin rights. Please remove the admin rights first.',
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ return [
|
|||
],
|
||||
'cart_product_not_allowed_for_order_type' => 'El carrito contiene artículos que no están previstos para este tipo de pedido. Vacía el carrito y elige solo productos que correspondan a este pedido.',
|
||||
'gdpr_delete_success' => 'Eliminación RGPD ejecutada: Todos los datos personales han sido anonimizados de forma irreversible.',
|
||||
'gdpr_delete_customer_success' => 'Eliminación RGPD ejecutada: Todos los datos personales del cliente han sido anonimizados de forma irreversible.',
|
||||
'gdpr_delete_customer_success' => 'Eliminación RGPD ejecutada: :count registro(s) de cliente procesado(s). Se han eliminado los datos de contacto y los accesos al portal; el nombre y la dirección de facturación se conservan en los pedidos por la obligación legal de conservación. Los registros están ocultos en la lista de clientes y bloqueados.',
|
||||
'gdpr_customer_locked' => 'Este cliente ha sido eliminado según el RGPD y ya no se puede editar.',
|
||||
'gdpr_delete_customer_button' => 'Eliminar cliente (RGPD)',
|
||||
'gdpr_confirm_checkbox_missing' => 'Por favor confirma la eliminación RGPD mediante la casilla de verificación.',
|
||||
'gdpr_error_is_admin' => 'Eliminación RGPD no posible: La cuenta tiene derechos de administrador. Por favor retira primero los derechos de administrador.',
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ return [
|
|||
'cart_product_not_allowed_for_order_type' => 'Le panier contient des articles qui ne sont pas prévus pour ce type de commande. Veuillez vider le panier et sélectionner uniquement les produits adaptés à cette commande.',
|
||||
'cart_max_weight_reached' => "Le poids d'expédition maximal est atteint. Aucun autre produit ne peut être ajouté.",
|
||||
'gdpr_delete_success' => 'Suppression RGPD exécutée : Toutes les données personnelles ont été anonymisées de manière irréversible.',
|
||||
'gdpr_delete_customer_success' => 'Suppression RGPD exécutée : Toutes les données personnelles du client ont été anonymisées de manière irréversible.',
|
||||
'gdpr_delete_customer_success' => 'Suppression RGPD exécutée : :count fiche(s) client traitée(s). Les coordonnées et les accès au portail ont été supprimés ; le nom et l’adresse de facturation sont conservés sur les commandes en raison de l’obligation légale de conservation. Les fiches sont masquées dans la liste des clients et verrouillées.',
|
||||
'gdpr_customer_locked' => 'Ce client a été supprimé conformément au RGPD et ne peut plus être modifié.',
|
||||
'gdpr_delete_customer_button' => 'Supprimer le client (RGPD)',
|
||||
'gdpr_confirm_checkbox_missing' => 'Veuillez confirmer la suppression RGPD via la case à cocher.',
|
||||
'gdpr_error_is_admin' => "Suppression RGPD impossible : Le compte dispose de droits d'administrateur. Veuillez d'abord retirer les droits d'administrateur.",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
<div class="card">
|
||||
<!-- Info -->
|
||||
<div class="card-body pb-1">
|
||||
@if($shopping_user->isGdprDeleted())
|
||||
<div class="alert alert-secondary">
|
||||
<b>DSGVO-gelöscht am {{ $shopping_user->gdpr_deleted_at->format('d.m.Y H:i') }}.</b>
|
||||
Kontaktdaten wurden entfernt. Name und Rechnungsadresse bleiben wegen der gesetzlichen
|
||||
Aufbewahrungspflicht an den Bestellungen erhalten. Der Eintrag kann nicht mehr bearbeitet werden.
|
||||
</div>
|
||||
@endif
|
||||
@if($isAdmin)
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
|
|
@ -20,7 +27,9 @@
|
|||
{{$shopping_user->created_at->format("d.m.Y H:i")}}
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
@if(!$shopping_user->isGdprDeleted())
|
||||
<a class="btn btn-sm btn-secondary mt-2" href="{{route('admin_customer_edit', [$shopping_user->id])}}">{{ __('customer.edit_customer_data') }}</a>
|
||||
@endif
|
||||
<button type="button" class="btn btn-sm btn-danger mt-2" data-toggle="modal" data-target="#modals-customer-delete">{{ __('customer.delete_customer') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -44,7 +53,7 @@
|
|||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
|
||||
@if(!$shopping_user->is_like)
|
||||
@if(!$shopping_user->is_like && !$shopping_user->isGdprDeleted())
|
||||
<a class="btn btn-sm btn-secondary mt-2" href="{{route('user_customer_edit', [$shopping_user->id])}}">{{ __('customer.edit_customer_data') }}</a>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@
|
|||
<td>{{ $row->id }}</td>
|
||||
<td>{{ $row->number ?: '-' }}</td>
|
||||
<td>{{ trim($row->billing_firstname.' '.$row->billing_lastname) }}
|
||||
@if ($row->deleted_at) <span class="badge badge-secondary">gelöscht</span> @endif
|
||||
@if ($row->deleted_at) <span class="badge badge-secondary">gelöscht</span>
|
||||
@elseif ($row->isGdprDeleted()) <span class="badge badge-secondary">DSGVO-gelöscht</span> @endif
|
||||
</td>
|
||||
<td>{{ $row->member->email ?? '-' }}</td>
|
||||
<td>{{ $deletion['orders_by_row'][$row->id] ?? 0 }}</td>
|
||||
|
|
@ -60,12 +61,24 @@
|
|||
</table>
|
||||
</div>
|
||||
|
||||
@if ($deletion['orders_count'] > 0)
|
||||
@if ($deletion['all_gdpr_deleted'])
|
||||
<div class="alert alert-success mb-0">
|
||||
Der Kunde wurde bereits am <b>{{ $deletion['gdpr_deleted_at']->format('d.m.Y H:i') }}</b> DSGVO-gelöscht.
|
||||
Alle Kontaktdaten (E-Mail, Telefon, Notizen, Newsletter) und Portal-Logins sind entfernt. Name und
|
||||
Rechnungsadresse bleiben wegen der gesetzlichen Aufbewahrungspflicht an den Bestellungen erhalten.
|
||||
Die Einträge sind in der Kundenliste ausgeblendet (Filter „DSGVO-gelöschte Kunden“) und gegen
|
||||
Bearbeiten gesperrt.
|
||||
</div>
|
||||
@elseif ($deletion['orders_count'] > 0)
|
||||
<div class="alert alert-warning">
|
||||
Der Kunde hat schon <b>{{ $deletion['orders_count'] }} Bestellung(en)</b>. Es ist nur <b>DSGVO löschen</b> möglich:
|
||||
Alle personenbezogenen Daten werden unwiderruflich anonymisiert. Bei Einträgen mit Bestellung bleiben Name und
|
||||
Rechnungsadresse wegen der gesetzlichen Aufbewahrungspflichten erhalten; alle Kontaktdaten (E-Mail, Telefon,
|
||||
Notizen, Newsletter) sowie die Portal-Logins werden entfernt.
|
||||
Alle Kontaktdaten (E-Mail, Telefon, Notizen, Newsletter) sowie die Portal-Logins werden unwiderruflich entfernt.
|
||||
<b>Name und Rechnungsadresse bleiben</b> bei Einträgen mit Bestellung wegen der gesetzlichen
|
||||
Aufbewahrungspflicht erhalten. Die Einträge werden danach in der Kundenliste ausgeblendet und gegen
|
||||
Bearbeiten gesperrt.
|
||||
@if ($deletion['gdpr_deleted_rows'] > 0)
|
||||
<br>{{ $deletion['gdpr_deleted_rows'] }} von {{ $deletion['rows']->count() }} Einträgen sind bereits gelöscht.
|
||||
@endif
|
||||
@if ($deletion['active_abos'] > 0)
|
||||
<br><b>{{ __('msg.gdpr_error_active_abo') }}</b>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="form-row align-items-center">
|
||||
<div class="col-sm-10 mb-2">
|
||||
<div class="col-sm-7 mb-2">
|
||||
<label class="form-label" for="filter_user_shop_id">Filter zugewiesener Berater</label>
|
||||
<select class="selectpicker" data-style="btn-default" name="filter_member_id" id="filter_member_id" data-live-search="true">
|
||||
<option value="">Filter aus</option>
|
||||
|
|
@ -19,6 +19,14 @@
|
|||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-3 mb-2">
|
||||
<label class="form-label" for="filter_gdpr_deleted">DSGVO-gelöschte Kunden</label>
|
||||
<select class="custom-select" name="filter_gdpr_deleted" id="filter_gdpr_deleted">
|
||||
<option value="">ausblenden</option>
|
||||
<option value="all" @if(get_user_attr('filter_gdpr_deleted') === 'all') selected @endif>mit anzeigen</option>
|
||||
<option value="only" @if(get_user_attr('filter_gdpr_deleted') === 'only') selected @endif>nur gelöschte</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-2 col-sm-2 mb-2 mt-4">
|
||||
<a href="{{ route('admin_customers') }}?reset=filter" data-toggle="tooltip" data-placement="top" title="Reset Filter" class="btn icon-btn btn-sm btn-outline-dark float-right">
|
||||
<span class="fa fa-sync"></span>
|
||||
|
|
@ -73,6 +81,7 @@
|
|||
data: function(d) {
|
||||
d.filter_member_id = $('select[name=filter_member_id]').val();
|
||||
d.filter_customer_member = $('select[name=filter_customer_member]').val();
|
||||
d.filter_gdpr_deleted = $('select[name=filter_gdpr_deleted]').val();
|
||||
}
|
||||
},
|
||||
"order": [[0, "desc" ]],
|
||||
|
|
@ -105,6 +114,9 @@
|
|||
$('#filter_customer_member').on('change', function(){
|
||||
oTable.draw();
|
||||
});
|
||||
$('#filter_gdpr_deleted').on('change', function(){
|
||||
oTable.draw();
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -111,6 +111,19 @@
|
|||
font-size: 0.8em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.order-source-highlight {
|
||||
background: #f3f7f0;
|
||||
border: 1px solid #97b085;
|
||||
border-left: 4px solid #97b085;
|
||||
border-radius: 4px;
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.order-source-highlight label {
|
||||
font-weight: 600;
|
||||
color: #5f7a4e;
|
||||
}
|
||||
select.form-control:not([size]):not([multiple]) {
|
||||
height: 38px;
|
||||
color: #6f6f6f;
|
||||
|
|
@ -554,9 +567,10 @@
|
|||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12 col-sm-12">
|
||||
<div class="form-group {{($errors->has('customer_order_source_comment') ? 'error' : '')}}">
|
||||
<label class="mt-0 fs-14 fw-400" for="customer_order_source_comment">Wie bist du auf uns aufmerksam geworden? *</label>
|
||||
{!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500, 'required' => 'required', 'placeholder' => 'Bitte konkret: z. B. Name der Person, die dich empfohlen hat, oder die genaue Messe/Veranstaltung']) !!}
|
||||
<div class="form-group order-source-highlight {{($errors->has('customer_order_source_comment') ? 'error' : '')}}">
|
||||
<label class="mt-0 fs-14" for="customer_order_source_comment"><i class="fa fa-heart"></i> Wie bist du auf uns aufmerksam geworden?</label>
|
||||
<span class="info-small mb-2" style="display: block;">Freiwillig – hilft uns aber sehr, dich der richtigen Person zuzuordnen.</span>
|
||||
{!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500, 'placeholder' => 'z. B. Name der Person, die dich empfohlen hat, oder die genaue Messe/Veranstaltung']) !!}
|
||||
@if ($errors->has('customer_order_source_comment'))
|
||||
<label for="customer_order_source_comment" class="error text-danger small" style="display: block;">{{ $errors->first('customer_order_source_comment') }}</label>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -387,12 +387,12 @@ it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () {
|
|||
$checkoutRepository = file_get_contents(app_path('Repositories/CheckoutRepository.php'));
|
||||
$orderDetail = file_get_contents(resource_path('views/admin/sales/_detail.blade.php'));
|
||||
|
||||
// Kundenentscheid F6: nur noch Freitext-Pflichtfeld, kein Dropdown mehr
|
||||
// Kundenentscheid F6: nur noch Freitext, kein Dropdown mehr; seit 10/2026 freiwillig
|
||||
expect($checkoutView)->toContain('customer_order_source_comment');
|
||||
expect($checkoutView)->toContain('Wie bist du auf uns aufmerksam geworden?');
|
||||
expect($checkoutView)->not->toContain('name="customer_order_source"');
|
||||
expect($checkoutController)->toContain("Request::get('is_from') === 'shopping'");
|
||||
expect($checkoutController)->toContain("\$rules['customer_order_source_comment'] = 'required|string|max:500'");
|
||||
expect($checkoutController)->toContain("\$rules['customer_order_source_comment'] = 'nullable|string|max:500'");
|
||||
expect($checkoutRepository)->toContain('$shopping_user->is_from === \'shopping\'');
|
||||
expect($checkoutRepository)->toContain('customer_order_source_comment');
|
||||
expect($orderDetail)->toContain('getCustomerOrderSourceLabel');
|
||||
|
|
|
|||
|
|
@ -37,12 +37,19 @@ function cosvBaseCheckoutInput(array $overrides = []): array
|
|||
], $overrides);
|
||||
}
|
||||
|
||||
it('verlangt den Herkunfts-Freitext bei Shop-Kundenbestellungen', function () {
|
||||
it('akzeptiert Shop-Kundenbestellungen ohne Herkunfts-Freitext', function () {
|
||||
$validator = cosvValidateCheckout(cosvBaseCheckoutInput());
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
expect($validator->errors()->has('customer_order_source_comment'))->toBeTrue();
|
||||
expect($validator->errors()->first('customer_order_source_comment'))->toContain('aufmerksam geworden');
|
||||
expect($validator->fails())->toBeFalse();
|
||||
expect($validator->errors()->has('customer_order_source_comment'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('akzeptiert einen leeren Herkunfts-Freitext', function () {
|
||||
$validator = cosvValidateCheckout(cosvBaseCheckoutInput([
|
||||
'customer_order_source_comment' => null,
|
||||
]));
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('akzeptiert den Checkout ohne das frühere Herkunfts-Dropdown', function () {
|
||||
|
|
|
|||
214
tests/Feature/GdprCustomerLockTest.php
Normal file
214
tests/Feature/GdprCustomerLockTest.php
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\GdprDeletionException;
|
||||
use App\Models\Country;
|
||||
use App\Models\Logger;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\GdprDeletionService;
|
||||
use App\Services\ShoppingUserService;
|
||||
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->member = User::forceCreate([
|
||||
'email' => 'gcl-member-'.uniqid('', true).'@example.com',
|
||||
'password' => Hash::make('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$this->userShop = UserShop::create([
|
||||
'user_id' => $this->member->id,
|
||||
'name' => 'GCL'.substr(uniqid('', true), 0, 8),
|
||||
'slug' => 'gcl-'.uniqid(),
|
||||
'active' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
function gclCustomer(array $attributes = []): ShoppingUser
|
||||
{
|
||||
return ShoppingUser::create(array_merge([
|
||||
'auth_user_id' => 0,
|
||||
'member_id' => test()->member->id,
|
||||
'number' => 5100,
|
||||
'billing_firstname' => 'Susann',
|
||||
'billing_lastname' => 'Voigt',
|
||||
'billing_address' => 'Hauptstraße 1',
|
||||
'billing_zipcode' => '19243',
|
||||
'billing_city' => 'Wittenburg',
|
||||
'billing_phone' => '0123',
|
||||
'billing_email' => 'susann@example.com',
|
||||
'billing_country_id' => test()->country->id,
|
||||
'shipping_country_id' => test()->country->id,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function gclOrderFor(ShoppingUser $customer): ShoppingOrder
|
||||
{
|
||||
return ShoppingOrder::create([
|
||||
'shopping_user_id' => $customer->id,
|
||||
'auth_user_id' => 0,
|
||||
'country_id' => test()->country->id,
|
||||
'user_shop_id' => test()->userShop->id,
|
||||
'payment_for' => 6,
|
||||
'total' => 100,
|
||||
'subtotal' => 90,
|
||||
]);
|
||||
}
|
||||
|
||||
it('markiert alle Einträge bei der DSGVO-Löschung und meldet den Status im Modal', function () {
|
||||
$first = gclCustomer();
|
||||
$second = gclCustomer();
|
||||
gclOrderFor($first);
|
||||
|
||||
$service = app(GdprDeletionService::class);
|
||||
$before = $service->analyzeShoppingUser($first);
|
||||
expect($before['all_gdpr_deleted'])->toBeFalse()
|
||||
->and($before['gdpr_deleted_rows'])->toBe(0);
|
||||
|
||||
$service->deleteShoppingUser($first, null);
|
||||
|
||||
expect($first->fresh()->isGdprDeleted())->toBeTrue()
|
||||
->and($second->fresh()->isGdprDeleted())->toBeTrue()
|
||||
->and($first->fresh()->billing_lastname)->toBe('Voigt');
|
||||
|
||||
$after = $service->analyzeShoppingUser($first->fresh());
|
||||
expect($after['all_gdpr_deleted'])->toBeTrue()
|
||||
->and($after['gdpr_deleted_rows'])->toBe(2)
|
||||
->and($after['gdpr_deleted_at'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('blendet DSGVO-gelöschte Einträge über den Scope aus', function () {
|
||||
$deleted = gclCustomer();
|
||||
gclOrderFor($deleted);
|
||||
app(GdprDeletionService::class)->deleteShoppingUser($deleted, null);
|
||||
|
||||
$other = gclCustomer(['number' => 5200, 'billing_email' => 'andere@example.com']);
|
||||
|
||||
$visibleIds = ShoppingUser::query()->withoutGdprDeleted()->pluck('id')->all();
|
||||
|
||||
expect($visibleIds)->toContain($other->id)
|
||||
->not->toContain($deleted->id);
|
||||
});
|
||||
|
||||
it('verhindert, dass Kontaktdaten in einen DSGVO-gelöschten Eintrag zurückgeschrieben werden', function (string $field, string $value) {
|
||||
$customer = gclCustomer();
|
||||
gclOrderFor($customer);
|
||||
app(GdprDeletionService::class)->deleteShoppingUser($customer, null);
|
||||
|
||||
$customer = $customer->fresh();
|
||||
$customer->{$field} = $value;
|
||||
$customer->save();
|
||||
})->with([
|
||||
'E-Mail' => ['billing_email', 'susann@example.com'],
|
||||
'Telefon' => ['billing_phone', '0123'],
|
||||
'Liefer-E-Mail' => ['shipping_email', 'susann@example.com'],
|
||||
'Liefer-Telefon' => ['shipping_phone', '0456'],
|
||||
])->throws(GdprDeletionException::class);
|
||||
|
||||
it('erlaubt an DSGVO-gelöschten Einträgen weiterhin Änderungen ohne Kontaktdaten', function () {
|
||||
$customer = gclCustomer();
|
||||
gclOrderFor($customer);
|
||||
app(GdprDeletionService::class)->deleteShoppingUser($customer, null);
|
||||
|
||||
$newMember = User::forceCreate([
|
||||
'email' => 'gcl-new-member-'.uniqid('', true).'@example.com',
|
||||
'password' => Hash::make('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$customer = $customer->fresh();
|
||||
$customer->member_id = $newMember->id;
|
||||
$customer->save();
|
||||
|
||||
expect($customer->fresh()->member_id)->toBe($newMember->id);
|
||||
});
|
||||
|
||||
it('lässt eine erneute DSGVO-Löschung eines bereits gelöschten Kunden zu', function () {
|
||||
$customer = gclCustomer();
|
||||
gclOrderFor($customer);
|
||||
$service = app(GdprDeletionService::class);
|
||||
|
||||
$service->deleteShoppingUser($customer, null);
|
||||
$firstDeletedAt = $customer->fresh()->gdpr_deleted_at;
|
||||
|
||||
$service->deleteShoppingUser($customer->fresh(), null);
|
||||
|
||||
expect($customer->fresh()->gdpr_deleted_at->equalTo($firstDeletedAt))->toBeTrue();
|
||||
});
|
||||
|
||||
it('markiert auch komplett gelöschte Kunden ohne Bestellung', function () {
|
||||
$customer = gclCustomer();
|
||||
|
||||
app(GdprDeletionService::class)->purgeShoppingUser($customer, null);
|
||||
|
||||
$row = ShoppingUser::withTrashed()->find($customer->id);
|
||||
expect($row->trashed())->toBeTrue()
|
||||
->and($row->isGdprDeleted())->toBeTrue();
|
||||
});
|
||||
|
||||
it('setzt faker_mail bei DSGVO-gelöschten Einträgen nicht zurück', function () {
|
||||
$customer = gclCustomer();
|
||||
gclOrderFor($customer);
|
||||
app(GdprDeletionService::class)->deleteShoppingUser($customer, null);
|
||||
|
||||
ShoppingUserService::setFakerMail($this->member);
|
||||
|
||||
expect($customer->fresh()->faker_mail)->toBeTrue();
|
||||
});
|
||||
|
||||
it('zieht protokollierte Löschungen nach, deren Kontaktdaten wieder befüllt wurden', function () {
|
||||
$restored = gclCustomer();
|
||||
$sibling = gclCustomer();
|
||||
gclOrderFor($restored);
|
||||
gclOrderFor($sibling);
|
||||
|
||||
$service = app(GdprDeletionService::class);
|
||||
$service->deleteShoppingUser($sibling, null);
|
||||
|
||||
// Zustand wie bei Kunde 45013: Kontaktdaten vor Einführung der Sperre wieder befüllt
|
||||
ShoppingUser::whereKey($restored->id)->update([
|
||||
'billing_email' => 'susann@example.com',
|
||||
'billing_phone' => '0123',
|
||||
'faker_mail' => false,
|
||||
'gdpr_deleted_at' => null,
|
||||
]);
|
||||
|
||||
$deletionLoggedAt = Logger::where('action', 'gdpr-delete-customer')->value('created_at');
|
||||
$this->travelTo($deletionLoggedAt->copy()->addDay());
|
||||
$lateCustomer = gclCustomer(['billing_email' => 'susann-neu@example.com']);
|
||||
gclOrderFor($lateCustomer);
|
||||
|
||||
$preview = $service->repairLoggedShoppingUserDeletions(true);
|
||||
expect(array_column($preview, 'id'))->toBe([$restored->id])
|
||||
->and($restored->fresh()->billing_email)->toBe('susann@example.com');
|
||||
|
||||
$service->repairLoggedShoppingUserDeletions(false);
|
||||
|
||||
$restored = $restored->fresh();
|
||||
expect($restored->billing_email)->toEndWith('@dsgvo.invalid')
|
||||
->and($restored->billing_phone)->toBeNull()
|
||||
->and($restored->isGdprDeleted())->toBeTrue()
|
||||
->and($lateCustomer->fresh()->billing_email)->toBe('susann-neu@example.com')
|
||||
->and($lateCustomer->fresh()->isGdprDeleted())->toBeFalse()
|
||||
->and($service->repairLoggedShoppingUserDeletions(true))->toBe([]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue