mivita/app/Services/Backoffice/PersonalStatisticsService.php
Kevin 1900860c74 Statistik: Stornos mindern die richtige Punktekategorie
Ein Storno einer Shop-Bestellung wurde als Eigenpunkt gebucht und liess damit
die Shop-Provisionsbasis ungemindert. Betroffen sind 7 Faelle aus April bis
August 2026 (178,82 EUR Differenz in der Provisionsbasis, gegen user_businesses
geprueft).

Ursache: `status_turnover` beschreibt nur, wohin der UMSATZ gehoert. Die Punkte
folgen einer anderen Regel — eine Gutschrift (Status 4) bucht ihre Punkte immer
auf KP/TP, auch wenn ihr Umsatz dem Shop zugeordnet ist. Die Gegenbuchung kann
diese Aufteilung ohne die Ursprungsart nicht spiegeln, deshalb haelt
`storno_origin_status` sie jetzt auf der Stornozeile fest. NULL heisst
"Altzeile ohne Angabe" und faellt auf das bisherige Verhalten zurueck —
abgeschlossene Monate aendern sich dadurch nicht.

Stornoregel (Kundenentscheidung): Ein Storno mindert nur den Monat der
Ursprungsbestellung. Ist der Monat abgeschlossen, passiert nichts mehr, denn
Abrechnung und Gutschriften stehen. Ein Minus in den neuen Monat mitzunehmen
waere nicht nachvollziehbar. Fuer den Ausnahmefall gibt es den Settings-Schalter
`storno-points-current-month`, standardmaessig aus.

Ausserdem: Die persoenliche Monatsstatistik summierte Status 0 (nicht
zugeordnet) und 3 (pending) mit, die gemeinsame Punkteauswertung nicht. Beide
nutzen jetzt dieselbe Statusliste, und ein Storno mindert das Segment seiner
Ursprungsbuchung statt pauschal in "Sonstige" zu laufen.

Die Migration fuer `status_turnover` zieht nur das Schema auf den tatsaechlichen
Stand nach (nullable statt NOT NULL) — auf Produktion faktisch wirkungslos,
aber Neuinstallationen und die Testdatenbank haben damit dieselbe Semantik.

Audit-Befunde A02, A06, A15, R01-R03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:18:40 +00:00

157 lines
5.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Backoffice;
use App\Models\UserSalesVolume;
use App\User;
/**
* Persönliche Monatsstatistik eines Beraters: nachvollziehbare Auflistung der
* Punkte/Umsätze des Monats, gruppiert nach eigenen Bestellungen, Shop-Bestellungen
* und Abos.
*
* Im Gegensatz zur Team-Statistik ({@see BackofficeDashboardService}) werden hier die
* einzelnen `user_sales_volumes`-Zeilen (per-Zeile `points`/`total_net`) des Users selbst
* gruppiert – nicht die kumulativen month_*-Snapshots.
*/
class PersonalStatisticsService
{
/**
* @return array{
* month: int,
* year: int,
* totals: array{own_points: float, shop_points: float, abo_points: float, other_points: float, total_points: float, turnover_net: float},
* groups: array<string, array{key: string, label: string, points: float, net: float, rows: array<int, array<string, mixed>>}>
* }
*/
public function monthly(User $user, int $month, int $year): array
{
$volumes = UserSalesVolume::query()
->with(['shopping_order.shopping_user'])
->where('user_id', $user->id)
->where('month', $month)
->where('year', $year)
// Dieselbe Abgrenzung wie die Team-Auswertung: nicht zugeordnete (0) und
// pending (3) Buchungen zaehlen nirgends mit, sonst weicht die persoenliche
// Summe von der gemeinsamen Punktebasis ab.
->whereIn('status', SalesVolumePoints::countableStatuses())
->orderBy('id')
->get();
$groups = [
'own' => $this->emptyGroup('own', 'Eigene Bestellungen'),
'shop' => $this->emptyGroup('shop', 'Shop-Bestellungen'),
'abo' => $this->emptyGroup('abo', 'Abos'),
'other' => $this->emptyGroup('other', 'Sonstige'),
];
foreach ($volumes as $volume) {
$key = $this->groupKey($volume);
$groups[$key]['rows'][] = $this->mapRow($volume);
$groups[$key]['points'] += (float) $volume->points;
$groups[$key]['net'] += (float) $volume->total_net;
}
$totals = [
'own_points' => $groups['own']['points'],
'shop_points' => $groups['shop']['points'],
'abo_points' => $groups['abo']['points'],
'other_points' => $groups['other']['points'],
'total_points' => $groups['own']['points'] + $groups['shop']['points'] + $groups['abo']['points'] + $groups['other']['points'],
'turnover_net' => $groups['own']['net'] + $groups['shop']['net'] + $groups['abo']['net'] + $groups['other']['net'],
];
return [
'month' => $month,
'year' => $year,
'totals' => $totals,
'groups' => $groups,
];
}
private function groupKey(UserSalesVolume $volume): string
{
$status = (int) $volume->status;
$order = $volume->shopping_order;
if ($status === 1) {
return 'own';
}
if ($status === 2 && $order) {
return $order->is_abo ? 'abo' : 'shop';
}
if ($status === 6) {
return $this->stornoGroupKey($volume, $order);
}
return 'other';
}
/**
* Ein Storno gehoert in das Segment seiner Ursprungsbuchung — sonst steht das
* Minus unter "Sonstige", waehrend der stornierte Umsatz weiter im Shop- oder
* Abo-Segment ausgewiesen wird.
*
* Altzeilen ohne festgehaltene Ursprungsart bleiben unter "Sonstige": ihre
* Herkunft ist nicht rekonstruierbar.
*/
private function stornoGroupKey(UserSalesVolume $volume, mixed $order): string
{
$origin = $volume->storno_origin_status;
if ($origin === null) {
return 'other';
}
if ((int) $origin === 1) {
return 'own';
}
if ((int) $origin === 2 && $order) {
return $order->is_abo ? 'abo' : 'shop';
}
return 'other';
}
/**
* @return array{key: string, label: string, points: float, net: float, rows: array<int, array<string, mixed>>}
*/
private function emptyGroup(string $key, string $label): array
{
return [
'key' => $key,
'label' => $label,
'points' => 0.0,
'net' => 0.0,
'rows' => [],
];
}
/**
* @return array<string, mixed>
*/
private function mapRow(UserSalesVolume $volume): array
{
$order = $volume->shopping_order;
$customer = $order?->shopping_user;
return [
'date' => $volume->date,
'points' => (float) $volume->points,
'total_net' => (float) $volume->total_net,
'status' => (int) $volume->status,
'status_label' => $volume->getStatusTypeWithAbo(),
'status_color' => $volume->getStatusColor(),
'order_id' => $order?->id,
'order_type' => $order ? ((int) $volume->status === 1 ? 'own' : 'shop') : null,
'is_abo' => $volume->isAboOrder(),
'customer_name' => $customer ? trim(($customer->billing_firstname ?? '').' '.($customer->billing_lastname ?? '')) : null,
'customer_email' => $customer?->billing_email,
'info' => $volume->info,
'message' => $volume->message,
];
}
}