mivita/tests/Feature/DecimalPointsRepairTest.php
Kevin a21bebc932 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>
2026-09-23 15:42:27 +00:00

288 lines
11 KiB
PHP

<?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');