7), zusaetzlich war user_abo_one_time_items.points * eine INT-Spalte. Betrifft shopping_order_items mit is_abo_addon = 1. * B) Shop-API-Sammelbestellungen: shopping_collect_orders.points war INT, die * korrekt gerechnete Summe wurde beim Speichern gerundet (239,5 -> 240). * * Beide Fehler laufen ueber shopping_orders.points in user_sales_volumes und * damit in die Provisionsberechnung. * * Bewusste Entscheidungen: * - Es wird ausschliesslich mit DELTAS gearbeitet, nie mit einer Neuberechnung * aus den Positionen. Sammelbestellungen tragen nur eine Dummy-Position ohne * Produkt; eine Neuberechnung wuerde deren Punkte auf 0 setzen. * - Abgeschlossene Monate bleiben unangetastet (Entscheidung 2026-09-18). * Bereits ausgestellte Rechnungen/Lieferscheine werden NICHT neu erzeugt -- * sie liegen als PDF-Datei im Storage und bleiben, wie sie ausgestellt wurden. * - Positionen mit 0 Punkten (Gratis-/Comp-Artikel) werden nicht angefasst. * - Storno-Zeilen (status 6) spiegeln ihre Ursprungsbuchung und bekommen das * invertierte Delta, damit die Spiegelung erhalten bleibt. */ class DecimalPointsRepairService { /** * @return array{ * month: int, * year: int, * dry_run: bool, * addon_items: list, * collect_orders: list, * orders: list, * sales_volumes: list, * users_recalculated: list, * total_delta: float * } */ public function repair(int $month, int $year, bool $dryRun = true): array { $salesVolumes = DB::table('user_sales_volumes') ->where('month', $month) ->where('year', $year) ->whereNotNull('shopping_order_id') ->get(['id', 'user_id', 'shopping_order_id', 'points', 'status']); $orderIds = $salesVolumes->pluck('shopping_order_id')->unique()->values(); $addonItems = $this->findTruncatedAddonItems($orderIds->all()); $collectOrders = $this->findRoundedCollectOrders($orderIds->all()); $deltaByOrder = []; foreach ($addonItems as $row) { $deltaByOrder[$row['order_id']] = ($deltaByOrder[$row['order_id']] ?? 0.0) + $row['delta']; } foreach ($collectOrders as $row) { $deltaByOrder[$row['order_id']] = ($deltaByOrder[$row['order_id']] ?? 0.0) + $row['delta']; } $orders = []; foreach (DB::table('shopping_orders')->whereIn('id', array_keys($deltaByOrder))->get(['id', 'points']) as $order) { $delta = round($deltaByOrder[$order->id], 2); $orders[] = [ 'order_id' => (int) $order->id, 'from' => (float) $order->points, 'to' => round((float) $order->points + $delta, 2), 'delta' => $delta, ]; } $affectedVolumes = []; foreach ($salesVolumes as $volume) { if (! isset($deltaByOrder[$volume->shopping_order_id])) { continue; } // Storno spiegelt die Ursprungsbuchung negativ -- Delta mitspiegeln. $delta = round($deltaByOrder[$volume->shopping_order_id], 2); if ((int) $volume->status === 6) { $delta = -$delta; } $affectedVolumes[] = [ 'sv_id' => (int) $volume->id, 'user_id' => $volume->user_id !== null ? (int) $volume->user_id : null, 'status' => (int) $volume->status, 'from' => (float) $volume->points, 'to' => round((float) $volume->points + $delta, 2), 'delta' => $delta, ]; } $userIds = collect($affectedVolumes) ->pluck('user_id') ->filter(fn (?int $id) => $id !== null) ->unique() ->values() ->all(); if (! $dryRun) { $this->apply($addonItems, $collectOrders, $orders, $affectedVolumes); foreach ($userIds as $userId) { SalesPointsVolume::reCalculateSalesPointsVolume($userId, $month, $year); } } return [ 'month' => $month, 'year' => $year, 'dry_run' => $dryRun, 'addon_items' => $addonItems, 'collect_orders' => $collectOrders, 'orders' => $orders, 'sales_volumes' => $affectedVolumes, 'users_recalculated' => $userIds, 'total_delta' => round(array_sum(array_column($orders, 'delta')), 2), ]; } /** * Abo-Einmalartikel, deren Punkte auf die naechste ganze Zahl abgeschnitten * wurden. Bewusst eng gefasst: nur is_abo_addon-Positionen, nur Produkte mit * Nachkommastelle, und nur wenn der gespeicherte Wert exakt dem abgerundeten * Produktwert entspricht. Damit bleiben legitime Abweichungen (Gratis-Artikel * mit 0 Punkten, historisch geaenderte Produktpunkte) unangetastet. * * @param list $orderIds * @return list */ private function findTruncatedAddonItems(array $orderIds): array { if ($orderIds === []) { return []; } $rows = DB::table('shopping_order_items as i') ->join('products as p', 'p.id', '=', 'i.product_id') ->whereIn('i.shopping_order_id', $orderIds) ->where('i.is_abo_addon', 1) ->whereNull('i.deleted_at') ->whereRaw('p.points <> FLOOR(p.points)') ->whereRaw('i.points = FLOOR(p.points)') ->orderBy('i.id') ->get(['i.id', 'i.shopping_order_id', 'i.points', 'i.qty', 'p.points as product_points', 'p.number', 'p.name']); return $rows->map(fn ($row) => [ 'order_id' => (int) $row->shopping_order_id, 'item_id' => (int) $row->id, 'product' => trim($row->number.' '.$row->name), 'from' => (float) $row->points, 'to' => (float) $row->product_points, 'qty' => (int) $row->qty, 'delta' => round(((float) $row->product_points - (float) $row->points) * (int) $row->qty, 2), ])->all(); } /** * Sammelbestellungen, deren gespeicherte Punktesumme von der Summe der * Positionen im shop_items-JSON abweicht (Rundung der frueheren INT-Spalte). * * @param list $orderIds * @return list */ private function findRoundedCollectOrders(array $orderIds): array { if ($orderIds === []) { return []; } $repaired = []; foreach (ShoppingCollectOrder::whereIn('shopping_order_id', $orderIds)->get() as $collectOrder) { $stored = (float) $collectOrder->getRawOriginal('points'); $actual = $this->sumCollectOrderPoints($collectOrder); if ($actual === null || abs($actual - $stored) < 0.005) { continue; } $repaired[] = [ 'order_id' => (int) $collectOrder->shopping_order_id, 'collect_id' => (int) $collectOrder->id, 'from' => $stored, 'to' => $actual, 'delta' => round($actual - $stored, 2), ]; } return $repaired; } /** * Punktesumme einer Sammelbestellung aus dem shop_items-JSON. Gibt null * zurueck, wenn keine Positionen hinterlegt sind -- dann fehlt die * Vergleichsbasis und es wird bewusst nichts geaendert. */ private function sumCollectOrderPoints(ShoppingCollectOrder $collectOrder): ?float { $items = $collectOrder->shop_items; if (empty($items)) { return null; } $sum = 0.0; foreach ($items as $item) { $sum += (float) (is_array($item) ? ($item['points_total'] ?? 0) : ($item->points_total ?? 0)); } return round($sum, 2); } /** * @param list $addonItems * @param list $collectOrders * @param list $orders * @param list $salesVolumes */ private function apply(array $addonItems, array $collectOrders, array $orders, array $salesVolumes): void { DB::transaction(function () use ($addonItems, $collectOrders, $orders, $salesVolumes) { foreach ($addonItems as $row) { DB::table('shopping_order_items')->where('id', $row['item_id'])->update(['points' => $row['to']]); } foreach ($collectOrders as $row) { DB::table('shopping_collect_orders')->where('id', $row['collect_id'])->update(['points' => $row['to']]); } foreach ($orders as $row) { DB::table('shopping_orders')->where('id', $row['order_id'])->update(['points' => $row['to']]); } foreach ($salesVolumes as $row) { DB::table('user_sales_volumes')->where('id', $row['sv_id'])->update(['points' => $row['to']]); } }); } }