From a21bebc9321a957c476c9469acdd0d9be70eca33 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 23 Sep 2026 15:42:27 +0000 Subject: [PATCH] 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) --- app/Console/Commands/RepairDecimalPoints.php | 103 ++++++ .../Commands/ReportDecimalPointsDiff.php | 119 ++++++ app/Models/HomepartyUserOrderItem.php | 2 +- app/Models/Product.php | 2 +- app/Models/ShoppingCollectOrder.php | 2 +- app/Models/ShoppingOrderItem.php | 2 +- app/Models/UserAboOneTimeItem.php | 4 +- app/Services/AboOrderCart.php | 4 +- .../DecimalPointsDiffReportService.php | 341 ++++++++++++++++++ app/Services/DecimalPointsRepairService.php | 251 +++++++++++++ ...ge_remaining_points_columns_to_decimal.php | 60 +++ tests/Feature/AboOneTimeDecimalPointsTest.php | 84 +++++ tests/Feature/AboOneTimeServiceTest.php | 2 +- tests/Feature/DecimalPointsDiffReportTest.php | 284 +++++++++++++++ tests/Feature/DecimalPointsRepairTest.php | 288 +++++++++++++++ 15 files changed, 1539 insertions(+), 9 deletions(-) create mode 100644 app/Console/Commands/RepairDecimalPoints.php create mode 100644 app/Console/Commands/ReportDecimalPointsDiff.php create mode 100644 app/Services/DecimalPointsDiffReportService.php create mode 100644 app/Services/DecimalPointsRepairService.php create mode 100644 database/migrations/2026_09_18_100000_change_remaining_points_columns_to_decimal.php create mode 100644 tests/Feature/AboOneTimeDecimalPointsTest.php create mode 100644 tests/Feature/DecimalPointsDiffReportTest.php create mode 100644 tests/Feature/DecimalPointsRepairTest.php diff --git a/app/Console/Commands/RepairDecimalPoints.php b/app/Console/Commands/RepairDecimalPoints.php new file mode 100644 index 00000000..2f899503 --- /dev/null +++ b/app/Console/Commands/RepairDecimalPoints.php @@ -0,0 +1,103 @@ +option('month') ?: now()->month); + $year = (int) ($this->option('year') ?: now()->year); + $dryRun = ! $this->option('force'); + + $isCurrentMonth = $month === (int) now()->month && $year === (int) now()->year; + if (! $dryRun && ! $isCurrentMonth && ! $this->option('allow-closed-month')) { + $this->error("Korrektur in {$month}/{$year} abgelehnt: das ist nicht der laufende Monat."); + $this->line('Abgeschlossene Provisionsmonate bleiben so, wie sie abgerechnet wurden.'); + $this->line('Nur wenn das wirklich gewollt ist: --allow-closed-month'); + + return self::FAILURE; + } + + $this->info("Punkte-Repair für {$month}/{$year}".($dryRun ? ' (TROCKENLAUF — es wird nichts geschrieben)' : '')); + + $result = $service->repair($month, $year, $dryRun); + + if ($result['orders'] === []) { + $this->info('Keine abweichenden Punkte gefunden. Nichts zu tun.'); + + return self::SUCCESS; + } + + if ($result['addon_items'] !== []) { + $this->newLine(); + $this->info('Abo-Einmalartikel (abgeschnitten):'); + $this->table( + ['Order', 'Position', 'Produkt', 'von', 'auf', 'Menge', 'Delta'], + array_map(fn (array $row) => [ + $row['order_id'], + $row['item_id'], + mb_strimwidth($row['product'], 0, 40, '…'), + number_format($row['from'], 2, ',', '.'), + number_format($row['to'], 2, ',', '.'), + $row['qty'], + number_format($row['delta'], 2, ',', '.'), + ], $result['addon_items']) + ); + } + + if ($result['collect_orders'] !== []) { + $this->newLine(); + $this->info('Sammelbestellungen (gerundet):'); + $this->table( + ['Order', 'Sammel-ID', 'von', 'auf', 'Delta'], + array_map(fn (array $row) => [ + $row['order_id'], + $row['collect_id'], + number_format($row['from'], 2, ',', '.'), + number_format($row['to'], 2, ',', '.'), + number_format($row['delta'], 2, ',', '.'), + ], $result['collect_orders']) + ); + } + + $this->newLine(); + $this->info('Zusammenfassung:'); + $this->line(' Korrigierte Positionen: '.count($result['addon_items'])); + $this->line(' Korrigierte Sammelbest.: '.count($result['collect_orders'])); + $this->line(' Betroffene Bestellungen: '.count($result['orders'])); + $this->line(' Betroffene Umsatz-Zeilen: '.count($result['sales_volumes'])); + $this->line(' Betroffene Berater: '.count($result['users_recalculated'])); + $this->line(' Punkte-Differenz gesamt: '.number_format($result['total_delta'], 2, ',', '.')); + + if ($dryRun) { + $this->newLine(); + $this->warn('Trockenlauf — es wurde nichts geschrieben. Zum Schreiben: --force'); + + return self::SUCCESS; + } + + $this->newLine(); + $this->info('Geschrieben. Monatssummen für '.count($result['users_recalculated']).' Berater neu berechnet.'); + $this->line('Bereits ausgestellte Rechnungen/Lieferscheine bleiben unverändert (PDFs im Storage).'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ReportDecimalPointsDiff.php b/app/Console/Commands/ReportDecimalPointsDiff.php new file mode 100644 index 00000000..430bf24c --- /dev/null +++ b/app/Console/Commands/ReportDecimalPointsDiff.php @@ -0,0 +1,119 @@ +option('from') ?: null; + $to = $this->option('to') ?: null; + + $this->info('Punkte-Differenzen'.($from || $to ? " ({$from} bis {$to})" : ' (gesamter Zeitraum)')); + $this->line('Vorzeichen: + = Berater bekam ZU WENIG (Gutschrift), − = ZU VIEL'); + $this->newLine(); + + $report = $service->report($from, $to); + + if ($report['rows'] === []) { + $this->info('Keine Differenzen gefunden.'); + + return self::SUCCESS; + } + + if ($this->option('details')) { + $this->info('Je Berater und Monat:'); + $this->table( + ['Monat', 'Berater', 'Abo-Addon', 'Sammelbest.', 'Homeparty', 'Summe'], + array_map(fn (array $row) => [ + sprintf('%02d/%d', $row['month'], $row['year']), + mb_strimwidth($row['consultant'], 0, 38, '…'), + $this->fmt($row['addon']), + $this->fmt($row['collect']), + $this->fmt($row['homeparty']), + $this->fmt($row['delta']), + ], $report['rows']) + ); + $this->newLine(); + } + + $this->info('Je Monat:'); + $this->table( + ['Monat', 'Berater', 'Abo-Addon', 'Sammelbest.', 'Homeparty', 'Summe'], + array_map(fn (array $row) => [ + sprintf('%02d/%d', $row['month'], $row['year']), + $row['users'], + $this->fmt($row['addon']), + $this->fmt($row['collect']), + $this->fmt($row['homeparty']), + $this->fmt($row['delta']), + ], $report['by_month']) + ); + + $totals = $report['totals']; + $this->newLine(); + $this->info('Gesamt:'); + $this->line(' Abo-Einmalartikel (zu wenig): '.$this->fmt($totals['addon'])); + $this->line(' Sammelbestellungen (zu viel): '.$this->fmt($totals['collect'])); + $this->line(' Homeparty (zu viel, Obergrenze): '.$this->fmt($totals['homeparty'])); + $this->line(' ── Netto: '.$this->fmt($totals['delta'])); + $this->newLine(); + $this->line(' Gutschriften an Berater: '.$this->fmt($report['credits_owed'])); + $this->line(' Zu viel gebucht: '.$this->fmt($report['overbooked'])); + $this->line(' Betroffene Berater: '.$totals['users']); + + if ($csv = $this->option('csv')) { + $this->writeCsv($csv, $report['rows']); + $this->newLine(); + $this->info("CSV geschrieben: {$csv}"); + } + + return self::SUCCESS; + } + + private function fmt(float $value): string + { + return ($value > 0 ? '+' : '').number_format($value, 2, ',', '.'); + } + + /** + * @param list> $rows + */ + private function writeCsv(string $path, array $rows): void + { + $handle = fopen($path, 'w'); + fputcsv($handle, ['Jahr', 'Monat', 'UserId', 'Berater', 'AboAddon', 'Sammelbestellung', 'Homeparty', 'Summe'], ';'); + + foreach ($rows as $row) { + fputcsv($handle, [ + $row['year'], + sprintf('%02d', $row['month']), + $row['user_id'], + $row['consultant'], + number_format((float) $row['addon'], 2, ',', ''), + number_format((float) $row['collect'], 2, ',', ''), + number_format((float) $row['homeparty'], 2, ',', ''), + number_format((float) $row['delta'], 2, ',', ''), + ], ';'); + } + + fclose($handle); + } +} diff --git a/app/Models/HomepartyUserOrderItem.php b/app/Models/HomepartyUserOrderItem.php index 81f87d9c..34a7536f 100644 --- a/app/Models/HomepartyUserOrderItem.php +++ b/app/Models/HomepartyUserOrderItem.php @@ -21,7 +21,7 @@ use Illuminate\Database\Eloquent\Model; * @property float $price * @property float $price_net * @property float $tax_rate - * @property int $points + * @property float $points * @property float $margin * @property float $ek_price * @property string $slug diff --git a/app/Models/Product.php b/app/Models/Product.php index 17754ca2..69024590 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -94,7 +94,7 @@ use Illuminate\Support\Carbon; * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Product whereShowAt($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\Models\Product whereWeight($value) * - * @property int|null $points + * @property float|null $points * @property-read Collection|ProductImage[] $imagesActive * @property-read int|null $images_active_count * diff --git a/app/Models/ShoppingCollectOrder.php b/app/Models/ShoppingCollectOrder.php index cf8cc37b..4f67c4f2 100644 --- a/app/Models/ShoppingCollectOrder.php +++ b/app/Models/ShoppingCollectOrder.php @@ -24,7 +24,7 @@ use Illuminate\Database\Eloquent\Model; * @property float|null $price_total * @property float|null $tax_total * @property int $qty_total - * @property int|null $points + * @property float|null $points * @property string|null $tax_split * @property AsArrayObject|null $orders * @property AsArrayObject|null $shop_items diff --git a/app/Models/ShoppingOrderItem.php b/app/Models/ShoppingOrderItem.php index 2c9f5789..f6249fec 100644 --- a/app/Models/ShoppingOrderItem.php +++ b/app/Models/ShoppingOrderItem.php @@ -59,7 +59,7 @@ use Illuminate\Support\Carbon; * @property string|null $tax * @property string|null $price_vk_net * @property string|null $discount - * @property int|null $points + * @property float|null $points * * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem whereDiscount($value) * @method static \Illuminate\Database\Eloquent\Builder|ShoppingOrderItem wherePoints($value) diff --git a/app/Models/UserAboOneTimeItem.php b/app/Models/UserAboOneTimeItem.php index 2b5b2779..77d7f55a 100644 --- a/app/Models/UserAboOneTimeItem.php +++ b/app/Models/UserAboOneTimeItem.php @@ -29,7 +29,7 @@ use Illuminate\Support\Carbon; * @property float|null $tax * @property float|null $price_vk_net * @property float|null $discount - * @property int|null $points + * @property float|null $points * @property int $status * @property-read Product $product * @property-read UserAbo $user_abo @@ -54,7 +54,7 @@ class UserAboOneTimeItem extends Model 'tax' => 'float', 'price_vk_net' => 'float', 'discount' => 'float', - 'points' => 'int', + 'points' => 'float', 'status' => 'int', ]; diff --git a/app/Services/AboOrderCart.php b/app/Services/AboOrderCart.php index b81c43a6..52f25386 100644 --- a/app/Services/AboOrderCart.php +++ b/app/Services/AboOrderCart.php @@ -290,7 +290,7 @@ class AboOrderCart * Erstellt einen Preis-Snapshot für ein einmalig hinzugefügtes Produkt, konsistent * zur Cart-Preisberechnung in {@see self::addProductToCart()}. * - * @return array{price: float, price_net: float, tax_rate: float, tax: float, price_vk_net: float, discount: float, points: int} + * @return array{price: float, price_net: float, tax_rate: float, tax: float, price_vk_net: float, discount: float, points: float} */ public static function buildOneTimeSnapshot(Product $product, UserAbo $user_abo): array { @@ -322,7 +322,7 @@ class AboOrderCart 'tax' => $tax, 'price_vk_net' => $price_vk_net, 'discount' => $discount, - 'points' => (int) $product->points, + 'points' => round((float) $product->points, 2), ]; } diff --git a/app/Services/DecimalPointsDiffReportService.php b/app/Services/DecimalPointsDiffReportService.php new file mode 100644 index 00000000..0e773b7b --- /dev/null +++ b/app/Services/DecimalPointsDiffReportService.php @@ -0,0 +1,341 @@ + 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(); + } +} diff --git a/app/Services/DecimalPointsRepairService.php b/app/Services/DecimalPointsRepairService.php new file mode 100644 index 00000000..c18b8462 --- /dev/null +++ b/app/Services/DecimalPointsRepairService.php @@ -0,0 +1,251 @@ + 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']]); + } + }); + } +} diff --git a/database/migrations/2026_09_18_100000_change_remaining_points_columns_to_decimal.php b/database/migrations/2026_09_18_100000_change_remaining_points_columns_to_decimal.php new file mode 100644 index 00000000..ca282894 --- /dev/null +++ b/database/migrations/2026_09_18_100000_change_remaining_points_columns_to_decimal.php @@ -0,0 +1,60 @@ + 7) + * - homeparty_user_order_items: points (MySQL rundete auf: 7,5 -> 8) + * - shopping_collect_orders: points (Summe des Shop-API-Pfads wurde gerundet) + * + * Grund: Produkte können Dezimal-Punkte haben (z.B. 7,5 Punkte). Die Punkte + * fließen über shopping_orders.points in user_sales_volumes und damit in die + * Provisionsberechnung ein. + * + * Hinweis: `homeparty_user_order_items` hat keine eigene Migration und existiert + * daher nicht in jedem Schema (z.B. nicht in der SQLite-Testdatenbank). Alle + * Änderungen sind deshalb über hasColumn() abgesichert. + */ +return new class extends Migration +{ + /** + * @var list + */ + private array $tables = [ + 'user_abo_one_time_items', + 'homeparty_user_order_items', + 'shopping_collect_orders', + ]; + + public function up(): void + { + foreach ($this->tables as $table) { + if (! Schema::hasColumn($table, 'points')) { + continue; + } + + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->decimal('points', 10, 2)->unsigned()->nullable()->change(); + }); + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + if (! Schema::hasColumn($table, 'points')) { + continue; + } + + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->unsignedInteger('points')->nullable()->change(); + }); + } + } +}; diff --git a/tests/Feature/AboOneTimeDecimalPointsTest.php b/tests/Feature/AboOneTimeDecimalPointsTest.php new file mode 100644 index 00000000..ed95e42c --- /dev/null +++ b/tests/Feature/AboOneTimeDecimalPointsTest.php @@ -0,0 +1,84 @@ + 7); zusätzlich war user_abo_one_time_items.points noch + * eine INT-Spalte. Die Punkte fließen über shopping_orders.points in + * user_sales_volumes und damit in die Provisionsberechnung ein, der Verlust war + * also nicht nur ein Anzeigefehler auf der Rechnung. + */ + +use App\Models\Product; +use App\Models\UserAboOneTimeItem; +use App\Services\AboOneTimeService; +use App\Services\AboOrderCart; +use App\Services\Facade\Yard; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; +use Tests\TestCase; + +uses(TestCase::class, RefreshDatabase::class); + +/** Setzt die Punkte am Produkt an der Datenbank vorbei am Mutator-freien Weg. */ +function makeProductWithPoints(array $showOn, float $points): Product +{ + $product = makeProduct($showOn); + DB::table('products')->where('id', $product->id)->update(['points' => $points]); + + return Product::findOrFail($product->id); +} + +describe('Abo-Einmalartikel mit Dezimal-Punkten', function () { + beforeEach(function () { + makeShopEnv(); + $this->service = new AboOneTimeService; + }); + + it('behält halbe Punkte im Preis-Snapshot', function () { + $abo = makeMeAbo(); + $product = makeProductWithPoints(['2'], 7.5); + + $snapshot = AboOrderCart::buildOneTimeSnapshot($product, $abo); + + expect($snapshot['points'])->toBe(7.5); + }); + + it('persistiert halbe Punkte am Einmal-Artikel', function () { + $abo = makeMeAbo(); + $product = makeProductWithPoints(['2'], 7.5); + + $message = $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + + expect($message)->toBeNull(); + + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->firstOrFail(); + expect((float) $item->points)->toBe(7.5) + ->and((float) DB::table('user_abo_one_time_items')->where('id', $item->id)->value('points'))->toBe(7.5); + }); + + it('übernimmt halbe Punkte in den Abo-Yard', function () { + $abo = makeMeAbo(); + $product = makeProductWithPoints(['2'], 7.5); + + $this->service->handleAction($abo, ['action' => 'add', 'product_id' => $product->id]); + + $item = UserAboOneTimeItem::where('user_abo_id', $abo->id)->firstOrFail(); + $item->update(['qty' => 2, 'confirmed_qty' => 2, 'confirmed_at' => now()]); + + AboOrderCart::addOneTimeItemsToYard($abo->fresh()); + + expect(Yard::instance(AboOrderCart::INSTANCE)->points())->toBe(15.0); + }); + + it('lässt ganzzahlige Punkte unverändert', function () { + $abo = makeMeAbo(); + $product = makeProductWithPoints(['2'], 14.0); + + $snapshot = AboOrderCart::buildOneTimeSnapshot($product, $abo); + + expect($snapshot['points'])->toBe(14.0); + }); +})->group('abo'); diff --git a/tests/Feature/AboOneTimeServiceTest.php b/tests/Feature/AboOneTimeServiceTest.php index 2e361858..b5ca1173 100644 --- a/tests/Feature/AboOneTimeServiceTest.php +++ b/tests/Feature/AboOneTimeServiceTest.php @@ -210,7 +210,7 @@ describe('AboOrderCart::buildOneTimeSnapshot', function () { ->and($snapshot['tax_rate'])->toBe(19.0) ->and(round($snapshot['price_net'], 2))->toBe(100.0) ->and(round($snapshot['tax'], 2))->toBe(19.0) - ->and($snapshot['points'])->toBe(7); + ->and($snapshot['points'])->toBe(7.0); }); }); diff --git a/tests/Feature/DecimalPointsDiffReportTest.php b/tests/Feature/DecimalPointsDiffReportTest.php new file mode 100644 index 00000000..ae9f50ea --- /dev/null +++ b/tests/Feature/DecimalPointsDiffReportTest.php @@ -0,0 +1,284 @@ +insertGetId([ + 'first_name' => $first, + 'last_name' => $last, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return User::forceCreate([ + 'email' => 'u-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'account_id' => $accountId, + ]); +} + +function makeDiffProduct(float $points): int +{ + return (int) DB::table('products')->insertGetId([ + 'name' => 'P-'.uniqid('', true), + 'title' => 'Produkt', + 'active' => true, + 'price' => 119, + 'tax' => 19, + 'weight' => 500, + 'points' => $points, + 'show_on' => json_encode(['2']), + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function makeDiffOrder(int $userId, float $points): int +{ + return (int) DB::table('shopping_orders')->insertGetId([ + 'shopping_user_id' => 1, + 'country_id' => 1, + 'user_shop_id' => 1, + 'auth_user_id' => $userId, + 'points' => $points, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function makeDiffItem(int $orderId, int $productId, float $points, int $qty = 1, bool $addon = true, ?string $createdAt = null): int +{ + return (int) DB::table('shopping_order_items')->insertGetId([ + 'shopping_order_id' => $orderId, + 'product_id' => $productId, + 'qty' => $qty, + 'points' => $points, + 'is_abo_addon' => $addon, + 'created_at' => $createdAt ?? now(), + 'updated_at' => now(), + ]); +} + +function makeDiffVolume(int $userId, int $orderId, int $month, int $year, int $status = 1): int +{ + return (int) DB::table('user_sales_volumes')->insertGetId([ + 'user_id' => $userId, + 'shopping_order_id' => $orderId, + 'month' => $month, + 'year' => $year, + 'date' => sprintf('01.%02d.%d', $month, $year), + 'points' => 0, + 'total_net' => 100, + 'status_points' => 1, + 'status' => $status, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +/** + * Setzt den Produkt-Stichtag, indem eine regulaere Position mit dem + * Dezimalwert angelegt wird. Ohne so eine Position gilt das Produkt als + * "Stichtag unbekannt" und wird nicht gewertet. + */ +function setDiffCutoff(int $userId, int $productId, float $points, string $at): void +{ + $order = makeDiffOrder($userId, $points); + makeDiffItem($order, $productId, $points, 1, false, $at); +} + +describe('DecimalPointsDiffReportService', function () { + beforeEach(function () { + $this->service = new DecimalPointsDiffReportService; + }); + + it('weist abgeschnittene Abo-Einmalartikel als Gutschrift aus', function () { + $user = makeDiffUser('Anna', 'Berger'); + $product = makeDiffProduct(7.5); + setDiffCutoff($user->id, $product, 7.5, '2026-01-27 09:00:00'); + + $order = makeDiffOrder($user->id, 7.0); + makeDiffItem($order, $product, 7.0, 2, true, '2026-07-10 08:00:00'); + makeDiffVolume($user->id, $order, 7, 2026); + + $report = $this->service->report(); + + expect($report['credits_owed'])->toBe(1.0) + ->and($report['totals']['addon'])->toBe(1.0) + ->and($report['rows'][0]['consultant'])->toBe('Anna Berger (#'.$user->id.')') + ->and($report['rows'][0]['month'])->toBe(7); + }); + + it('ignoriert Positionen von vor dem Produkt-Stichtag', function () { + $user = makeDiffUser(); + $product = makeDiffProduct(7.5); + setDiffCutoff($user->id, $product, 7.5, '2026-07-14 10:00:00'); + + $order = makeDiffOrder($user->id, 7.0); + makeDiffItem($order, $product, 7.0, 1, true, '2026-03-01 08:00:00'); + makeDiffVolume($user->id, $order, 3, 2026); + + expect($this->service->report()['rows'])->toBe([]); + }); + + it('ignoriert Produkte ohne bekannten Stichtag', function () { + $user = makeDiffUser(); + $product = makeDiffProduct(7.5); + + $order = makeDiffOrder($user->id, 7.0); + makeDiffItem($order, $product, 7.0, 1, true, '2026-07-10 08:00:00'); + makeDiffVolume($user->id, $order, 7, 2026); + + expect($this->service->report()['rows'])->toBe([]); + }); + + it('weist gerundete Sammelbestellungen als Überbuchung aus', function () { + $user = makeDiffUser(); + $order = makeDiffOrder($user->id, 240.0); + makeDiffVolume($user->id, $order, 8, 2026); + DB::table('shopping_collect_orders')->insert([ + 'user_id' => $user->id, + 'shopping_order_id' => $order, + 'points' => 240, + 'qty_total' => 2, + 'shop_items' => json_encode([['points_total' => 120.0], ['points_total' => 119.5]]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $report = $this->service->report(); + + expect($report['overbooked'])->toBe(-0.5) + ->and($report['totals']['collect'])->toBe(-0.5) + ->and($report['credits_owed'])->toBe(0.0); + }); + + it('summiert je Berater und Monat', function () { + $user = makeDiffUser(); + $product = makeDiffProduct(7.5); + setDiffCutoff($user->id, $product, 7.5, '2026-01-27 09:00:00'); + + foreach ([7, 7, 8] as $month) { + $order = makeDiffOrder($user->id, 7.0); + makeDiffItem($order, $product, 7.0, 1, true, '2026-07-10 08:00:00'); + makeDiffVolume($user->id, $order, $month, 2026); + } + + $report = $this->service->report(); + + expect($report['rows'])->toHaveCount(2) + ->and($report['by_month'])->toHaveCount(2) + ->and(collect($report['by_month'])->firstWhere('month', 7)['delta'])->toBe(1.0) + ->and(collect($report['by_month'])->firstWhere('month', 8)['delta'])->toBe(0.5) + ->and($report['totals']['delta'])->toBe(1.5) + ->and($report['totals']['users'])->toBe(1); + }); + + it('trennt Berater voneinander', function () { + $product = makeDiffProduct(7.5); + $first = makeDiffUser('Anna', 'Berger'); + setDiffCutoff($first->id, $product, 7.5, '2026-01-27 09:00:00'); + $second = makeDiffUser('Bea', 'Clausen'); + + foreach ([$first, $second] as $user) { + $order = makeDiffOrder($user->id, 7.0); + makeDiffItem($order, $product, 7.0, 1, true, '2026-07-10 08:00:00'); + makeDiffVolume($user->id, $order, 7, 2026); + } + + $report = $this->service->report(); + + expect($report['rows'])->toHaveCount(2) + ->and($report['totals']['users'])->toBe(2) + ->and($report['credits_owed'])->toBe(1.0); + }); + + it('grenzt auf den Zeitraum ein', function () { + $user = makeDiffUser(); + $product = makeDiffProduct(7.5); + setDiffCutoff($user->id, $product, 7.5, '2026-01-27 09:00:00'); + + foreach ([7, 8] as $month) { + $order = makeDiffOrder($user->id, 7.0); + makeDiffItem($order, $product, 7.0, 1, true, '2026-07-10 08:00:00'); + makeDiffVolume($user->id, $order, $month, 2026); + } + + expect($this->service->report(null, '2026-07')['totals']['delta'])->toBe(0.5) + ->and($this->service->report('2026-08', null)['totals']['delta'])->toBe(0.5) + ->and($this->service->report('2026-07', '2026-08')['totals']['delta'])->toBe(1.0); + }); + + it('spiegelt das Delta bei Storno-Zeilen', function () { + $user = makeDiffUser(); + $product = makeDiffProduct(7.5); + setDiffCutoff($user->id, $product, 7.5, '2026-01-27 09:00:00'); + + $order = makeDiffOrder($user->id, 7.0); + makeDiffItem($order, $product, 7.0, 1, true, '2026-07-10 08:00:00'); + makeDiffVolume($user->id, $order, 7, 2026); + makeDiffVolume($user->id, $order, 7, 2026, 6); + + expect($this->service->report()['totals']['delta'])->toBe(0.0); + }); + + it('ignoriert reguläre Positionen und Gratis-Artikel', function () { + $user = makeDiffUser(); + $product = makeDiffProduct(7.5); + setDiffCutoff($user->id, $product, 7.5, '2026-01-27 09:00:00'); + + $regular = makeDiffOrder($user->id, 7.0); + makeDiffItem($regular, $product, 7.0, 1, false, '2026-07-10 08:00:00'); + makeDiffVolume($user->id, $regular, 7, 2026); + + $free = makeDiffOrder($user->id, 0.0); + makeDiffItem($free, $product, 0.0, 1, true, '2026-07-10 08:00:00'); + makeDiffVolume($user->id, $free, 7, 2026); + + expect($this->service->report()['rows'])->toBe([]); + }); + + it('läuft auch ohne die Homeparty-Tabelle', function () { + expect(Schema::hasTable('homeparty_user_order_items'))->toBeFalse() + ->and($this->service->report()['totals']['homeparty'])->toBe(0.0); + }); +})->group('points'); + +describe('points:report-decimal-diff Command', function () { + it('läuft und schreibt nichts', function () { + $this->artisan('points:report-decimal-diff')->assertExitCode(0); + }); + + it('exportiert CSV', function () { + $user = makeDiffUser('Anna', 'Berger'); + $product = makeDiffProduct(7.5); + setDiffCutoff($user->id, $product, 7.5, '2026-01-27 09:00:00'); + $order = makeDiffOrder($user->id, 7.0); + makeDiffItem($order, $product, 7.0, 1, true, '2026-07-10 08:00:00'); + makeDiffVolume($user->id, $order, 7, 2026); + + $path = sys_get_temp_dir().'/diff-'.uniqid('', true).'.csv'; + $this->artisan('points:report-decimal-diff', ['--csv' => $path])->assertExitCode(0); + + $csv = file_get_contents($path); + unlink($path); + + expect($csv)->toContain('Berater') + ->and($csv)->toContain('Anna Berger'); + }); +})->group('points'); diff --git a/tests/Feature/DecimalPointsRepairTest.php b/tests/Feature/DecimalPointsRepairTest.php new file mode 100644 index 00000000..0191da68 --- /dev/null +++ b/tests/Feature/DecimalPointsRepairTest.php @@ -0,0 +1,288 @@ + 'u-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + ]); +} + +function makePointsProduct(float $points): int +{ + return (int) DB::table('products')->insertGetId([ + 'name' => 'P-'.uniqid('', true), + 'title' => 'Produkt', + 'active' => true, + 'price' => 119, + 'tax' => 19, + 'weight' => 500, + 'points' => $points, + 'show_on' => json_encode(['2']), + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function makePointsOrder(float $points, int $userId = 1): int +{ + return (int) DB::table('shopping_orders')->insertGetId([ + 'shopping_user_id' => 1, + 'country_id' => 1, + 'user_shop_id' => 1, + 'auth_user_id' => $userId, + 'points' => $points, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function makePointsItem(int $orderId, int $productId, float $points, int $qty = 1, bool $addon = true): int +{ + return (int) DB::table('shopping_order_items')->insertGetId([ + 'shopping_order_id' => $orderId, + 'product_id' => $productId, + 'qty' => $qty, + 'points' => $points, + 'is_abo_addon' => $addon, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function makePointsVolume(int $userId, int $orderId, float $points, int $status = 1): int +{ + return (int) DB::table('user_sales_volumes')->insertGetId([ + 'user_id' => $userId, + 'shopping_order_id' => $orderId, + 'month' => (int) now()->month, + 'year' => (int) now()->year, + 'date' => now()->format('d.m.Y'), + 'points' => $points, + 'total_net' => 100, + 'status_points' => 1, + 'status' => $status, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +describe('DecimalPointsRepairService', function () { + beforeEach(function () { + $this->service = new DecimalPointsRepairService; + $this->month = (int) now()->month; + $this->year = (int) now()->year; + }); + + it('zieht abgeschnittene Abo-Einmalartikel nach', function () { + $user = makePointsUser(); + $product = makePointsProduct(7.5); + $order = makePointsOrder(7.0, $user->id); + $item = makePointsItem($order, $product, 7.0); + $volume = makePointsVolume($user->id, $order, 7.0); + + $result = $this->service->repair($this->month, $this->year, false); + + expect($result['total_delta'])->toBe(0.5) + ->and((float) DB::table('shopping_order_items')->where('id', $item)->value('points'))->toBe(7.5) + ->and((float) DB::table('shopping_orders')->where('id', $order)->value('points'))->toBe(7.5) + ->and((float) DB::table('user_sales_volumes')->where('id', $volume)->value('points'))->toBe(7.5); + }); + + it('rechnet das Delta mit der Menge hoch', function () { + $user = makePointsUser(); + $product = makePointsProduct(7.5); + $order = makePointsOrder(21.0, $user->id); + makePointsItem($order, $product, 7.0, 3); + $volume = makePointsVolume($user->id, $order, 21.0); + + $this->service->repair($this->month, $this->year, false); + + expect((float) DB::table('shopping_orders')->where('id', $order)->value('points'))->toBe(22.5) + ->and((float) DB::table('user_sales_volumes')->where('id', $volume)->value('points'))->toBe(22.5); + }); + + it('zieht die Monatssummen nach', function () { + $user = makePointsUser(); + $product = makePointsProduct(7.5); + $order = makePointsOrder(7.0, $user->id); + makePointsItem($order, $product, 7.0); + $volume = makePointsVolume($user->id, $order, 7.0); + + $this->service->repair($this->month, $this->year, false); + + $row = DB::table('user_sales_volumes')->where('id', $volume)->first(); + expect((float) $row->month_KP_points)->toBe(7.5) + ->and((float) $row->month_TP_points)->toBe(7.5); + }); + + it('korrigiert gerundete Sammelbestellungen nach unten', function () { + $user = makePointsUser(); + $order = makePointsOrder(240.0, $user->id); + $volume = makePointsVolume($user->id, $order, 240.0); + DB::table('shopping_collect_orders')->insert([ + 'user_id' => $user->id, + 'shopping_order_id' => $order, + 'points' => 240, + 'qty_total' => 2, + 'shop_items' => json_encode([ + ['points_total' => 120.0], + ['points_total' => 119.5], + ]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = $this->service->repair($this->month, $this->year, false); + + expect($result['total_delta'])->toBe(-0.5) + ->and((float) DB::table('shopping_orders')->where('id', $order)->value('points'))->toBe(239.5) + ->and((float) DB::table('user_sales_volumes')->where('id', $volume)->value('points'))->toBe(239.5); + }); + + it('spiegelt das Delta bei Storno-Zeilen', function () { + $user = makePointsUser(); + $product = makePointsProduct(7.5); + $order = makePointsOrder(7.0, $user->id); + makePointsItem($order, $product, 7.0); + $original = makePointsVolume($user->id, $order, 7.0); + $storno = makePointsVolume($user->id, $order, -7.0, 6); + + $this->service->repair($this->month, $this->year, false); + + expect((float) DB::table('user_sales_volumes')->where('id', $original)->value('points'))->toBe(7.5) + ->and((float) DB::table('user_sales_volumes')->where('id', $storno)->value('points'))->toBe(-7.5); + }); + + it('lässt Gratis-Positionen mit 0 Punkten unangetastet', function () { + $user = makePointsUser(); + $product = makePointsProduct(7.5); + $order = makePointsOrder(0.0, $user->id); + $item = makePointsItem($order, $product, 0.0); + makePointsVolume($user->id, $order, 0.0); + + $result = $this->service->repair($this->month, $this->year, false); + + expect($result['orders'])->toBe([]) + ->and((float) DB::table('shopping_order_items')->where('id', $item)->value('points'))->toBe(0.0); + }); + + it('lässt reguläre (nicht-Addon) Positionen unangetastet', function () { + $user = makePointsUser(); + $product = makePointsProduct(7.5); + $order = makePointsOrder(7.0, $user->id); + $item = makePointsItem($order, $product, 7.0, 1, false); + makePointsVolume($user->id, $order, 7.0); + + $result = $this->service->repair($this->month, $this->year, false); + + expect($result['orders'])->toBe([]) + ->and((float) DB::table('shopping_order_items')->where('id', $item)->value('points'))->toBe(7.0); + }); + + it('lässt Positionen mit ganzzahligen Produktpunkten unangetastet', function () { + $user = makePointsUser(); + $product = makePointsProduct(14.0); + $order = makePointsOrder(14.0, $user->id); + makePointsItem($order, $product, 14.0); + makePointsVolume($user->id, $order, 14.0); + + expect($this->service->repair($this->month, $this->year, false)['orders'])->toBe([]); + }); + + it('fasst andere Monate nicht an', function () { + $user = makePointsUser(); + $product = makePointsProduct(7.5); + $order = makePointsOrder(7.0, $user->id); + $item = makePointsItem($order, $product, 7.0); + DB::table('user_sales_volumes')->insert([ + 'user_id' => $user->id, + 'shopping_order_id' => $order, + 'month' => (int) now()->subMonthNoOverflow()->month, + 'year' => (int) now()->subMonthNoOverflow()->year, + 'date' => now()->subMonthNoOverflow()->format('d.m.Y'), + 'points' => 7.0, + 'total_net' => 100, + 'status_points' => 1, + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = $this->service->repair($this->month, $this->year, false); + + expect($result['orders'])->toBe([]) + ->and((float) DB::table('shopping_order_items')->where('id', $item)->value('points'))->toBe(7.0); + }); + + it('schreibt im Trockenlauf nichts', function () { + $user = makePointsUser(); + $product = makePointsProduct(7.5); + $order = makePointsOrder(7.0, $user->id); + $item = makePointsItem($order, $product, 7.0); + $volume = makePointsVolume($user->id, $order, 7.0); + + $result = $this->service->repair($this->month, $this->year, true); + + expect($result['total_delta'])->toBe(0.5) + ->and((float) DB::table('shopping_order_items')->where('id', $item)->value('points'))->toBe(7.0) + ->and((float) DB::table('shopping_orders')->where('id', $order)->value('points'))->toBe(7.0) + ->and((float) DB::table('user_sales_volumes')->where('id', $volume)->value('points'))->toBe(7.0); + }); + + it('ist idempotent', function () { + $user = makePointsUser(); + $product = makePointsProduct(7.5); + $order = makePointsOrder(7.0, $user->id); + makePointsItem($order, $product, 7.0); + $volume = makePointsVolume($user->id, $order, 7.0); + + $this->service->repair($this->month, $this->year, false); + $second = $this->service->repair($this->month, $this->year, false); + + expect($second['orders'])->toBe([]) + ->and((float) DB::table('user_sales_volumes')->where('id', $volume)->value('points'))->toBe(7.5); + }); +})->group('points'); + +describe('points:repair-decimal Command', function () { + it('verweigert abgeschlossene Monate ohne --allow-closed-month', function () { + $past = now()->subMonthNoOverflow(); + + $this->artisan('points:repair-decimal', [ + '--month' => $past->month, + '--year' => $past->year, + '--force' => true, + ])->assertExitCode(1); + }); + + it('erlaubt den Trockenlauf für abgeschlossene Monate', function () { + $past = now()->subMonthNoOverflow(); + + $this->artisan('points:repair-decimal', [ + '--month' => $past->month, + '--year' => $past->year, + ])->assertExitCode(0); + }); + + it('läuft für den laufenden Monat', function () { + $this->artisan('points:repair-decimal')->assertExitCode(0); + }); +})->group('points');