Statistiken/Abo: Kundenfeedback 2026-07-24 komplett umgesetzt + Steuern: DATEV-Fixes 08+09/2025 (Sammel-Commit Branch-WIP)

Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert

Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-24 13:51:16 +00:00
parent 657d4bd5b7
commit ac3bd6e8ea
136 changed files with 7610 additions and 2688 deletions

View file

@ -1,5 +1,6 @@
@php
$changeHistory = $user_abo->getChangeHistory();
$statusHistory = $user_abo->status_histories()->with('actor.account')->orderByDesc('created_at')->orderByDesc('id')->get();
$priceLabel = $user_abo->is_for === 'me' ? __('abo_history.price_net') : __('abo_history.price_gross');
@endphp
@ -20,6 +21,34 @@
@endif
</div>
@if($statusHistory->isNotEmpty())
<h6 class="font-weight-semibold text-muted mb-2">{{ __('abo_history.status_history_title') }}</h6>
<div class="table-responsive mb-4">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>{{ __('abo_history.col_date') }}</th>
<th>{{ __('abo_history.col_action') }}</th>
<th>{{ __('abo_history.col_details') }}</th>
<th>{{ __('abo_history.col_changed_by') }}</th>
<th>{{ __('abo_history.col_channel') }}</th>
</tr>
</thead>
<tbody>
@foreach($statusHistory as $statusEntry)
<tr>
<td class="text-nowrap small">{{ $statusEntry->getFormattedDate() }}</td>
<td>{!! $statusEntry->getActionBadge() !!}</td>
<td class="small">{{ $statusEntry->getDescription() }}</td>
<td class="small">{{ $statusEntry->getActorName() }}</td>
<td>{!! $statusEntry->getChannelBadge() !!}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@if($changeHistory->isEmpty())
<p class="text-muted mb-0">
<i class="fas fa-info-circle"></i> {{ __('abo_history.no_changes') }}

View file

@ -112,11 +112,11 @@
<div class="col-md-3 mb-3">
{!! $user_abo->getIsForFormated() !!}
@if ($user_abo->is_for === 'me')
@if ($user_abo->is_for === 'me' && Route::has('user_edit'))
<a class="btn btn-xs btn-secondary" href="{{ route('user_edit') }}"><i class="fa fa-edit"></i></a>
@endif
@if ($user_abo->is_for === 'ot')
@if ($user_abo->is_for === 'ot' && $user_abo->shopping_user && Route::has('user_customer_edit'))
<a class="btn btn-xs btn-secondary"
href="{{ route('user_customer_edit', $user_abo->shopping_user->id) }}"><i
class="fa fa-edit"></i></a>

View file

@ -1,5 +1,7 @@
@php($cartInstance = \App\Services\AboOrderCart::INSTANCE)
@php($change_locked = isset($change_locked) ? $change_locked : false)
{{-- Kunden-Portal: Versand brutto ("inkl. MwSt."), Berater/Admin: netto ("netto") --}}
@php($portal_view = $portal_view ?? false)
@if(isset($error_message) && $error_message)
<div class="alert alert-danger mt-2" id="insert_show_error_message">{{ $error_message }}</div>
@endif
@ -88,9 +90,10 @@
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('Delivery country') }}:</strong></td>
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->getShippingCountryName() }}</td>
</tr>
@php($shippingGross = $portal_view && ! $taxFree)
<tr>
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.shipping_costs') }}:</strong></td>
<td class="text-right small no-border-top">{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} € </td>
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('order.shipping_costs') }} ({{ $shippingGross ? __('order.incl_VAT') : __('order.net') }}):</strong></td>
<td class="text-right small no-border-top">{{ $shippingGross ? Yard::instance($cartInstance)->shipping() : Yard::instance($cartInstance)->shippingNet() }} € </td>
</tr>
@if($taxFree)
<tr>

View file

