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, * by_month: list, * 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 */ 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 $cutoffs * @return list */ 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 */ 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 $cutoffs * @return list */ 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 $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 $entries * @return array{rows: list>, by_month: list>, totals: array, 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 $userIds * @return array */ 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(); } }