|
|
@@ -6,10 +6,12 @@ use App\Console\Commands\AutoGrantRewards;
|
|
|
use App\Http\Controllers\admin\Backflow as BackflowController;
|
|
|
use App\Http\Controllers\admin\Rebate as RebateController;
|
|
|
use App\Http\Controllers\admin\RewardGrantSettings;
|
|
|
+use App\Http\Controllers\admin\RewardRules;
|
|
|
use App\Models\Rebate;
|
|
|
use App\Services\AutomaticRewardGrantService;
|
|
|
use App\Services\BackflowService;
|
|
|
use App\Services\RebateService;
|
|
|
+use App\Services\RewardBaseSyncService;
|
|
|
use App\Services\RewardGrantService;
|
|
|
use App\Services\RewardGrantSettingsService;
|
|
|
use Carbon\Carbon;
|
|
|
@@ -64,6 +66,25 @@ class AutomaticRewardGrantTest extends TestCase
|
|
|
['field' => 'huishui_restriction', 'val' => '100'],
|
|
|
['field' => 'huishui_percentage', 'val' => '0.05'],
|
|
|
]);
|
|
|
+ Schema::create('users', function (Blueprint $t) {
|
|
|
+ $t->id(); $t->string('member_id')->unique(); $t->integer('level')->default(1);
|
|
|
+ $t->string('username')->default(''); $t->string('first_name')->default('');
|
|
|
+ });
|
|
|
+ Schema::create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->string('level_name'); });
|
|
|
+ DB::table('level')->insert([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']]);
|
|
|
+ Schema::create('share_bet_facts', function (Blueprint $t) {
|
|
|
+ $t->id(); $t->string('source', 32); $t->string('source_id', 64); $t->unsignedBigInteger('user_id'); $t->string('member_id', 64);
|
|
|
+ $t->string('category', 32); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable();
|
|
|
+ foreach (['bet_amount', 'net_profit', 'effective_amount'] as $c) $t->decimal($c, 24, 10)->default(0);
|
|
|
+ $t->boolean('settled')->default(false); $t->boolean('voided')->default(false);
|
|
|
+ $t->string('currency', 8)->default('CNY'); $t->string('source_version', 64)->default(''); $t->timestamps();
|
|
|
+ });
|
|
|
+ Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value'); $t->timestamps(); });
|
|
|
+ Schema::create('wallet_bonus', function (Blueprint $t) {
|
|
|
+ $t->id(); $t->string('member_id'); $t->integer('type')->default(0); $t->unsignedBigInteger('activity_id')->default(0);
|
|
|
+ $t->unsignedBigInteger('related_id'); foreach (['amount', 'need_flow', 'remaind_flow'] as $c) $t->decimal($c, 24, 2);
|
|
|
+ $t->integer('status'); $t->timestamps();
|
|
|
+ });
|
|
|
Schema::create('wallets', function (Blueprint $t) {
|
|
|
$t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps();
|
|
|
});
|
|
|
@@ -77,6 +98,7 @@ class AutomaticRewardGrantTest extends TestCase
|
|
|
$t->id(); $t->string('member_id'); $t->string('date');
|
|
|
$t->decimal('betting_amount', 24, 2); $t->decimal('effective_betting_amount', 24, 2);
|
|
|
$t->decimal('rebate_ratio', 12, 4)->default(0); $t->decimal('amount', 24, 2)->default(0);
|
|
|
+ $t->decimal('profit', 24, 2)->default(0); $t->string('username')->default(''); $t->string('first_name')->default('');
|
|
|
$t->integer('status')->default(0); $t->string('audited_by')->nullable(); $t->bigInteger('grant_time')->default(0); $t->timestamps();
|
|
|
});
|
|
|
Schema::create('backflow', function (Blueprint $t) {
|
|
|
@@ -85,6 +107,9 @@ class AutomaticRewardGrantTest extends TestCase
|
|
|
$t->integer('status')->default(0); $t->bigInteger('grant_time')->default(0); $t->timestamps();
|
|
|
});
|
|
|
$this->migration()->up();
|
|
|
+ $this->rulesMigration()->up();
|
|
|
+ DB::table('rebate_rules')->insert(['category' => 'self:lottery', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0]);
|
|
|
+ DB::table('backflow_rules')->insert(['category' => 'self:lottery', 'min_loss_amount' => 100, 'reward_amount' => 35, 'turnover_multiple' => 0]);
|
|
|
$this->request('/admin/rebate');
|
|
|
}
|
|
|
|
|
|
@@ -99,6 +124,11 @@ class AutomaticRewardGrantTest extends TestCase
|
|
|
return require dirname(__DIR__, 2) . '/database/migrations/2026_10_25_180000_create_reward_grant_settings.php';
|
|
|
}
|
|
|
|
|
|
+ private function rulesMigration()
|
|
|
+ {
|
|
|
+ return require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_160000_create_reward_rules.php';
|
|
|
+ }
|
|
|
+
|
|
|
private function request(string $path, array $params = [], string $method = 'GET'): void
|
|
|
{
|
|
|
$request = Request::create($path, $method, $params);
|
|
|
@@ -106,16 +136,39 @@ class AutomaticRewardGrantTest extends TestCase
|
|
|
$this->app->instance('request', $request);
|
|
|
}
|
|
|
|
|
|
+ private function user(string $memberId, int $level = 1): int
|
|
|
+ {
|
|
|
+ DB::table('users')->insertOrIgnore(['member_id' => $memberId, 'level' => $level]);
|
|
|
+ return (int) DB::table('users')->where('member_id', $memberId)->value('id');
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Legacy PC28 rows: no facts, so the stored effective bet is the payout basis. */
|
|
|
private function rebate(array $values = []): int
|
|
|
{
|
|
|
+ $this->user((string) ($values['member_id'] ?? '1001'));
|
|
|
return DB::table('rebates')->insertGetId($values + ['member_id' => '1001', 'date' => '2026-10-23',
|
|
|
'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00']);
|
|
|
}
|
|
|
|
|
|
+ /** The month's lottery loss mirrors the old |recharge + withdrawal| fixture. */
|
|
|
private function backflow(array $values = []): int
|
|
|
{
|
|
|
- return DB::table('backflow')->insertGetId($values + ['member_id' => '1001', 'date' => '2026-09',
|
|
|
- 'recharge_amount' => '1000.00', 'withdrawal_amount' => '-300.00']);
|
|
|
+ $values += ['member_id' => '1001', 'date' => '2026-09', 'recharge_amount' => '1000.00', 'withdrawal_amount' => '-300.00'];
|
|
|
+ if (($values['status'] ?? 0) === 0) {
|
|
|
+ $loss = bcadd((string) $values['recharge_amount'], (string) $values['withdrawal_amount'], 2);
|
|
|
+ $this->fact($values['member_id'], 'self:lottery', $values['date'] . '-10 12:00:00', '0', bcsub('0', $loss, 2));
|
|
|
+ }
|
|
|
+ return DB::table('backflow')->insertGetId($values);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function fact(string $memberId, string $category, string $betAt, string $effective, string $profit, array $values = []): void
|
|
|
+ {
|
|
|
+ static $sequence = 0;
|
|
|
+ DB::table('share_bet_facts')->insert($values + ['source' => 'test', 'source_id' => (string) ++$sequence,
|
|
|
+ 'user_id' => $this->user($memberId), 'member_id' => $memberId, 'category' => $category, 'bet_at' => $betAt,
|
|
|
+ 'bet_date' => substr($betAt, 0, 10), 'bet_amount' => $effective, 'effective_amount' => $effective,
|
|
|
+ 'net_profit' => $profit, 'settled' => 1, 'voided' => 0, 'currency' => 'CNY',
|
|
|
+ 'created_at' => now()->format('Y-m-d H:i:s'), 'updated_at' => now()->format('Y-m-d H:i:s')]);
|
|
|
}
|
|
|
|
|
|
private function enable(string $type, string $time = '14:30'): void
|
|
|
@@ -351,4 +404,137 @@ class AutomaticRewardGrantTest extends TestCase
|
|
|
$this->rebate(['member_id' => 'missing']); $tester->execute([]);
|
|
|
$this->assertSame(1, $tester->getStatusCode());
|
|
|
}
|
|
|
+
|
|
|
+ public function test_rules_api_lists_categories_and_levels_replaces_one_category_and_rejects_bad_tiers(): void
|
|
|
+ {
|
|
|
+ $body = (new RewardRules())->rebate()->getData(true);
|
|
|
+ $this->assertSame(0, $body['code']);
|
|
|
+ $this->assertContains(['value' => 'third:1', 'label' => '真人'], $body['data']['categories']);
|
|
|
+ $this->assertSame([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']], $body['data']['levels']);
|
|
|
+ $this->assertSame([], $body['data']['rules']['third:2']);
|
|
|
+ $this->request('/admin/rebate/rules', ['category' => 'third:2', 'rules' => [
|
|
|
+ ['level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3],
|
|
|
+ ['level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0],
|
|
|
+ ]], 'POST');
|
|
|
+ $body = (new RewardRules())->saveRebate()->getData(true);
|
|
|
+ $this->assertSame(0, $body['code']);
|
|
|
+ $this->assertSame(['0.00', '1000.00'], array_column($body['data']['rules']['third:2'], 'min_effective_amount'));
|
|
|
+ $this->assertSame('3.00', $body['data']['rules']['third:2'][1]['turnover_multiple']);
|
|
|
+ $this->assertCount(1, $body['data']['rules']['self:lottery']);
|
|
|
+ foreach ([['category' => 'third:9', 'rules' => []],
|
|
|
+ ['category' => 'third:2', 'rules' => [['level' => 7, 'min_effective_amount' => 0, 'percentage' => 1, 'turnover_multiple' => 0]]],
|
|
|
+ ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => '1e3', 'percentage' => 1, 'turnover_multiple' => 0]]],
|
|
|
+ ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 0, 'percentage' => 101, 'turnover_multiple' => 0]]],
|
|
|
+ ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 5, 'percentage' => 1, 'turnover_multiple' => 0],
|
|
|
+ ['level' => 1, 'min_effective_amount' => 5, 'percentage' => 2, 'turnover_multiple' => 0]]]] as $bad) {
|
|
|
+ $this->request('/admin/rebate/rules', $bad, 'POST');
|
|
|
+ $this->assertSame(-3, (new RewardRules())->saveRebate()->getData(true)['code']);
|
|
|
+ }
|
|
|
+ $this->assertSame(2, DB::table('rebate_rules')->where('category', 'third:2')->count());
|
|
|
+ $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => [
|
|
|
+ ['min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2]]], 'POST');
|
|
|
+ $body = (new RewardRules())->saveBackflow()->getData(true);
|
|
|
+ $this->assertSame([['id' => 2, 'category' => 'third:1', 'min_loss_amount' => '2000.00', 'reward_amount' => '60.00',
|
|
|
+ 'turnover_multiple' => '2.00']], $body['data']['rules']['third:1']);
|
|
|
+ $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => []], 'POST');
|
|
|
+ $this->assertSame(0, (new RewardRules())->saveBackflow()->getData(true)['code']);
|
|
|
+ $this->assertSame(0, DB::table('backflow_rules')->where('category', 'third:1')->count());
|
|
|
+
|
|
|
+ $router = new Router(new Dispatcher($this->app), $this->app);
|
|
|
+ $this->app->instance('router', $router);
|
|
|
+ $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
|
|
|
+ foreach (['rebate' => 'submit', 'backflow' => 'grant'] as $type => $permission) {
|
|
|
+ foreach (['GET', 'POST'] as $method) {
|
|
|
+ $route = $router->getRoutes()->match(Request::create('/admin/' . $type . '/rules', $method));
|
|
|
+ $this->assertContains('check.button.uri', $route->middleware());
|
|
|
+ $this->assertSame('admin/' . $type . '/' . $permission, $route->defaults['permission_uri']);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_rebate_sums_every_category_by_new_york_day_and_uses_the_members_level_tier_and_turnover(): void
|
|
|
+ {
|
|
|
+ $this->user('2001', 2);
|
|
|
+ DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']);
|
|
|
+ DB::table('rebate_rules')->insert([
|
|
|
+ ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0],
|
|
|
+ ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3],
|
|
|
+ ['category' => 'third:2', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '2', 'turnover_multiple' => 0],
|
|
|
+ ['category' => 'self:sport', 'level' => 2, 'min_effective_amount' => 500, 'percentage' => '1', 'turnover_multiple' => 0],
|
|
|
+ ]);
|
|
|
+ // New York 2026-10-23 is Shanghai 2026-10-23 12:00 to 2026-10-24 11:59:59.
|
|
|
+ $this->fact('2001', 'third:2', '2026-10-23 20:00:00', '1000', '-1000');
|
|
|
+ $this->fact('2001', 'third:2', '2026-10-24 10:00:00', '500', '500');
|
|
|
+ $this->fact('2001', 'self:sport', '2026-10-24 11:00:00', '300', '-300');
|
|
|
+ $this->fact('2001', 'third:2', '2026-10-24 11:30:00', '9999', '0', ['voided' => 1]);
|
|
|
+ $this->fact('2001', 'third:2', '2026-10-24 13:00:00', '7777', '-7777');
|
|
|
+ $this->assertSame(['rebates' => 2, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run());
|
|
|
+ $row = DB::table('rebates')->where('member_id', '2001')->where('date', '2026-10-23')->first();
|
|
|
+ $this->assertEquals(1800, $row->betting_amount);
|
|
|
+ $this->assertEquals(1800, $row->effective_betting_amount);
|
|
|
+ $this->assertEquals(15, $row->amount);
|
|
|
+ $this->assertEquals(45, $row->need_flow);
|
|
|
+ $this->assertSame(['self:sport', 'third:2'], array_column(json_decode($row->details, true), 'category'));
|
|
|
+ $this->assertTrue(RewardGrantService::grant('rebate', (int) $row->id, 'operator-one'));
|
|
|
+ $this->assertEquals(15, DB::table('wallets')->where('member_id', '2001')->value('available_balance'));
|
|
|
+ $bonus = DB::table('wallet_bonus')->first();
|
|
|
+ $this->assertSame('2001', $bonus->member_id);
|
|
|
+ $this->assertEquals(45, $bonus->need_flow);
|
|
|
+ $this->assertEquals(15, $bonus->amount);
|
|
|
+ $this->assertSame((int) $row->id, (int) $bonus->related_id);
|
|
|
+ $this->assertStringContainsString('电子:1500.00×1.0000%', DB::table('balance_logs')->value('remark'));
|
|
|
+
|
|
|
+ // A paid day is never rewritten by later facts.
|
|
|
+ $this->fact('2001', 'third:2', '2026-10-24 09:00:00', '100', '-100');
|
|
|
+ (new RewardBaseSyncService())->run();
|
|
|
+ $this->assertEquals(1800, DB::table('rebates')->where('id', $row->id)->value('effective_betting_amount'));
|
|
|
+ $this->assertSame(1, DB::table('wallet_bonus')->count());
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_backflow_pays_fixed_rewards_for_each_categorys_monthly_net_loss(): void
|
|
|
+ {
|
|
|
+ $this->user('2001');
|
|
|
+ DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']);
|
|
|
+ DB::table('backflow_rules')->insert([
|
|
|
+ ['category' => 'third:1', 'min_loss_amount' => 1000, 'reward_amount' => 20, 'turnover_multiple' => 0],
|
|
|
+ ['category' => 'third:1', 'min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2],
|
|
|
+ ['category' => 'third:2', 'min_loss_amount' => 0, 'reward_amount' => 99, 'turnover_multiple' => 0],
|
|
|
+ ['category' => 'self:sport', 'min_loss_amount' => 100, 'reward_amount' => 5, 'turnover_multiple' => 0],
|
|
|
+ ]);
|
|
|
+ $this->fact('2001', 'third:1', '2026-09-03 10:00:00', '2000', '-2000');
|
|
|
+ $this->fact('2001', 'third:1', '2026-09-28 10:00:00', '500', '-500');
|
|
|
+ $this->fact('2001', 'third:2', '2026-09-05 10:00:00', '100', '100');
|
|
|
+ $this->fact('2001', 'self:sport', '2026-09-30 23:00:00', '50', '-50');
|
|
|
+ $this->fact('2001', 'self:sport', '2026-10-01 00:30:00', '900', '-900');
|
|
|
+ (new RewardBaseSyncService())->run();
|
|
|
+ $row = DB::table('backflow')->where('member_id', '2001')->where('date', '2026-09')->first();
|
|
|
+ $this->assertEquals(2550, $row->loss_amount);
|
|
|
+ $this->assertEquals(60, $row->amount);
|
|
|
+ $this->assertTrue(RewardGrantService::grant('backflow', (int) $row->id, 'operator-one'));
|
|
|
+ $this->assertEquals(60, DB::table('wallets')->where('member_id', '2001')->value('available_balance'));
|
|
|
+ $this->assertEquals(120, DB::table('wallet_bonus')->value('need_flow'));
|
|
|
+ $this->assertEquals(120, DB::table('backflow')->where('id', $row->id)->value('need_flow'));
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_sync_cursor_only_revisits_changed_facts_and_backfill_range_creates_missing_rows(): void
|
|
|
+ {
|
|
|
+ $this->fact('3001', 'third:1', '2026-10-20 13:00:00', '100', '-100', ['updated_at' => '2026-10-20 13:00:00']);
|
|
|
+ $this->assertSame(0, (new RewardBaseSyncService())->run()['rebates']);
|
|
|
+ $this->assertSame(['rebates' => 1, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20'));
|
|
|
+ $this->assertEquals(100, DB::table('rebates')->where('member_id', '3001')->value('effective_betting_amount'));
|
|
|
+ $this->assertNotNull(DB::table('share_system_state')->where('key', 'reward_bases_cursor')->value('value'));
|
|
|
+ $this->expectException(\InvalidArgumentException::class);
|
|
|
+ (new RewardBaseSyncService())->run('2026-10-20', null);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_rules_migration_removes_the_three_global_settings_and_rollback_restores_them(): void
|
|
|
+ {
|
|
|
+ $this->assertSame(['huishui_restriction'], DB::table('config')->pluck('field')->all());
|
|
|
+ $this->assertTrue(Schema::hasColumns('rebates', ['details', 'need_flow']));
|
|
|
+ $this->rulesMigration()->down();
|
|
|
+ $this->assertEqualsCanonicalizing(['rebate', 'huishui_restriction', 'huishui_percentage'], DB::table('config')->pluck('field')->all());
|
|
|
+ $this->assertSame('0.005', DB::table('config')->where('field', 'rebate')->value('val'));
|
|
|
+ $this->assertFalse(Schema::hasTable('rebate_rules'));
|
|
|
+ $this->assertFalse(Schema::hasColumn('backflow', 'loss_amount'));
|
|
|
+ }
|
|
|
}
|