@ -1,6 +1,9 @@
@php
$cartInstance = \App\Services\AboOrderCart::INSTANCE;
$taxFree = Yard::instance($cartInstance)->getUserTaxFree();
// Kunden-Portal: Versand brutto ("inkl. MwSt."), Berater/Admin: netto ("netto")
$portal_view = $portal_view ?? false;
$shippingGross = $portal_view && ! $taxFree;
@endphp
<div class="card-header bg-primary">
<h5 class="font-weight-semibold mb-0 text-white"> <i class="fa fa-shopping-cart text-white"></i> &nbsp;
@ -26,9 +29,10 @@
</td>
</tr>
<tr>
<td class="small no-border-top"><strong>{{ __('order.shipping_costs') }}:</strong></td>
<td class="small no-border-top"><strong>{{ __('order.shipping_costs') }}
({{ $shippingGross ? __('order.incl_VAT') : __('order.net') }}):</strong></td>
<td class="text-right small no-border-top">
{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }}
{{ $shippingGross ? Yard::instance($cartInstance)->shipping() : Yard::instance($cartInstance)->shippingNet() }}
€</td>
</tr>
@if ($taxFree)

View file

@ -90,8 +90,7 @@
$canCancel = App\Services\AboHelper::canCancelAbo($user_abo, $data['view']);
@endphp
<hr>
@if($canCancel || $data['view'] == 'admin')
@if($data['view'] === 'admin')
<div class="form-row">
<div class="col-sm-12">
<label class="switcher switcher-primary">
@ -106,23 +105,49 @@
<div class="col-sm-12 mt-1">
<i class="text-muted">{{ __('abo.abo_copy_active') }}</i>
</div>
@if($data['view'] !== 'admin')
<div class="col-sm-12 mt-3">
<button type="submit" name="abo_cancel" value="true" class="btn btn-danger btn-sm" onclick="return confirm('{{ __('abo.confirm_cancel') }}')">
<i class="ion ion-ios-trash"></i> {{ __('abo.cancel_abo') }}
</button>
</div>
@endif
</div>
@else
<input type="hidden" name="abo_is_active" value="{{ $user_abo->active }}">
<div class="mt-1">
<div class="alert alert-warning">
{{ __('abo.info_min_duration_orders_left', ['count' => App\Services\AboHelper::getAboMinDuration() - $user_abo->getCountPaidOrders()]) }}
@elseif($canCancel)
{{-- Selfservice: kein Aktiv-Schalter mehr (die Pause übernimmt diese Rolle),
aktueller Zustand wird unverändert mitgesendet. --}}
<input type="hidden" name="abo_is_active" value="{{ $user_abo->active }}">
@php
$canPause = App\Services\AboHelper::canPauseAbo($user_abo, $data['view']);
$nextPossiblePause = App\Services\AboHelper::nextPossiblePauseDate($user_abo);
@endphp
@if($canPause || ($user_abo->active && $nextPossiblePause))
<div class="form-row">
<div class="col-sm-12">
@if($canPause)
<button type="submit" name="abo_pause" value="true" class="btn btn-outline-primary btn-sm" onclick="return confirm('{{ __('abo.confirm_pause') }}')">
<i class="ion ion-md-pause"></i> {{ __('abo.pause_abo') }}
</button>
<div class="mt-1">
<i class="text-muted">{{ __('abo.abo_copy_pause', ['months' => App\Services\AboHelper::getPauseIntervalMonths()]) }}</i>
</div>
@else
<i class="text-muted">{{ __('abo.info_pause_available_from', ['date' => $nextPossiblePause->format(Util::formatDateDB()), 'months' => App\Services\AboHelper::getPauseIntervalMonths()]) }}</i>
@endif
</div>
</div>
</div>
<hr>
@endif
<div class="form-row">
<div class="col-sm-12">
<button type="submit" name="abo_cancel" value="true" class="btn btn-outline-danger btn-sm" onclick="return confirm('{{ __('abo.confirm_cancel') }}')">
<i class="ion ion-ios-trash"></i> {{ __('abo.cancel_abo') }}
</button>
</div>
</div>
@else
<input type="hidden" name="abo_is_active" value="{{ $user_abo->active }}">
<div class="mt-1">
<div class="alert alert-warning">
{{ __('abo.info_min_duration_orders_left', ['count' => App\Services\AboHelper::getAboMinDuration() - $user_abo->getCountPaidOrders()]) }}
</div>
</div>
@endif
@endif
</div>

View file

