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>
This commit is contained in:
Kevin 2026-09-23 15:42:27 +00:00
parent e009b11259
commit a21bebc932
15 changed files with 1539 additions and 9 deletions

View file

@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Services\DecimalPointsRepairService;
use Illuminate\Console\Command;
/**
* Repair: Dezimal-Punkte (z.B. 7,5) im laufenden Provisionsmonat nachziehen.
*
* Hintergrund siehe {@see DecimalPointsRepairService}. Standard ist ein
* Trockenlauf -- geschrieben wird nur mit --force.
*/
class RepairDecimalPoints extends Command
{
protected $signature = 'points:repair-decimal
{--month= : Monat (Standard: aktueller Monat)}
{--year= : Jahr (Standard: aktuelles Jahr)}
{--force : Aenderungen wirklich schreiben (ohne: Trockenlauf)}
{--allow-closed-month : Auch einen abgeschlossenen Monat korrigieren}';
protected $description = 'Abgeschnittene/gerundete Dezimal-Punkte im laufenden Monat korrigieren und Monatssummen nachziehen';
public function handle(DecimalPointsRepairService $service): int
{
$month = (int) ($this->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;
}
}

View file

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use App\Services\DecimalPointsDiffReportService;
use Illuminate\Console\Command;
/**
* Read-only Auswertung der Punkte-Differenzen aus dem Dezimal-Punkte-Fehler,
* je Berater und Provisionsmonat. Grundlage fuer Gutschrift-Entscheidungen.
*
* Schreibt nichts. Hintergrund siehe {@see DecimalPointsDiffReportService}.
*/
class ReportDecimalPointsDiff extends Command
{
protected $signature = 'points:report-decimal-diff
{--from= : Startmonat YYYY-MM (Standard: alles)}
{--to= : Endmonat YYYY-MM (Standard: alles)}
{--csv= : Pfad fuer CSV-Export je Berater/Monat}
{--details : Einzelzeilen je Berater ausgeben}';
protected $description = 'Punkte-Differenzen aus dem Dezimal-Punkte-Fehler je Berater und Monat auswerten (read-only)';
public function handle(DecimalPointsDiffReportService $service): int
{
$from = $this->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<array<string, mixed>> $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);
}
}

View file

@ -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

View file

@ -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
*

View file

@ -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

View file

@ -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)

View file

@ -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',
];

View file

@ -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),
];
}

View file

@ -0,0 +1,341 @@
<?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();
}
}

View file

@ -0,0 +1,251 @@
<?php
namespace App\Services;
use App\Models\ShoppingCollectOrder;
use App\Services\BusinessPlan\SalesPointsVolume;
use Illuminate\Support\Facades\DB;
/**
* Repair fuer Dezimal-Punkte (z.B. 7,5) im laufenden Provisionsmonat.
*
* Produkte koennen halbe Punkte haben. Zwei Pfade haben sie verloren bzw.
* verfaelscht, weil Spalten/Casts noch auf Integer standen:
*
* A) Abo-Einmalartikel: AboOrderCart::buildOneTimeSnapshot() hat per (int)-Cast
* abgeschnitten (7,5 -> 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<array{order_id: int, item_id: int, product: string, from: float, to: float, qty: int, delta: float}>,
* collect_orders: list<array{order_id: int, collect_id: int, from: float, to: float, delta: float}>,
* orders: list<array{order_id: int, from: float, to: float, delta: float}>,
* sales_volumes: list<array{sv_id: int, user_id: int|null, status: int, from: float, to: float, delta: float}>,
* users_recalculated: list<int>,
* 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<int> $orderIds
* @return list<array{order_id: int, item_id: int, product: string, from: float, to: float, qty: int, delta: float}>
*/
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<int> $orderIds
* @return list<array{order_id: int, collect_id: int, from: float, to: float, delta: float}>
*/
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<array{item_id: int, to: float}> $addonItems
* @param list<array{collect_id: int, to: float}> $collectOrders
* @param list<array{order_id: int, to: float}> $orders
* @param list<array{sv_id: int, to: float}> $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']]);
}
});
}
}

View file

@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Migration: Verbliebene Points-Spalten von INT auf DECIMAL(10,2) umstellen
*
* Nachzug zu 2026_01_22_154524_change_points_columns_to_decimal, dort wurden
* diese drei Tabellen übersehen:
* - user_abo_one_time_items: points (PHP-Cast schnitt zusätzlich ab: 7,5 -> 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<string>
*/
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();
});
}
}
};

View file

@ -0,0 +1,84 @@
<?php
/**
* Dezimal-Punkte bei Abo-Einmalartikeln.
*
* Produkte können halbe Punkte haben (z.B. 7,5). Der Snapshot in
* {@see AboOrderCart::buildOneTimeSnapshot()} hat diese früher per (int)-Cast
* abgeschnitten (7,5 -> 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');

View file

@ -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);
});
});

View file

@ -0,0 +1,284 @@
<?php
/**
* Auswertung der Punkte-Differenzen aus dem Dezimal-Punkte-Fehler.
*
* Die Zahlen dienen als Grundlage fuer Gutschriften, deshalb sind hier vor allem
* die Abgrenzungen wichtig: was NICHT als Schaden gezaehlt werden darf.
*/
use App\Services\DecimalPointsDiffReportService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function makeDiffUser(string $first = 'Erika', string $last = 'Muster'): User
{
$accountId = DB::table('user_accounts')->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');

View file

@ -0,0 +1,288 @@
<?php
/**
* Repair der Dezimal-Punkte im laufenden Provisionsmonat.
*
* Deckt beide Schadensquellen ab (abgeschnittene Abo-Einmalartikel und
* gerundete Sammelbestellungen) sowie die Abgrenzungen, die NICHT angefasst
* werden duerfen.
*/
use App\Services\DecimalPointsRepairService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function makePointsUser(): User
{
return User::forceCreate([
'email' => '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');