, rebooked: array, created: array} */ public function repair(int $month, int $year, bool $dryRun = false): array { $start = Carbon::createFromDate($year, $month, 1)->startOfMonth(); $end = $start->copy()->endOfMonth(); $scanStart = $start->copy()->subMonth()->startOfMonth(); $orders = ShoppingOrder::query() ->whereNotNull('auth_user_id') ->where('paid', true) ->whereBetween('created_at', [$scanStart, $end]) ->whereHas('shopping_order_items.product', function ($q) { $q->where('sponsor_buying_points', true); }) ->with('shopping_order_items.product') ->orderBy('id') ->get(); $buyers = User::query() ->with(['account', 'user_sponsor.account']) ->whereIn('id', $orders->pluck('auth_user_id')->unique()) ->get() ->keyBy('id'); // Kandidaten-Infos vorab (Format wie Payment::addSponsorBuyingPoints), // damit Alt-Zeilen ohne Order-Referenz gebatcht geladen werden koennen. $candidateInfos = $orders ->flatMap(function (ShoppingOrder $order) use ($buyers) { $buyer = $buyers->get($order->auth_user_id); return $buyer === null ? [] : $order->shopping_order_items ->filter(fn ($item) => $item->product?->sponsor_buying_points) ->map(fn ($item) => 'VP: '.$buyer->getFullName(false).' | '.$item->product->name); }) ->unique() ->values(); // Sponsor-UNABHAENGIG deduplizieren: liegt die Buchung (nach Umhaengung) // bei einem anderen Sponsor, wird unterschieden — Firmenaccount hat nie // Anspruch auf VP-Punkte (User-Fehler bei Registrierung ueber die // Hauptwebseite, Kundenentscheid 2026-08-19) und wird automatisch auf den // Berater umgebucht; Berater-zu-Berater bleibt manuelle Pruefung. [$bookedOrderRows, $legacyInfoRows] = $this->existingBookings($orders->pluck('id'), $candidateInfos, $scanStart, $end); $companyUserId = (int) config('mivita.company_user_id'); $created = []; $rebooked = []; $skippedExisting = 0; $skippedOtherSponsor = []; $affectedSponsorIds = []; foreach ($orders as $order) { $buyer = $buyers->get($order->auth_user_id); $sponsor = $buyer?->user_sponsor; if (! $buyer || ! $sponsor) { continue; } foreach ($order->shopping_order_items as $item) { $product = $item->product; if (! $product || ! $product->sponsor_buying_points) { continue; } $info = 'VP: '.$buyer->getFullName(false).' | '.$product->name; $existingRow = $bookedOrderRows[$order->id] ?? null; if ($existingRow === null && ($legacyInfoRows[$info] ?? []) !== []) { // Alt-Zeile ohne Order-Referenz: deckt genau EINE Bestellung ab. $existingRow = array_shift($legacyInfoRows[$info]); } if ($existingRow !== null) { $existingSponsorId = (int) $existingRow['user_id']; if ($existingSponsorId === (int) $sponsor->id) { $skippedExisting++; continue; } if ($existingSponsorId === $companyUserId && (int) $sponsor->id !== $companyUserId) { $rebooked[] = [ 'order_id' => $order->id, 'sponsor_id' => (int) $sponsor->id, 'sponsor' => $sponsor->getFullName(false), 'buyer' => $buyer->getFullName(false), 'product' => $product->name, 'points' => (int) $product->sponsor_buying_points_amount, ]; if (! $dryRun) { $this->rebookFromCompany($existingRow, $order, $sponsor, $buyer, $product, $info, $month, $year, $affectedSponsorIds); } continue; } $skippedExisting++; $skippedOtherSponsor[] = [ 'order_id' => $order->id, 'buyer' => $buyer->getFullName(false), 'current_sponsor_id' => $sponsor->id, 'booked_sponsor_id' => $existingSponsorId, ]; Log::warning('SponsorBuyingPointsRepair: VP-Buchung liegt bei anderem Berater — manuell pruefen (Umhaengung?)', [ 'shopping_order_id' => $order->id, 'buyer_id' => $buyer->id, 'current_sponsor_id' => $sponsor->id, 'booked_sponsor_id' => $existingSponsorId, ]); continue; } $created[] = [ 'order_id' => $order->id, 'sponsor_id' => $sponsor->id, 'sponsor' => $sponsor->getFullName(false), 'buyer' => $buyer->getFullName(false), 'product' => $product->name, 'points' => (int) $product->sponsor_buying_points_amount, ]; if ($dryRun) { continue; } UserSalesVolume::create([ 'user_id' => $sponsor->id, 'shopping_order_id' => $order->id, 'month' => $month, 'year' => $year, 'date' => now()->format('d.m.Y'), 'points' => (int) $product->sponsor_buying_points_amount, 'status_points' => 2, 'total_net' => 0, 'message' => 'hinzugefügt: '.now()->format('d.m.Y').' (Nacht-Repair)', 'info' => $info, 'syslog' => [now()->format('d.m.Y-h:i:s') => 'sales:repair-sponsor-vp order #'.$order->id], 'status' => 5, ]); $bookedOrderRows[$order->id] = ['id' => 0, 'user_id' => (int) $sponsor->id, 'month' => $month, 'year' => $year]; $affectedSponsorIds[$sponsor->id] = true; Log::info('SponsorBuyingPointsRepair: VP-Punkte nachgebucht', [ 'shopping_order_id' => $order->id, 'sponsor_id' => $sponsor->id, 'buyer_id' => $buyer->id, 'points' => (int) $product->sponsor_buying_points_amount, 'month' => $month, 'year' => $year, ]); } } foreach (array_keys($affectedSponsorIds) as $sponsorId) { SalesPointsVolume::reCalculateSalesPointsVolume($sponsorId, $month, $year); } return [ 'checked' => $orders->count(), 'skipped_existing' => $skippedExisting, 'skipped_other_sponsor' => $skippedOtherSponsor, 'rebooked' => $rebooked, 'created' => $created, ]; } /** * Umbuchung Firmenaccount → Berater (Kundenentscheid 2026-08-19: VP-Punkte * stehen immer dem Berater zu; Landung beim Firmenaccount = User-Fehler bei * der Registrierung ueber die Hauptwebseite). * * - Zeile im Ziel-Monat: in place umhaengen (eine Zeile, Syslog-Spur). * - Zeile in einem abgeschlossenen Monat: der Monat bleibt unangetastet — * neue Buchung beim Berater im Ziel-Monat plus Gegenbuchung (negative * Punkte) beim Firmenaccount im Ziel-Monat, damit die Summen stimmen. * * @param array{id: int, user_id: int, month: int, year: int} $existingRow * @param array $affectedSponsorIds */ private function rebookFromCompany(array $existingRow, ShoppingOrder $order, User $sponsor, User $buyer, $product, string $info, int $month, int $year, array &$affectedSponsorIds): void { $points = (int) $product->sponsor_buying_points_amount; $isTargetMonth = (int) $existingRow['month'] === $month && (int) $existingRow['year'] === $year; if ($isTargetMonth) { $volume = UserSalesVolume::find($existingRow['id']); if ($volume !== null) { $syslog = $volume->syslog ?? []; $syslog[now()->format('d.m.Y-h:i:s')] = 'sales:repair-sponsor-vp: Umbuchung Firmenaccount #'.$existingRow['user_id'].' -> Berater #'.$sponsor->id.' (order #'.$order->id.')'; $volume->user_id = $sponsor->id; $volume->shopping_order_id = $order->id; $volume->info = $info; $volume->syslog = $syslog; $volume->save(); } } else { UserSalesVolume::create([ 'user_id' => $sponsor->id, 'shopping_order_id' => $order->id, 'month' => $month, 'year' => $year, 'date' => now()->format('d.m.Y'), 'points' => $points, 'status_points' => 2, 'total_net' => 0, 'message' => 'hinzugefügt: '.now()->format('d.m.Y').' (Umbuchung vom Firmenaccount)', 'info' => $info, 'syslog' => [now()->format('d.m.Y-h:i:s') => 'sales:repair-sponsor-vp order #'.$order->id.' Umbuchung von Firmenaccount #'.$existingRow['user_id'].' ('.$existingRow['month'].'/'.$existingRow['year'].')'], 'status' => 5, ]); // Bewusst abweichendes info-Format ("VP-Storno"), damit die // Gegenbuchung nie als Alt-Buchung einer Bestellung erkannt wird. UserSalesVolume::create([ 'user_id' => $existingRow['user_id'], 'shopping_order_id' => null, 'month' => $month, 'year' => $year, 'date' => now()->format('d.m.Y'), 'points' => -$points, 'status_points' => 2, 'total_net' => 0, 'message' => 'hinzugefügt: '.now()->format('d.m.Y').' (Gegenbuchung Umbuchung an Berater)', 'info' => 'VP-Storno: '.$buyer->getFullName(false).' | '.$product->name.' -> Berater #'.$sponsor->id, 'syslog' => [now()->format('d.m.Y-h:i:s') => 'sales:repair-sponsor-vp order #'.$order->id.' Gegenbuchung zu Zeile #'.$existingRow['id']], 'status' => 5, ]); } $affectedSponsorIds[$sponsor->id] = true; $affectedSponsorIds[$existingRow['user_id']] = true; Log::info('SponsorBuyingPointsRepair: VP-Punkte vom Firmenaccount auf Berater umgebucht', [ 'shopping_order_id' => $order->id, 'buyer_id' => $buyer->id, 'company_user_id' => $existingRow['user_id'], 'sponsor_id' => $sponsor->id, 'points' => $points, 'in_place' => $isTargetMonth, ]); } /** * Bestehende VP-Buchungen im Pruef-Fenster, einmal gebatcht und bewusst * sponsor-unabhaengig (Umhaengungs-Faelle!): Order-Referenzen * (monatsunabhaengig eindeutig) plus Alt-Zeilen ohne Referenz je info-Text — * jede Alt-Zeile deckt genau EINE Bestellung ab (zaehlend, damit zwei * gleiche Bestellungen nicht als eine erkannt werden). * * @param Collection $orderIds * @param Collection $candidateInfos * @return array{0: array, 1: array>} */ private function existingBookings(Collection $orderIds, Collection $candidateInfos, Carbon $scanStart, Carbon $end): array { if ($orderIds->isEmpty()) { return [[], []]; } $rows = UserSalesVolume::query() ->where('status', 5) ->where(function ($q) use ($orderIds, $candidateInfos, $scanStart, $end) { $q->whereIn('shopping_order_id', $orderIds) ->orWhere(function ($qq) use ($candidateInfos, $scanStart, $end) { $qq->whereNull('shopping_order_id') ->whereIn('info', $candidateInfos) ->where(function ($window) use ($scanStart, $end) { $window->where(fn ($w) => $w->where('month', (int) $scanStart->month)->where('year', (int) $scanStart->year)) ->orWhere(fn ($w) => $w->where('month', (int) $end->month)->where('year', (int) $end->year)); }); }); }) ->get(['id', 'user_id', 'shopping_order_id', 'info', 'month', 'year']); $bookedOrderRows = []; $legacyInfoRows = []; foreach ($rows as $row) { $rowData = [ 'id' => (int) $row->id, 'user_id' => (int) $row->user_id, 'month' => (int) $row->month, 'year' => (int) $row->year, ]; if ($row->shopping_order_id !== null) { $bookedOrderRows[(int) $row->shopping_order_id] = $rowData; } else { $legacyInfoRows[$row->info][] = $rowData; } } return [$bookedOrderRows, $legacyInfoRows]; } }