@ -9,4 +9,40 @@
@include('admin.customer._detail')
<a href="{{route('admin_customers')}}" class="btn btn-sm btn-default mt-2 float-right">{{ __('back') }}</a>
@if (Auth::user()->isSuperAdmin() && !$shopping_user->auth_user_id)
<button type="button" class="btn btn-sm btn-danger mt-2" data-toggle="modal"
data-target="#modals-customer-gdpr-delete">{{ __('msg.gdpr_delete_customer_button') }}</button>
<!-- Modal DSGVO-Löschung -->
<div class="modal fade" id="modals-customer-gdpr-delete">
<div class="modal-dialog">
<form class="modal-content" action="{{ route('admin_customer_gdpr_delete', [$shopping_user->id]) }}"
method="post">
@csrf
<div class="modal-header">
<h5 class="modal-title">{{ __('msg.gdpr_delete_customer_button') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
</div>
<div class="modal-body">
<p>Alle personenbezogenen Daten dieses Kunden werden <b>unwiderruflich anonymisiert</b>
(alle Einträge mit der Kundennummer {{ $shopping_user->number }}).
Bei Bestellungen mit Rechnung bleiben Name und Rechnungsadresse wegen der
gesetzlichen Aufbewahrungspflichten erhalten; alle Kontaktdaten
(E-Mail, Telefon, Notizen, Newsletter) werden entfernt.
Aktive Abos müssen vorher gekündigt werden.</p>
<label class="custom-control custom-checkbox m-0">
<input type="checkbox" class="custom-control-input" name="gdpr_delete_customer" value="1">
<span class="custom-control-label">DSGVO-Löschung unwiderruflich ausführen?</span>
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
<button type="submit"
class="btn btn-danger">{{ __('msg.gdpr_delete_customer_button') }}</button>
</div>
</form>
</div>
</div>
@endif
@endsection

View file

@ -50,15 +50,15 @@
--}}
<tr class="unselectable">
<td>Bankinhaber</td>
<td><div class="selectall">{{ $UserCredit->user->account->bank_owner }}</div></td>
<td><div class="selectall">{{ $UserCredit->user?->account?->bank_owner }}</div></td>
</tr>
<tr>
<td>IBAN:</td>
<td><div class="selectall">{{ $UserCredit->user->account->bank_iban }}</div></td>
<td><div class="selectall">{{ $UserCredit->user?->account?->bank_iban }}</div></td>
</tr>
<tr>
<td>BIC:</td>
<td><div class="selectall">{{ $UserCredit->user->account->bank_bic }}</div></td>
<td><div class="selectall">{{ $UserCredit->user?->account?->bank_bic }}</div></td>
</tr>
</tbody>
</table>

View file

@ -124,6 +124,14 @@
<small class="form-text text-muted">Anzahl Tage vor der Ausführung, ab der das Abo nicht mehr pausiert oder gekündigt werden kann (Standard: 3).</small>
</div>
</div>
<div class="form-row">
<div class="form-group col-sm-6">
<label class="form-label">Pause-Intervall (Monate)*</label>
{{ Form::text('settings[abo-pause-interval-months][val]', \App\Models\Setting::getContentBySlug('abo-pause-interval-months'), array('class'=>'form-control')) }}
{{ Form::hidden('settings[abo-pause-interval-months][type]', 'int') }}
<small class="form-text text-muted">Maximal eine Pause pro X Monate (rollierend ab der letzten Pause, Standard: 6).</small>
</div>
</div>
<button type="submit" name="action" value="save_prepayment" class="btn btn-primary btn-sm mb-2"><i class="ion ion-ios-save"></i> speichern</button>
</div>
</div>

View file

@ -359,6 +359,19 @@
<span class="custom-control-label">User komplett ohne Wiederherstellung löschen?</span>
</label>
</div>
<div class="form-group">
<hr>
<h5 class="text-danger">DSGVO-Löschung (Art. 17 DSGVO)</h5>
<p>Alle personenbezogenen Daten dieses Users werden <b>unwiderruflich anonymisiert</b>
(Stammdaten, Adressen, Bankdaten, Uploads, Zahlungs-Tokens, Kontaktdaten).
Die Downline wird auf den nächsten aktiven Sponsor übertragen.
Rechnungen, Gutschriften und Provisionsdaten bleiben wegen der gesetzlichen
Aufbewahrungspflichten erhalten. Aktive Abos müssen vorher gekündigt werden.</p>
<label class="custom-control custom-checkbox m-0">
<input type="checkbox" class="custom-control-input" name="gdpr_delete_user">
<span class="custom-control-label">DSGVO-Löschung unwiderruflich ausführen?</span>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>