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>
251 lines
9.9 KiB
PHP
251 lines
9.9 KiB
PHP
<?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']]);
|
|
}
|
|
});
|
|
}
|
|
}
|