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