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
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']]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue