mivita/app/Services/DecimalPointsDiffReportService.php
Kevin a21bebc932 Punkte: Dezimalpunkte in Einmalprodukten, Homeparty und Shop-API nicht mehr abschneiden
Produkte mit 7,5 Punkten wurden an drei Stellen zu ganzen Zahlen:
user_abo_one_time_items (PHP-Cast int, 7,5 -> 7), homeparty_user_order_items
(MySQL rundete auf 8) und shopping_collect_orders (Summe der Shop-API).
Die Migration zieht die drei Spalten auf DECIMAL(10,2) nach (Nachtrag zu
2026_01_22), Cast und Abo-Snapshot rechnen mit float.

points:report-decimal-diff wertet die Differenzen je Berater und Monat aus
(nur lesend), points:repair-decimal korrigiert den laufenden Monat und
zieht die Monatssummen nach.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:42:27 +00:00

341 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\ShoppingCollectOrder;
use App\Repositories\CheckoutRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Read-only Auswertung der Punkte-Differenzen aus dem Dezimal-Punkte-Fehler.
*
* Liefert je Berater und Provisionsmonat, wie viele Punkte zu wenig oder zu viel
* gutgeschrieben wurden. Grundlage fuer Gutschrift-Entscheidungen -- dieser
* Service schreibt nichts.
*
* Drei Quellen, mit unterschiedlicher Beweislage:
*
* A) ADDON Abo-Einmalartikel wurden per (int)-Cast abgeschnitten (7,5 -> 7).
* Berater bekamen ZU WENIG. Exakt belegbar: die Differenz je Position
* steht direkt in shopping_order_items.
* B) SAMMEL Shop-API-Sammelbestellungen wurden beim Speichern gerundet
* (239,5 -> 240). Berater bekamen ZU VIEL. Exakt belegbar ueber die
* Positionssummen im shop_items-JSON.
* C) PARTY Homeparty-Positionen wurden beim Speichern aufgerundet (7,5 -> 8).
* Berater bekamen ZU VIEL. NUR OBERGRENZE: ob der Betrag 1:1 in
* shopping_orders.points landet, ist nicht belegbar, weil dort
* zusaetzlich `bonus_points_diff` verrechnet wird
* ({@see CheckoutRepository}).
*
* Stichtag: Produkte bekamen ihre Dezimal-Punkte Ende Januar 2026. Vorher ist
* eine Abweichung "gespeichert = Produkt + 0,5" keine Rundung, sondern eine
* spaetere Produktaenderung. Der Stichtag wird deshalb JE PRODUKT aus der ersten
* Bestellposition bestimmt, die den Dezimalwert traegt. Ist er unbekannt, wird
* das Produkt nicht gewertet -- lieber zu wenig ausweisen als zu viel.
*/
class DecimalPointsDiffReportService
{
public const SOURCE_ADDON = 'addon';
public const SOURCE_COLLECT = 'collect';
public const SOURCE_HOMEPARTY = 'homeparty';
/**
* @return array{
* rows: list<array{user_id: int|null, consultant: string, year: int, month: int, addon: float, collect: float, homeparty: float, delta: float}>,
* by_month: list<array{year: int, month: int, addon: float, collect: float, homeparty: float, delta: float, users: int}>,
* totals: array{addon: float, collect: float, homeparty: float, delta: float, users: int},
* credits_owed: float,
* overbooked: float
* }
*/
public function report(?string $from = null, ?string $to = null): array
{
$cutoffs = $this->productCutoffs();
$entries = array_merge(
$this->addonEntries($cutoffs),
$this->collectEntries(),
$this->homepartyEntries($cutoffs),
);
$entries = array_values(array_filter(
$entries,
fn (array $entry) => $this->withinRange($entry['year'], $entry['month'], $from, $to)
));
return $this->aggregate($entries);
}
/**
* Stichtag je Produkt: erste Bestellposition, die den heutigen Dezimalwert
* traegt. Produkte ohne solche Position fehlen bewusst in der Map.
*
* @return array<int, string>
*/
private function productCutoffs(): array
{
return DB::table('shopping_order_items as i')
->join('products as p', 'p.id', '=', 'i.product_id')
->whereRaw('p.points <> FLOOR(p.points)')
->whereRaw('i.points = p.points')
->groupBy('i.product_id')
->selectRaw('i.product_id, MIN(i.created_at) as first_decimal')
->get()
->mapWithKeys(fn ($row) => [(int) $row->product_id => (string) $row->first_decimal])
->all();
}
/**
* @param array<int, string> $cutoffs
* @return list<array{user_id: int|null, year: int, month: int, source: string, delta: float}>
*/
private function addonEntries(array $cutoffs): array
{
$rows = DB::table('shopping_order_items as i')
->join('products as p', 'p.id', '=', 'i.product_id')
->join('user_sales_volumes as sv', 'sv.shopping_order_id', '=', 'i.shopping_order_id')
->where('i.is_abo_addon', 1)
->whereNull('i.deleted_at')
->whereRaw('p.points <> FLOOR(p.points)')
->whereRaw('i.points = FLOOR(p.points)')
->get([
'sv.user_id', 'sv.month', 'sv.year', 'sv.status',
'i.product_id', 'i.points', 'i.qty', 'i.created_at',
'p.points as product_points',
]);
$entries = [];
foreach ($rows as $row) {
if (! $this->afterCutoff($cutoffs, (int) $row->product_id, (string) $row->created_at)) {
continue;
}
$delta = ((float) $row->product_points - (float) $row->points) * (int) $row->qty;
$entries[] = [
'user_id' => $row->user_id !== null ? (int) $row->user_id : null,
'year' => (int) $row->year,
'month' => (int) $row->month,
'source' => self::SOURCE_ADDON,
'delta' => (int) $row->status === 6 ? -$delta : $delta,
];
}
return $entries;
}
/**
* @return list<array{user_id: int|null, year: int, month: int, source: string, delta: float}>
*/
private function collectEntries(): array
{
$entries = [];
foreach (ShoppingCollectOrder::whereNotNull('shopping_order_id')->get() as $collectOrder) {
$items = $collectOrder->shop_items;
if (empty($items)) {
continue;
}
$actual = 0.0;
foreach ($items as $item) {
$actual += (float) (is_array($item) ? ($item['points_total'] ?? 0) : ($item->points_total ?? 0));
}
$actual = round($actual, 2);
$stored = (float) $collectOrder->getRawOriginal('points');
if (abs($actual - $stored) < 0.005) {
continue;
}
foreach (DB::table('user_sales_volumes')->where('shopping_order_id', $collectOrder->shopping_order_id)->get(['user_id', 'month', 'year', 'status']) as $volume) {
$delta = $actual - $stored;
$entries[] = [
'user_id' => $volume->user_id !== null ? (int) $volume->user_id : null,
'year' => (int) $volume->year,
'month' => (int) $volume->month,
'source' => self::SOURCE_COLLECT,
'delta' => (int) $volume->status === 6 ? -$delta : $delta,
];
}
}
return $entries;
}
/**
* Homeparty-Positionen, die beim Speichern aufgerundet wurden. Der Betrag ist
* eine OBERGRENZE (siehe Klassen-Doc). Traegt eine Homeparty mehrere
* Bestellungen, wird sie uebersprungen -- dann ist die Zuordnung der Punkte
* zur Bestellung nicht eindeutig.
*
* @param array<int, string> $cutoffs
* @return list<array{user_id: int|null, year: int, month: int, source: string, delta: float}>
*/
private function homepartyEntries(array $cutoffs): array
{
// Die Tabelle hat keine Migration und fehlt in manchen Schemata
// (z.B. der SQLite-Testdatenbank).
if (! Schema::hasTable('homeparty_user_order_items')) {
return [];
}
$rows = DB::table('homeparty_user_order_items as t')
->join('products as p', 'p.id', '=', 't.product_id')
->join('homeparty_users as hu', 'hu.id', '=', 't.homeparty_user_id')
->whereRaw('p.points <> FLOOR(p.points)')
->whereRaw('CAST(t.points AS DECIMAL(10,2)) - CAST(p.points AS DECIMAL(10,2)) = 0.5')
->get(['hu.homeparty_id', 't.product_id', 't.qty', 't.created_at']);
$byParty = [];
foreach ($rows as $row) {
if (! $this->afterCutoff($cutoffs, (int) $row->product_id, (string) $row->created_at)) {
continue;
}
$partyId = (int) $row->homeparty_id;
$byParty[$partyId] = ($byParty[$partyId] ?? 0.0) + 0.5 * (int) $row->qty;
}
$entries = [];
foreach ($byParty as $partyId => $overbooked) {
$orders = DB::table('shopping_orders')->where('homeparty_id', $partyId)->pluck('id');
if ($orders->count() !== 1) {
continue;
}
foreach (DB::table('user_sales_volumes')->where('shopping_order_id', $orders->first())->get(['user_id', 'month', 'year', 'status']) as $volume) {
$delta = -$overbooked;
$entries[] = [
'user_id' => $volume->user_id !== null ? (int) $volume->user_id : null,
'year' => (int) $volume->year,
'month' => (int) $volume->month,
'source' => self::SOURCE_HOMEPARTY,
'delta' => (int) $volume->status === 6 ? -$delta : $delta,
];
}
}
return $entries;
}
/**
* @param array<int, string> $cutoffs
*/
private function afterCutoff(array $cutoffs, int $productId, string $createdAt): bool
{
return isset($cutoffs[$productId]) && $createdAt >= $cutoffs[$productId];
}
private function withinRange(int $year, int $month, ?string $from, ?string $to): bool
{
$key = sprintf('%04d-%02d', $year, $month);
return ($from === null || $key >= $from) && ($to === null || $key <= $to);
}
/**
* @param list<array{user_id: int|null, year: int, month: int, source: string, delta: float}> $entries
* @return array{rows: list<array<string, mixed>>, by_month: list<array<string, mixed>>, totals: array<string, mixed>, credits_owed: float, overbooked: float}
*/
private function aggregate(array $entries): array
{
$names = $this->consultantNames(array_filter(array_column($entries, 'user_id'), fn ($id) => $id !== null));
$byUserMonth = [];
foreach ($entries as $entry) {
$key = $entry['user_id'].'|'.$entry['year'].'|'.$entry['month'];
$byUserMonth[$key] ??= [
'user_id' => $entry['user_id'],
'consultant' => $names[$entry['user_id']] ?? ('#'.($entry['user_id'] ?? '?')),
'year' => $entry['year'],
'month' => $entry['month'],
'addon' => 0.0,
'collect' => 0.0,
'homeparty' => 0.0,
'delta' => 0.0,
];
$byUserMonth[$key][$entry['source']] += $entry['delta'];
$byUserMonth[$key]['delta'] += $entry['delta'];
}
$rows = array_values(array_map(
fn (array $row) => array_merge($row, [
'addon' => round($row['addon'], 2),
'collect' => round($row['collect'], 2),
'homeparty' => round($row['homeparty'], 2),
'delta' => round($row['delta'], 2),
]),
$byUserMonth
));
usort($rows, fn (array $a, array $b) => [$a['year'], $a['month'], $b['delta']] <=> [$b['year'], $b['month'], $a['delta']]);
$byMonth = [];
foreach ($rows as $row) {
$key = $row['year'].'|'.$row['month'];
$byMonth[$key] ??= ['year' => $row['year'], 'month' => $row['month'], 'addon' => 0.0, 'collect' => 0.0, 'homeparty' => 0.0, 'delta' => 0.0, 'users' => 0];
$byMonth[$key]['addon'] += $row['addon'];
$byMonth[$key]['collect'] += $row['collect'];
$byMonth[$key]['homeparty'] += $row['homeparty'];
$byMonth[$key]['delta'] += $row['delta'];
$byMonth[$key]['users']++;
}
$byMonth = array_values(array_map(
fn (array $row) => array_merge($row, [
'addon' => round($row['addon'], 2),
'collect' => round($row['collect'], 2),
'homeparty' => round($row['homeparty'], 2),
'delta' => round($row['delta'], 2),
]),
$byMonth
));
usort($byMonth, fn (array $a, array $b) => [$a['year'], $a['month']] <=> [$b['year'], $b['month']]);
return [
'rows' => $rows,
'by_month' => $byMonth,
'totals' => [
'addon' => round(array_sum(array_column($rows, 'addon')), 2),
'collect' => round(array_sum(array_column($rows, 'collect')), 2),
'homeparty' => round(array_sum(array_column($rows, 'homeparty')), 2),
'delta' => round(array_sum(array_column($rows, 'delta')), 2),
'users' => count(array_unique(array_column($rows, 'user_id'), SORT_REGULAR)),
],
'credits_owed' => round(array_sum(array_filter(array_column($rows, 'delta'), fn (float $d) => $d > 0)), 2),
'overbooked' => round(array_sum(array_filter(array_column($rows, 'delta'), fn (float $d) => $d < 0)), 2),
];
}
/**
* @param array<int, int> $userIds
* @return array<int, string>
*/
private function consultantNames(array $userIds): array
{
if ($userIds === []) {
return [];
}
return DB::table('users as u')
->leftJoin('user_accounts as a', 'a.id', '=', 'u.account_id')
->whereIn('u.id', array_unique($userIds))
->get(['u.id', 'u.email', 'a.first_name', 'a.last_name', 'a.company'])
->mapWithKeys(function ($row) {
$name = trim(($row->first_name ?? '').' '.($row->last_name ?? ''));
if ($name === '') {
$name = $row->company ?: $row->email;
}
return [(int) $row->id => $name.' (#'.$row->id.')'];
})
->all();
}
}