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>
341 lines
14 KiB
PHP
341 lines
14 KiB
PHP
<?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();
|
|
}
|
|
}
|