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>
288 lines
11 KiB
PHP
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');
|