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:
parent
e009b11259
commit
a21bebc932
15 changed files with 1539 additions and 9 deletions
103
app/Console/Commands/RepairDecimalPoints.php
Normal file
103
app/Console/Commands/RepairDecimalPoints.php
Normal 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;
|
||||
}
|
||||
}
|
||||
119
app/Console/Commands/ReportDecimalPointsDiff.php
Normal file
119
app/Console/Commands/ReportDecimalPointsDiff.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
341
app/Services/DecimalPointsDiffReportService.php
Normal file
341
app/Services/DecimalPointsDiffReportService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
251
app/Services/DecimalPointsRepairService.php
Normal file
251
app/Services/DecimalPointsRepairService.php
Normal 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']]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
84
tests/Feature/AboOneTimeDecimalPointsTest.php
Normal file
84
tests/Feature/AboOneTimeDecimalPointsTest.php
Normal 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');
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
284
tests/Feature/DecimalPointsDiffReportTest.php
Normal file
284
tests/Feature/DecimalPointsDiffReportTest.php
Normal 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');
|
||||
288
tests/Feature/DecimalPointsRepairTest.php
Normal file
288
tests/Feature/DecimalPointsRepairTest.php
Normal 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');
|
||||
Loading…
Add table
Add a link
Reference in a new issue