A01 — Abo-Regeln waren ueber das Aktiv-Feld umgehbar. Das Selbstbedienungsformular hat gar keinen Aktiv-Schalter, es spiegelt den Zustand nur in einem versteckten Feld zurueck; `updateStatus()` hat den Wert trotzdem uebernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft deaktivieren und so die Mindestlaufzeit umgehen, am Pausenpfad mit seinen Sperren vorbei. Das Feld wirkt jetzt nur noch im Admin-Pfad. A16 — Statuswechsel wurden nur aus dem Formular protokolliert. Recovery-Zahlung, Retry, externe Erledigung und die DSGVO-Anonymisierung schreiben `active` direkt auf das Modell. Das ist heikel, weil die historische Bestandsrueckrechnung ein Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine spaetere protokollierte Reaktivierung aufgehoben ist — ein stiller `active = true` nimmt es dauerhaft aus allen Folgemonaten. Der Eintrag liegt deshalb jetzt am Model (`UserAbo::logStatusTransition()`) und nicht in den einzelnen Diensten: so kann ihn ein kuenftiger Schreibpfad nicht vergessen, und er entsteht in derselben Transaktion wie die Statusaenderung. Nebeneffekt: Die DSGVO-Anonymisierung setzt `active = false` ohne `cancel_date` — bisher fiel ein so abgeschaltetes Abo rueckwirkend aus allen vergangenen Monaten, jetzt bleibt die Historie korrekt. A13 — Ein heute faelliges Abo galt ab Mitternacht als erledigt, der Ausfuehrungsjob laeuft aber erst um 04:00. In diesem Fenster fehlten die Punkte in der Prognose UND im Ist. Der neue Scope `UserAbo::scopeOpenExecutionUntil()` zaehlt den heutigen Liefertag mit, solange keine Ausfuehrung angelegt ist — genau diese Bedingung verhindert die Doppelzaehlung. Alle drei Prognosen nutzen jetzt denselben Scope statt je einer eigenen Kopie der Datumsregel. A11 — Der Namens-Tiebreak der Top 3 lief nach dem Abschneiden auf 25 Kandidaten, bei Gleichstand entschied also die Reihenfolge der Abfrage. Die Auswahlgrenze wird jetzt um alle Gleichstaende an dieser Grenze erweitert, und Kandidaten ohne Account fallen vorher weg statt die Plaetze zu blockieren. Als letzte Regel entscheidet die user_id, damit eine gecachte Rangliste reproduzierbar bleibt. A14 — Sortiert wurde ueber den angezeigten Zellentext. Unter `en` liefert die Anwendung `Y-m-d` und `1,000.00`, damit ergab jedes Datum 0 und `1,000.00` wurde zur 1. Die Zellen tragen jetzt `data-sort-value` mit dem unformatierten Wert; ohne Attribut gilt weiter der Textweg. Dabei mitgenommen: Umlaute landeten hinter dem Z (jetzt localeCompare), leere Werte lagen als 0 zwischen den negativen Stornowerten, und `next_date` wurde doppelt formatiert. Ausserdem A08 (Prognose auch ohne erreichte Qualifikationsstufe), A09 (Summenzeile im Neupartner-CSV), A10 (Lieferungszahl vorgeladen), A17 (Faelligkeitsgruppen ohne Monatsueberlauf), A18 (Formelinhalte im CSV als Text) und A19 (fehlgeschlagene Provisionsberechnung ist als solche erkennbar statt 0,00 EUR). Audit-Befunde A01, A08, A09, A10, A11, A13, A14, A16, A17, A18, A19. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
240 lines
20 KiB
PHP
240 lines
20 KiB
PHP
@extends('layouts.layout-2')
|
|
|
|
@section('content')
|
|
<h4 class="font-weight-bold py-2 mb-2 d-flex justify-content-between align-items-center w-100">
|
|
<div>
|
|
{{ __('navigation.statistics') }} / {{ $details['metric_label'] }}
|
|
<span class="badge badge-secondary ml-2">VIP</span>
|
|
@if (! empty($details['only_new']))
|
|
<span class="badge badge-success ml-1">Nur neue im {{ $details['month'] }}/{{ $details['year'] }}</span>
|
|
@endif
|
|
</div>
|
|
<div>
|
|
<a href="{{ route('user_backoffice_statistics_export', [
|
|
'line' => $details['line'],
|
|
'metric' => $details['metric'],
|
|
'month' => $selectedMonth,
|
|
'year' => $selectedYear,
|
|
'only_new' => ($details['only_new'] ?? false) ? 1 : 0,
|
|
]) }}"
|
|
class="btn btn-sm btn-outline-secondary mr-2">
|
|
<span class="ion ion-md-download mr-1"></span> CSV
|
|
</a>
|
|
<a href="{{ route('user_backoffice_statistics', ['month' => $selectedMonth, 'year' => $selectedYear]) }}"
|
|
class="btn btn-sm btn-default">
|
|
{{ __('back') }}
|
|
</a>
|
|
</div>
|
|
</h4>
|
|
|
|
<div class="card mb-4">
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Linie</div>
|
|
<strong>{{ $details['line_label'] }}</strong>
|
|
</div>
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Zeitraum</div>
|
|
<strong>{{ $details['month'] }}/{{ $details['year'] }}</strong>
|
|
</div>
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Kennzahl</div>
|
|
<strong>{{ $details['metric_label'] }}</strong>
|
|
</div>
|
|
<div class="col-md-3 mb-2">
|
|
<div class="text-muted small">Treffer</div>
|
|
<strong>{{ number_format($details['summary']['count'], 0, ',', '.') }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-body p-0">
|
|
<div class="alert alert-warning rounded-0 border-left-0 border-right-0 border-top-0 mb-0">
|
|
<strong>Datenschutz-Hinweis:</strong>
|
|
Die Anzeige personenbezogener Detaildaten befindet sich noch in rechtlicher Klärung und ist aktuell nur für berechtigte VIP-Auswertungen vorgesehen.
|
|
</div>
|
|
@if ($details['rows'] === [])
|
|
<div class="p-4">
|
|
<p class="text-muted mb-0">{{ __('tables.no_data_available') }}</p>
|
|
</div>
|
|
@else
|
|
<div class="p-3 border-bottom">
|
|
<div class="input-group">
|
|
<div class="input-group-prepend">
|
|
<span class="input-group-text">
|
|
<span class="ion ion-ios-search"></span>
|
|
</span>
|
|
</div>
|
|
<input type="search" id="backoffice-statistics-detail-search" class="form-control"
|
|
placeholder="Detailtabelle durchsuchen...">
|
|
</div>
|
|
<small class="text-muted d-block mt-2">
|
|
Suche nach Name, E-Mail, Status, Punkten oder Datum. Die Summenzeile bleibt unverändert.
|
|
</small>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0" id="backoffice-statistics-detail-table">
|
|
<thead>
|
|
<tr>
|
|
<th data-sortable="true"><span class="sortable-label">Name <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th data-sortable="true"><span class="sortable-label">E-Mail <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th data-sortable="true"><span class="sortable-label">Karriere-Level <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
@if ($details['metric'] === 'team_customer_abos')
|
|
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Kundenabos <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Volumenpunkte gesamt <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
@elseif ($details['metric'] === 'team_partner_abos')
|
|
<th data-sortable="true"><span class="sortable-label">Berater <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Abo-Punkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th data-sortable="true"><span class="sortable-label">Status <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Besteht seit <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Nächste Ausführung <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Lieferungen <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
@elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true))
|
|
<th class="text-right" data-sortable="true" data-sort-type="number" style="background-color:#d4edda;"><span class="sortable-label justify-content-end">Eigenpunkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Shop-Punkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Abo-Punkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Sonstige Punkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number" style="background-color:#d4edda;"><span class="sortable-label justify-content-end">Kundenpunkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Gesamtpunkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
@elseif ($details['metric'] === 'new_partners')
|
|
<th data-sortable="true"><span class="sortable-label">Telefon <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th data-sortable="true"><span class="sortable-label">Sponsor <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number" title="Punkte aus den Bestellungen SEINER Kunden (Shop und Abo) — ohne seine eigenen Bestellungen."><span class="sortable-label justify-content-end">Punkte seiner Kunden <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th class="text-right" data-sortable="true" data-sort-type="number" title="Punkte seiner Kunden PLUS seine eigenen Bestellungen — hier steckt vor allem das Starterpaket drin."><span class="sortable-label justify-content-end">Gesamt (inkl. Eigenbestellung) <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Aktiv seit <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Account gültig bis <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
@else
|
|
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Aktiv seit <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Account gültig bis <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
|
@endif
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach ($details['rows'] as $row)
|
|
<tr
|
|
class="{{ isset($row['is_account_active']) && ! $row['is_account_active'] ? 'table-danger' : '' }} {{ ! empty($row['is_new_this_month']) ? 'table-success' : '' }}">
|
|
<td class="font-weight-semibold">{{ $row['name'] }}</td>
|
|
<td>{{ $row['email'] ?? '-' }}</td>
|
|
<td>{{ $row['career_level'] ?? '-' }}</td>
|
|
@if ($details['metric'] === 'team_customer_abos')
|
|
<td class="text-right" data-sort-value="{{ $row['abo_count'] }}">{{ number_format($row['abo_count'], 0, ',', '.') }}</td>
|
|
<td class="text-right" data-sort-value="{{ $row['points'] }}">
|
|
<span class="font-weight-bold">{{ \App\Services\Util::formatNumber($row['points']) }}</span>
|
|
@if (! empty($row['abos']))
|
|
<a class="d-block small" data-toggle="collapse"
|
|
href="#customer-abos-{{ $loop->index }}" role="button">
|
|
Abos anzeigen
|
|
</a>
|
|
<div class="collapse text-left" id="customer-abos-{{ $loop->index }}">
|
|
<ul class="list-unstyled small text-muted mt-2 mb-0">
|
|
@foreach ($row['abos'] as $aboItem)
|
|
<li>
|
|
{{ $aboItem['start_date'] ?? '-' }} →
|
|
{{ $aboItem['next_date'] ?? '-' }}:
|
|
<span class="font-weight-semibold">{{ \App\Services\Util::formatNumber($aboItem['points']) }}</span> P.
|
|
@if (! empty($aboItem['is_new_this_month']))
|
|
<span class="badge badge-success ml-1">Neu</span>
|
|
@endif
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
</div>
|
|
@endif
|
|
</td>
|
|
@elseif ($details['metric'] === 'team_partner_abos')
|
|
<td>{{ $row['consultant_name'] ?? $row['name'] }}</td>
|
|
<td class="text-right" data-sort-value="{{ $row['points'] }}">{{ \App\Services\Util::formatNumber($row['points']) }}</td>
|
|
<td data-sort-value="{{ $row['status_label'] ?? '' }}">
|
|
{!! $row['status_badge'] ?? '' !!}
|
|
@if (! empty($row['status_reason']))
|
|
<div class="small text-muted mt-1">{{ $row['status_reason'] }}</div>
|
|
@endif
|
|
</td>
|
|
<td data-sort-value="{{ $row['start_date_sort'] ?? '' }}">
|
|
{{ $row['start_date'] ?? '-' }}
|
|
@if (! empty($row['is_new_this_month']))
|
|
<span class="badge badge-success ml-1">Neu</span>
|
|
@endif
|
|
</td>
|
|
<td data-sort-value="{{ $row['next_date_sort'] ?? '' }}">{{ $row['next_date'] ?? '-' }}</td>
|
|
<td class="text-right" data-sort-value="{{ $row['deliveries'] }}">{{ number_format($row['deliveries'], 0, ',', '.') }}</td>
|
|
@elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true))
|
|
<td class="text-right" data-sort-value="{{ $row['own_points'] }}"><span class="badge" style="background-color:#d4edda;color:#155724;font-size:90%;">{{ \App\Services\Util::formatNumber($row['own_points']) }}</span></td>
|
|
<td class="text-right" data-sort-value="{{ $row['customer_single_order_points'] }}">{{ \App\Services\Util::formatNumber($row['customer_single_order_points']) }}</td>
|
|
<td class="text-right" data-sort-value="{{ $row['customer_abo_points'] }}">{{ \App\Services\Util::formatNumber($row['customer_abo_points']) }}</td>
|
|
<td class="text-right" data-sort-value="{{ $row['customer_other_points'] }}">{{ \App\Services\Util::formatNumber($row['customer_other_points']) }}</td>
|
|
<td class="text-right font-weight-bold" data-sort-value="{{ $row['external_points'] }}"><span class="badge" style="background-color:#d4edda;color:#155724;font-size:90%;">{{ \App\Services\Util::formatNumber($row['external_points']) }}</span></td>
|
|
<td class="text-right font-weight-bold" data-sort-value="{{ $row['total_points'] }}">{{ \App\Services\Util::formatNumber($row['total_points']) }}</td>
|
|
@elseif ($details['metric'] === 'new_partners')
|
|
<td>{{ ($row['phone'] ?? '') !== '' ? $row['phone'] : '-' }}</td>
|
|
<td>{{ $row['sponsor'] ?? '-' }}</td>
|
|
<td class="text-right font-weight-bold" data-sort-value="{{ $row['customer_points'] ?? 0 }}">{{ \App\Services\Util::formatNumber($row['customer_points'] ?? 0) }}</td>
|
|
<td class="text-right font-weight-bold" data-sort-value="{{ $row['total_points'] ?? 0 }}">{{ \App\Services\Util::formatNumber($row['total_points'] ?? 0) }}</td>
|
|
<td data-sort-value="{{ $row['active_date_sort'] ?? '' }}">{{ $row['active_date'] ?? '-' }}</td>
|
|
<td data-sort-value="{{ $row['payment_account_sort'] ?? '' }}">{{ $row['payment_account'] ?? '-' }}</td>
|
|
@else
|
|
<td data-sort-value="{{ $row['active_date_sort'] ?? '' }}">{{ $row['active_date'] ?? '-' }}</td>
|
|
<td data-sort-value="{{ $row['payment_account_sort'] ?? '' }}">
|
|
{{ $row['payment_account'] ?? '-' }}
|
|
@if (isset($row['is_account_active']))
|
|
<span
|
|
class="badge badge-{{ $row['is_account_active'] ? 'success' : 'danger' }} ml-1">
|
|
{{ $row['account_status'] }}
|
|
</span>
|
|
@endif
|
|
</td>
|
|
@endif
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
<tfoot>
|
|
<tr class="font-weight-bold bg-light">
|
|
<td>Summe</td>
|
|
<td class="text-muted">{{ number_format($details['summary']['count'], 0, ',', '.') }} Einträge</td>
|
|
<td></td>
|
|
@if ($details['metric'] === 'team_customer_abos')
|
|
<td class="text-right">{{ number_format($details['summary']['abo_count'], 0, ',', '.') }}</td>
|
|
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['points']) }}</td>
|
|
@elseif ($details['metric'] === 'team_partner_abos')
|
|
<td></td>
|
|
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['points']) }}</td>
|
|
<td></td>
|
|
<td></td>
|
|
<td></td>
|
|
<td class="text-right">{{ number_format($details['summary']['deliveries'], 0, ',', '.') }}</td>
|
|
@elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true))
|
|
<td class="text-right" style="background-color:#d4edda;">{{ \App\Services\Util::formatNumber($details['summary']['own_points']) }}</td>
|
|
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_single_order_points']) }}</td>
|
|
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_abo_points']) }}</td>
|
|
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_other_points']) }}</td>
|
|
<td class="text-right" style="background-color:#d4edda;">{{ \App\Services\Util::formatNumber($details['summary']['external_points']) }}</td>
|
|
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['total_points']) }}</td>
|
|
@elseif ($details['metric'] === 'new_partners')
|
|
<td></td>
|
|
<td></td>
|
|
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_points'] ?? 0) }}</td>
|
|
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['total_points'] ?? 0) }}</td>
|
|
<td></td>
|
|
<td></td>
|
|
@else
|
|
<td></td>
|
|
<td></td>
|
|
@endif
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
@if ($details['rows'] !== [])
|
|
@include('components._sortable_table', [
|
|
'tableId' => 'backoffice-statistics-detail-table',
|
|
'searchId' => 'backoffice-statistics-detail-search',
|
|
])
|
|
@endif
|
|
@endsection
|