AutomaticRewardGrantTest.php 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Console\Commands\AutoGrantRewards;
  4. use App\Http\Controllers\admin\Backflow as BackflowController;
  5. use App\Http\Controllers\admin\Config as ConfigController;
  6. use App\Http\Controllers\admin\Rebate as RebateController;
  7. use App\Http\Controllers\admin\RewardGrantSettings;
  8. use App\Http\Controllers\admin\RewardRules;
  9. use App\Models\Rebate;
  10. use App\Services\AutomaticRewardGrantService;
  11. use App\Services\BackflowService;
  12. use App\Services\RebateService;
  13. use App\Services\RewardBaseSyncService;
  14. use App\Services\RewardGrantService;
  15. use App\Services\RewardGrantSettingsService;
  16. use App\Services\SiteInboxService;
  17. use App\Support\BackflowPeriod;
  18. use Carbon\Carbon;
  19. use Illuminate\Config\Repository;
  20. use Illuminate\Database\Capsule\Manager;
  21. use Illuminate\Database\Schema\Blueprint;
  22. use Illuminate\Events\Dispatcher;
  23. use Illuminate\Foundation\Application;
  24. use Illuminate\Http\JsonResponse;
  25. use Illuminate\Http\Request;
  26. use Illuminate\Pagination\PaginationState;
  27. use Illuminate\Routing\Router;
  28. use Illuminate\Support\Facades\DB;
  29. use Illuminate\Support\Facades\Facade;
  30. use Illuminate\Support\Facades\Schema;
  31. use Illuminate\Translation\ArrayLoader;
  32. use Illuminate\Translation\Translator;
  33. use Illuminate\Validation\Factory;
  34. use PHPUnit\Framework\Attributes\DataProvider;
  35. use PHPUnit\Framework\Attributes\PreserveGlobalState;
  36. use PHPUnit\Framework\Attributes\RunInSeparateProcess;
  37. use PHPUnit\Framework\TestCase;
  38. use Symfony\Component\Console\Tester\CommandTester;
  39. /** Real payout controllers, command, wallets and ledgers; no project boot, .env or live database. */
  40. class AutomaticRewardGrantTest extends TestCase
  41. {
  42. private Application $app;
  43. protected function setUp(): void
  44. {
  45. parent::setUp();
  46. Carbon::setTestNow(Carbon::parse('2026-10-25 14:30:00', 'Asia/Shanghai'));
  47. Facade::clearResolvedInstances();
  48. $this->app = new Application(dirname(__DIR__, 2));
  49. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
  50. Facade::setFacadeApplication($this->app);
  51. $db = new Manager($this->app);
  52. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  53. $db->setEventDispatcher(new Dispatcher($this->app));
  54. $db->setAsGlobal(); $db->bootEloquent();
  55. $this->app->instance('db', $db->getDatabaseManager());
  56. $this->app->bind('db.schema', fn () => $db->schema());
  57. $translator = new Translator(new ArrayLoader(), 'zh');
  58. $this->app->instance('translator', $translator);
  59. $this->app->instance('validator', new Factory($translator, $this->app));
  60. $this->app->instance('log', new \Psr\Log\NullLogger());
  61. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  62. Request::macro('validate', function (array $rules) {
  63. return app('validator')->make($this->all(), $rules)->validate();
  64. });
  65. Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val'); $t->integer('group_id')->default(0); });
  66. DB::table('config')->insert([
  67. ['field' => 'rebate', 'val' => '0.005'],
  68. ['field' => 'huishui_restriction', 'val' => '100'],
  69. ['field' => 'huishui_percentage', 'val' => '0.05'],
  70. ]);
  71. Schema::create('users', function (Blueprint $t) {
  72. $t->id(); $t->string('member_id')->unique(); $t->integer('level')->default(1);
  73. $t->string('username')->default(''); $t->string('first_name')->default('');
  74. $t->string('language')->nullable()->default('zh');
  75. $t->string('account')->nullable(); $t->string('admin_note')->nullable();
  76. });
  77. Schema::create('agents', function (Blueprint $t) { $t->id(); });
  78. Schema::create('operation_audits', function (Blueprint $t) {
  79. $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');
  80. $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->dateTime('created_at');
  81. });
  82. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_110000_create_site_messages.php')->up();
  83. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
  84. Schema::create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->string('level_name'); });
  85. DB::table('level')->insert([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']]);
  86. Schema::create('share_bet_facts', function (Blueprint $t) {
  87. $t->id(); $t->string('source', 32); $t->string('source_id', 64); $t->unsignedBigInteger('user_id'); $t->string('member_id', 64);
  88. $t->string('category', 32); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable();
  89. foreach (['bet_amount', 'net_profit', 'effective_amount'] as $c) $t->decimal($c, 24, 10)->default(0);
  90. $t->boolean('settled')->default(false); $t->boolean('voided')->default(false);
  91. $t->string('currency', 8)->default('CNY'); $t->string('source_version', 64)->default(''); $t->timestamps();
  92. });
  93. Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value'); $t->timestamps(); });
  94. Schema::create('lhc_order', function (Blueprint $t) {
  95. $t->id(); $t->unsignedBigInteger('member_id'); $t->string('ordernum'); $t->integer('type');
  96. });
  97. Schema::create('lhc_order_item', function (Blueprint $t) {
  98. $t->id(); $t->unsignedBigInteger('member_id'); $t->string('ordernum');
  99. });
  100. Schema::create('wallet_bonus', function (Blueprint $t) {
  101. $t->id(); $t->string('member_id'); $t->integer('type')->default(0); $t->unsignedBigInteger('activity_id')->default(0);
  102. $t->unsignedBigInteger('related_id'); foreach (['amount', 'need_flow', 'remaind_flow'] as $c) $t->decimal($c, 24, 2);
  103. $t->integer('status'); $t->timestamps();
  104. });
  105. Schema::create('wallets', function (Blueprint $t) {
  106. $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps();
  107. });
  108. DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '20.00']);
  109. Schema::create('balance_logs', function (Blueprint $t) {
  110. $t->id(); $t->string('member_id'); $t->string('change_type'); $t->unsignedBigInteger('related_id');
  111. foreach (['amount', 'before_balance', 'after_balance'] as $field) $t->decimal($field, 24, 10);
  112. $t->string('remark'); $t->timestamps();
  113. });
  114. Schema::create('rebates', function (Blueprint $t) {
  115. $t->id(); $t->string('member_id'); $t->string('date');
  116. $t->decimal('betting_amount', 24, 2); $t->decimal('effective_betting_amount', 24, 2);
  117. $t->decimal('rebate_ratio', 12, 4)->default(0); $t->decimal('amount', 24, 2)->default(0);
  118. $t->decimal('profit', 24, 2)->default(0); $t->string('username')->default(''); $t->string('first_name')->default('');
  119. $t->integer('status')->default(0); $t->string('audited_by')->nullable(); $t->bigInteger('grant_time')->default(0); $t->timestamps();
  120. });
  121. Schema::create('backflow', function (Blueprint $t) {
  122. $t->id(); $t->string('member_id'); $t->string('date');
  123. foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) $t->decimal($field, 24, 4)->default(0);
  124. $t->integer('status')->default(0); $t->bigInteger('grant_time')->default(0); $t->timestamps();
  125. });
  126. $this->migration()->up();
  127. $this->rulesMigration()->up();
  128. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php')->up();
  129. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php')->up();
  130. DB::table('rebate_rules')->insert(['category' => 'self:lottery', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0]);
  131. DB::table('backflow_rules')->insert(['category' => 'self:lottery:pc28', 'min_loss_amount' => 100, 'reward_amount' => 35, 'turnover_multiple' => 0]);
  132. $this->request('/admin/rebate');
  133. }
  134. protected function tearDown(): void
  135. {
  136. Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances();
  137. parent::tearDown();
  138. }
  139. private function migration()
  140. {
  141. return require dirname(__DIR__, 2) . '/database/migrations/2026_10_25_180000_create_reward_grant_settings.php';
  142. }
  143. private function rulesMigration()
  144. {
  145. return require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_160000_create_reward_rules.php';
  146. }
  147. private function request(string $path, array $params = [], string $method = 'GET'): void
  148. {
  149. $request = Request::create($path, $method, $params);
  150. $request->user = (object) ['id' => 1, 'username' => 'operator-one'];
  151. $this->app->instance('request', $request);
  152. }
  153. private function user(string $memberId, int $level = 1): int
  154. {
  155. DB::table('users')->insertOrIgnore(['member_id' => $memberId, 'level' => $level]);
  156. return (int) DB::table('users')->where('member_id', $memberId)->value('id');
  157. }
  158. /** Legacy PC28 rows: no facts, so the stored effective bet is the payout basis. */
  159. private function rebate(array $values = []): int
  160. {
  161. $this->user((string) ($values['member_id'] ?? '1001'));
  162. return DB::table('rebates')->insertGetId($values + ['member_id' => '1001', 'date' => '2026-10-23',
  163. 'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00']);
  164. }
  165. /** The month's lottery loss mirrors the old |recharge + withdrawal| fixture. */
  166. private function backflow(array $values = []): int
  167. {
  168. $values += ['member_id' => '1001', 'date' => '2026-09', 'recharge_amount' => '1000.00', 'withdrawal_amount' => '-300.00'];
  169. if (($values['status'] ?? 0) === 0) {
  170. $loss = bcadd((string) $values['recharge_amount'], (string) $values['withdrawal_amount'], 2);
  171. $this->lotteryFact($values['member_id'], 5, $values['date'] . '-10 12:00:00', '0', bcsub('0', $loss, 2));
  172. }
  173. return DB::table('backflow')->insertGetId($values);
  174. }
  175. private function fact(string $memberId, string $category, string $betAt, string $effective, string $profit, array $values = []): void
  176. {
  177. static $sequence = 0;
  178. DB::table('share_bet_facts')->insert($values + ['source' => 'test', 'source_id' => (string) ++$sequence,
  179. 'user_id' => $this->user($memberId), 'member_id' => $memberId, 'category' => $category, 'bet_at' => $betAt,
  180. 'bet_date' => substr($betAt, 0, 10), 'bet_amount' => $effective, 'effective_amount' => $effective,
  181. 'net_profit' => $profit, 'settled' => 1, 'voided' => 0, 'currency' => 'CNY',
  182. 'created_at' => now()->format('Y-m-d H:i:s'), 'updated_at' => now()->format('Y-m-d H:i:s')]);
  183. }
  184. private function lotteryFact(string $memberId, int $type, string $betAt, string $effective, string $profit, array $values = []): string
  185. {
  186. $orderNumber = 'lottery-' . DB::table('lhc_order')->count();
  187. $order = ['member_id' => $memberId, 'ordernum' => $orderNumber];
  188. DB::table('lhc_order')->insert($order + ['type' => $type]);
  189. $itemId = DB::table('lhc_order_item')->insertGetId($order);
  190. $this->fact($memberId, 'self:lottery', $betAt, $effective, $profit,
  191. ['source' => 'lhc_order_item', 'source_id' => (string) $itemId] + $values);
  192. return $orderNumber;
  193. }
  194. private function setGameList(array $items): void
  195. {
  196. DB::table('config')->insert(['field' => 'game_list', 'group_id' => 5, 'val' => json_encode($items, JSON_THROW_ON_ERROR)]);
  197. }
  198. private function enable(string $type, string $time = '14:30'): void
  199. {
  200. RewardGrantSettingsService::save($type, ['auto_enabled' => 1, 'grant_time' => $time]);
  201. }
  202. private function runRewards(): array { return (new AutomaticRewardGrantService())->run(); }
  203. #[DataProvider('payoutMessageCases')]
  204. public function test_manual_and_automatic_payouts_deliver_in_the_users_language(string $type, bool $automatic, ?string $language): void
  205. {
  206. $id = $type === 'rebate' ? $this->rebate() : $this->backflow();
  207. DB::table('users')->where('member_id', '1001')->update(['language' => $language]);
  208. if ($automatic) {
  209. $this->enable($type);
  210. $this->assertSame(1, $this->runRewards()[$type]['granted']);
  211. $this->assertSame(0, $this->runRewards()[$type]['granted']);
  212. } else {
  213. $this->request('/admin/' . $type, ['id' => $id], 'POST');
  214. $controller = $type === 'rebate' ? new RebateController() : new BackflowController();
  215. $method = $type === 'rebate' ? 'store' : 'grant';
  216. $this->assertSame(0, $controller->$method()->getData(true)['code']);
  217. $this->assertSame(-3, $controller->$method()->getData(true)['code']);
  218. }
  219. $memberId = (int) DB::table('users')->where('member_id', '1001')->value('id');
  220. $language = in_array($language, ['zh', 'en', 'vi'], true) ? $language : 'zh';
  221. $titles = $type === 'rebate'
  222. ? ['zh' => '笔笔返到账通知', 'en' => 'Bet rebate credited', 'vi' => 'Hoàn cược đã được ghi có']
  223. : ['zh' => '回水到账通知', 'en' => 'Loss rebate credited', 'vi' => 'Hoàn lỗ đã được ghi có'];
  224. $inbox = new SiteInboxService();
  225. $this->assertSame(1, $inbox->unread('user', $memberId)['count']);
  226. $message = $inbox->detail('user', $memberId, (int) DB::table('site_messages')->value('id'));
  227. $this->assertSame($language, $message['language']);
  228. $this->assertSame($titles[$language], $message['title']);
  229. $this->assertStringContainsString($type === 'rebate' ? '4.00' : '35.00', $message['content']);
  230. $this->assertStringContainsString($type === 'rebate' ? '2026-10-23' : '2026-09', $message['content']);
  231. $this->assertSame('internal', $message['category']);
  232. $this->assertSame(0, $inbox->unread('user', 1001)['count']);
  233. $this->assertSame(0, $inbox->unread('agent', $memberId)['count']);
  234. $this->assertSame(1, DB::table('site_messages')->count());
  235. $this->assertSame(1, DB::table('site_message_recipients')->count());
  236. $this->assertSame(1, DB::table('site_realtime_events')->count());
  237. $this->assertSame(1, DB::table('operation_audits')->where('resource_type', 'site_message')->count());
  238. }
  239. public static function payoutMessageCases(): array
  240. {
  241. $cases = [];
  242. foreach (['rebate', 'backflow'] as $type) foreach ([false, true] as $automatic) foreach (['zh', 'en', 'vi', null, '', 'unsupported'] as $language) {
  243. $cases[$type . '-' . (int) $automatic . '-' . ($language ?? 'null')] = [$type, $automatic, $language];
  244. }
  245. return $cases;
  246. }
  247. public function test_melbet_message_lists_returns_the_rebate_and_backflow_notices(): void
  248. {
  249. $rebate = $this->rebate(); $backflow = $this->backflow();
  250. Schema::table('users', function (Blueprint $t) { $t->string('user_id')->nullable(); $t->dateTime('delete_time')->nullable(); });
  251. DB::table('users')->where('member_id', '1001')->update(['user_id' => 'login-1001', 'language' => 'en']);
  252. RewardGrantService::grant('rebate', $rebate, 'operator-one');
  253. RewardGrantService::grant('backflow', $backflow, 'operator-one');
  254. $snapshot = tempnam(sys_get_temp_dir(), 'reward-inbox-');
  255. unlink($snapshot); // VACUUM INTO requires a new file.
  256. try {
  257. DB::statement('VACUUM INTO ?', [$snapshot]);
  258. $probe = new \Symfony\Component\Process\Process([PHP_BINARY, '-d', 'xdebug.mode=off', dirname(__DIR__) . '/fixtures/reward_message_inbox_probe.php', $snapshot, 'login-1001']);
  259. $probe->mustRun();
  260. $result = json_decode($probe->getOutput(), true, 512, JSON_THROW_ON_ERROR);
  261. $this->assertSame(2, $result['listing']['count']);
  262. $this->assertEqualsCanonicalizing(['Bet rebate credited', 'Loss rebate credited'], array_column($result['listing']['list'], 'title'));
  263. $this->assertSame(['en', 'en'], array_column($result['listing']['list'], 'language'));
  264. $this->assertSame([false, false], array_column($result['listing']['list'], 'is_read'));
  265. $this->assertCount(2, $result['details']);
  266. } finally {
  267. if (is_file($snapshot)) unlink($snapshot);
  268. }
  269. }
  270. public function test_message_failure_rolls_back_the_payout_and_retry_delivers_once(): void
  271. {
  272. $id = $this->rebate(); $this->enable('rebate');
  273. DB::statement("CREATE TRIGGER reject_reward_notice BEFORE INSERT ON bot_site_message_recipients BEGIN SELECT RAISE(ABORT, 'notice unavailable'); END");
  274. $this->assertSame(1, $this->runRewards()['rebate']['failed']);
  275. $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
  276. $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status'));
  277. foreach (['balance_logs', 'site_messages', 'site_message_translations', 'site_message_recipients', 'site_realtime_events', 'operation_audits'] as $table) {
  278. $this->assertSame(0, DB::table($table)->count(), $table);
  279. }
  280. DB::statement('DROP TRIGGER reject_reward_notice');
  281. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  282. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  283. $this->assertEquals(24, DB::table('wallets')->value('available_balance'));
  284. $this->assertSame(1, DB::table('site_message_recipients')->count());
  285. }
  286. private function scheduledEvents(): array
  287. {
  288. $this->app['config']->set('cache', ['default' => 'array', 'stores' => ['array' => ['driver' => 'array']]]);
  289. $this->app->register(\Illuminate\Cache\CacheServiceProvider::class);
  290. $schedule = new \Illuminate\Console\Scheduling\Schedule('Asia/Shanghai');
  291. $kernel = new \App\Console\Kernel($this->app, new Dispatcher($this->app));
  292. $method = new \ReflectionMethod($kernel, 'schedule');
  293. $method->setAccessible(true);
  294. $method->invoke($kernel, $schedule);
  295. return $schedule->events();
  296. }
  297. public function test_console_scheduler_registers_the_automatic_payout_command_every_minute_with_overlap_protection(): void
  298. {
  299. $events = array_values(array_filter($this->scheduledEvents(), fn ($event) => str_contains($event->command ?? '', 'rewards:auto-grant')));
  300. $this->assertCount(1, $events);
  301. $this->assertSame('* * * * *', $events[0]->expression);
  302. $this->assertTrue($events[0]->withoutOverlapping);
  303. $this->assertSame(60, $events[0]->expiresAt);
  304. }
  305. public function test_console_scheduler_refreshes_reward_bases_and_wagers_after_bet_facts(): void
  306. {
  307. $commands = ['share:sync-facts --write', 'rewards:sync-bases', 'wagering:sync'];
  308. $scheduled = [];
  309. foreach ($this->scheduledEvents() as $event) {
  310. foreach ($commands as $command) {
  311. if (!str_ends_with($event->command ?? '', ' ' . $command)) continue;
  312. $this->assertSame('* * * * *', $event->expression);
  313. $this->assertTrue($event->withoutOverlapping);
  314. $scheduled[] = $command;
  315. }
  316. }
  317. $this->assertSame($commands, $scheduled);
  318. }
  319. public function test_migration_defaults_to_manual_and_both_settings_routes_keep_admin_and_existing_grant_permissions(): void
  320. {
  321. $id = $this->rebate(); $this->backflow();
  322. foreach (['rebate', 'backflow'] as $type) {
  323. $this->assertSame(['type' => $type, 'auto_enabled' => 0, 'grant_time' => '03:00', 'timezone' => 'Asia/Shanghai'], RewardGrantSettingsService::get($type));
  324. }
  325. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  326. $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status'));
  327. $this->assertSame(0, DB::table('balance_logs')->count());
  328. $router = new Router(new Dispatcher($this->app), $this->app);
  329. $this->app->instance('router', $router);
  330. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  331. foreach (['rebate' => 'submit', 'backflow' => 'grant'] as $type => $permission) {
  332. foreach (['GET', 'POST'] as $method) {
  333. $route = $router->getRoutes()->match(Request::create('/admin/' . $type . '/grant-settings', $method));
  334. $this->assertContains('admin.jwt', $route->middleware());
  335. $this->assertContains('check.button.uri', $route->middleware());
  336. $this->assertSame('admin/' . $type . '/' . $permission, $route->defaults['permission_uri']);
  337. }
  338. }
  339. $this->migration()->down();
  340. $this->assertFalse(Schema::hasTable('reward_grant_settings'));
  341. }
  342. public function test_settings_api_persists_independent_times_and_disabling_preserves_the_previous_time_without_paying(): void
  343. {
  344. $this->rebate();
  345. $this->request('/admin/rebate/grant-settings', ['auto_enabled' => true, 'grant_time' => '14:30'], 'POST');
  346. $body = (new RewardGrantSettings())->saveRebate()->getData(true);
  347. $this->assertSame(0, $body['code']);
  348. $this->assertSame(1, $body['data']['auto_enabled']);
  349. $this->assertSame('14:30', $body['data']['grant_time']);
  350. $this->assertSame(0, RewardGrantSettingsService::get('backflow')['auto_enabled']);
  351. $this->request('/admin/backflow/grant-settings', ['auto_enabled' => 1, 'grant_time' => '20:15'], 'POST');
  352. $this->assertSame(0, (new RewardGrantSettings())->saveBackflow()->getData(true)['code']);
  353. $this->assertSame('20:15', RewardGrantSettingsService::get('backflow')['grant_time']);
  354. $this->request('/admin/rebate/grant-settings', ['auto_enabled' => 0], 'POST');
  355. $body = (new RewardGrantSettings())->saveRebate()->getData(true);
  356. $this->assertSame(0, $body['data']['auto_enabled']);
  357. $this->assertSame('14:30', $body['data']['grant_time']);
  358. $this->assertSame(0, DB::table('balance_logs')->count());
  359. $this->assertSame(0, (new RewardGrantSettings())->rebate()->getData(true)['code']);
  360. }
  361. #[DataProvider('invalidSettings')]
  362. public function test_invalid_settings_do_not_change_the_switch_or_time(array $values): void
  363. {
  364. $this->request('/admin/rebate/grant-settings', $values, 'POST');
  365. $this->assertSame(-3, (new RewardGrantSettings())->saveRebate()->getData(true)['code']);
  366. $this->assertSame(0, RewardGrantSettingsService::get('rebate')['auto_enabled']);
  367. $this->assertSame('03:00', RewardGrantSettingsService::get('rebate')['grant_time']);
  368. }
  369. public static function invalidSettings(): array
  370. {
  371. return [[[]], [['auto_enabled' => 2]], [['auto_enabled' => 'auto']], [['auto_enabled' => 1]],
  372. [['auto_enabled' => true]], [['auto_enabled' => 1, 'grant_time' => '']],
  373. [['auto_enabled' => 1, 'grant_time' => '24:00']], [['auto_enabled' => 1, 'grant_time' => '9:00']],
  374. [['auto_enabled' => 1, 'grant_time' => '12:60']], [['auto_enabled' => 0, 'grant_time' => ['03:00']]]];
  375. }
  376. public function test_exact_minute_pays_both_types_once_and_retries_do_not_duplicate_wallet_or_ledger(): void
  377. {
  378. $rebate = $this->rebate(); $backflow = $this->backflow();
  379. $this->rebate(['date' => '2026-10-25']); $this->backflow(['date' => '2026-10']);
  380. $this->rebate(['status' => 1]); $this->backflow(['status' => 1]);
  381. $this->enable('rebate'); $this->enable('backflow');
  382. Carbon::setTestNow(Carbon::parse('2026-10-25 14:29:59', 'Asia/Shanghai'));
  383. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  384. $this->assertSame(0, DB::table('balance_logs')->count());
  385. Carbon::setTestNow(Carbon::parse('2026-10-25 14:30:00', 'Asia/Shanghai'));
  386. $result = $this->runRewards();
  387. $this->assertSame(1, $result['rebate']['granted']); $this->assertSame(1, $result['backflow']['granted']);
  388. $this->assertEquals(59, DB::table('wallets')->value('available_balance'));
  389. $this->assertEquals(4, DB::table('rebates')->where('id', $rebate)->value('amount'));
  390. $this->assertEquals(35, DB::table('backflow')->where('id', $backflow)->value('amount'));
  391. $this->assertSame('system:auto-grant', DB::table('rebates')->where('id', $rebate)->value('audited_by'));
  392. $this->assertSame(now()->timestamp, DB::table('backflow')->where('id', $backflow)->value('grant_time'));
  393. $this->assertSame(2, DB::table('balance_logs')->count());
  394. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  395. $this->assertSame(2, DB::table('balance_logs')->count());
  396. $this->assertEquals(59, DB::table('wallets')->value('available_balance'));
  397. }
  398. public function test_scheduled_cutoffs_keep_new_york_days_and_shanghai_months_even_when_catching_up_later(): void
  399. {
  400. Carbon::setTestNow(Carbon::parse('2026-11-01 03:00:00', 'Asia/Shanghai'));
  401. $old = $this->rebate(['date' => '2026-10-30']);
  402. $notYetEndedAtSchedule = $this->rebate(['date' => '2026-10-31']);
  403. $lastMonth = $this->backflow(['date' => '2026-10']);
  404. $currentMonth = $this->backflow(['date' => '2026-11']);
  405. $this->enable('rebate', '03:00'); $this->enable('backflow', '03:00');
  406. // At 14:00 the New York day has changed, but this run still belongs to the 03:00 schedule.
  407. Carbon::setTestNow(Carbon::parse('2026-11-01 14:00:00', 'Asia/Shanghai'));
  408. $this->runRewards();
  409. $this->assertSame(1, DB::table('rebates')->where('id', $old)->value('status'));
  410. $this->assertSame(0, DB::table('rebates')->where('id', $notYetEndedAtSchedule)->value('status'));
  411. $this->assertSame(1, DB::table('backflow')->where('id', $lastMonth)->value('status'));
  412. $this->assertSame(0, DB::table('backflow')->where('id', $currentMonth)->value('status'));
  413. Carbon::setTestNow(Carbon::parse('2026-11-02 02:59:59', 'Asia/Shanghai'));
  414. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  415. Carbon::setTestNow(Carbon::parse('2026-11-02 03:00:00', 'Asia/Shanghai'));
  416. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  417. $this->assertSame(1, DB::table('rebates')->where('id', $notYetEndedAtSchedule)->value('status'));
  418. }
  419. public function test_disabled_switch_stops_auto_but_both_manual_controllers_still_work_and_stale_calls_cannot_pay_twice(): void
  420. {
  421. $id = $this->rebate(); $backflow = $this->backflow();
  422. $stale = Rebate::findOrFail($id);
  423. $this->enable('rebate');
  424. RewardGrantSettingsService::save('rebate', ['auto_enabled' => 0]);
  425. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  426. $this->request('/admin/rebate/submit', ['id' => $id], 'POST');
  427. $this->assertSame(0, (new RebateController())->store()->getData(true)['code']);
  428. $this->assertSame(-3, (new RebateController())->store()->getData(true)['code']);
  429. $this->assertSame('operator-one', DB::table('rebates')->where('id', $id)->value('audited_by'));
  430. $this->request('/admin/backflow/grant', ['id' => $backflow], 'POST');
  431. $this->assertSame(0, (new BackflowController())->grant()->getData(true)['code']);
  432. $this->assertSame(-3, (new BackflowController())->grant()->getData(true)['code']);
  433. $this->enable('rebate'); $this->enable('backflow');
  434. $this->runRewards();
  435. $this->assertFalse(RewardGrantService::grant('rebate', $id, 'auto', true));
  436. $this->assertSame(2, DB::table('balance_logs')->count());
  437. try { RebateService::BibiReturn($stale, '999999'); $this->fail('A stale row must not pay again'); }
  438. catch (\RuntimeException $e) { $this->assertSame(-3, $e->getCode()); }
  439. $this->assertEquals(59, DB::table('wallets')->value('available_balance'));
  440. }
  441. public function test_payout_reads_fresh_effective_bet_and_zero_rewards_are_finalized_without_ledger_credits(): void
  442. {
  443. $id = $this->rebate(); $stale = Rebate::findOrFail($id);
  444. DB::table('rebates')->where('id', $id)->update(['effective_betting_amount' => 100]);
  445. RebateService::BibiReturn($stale, '800');
  446. $this->assertEquals(0.5, DB::table('rebates')->where('id', $id)->value('amount'));
  447. $this->rebate(['effective_betting_amount' => 0]);
  448. $below = $this->backflow(['recharge_amount' => 50, 'withdrawal_amount' => 0]);
  449. $this->enable('rebate'); $this->enable('backflow'); $this->runRewards();
  450. $this->assertSame(1, DB::table('backflow')->where('id', $below)->value('status'));
  451. $this->assertEquals(0, DB::table('backflow')->where('id', $below)->value('amount'));
  452. $this->assertSame(1, DB::table('balance_logs')->count());
  453. $this->assertEquals(20.5, DB::table('wallets')->value('available_balance'));
  454. $this->assertSame(1, DB::table('site_messages')->count(), 'Zero payouts do not send notices');
  455. }
  456. public function test_ledger_failure_rolls_back_wallet_and_reward_then_automatic_retry_credits_once(): void
  457. {
  458. $id = $this->rebate(); $this->enable('rebate');
  459. DB::statement("CREATE TRIGGER reject_reward_ledger BEFORE INSERT ON bot_balance_logs BEGIN SELECT RAISE(ABORT, 'ledger unavailable'); END");
  460. $this->assertSame(1, $this->runRewards()['rebate']['failed']);
  461. $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
  462. $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status'));
  463. $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('grant_time'));
  464. $this->assertSame(0, DB::table('balance_logs')->count());
  465. $this->assertSame(0, DB::table('site_messages')->count());
  466. DB::statement('DROP TRIGGER reject_reward_ledger');
  467. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  468. $this->assertEquals(24, DB::table('wallets')->value('available_balance'));
  469. $this->assertSame(1, DB::table('balance_logs')->count());
  470. }
  471. public function test_status_save_failure_rolls_back_backflow_wallet_and_ledger_and_missing_wallet_does_not_stop_other_rows(): void
  472. {
  473. $id = $this->backflow(); $this->enable('backflow');
  474. DB::statement("CREATE TRIGGER reject_backflow_status BEFORE UPDATE ON bot_backflow BEGIN SELECT RAISE(ABORT, 'reward unavailable'); END");
  475. $this->assertSame(1, $this->runRewards()['backflow']['failed']);
  476. $this->assertSame(0, DB::table('backflow')->where('id', $id)->value('status'));
  477. $this->assertSame(0, DB::table('balance_logs')->count());
  478. $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
  479. DB::statement('DROP TRIGGER reject_backflow_status');
  480. $bad = $this->rebate(['member_id' => 'no-wallet']); $good = $this->rebate(); $this->enable('rebate');
  481. $result = $this->runRewards();
  482. $this->assertSame(1, $result['rebate']['failed']); $this->assertSame(1, $result['rebate']['granted']);
  483. $this->assertSame(0, DB::table('rebates')->where('id', $bad)->value('status'));
  484. $this->assertSame(1, DB::table('rebates')->where('id', $good)->value('status'));
  485. $this->assertSame(1, $result['backflow']['granted']);
  486. }
  487. public function test_switch_and_time_are_rechecked_between_records_and_before_retrying(): void
  488. {
  489. $this->rebate(); $second = $this->rebate(); $this->enable('rebate');
  490. $changed = false;
  491. $this->app['db']->connection()->listen(function ($event) use (&$changed) {
  492. if (!$changed && str_starts_with(strtolower($event->sql), 'update "bot_rebates"')) {
  493. $changed = true;
  494. RewardGrantSettingsService::save('rebate', ['auto_enabled' => 0]);
  495. }
  496. });
  497. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  498. $this->assertSame(0, DB::table('rebates')->where('id', $second)->value('status'));
  499. $this->assertSame(1, DB::table('balance_logs')->count());
  500. $this->enable('rebate', '15:00');
  501. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  502. Carbon::setTestNow(Carbon::parse('2026-10-25 15:00:00', 'Asia/Shanghai'));
  503. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  504. }
  505. public function test_chunked_processing_does_not_skip_rows_and_command_reports_failure_without_forcing_disabled_rewards(): void
  506. {
  507. for ($i = 0; $i < 105; ++$i) $this->rebate(['effective_betting_amount' => 0]);
  508. $command = new AutoGrantRewards(); $command->setLaravel($this->app);
  509. $tester = new CommandTester($command);
  510. $tester->execute([]);
  511. $this->assertSame(0, $tester->getStatusCode());
  512. $this->assertSame(105, DB::table('rebates')->where('status', 0)->count());
  513. $this->enable('rebate'); $tester->execute([]);
  514. $this->assertSame(0, $tester->getStatusCode());
  515. $this->assertStringContainsString('"granted":105', $tester->getDisplay());
  516. $this->assertSame(105, DB::table('rebates')->where('status', 1)->count());
  517. $this->rebate(['member_id' => 'missing']); $tester->execute([]);
  518. $this->assertSame(1, $tester->getStatusCode());
  519. }
  520. public function test_rules_api_lists_categories_and_levels_replaces_one_category_and_rejects_bad_tiers(): void
  521. {
  522. $body = (new RewardRules())->rebate()->getData(true);
  523. $this->assertSame(0, $body['code']);
  524. $this->assertContains(['value' => 'third:1', 'label' => '真人'], $body['data']['categories']);
  525. $this->assertSame([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']], $body['data']['levels']);
  526. $this->assertSame([], $body['data']['rules']['third:2']);
  527. $this->request('/admin/rebate/rules', ['category' => 'third:2', 'rules' => [
  528. ['level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3],
  529. ['level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0],
  530. ]], 'POST');
  531. $body = (new RewardRules())->saveRebate()->getData(true);
  532. $this->assertSame(0, $body['code']);
  533. $this->assertSame(['0.00', '1000.00'], array_column($body['data']['rules']['third:2'], 'min_effective_amount'));
  534. $this->assertSame('3.00', $body['data']['rules']['third:2'][1]['turnover_multiple']);
  535. $this->assertCount(1, $body['data']['rules']['self:lottery']);
  536. foreach ([['category' => 'third:9', 'rules' => []],
  537. ['category' => 'third:2', 'rules' => [['level' => 7, 'min_effective_amount' => 0, 'percentage' => 1, 'turnover_multiple' => 0]]],
  538. ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => '1e3', 'percentage' => 1, 'turnover_multiple' => 0]]],
  539. ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 0, 'percentage' => 101, 'turnover_multiple' => 0]]],
  540. ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 5, 'percentage' => 1, 'turnover_multiple' => 0],
  541. ['level' => 1, 'min_effective_amount' => 5, 'percentage' => 2, 'turnover_multiple' => 0]]]] as $bad) {
  542. $this->request('/admin/rebate/rules', $bad, 'POST');
  543. $this->assertSame(-3, (new RewardRules())->saveRebate()->getData(true)['code']);
  544. }
  545. $this->assertSame(2, DB::table('rebate_rules')->where('category', 'third:2')->count());
  546. $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => [
  547. ['min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2]]], 'POST');
  548. $body = (new RewardRules())->saveBackflow()->getData(true);
  549. $this->assertSame([['id' => 2, 'category' => 'third:1', 'min_loss_amount' => '2000.00', 'reward_amount' => '60.00',
  550. 'turnover_multiple' => '2.00']], $body['data']['rules']['third:1']);
  551. $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => []], 'POST');
  552. $this->assertSame(0, (new RewardRules())->saveBackflow()->getData(true)['code']);
  553. $this->assertSame(0, DB::table('backflow_rules')->where('category', 'third:1')->count());
  554. $router = new Router(new Dispatcher($this->app), $this->app);
  555. $this->app->instance('router', $router);
  556. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  557. foreach (['rebate' => 'submit', 'backflow' => 'grant'] as $type => $permission) {
  558. foreach (['GET', 'POST'] as $method) {
  559. $route = $router->getRoutes()->match(Request::create('/admin/' . $type . '/rules', $method));
  560. $this->assertContains('check.button.uri', $route->middleware());
  561. $this->assertSame('admin/' . $type . '/' . $permission, $route->defaults['permission_uri']);
  562. }
  563. }
  564. }
  565. public function test_rebate_sums_every_category_by_new_york_day_and_uses_the_members_level_tier_and_turnover(): void
  566. {
  567. $this->user('2001', 2);
  568. DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']);
  569. DB::table('rebate_rules')->insert([
  570. ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0],
  571. ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3],
  572. ['category' => 'third:2', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '2', 'turnover_multiple' => 0],
  573. ['category' => 'self:sport', 'level' => 2, 'min_effective_amount' => 500, 'percentage' => '1', 'turnover_multiple' => 0],
  574. ]);
  575. // New York 2026-10-23 is Shanghai 2026-10-23 12:00 to 2026-10-24 11:59:59.
  576. $this->fact('2001', 'third:2', '2026-10-23 20:00:00', '1000', '-1000');
  577. $this->fact('2001', 'third:2', '2026-10-24 10:00:00', '500', '500');
  578. $this->fact('2001', 'self:sport', '2026-10-24 11:00:00', '300', '-300');
  579. $this->fact('2001', 'third:2', '2026-10-24 11:30:00', '9999', '0', ['voided' => 1]);
  580. $this->fact('2001', 'third:2', '2026-10-24 13:00:00', '7777', '-7777');
  581. $this->assertSame(['rebates' => 2, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run());
  582. $row = DB::table('rebates')->where('member_id', '2001')->where('date', '2026-10-23')->first();
  583. $this->assertEquals(1800, $row->betting_amount);
  584. $this->assertEquals(1800, $row->effective_betting_amount);
  585. $this->assertEquals(15, $row->amount);
  586. $this->assertEquals(45, $row->need_flow);
  587. $this->assertSame(['self:sport', 'third:2'], array_column(json_decode($row->details, true), 'category'));
  588. $this->assertTrue(RewardGrantService::grant('rebate', (int) $row->id, 'operator-one'));
  589. $this->assertEquals(15, DB::table('wallets')->where('member_id', '2001')->value('available_balance'));
  590. $bonus = DB::table('wallet_bonus')->first();
  591. $this->assertSame('2001', $bonus->member_id);
  592. $this->assertEquals(45, $bonus->need_flow);
  593. $this->assertEquals(15, $bonus->amount);
  594. $this->assertSame((int) $row->id, (int) $bonus->related_id);
  595. $this->assertStringContainsString('电子:1500.00×1.0000%', DB::table('balance_logs')->value('remark'));
  596. // A paid day is never rewritten by later facts.
  597. $this->fact('2001', 'third:2', '2026-10-24 09:00:00', '100', '-100');
  598. (new RewardBaseSyncService())->run();
  599. $this->assertEquals(1800, DB::table('rebates')->where('id', $row->id)->value('effective_betting_amount'));
  600. $this->assertSame(1, DB::table('wallet_bonus')->count());
  601. }
  602. public function test_backflow_rules_follow_the_game_list_and_save_each_lottery_independently(): void
  603. {
  604. $this->setGameList([
  605. ['type' => 1, 'game' => 'canada28', 'name' => '加拿大 28', 'sort' => 1],
  606. ['type' => 4, 'game' => 'lhc', 'params' => ['type' => 3], 'name' => '澳门六合彩', 'sort' => 6],
  607. ['type' => 2, 'game' => 'jisu28', 'name' => '极速 28', 'sort' => 9],
  608. ['type' => 12, 'game' => 'jisu', 'params' => ['type' => 7], 'name' => 'SG快3', 'sort' => 3, 'status' => 0],
  609. ]);
  610. $rule = ['min_loss_amount' => 10, 'reward_amount' => 20, 'turnover_multiple' => 2];
  611. DB::table('backflow_rules')->insert($rule + ['category' => 'self:lottery']);
  612. DB::table('backflow_rules')->insert($rule + ['category' => 'third:3']);
  613. $controller = new RewardRules();
  614. $gameList = (new ConfigController())->gameList()->getData(true)['data'];
  615. $this->request('/admin/backflow/rules');
  616. $data = $controller->backflow()->getData(true)['data'];
  617. $categories = array_column($data['categories'], null, 'value');
  618. $this->assertSame(['third:1', 'third:2', 'third:4', 'third:5', 'third:6', 'third:7', 'self:sport', 'self:lottery'], array_keys($categories));
  619. $this->assertArrayNotHasKey('self:lottery', $data['rules']);
  620. $this->assertArrayNotHasKey('third:3', $data['rules']);
  621. $children = $categories['self:lottery']['children'];
  622. $this->assertSame('F1彩票', $categories['self:lottery']['label']);
  623. $this->assertSame(array_column($gameList, 'name'), array_column($children, 'label'));
  624. $this->assertSame(['self:lottery:jisu28', 'self:lottery:lhc:3', 'self:lottery:jisu:7', 'self:lottery:pc28'],
  625. array_column($children, 'value'));
  626. $this->request('/admin/backflow/rules', ['category' => 'self:lottery:jisu28', 'rules' => [$rule]], 'POST');
  627. $saved = $controller->saveBackflow()->getData(true);
  628. $this->assertSame(0, $saved['code']);
  629. $this->assertSame($data['categories'], $saved['data']['categories']);
  630. $this->assertSame('20.00', $saved['data']['rules']['self:lottery:jisu28'][0]['reward_amount']);
  631. $this->assertSame('35.00', $saved['data']['rules']['self:lottery:pc28'][0]['reward_amount']);
  632. $this->request('/admin/backflow/rules', ['category' => 'self:lottery:pc28', 'rules' => []], 'POST');
  633. $cleared = $controller->saveBackflow()->getData(true);
  634. $this->assertSame([], $cleared['data']['rules']['self:lottery:pc28']);
  635. $this->assertSame($saved['data']['rules']['self:lottery:jisu28'], $cleared['data']['rules']['self:lottery:jisu28']);
  636. foreach (['self:lottery', 'self:lottery:jisu:6', 'third:3'] as $category) {
  637. $this->request('/admin/backflow/rules', ['category' => $category, 'rules' => [$rule]], 'POST');
  638. $this->assertSame(-3, $controller->saveBackflow()->getData(true)['code']);
  639. }
  640. }
  641. public function test_backflow_pays_fixed_rewards_for_each_categorys_monthly_net_loss(): void
  642. {
  643. $this->user('2001');
  644. DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']);
  645. DB::table('backflow_rules')->insert([
  646. ['category' => 'third:1', 'min_loss_amount' => 1000, 'reward_amount' => 20, 'turnover_multiple' => 0],
  647. ['category' => 'third:1', 'min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2],
  648. ['category' => 'third:2', 'min_loss_amount' => 0, 'reward_amount' => 99, 'turnover_multiple' => 0],
  649. ['category' => 'self:sport', 'min_loss_amount' => 100, 'reward_amount' => 5, 'turnover_multiple' => 0],
  650. ]);
  651. $this->fact('2001', 'third:1', '2026-09-03 10:00:00', '2000', '-2000');
  652. $this->fact('2001', 'third:1', '2026-09-28 10:00:00', '500', '-500');
  653. $this->fact('2001', 'third:2', '2026-09-05 10:00:00', '100', '100');
  654. $this->fact('2001', 'self:sport', '2026-09-30 23:00:00', '50', '-50');
  655. $this->fact('2001', 'self:sport', '2026-10-01 00:30:00', '900', '-900');
  656. (new RewardBaseSyncService())->run();
  657. $row = DB::table('backflow')->where('member_id', '2001')->where('date', '2026-09')->first();
  658. $this->assertEquals(2550, $row->loss_amount);
  659. $this->assertEquals(60, $row->amount);
  660. $this->assertTrue(RewardGrantService::grant('backflow', (int) $row->id, 'operator-one'));
  661. $this->assertEquals(60, DB::table('wallets')->where('member_id', '2001')->value('available_balance'));
  662. $this->assertEquals(120, DB::table('wallet_bonus')->value('need_flow'));
  663. $this->assertEquals(120, DB::table('backflow')->where('id', $row->id)->value('need_flow'));
  664. }
  665. #[RunInSeparateProcess]
  666. #[PreserveGlobalState(false)]
  667. public function test_backflow_recalculates_an_unpaid_month_by_lottery_and_keeps_the_paid_snapshot(): void
  668. {
  669. $longLabel = str_repeat('澳门六合彩', 35);
  670. $this->setGameList([
  671. ['game' => 'canada28', 'name' => '加拿大28'],
  672. ['game' => 'jisu28', 'name' => '极速28'],
  673. ['game' => 'lhc', 'params' => ['type' => 3], 'name' => $longLabel],
  674. ['game' => 'jisu', 'params' => ['type' => 7], 'name' => 'SG快3'],
  675. ]);
  676. DB::table('backflow_rules')->insert([
  677. ['category' => 'self:lottery', 'min_loss_amount' => 0, 'reward_amount' => 999, 'turnover_multiple' => 0],
  678. ['category' => 'self:lottery:pc28', 'min_loss_amount' => 150, 'reward_amount' => 50, 'turnover_multiple' => 2],
  679. ['category' => 'self:lottery:pc28', 'min_loss_amount' => 170, 'reward_amount' => 70, 'turnover_multiple' => 2],
  680. ['category' => 'self:lottery:jisu28', 'min_loss_amount' => 0, 'reward_amount' => 99, 'turnover_multiple' => 0],
  681. ['category' => 'self:lottery:lhc:3', 'min_loss_amount' => 120, 'reward_amount' => 7, 'turnover_multiple' => 3],
  682. ['category' => 'self:lottery:jisu:7', 'min_loss_amount' => 200, 'reward_amount' => 11, 'turnover_multiple' => 2],
  683. ['category' => 'third:3', 'min_loss_amount' => 100, 'reward_amount' => 4, 'turnover_multiple' => 0],
  684. ]);
  685. $september = '2026-09-30 23:59:59';
  686. $this->lotteryFact('1001', 5, $september, '200', '-200');
  687. $this->lotteryFact('1001', 5, $september, '40', '40');
  688. $this->lotteryFact('1001', 5, $september, '1000', '-1000', ['voided' => 1]);
  689. $this->lotteryFact('1001', 5, $september, '0', '-900', ['settled' => 0, 'bet_amount' => '900']);
  690. $this->lotteryFact('1001', 6, $september, '500', '500');
  691. $this->lotteryFact('1001', 3, $september, '120', '-120');
  692. $this->lotteryFact('1001', 13, $september, '200', '-200');
  693. $this->lotteryFact('1001', 7, $september, '1000', '-1000');
  694. $this->fact('1001', 'third:3', $september, '100', '-100');
  695. $duplicate = $this->lotteryFact('1001', 5, $september, '90', '-90');
  696. DB::table('lhc_order')->insert(['ordernum' => $duplicate, 'member_id' => '1002', 'type' => 3]);
  697. $this->fact('1001', 'self:lottery', $september, '35', '-35', ['source' => 'lhc_order_item', 'source_id' => '999']);
  698. $this->lotteryFact('1001', 5, '2026-10-01 00:00:00', '1000', '-1000');
  699. $this->lotteryFact('1001', 5, '2026-08-20 00:00:00', '1000', '-1000');
  700. $id = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-09', 'amount' => '1']);
  701. $paid = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-08', 'status' => 1,
  702. 'amount' => '35', 'granted_amount' => '35', 'details' => '[{"category":"self:lottery"}]']);
  703. $paidSnapshot = DB::table('backflow')->where('id', $paid)->first();
  704. $this->request('/admin/backflow', ['page' => 1, 'limit' => 10, 'date' => '2026-09']);
  705. PaginationState::resolveUsing($this->app);
  706. $list = (new BackflowController())->index()->getData(true);
  707. $this->assertSame(0, $list['code'], $list['msg']);
  708. $preview = $list['data']['data'][0];
  709. $this->assertSame(['68.00', '1605.00', '143.00'], [$preview['amount'], $preview['loss_amount'], $preview['need_flow']]);
  710. $details = array_column($preview['details'], null, 'category');
  711. $this->assertArrayNotHasKey('third:3', $details);
  712. $this->assertSame('160.00', $details['self:lottery:pc28']['loss_amount']);
  713. $this->assertSame('1140.00', $details['self:lottery:pc28']['bet_amount']);
  714. $this->assertSame('0.00', $details['self:lottery:jisu28']['amount']);
  715. $this->assertSame($longLabel, $details['self:lottery:lhc:3']['label']);
  716. $this->assertSame('11.00', $details['self:lottery:jisu:7']['amount']);
  717. $this->assertSame('0.00', $details['self:lottery:jisu:1']['amount']);
  718. $this->assertSame('未识别彩种', $details['self:lottery']['label']);
  719. $this->assertSame('125.00', $details['self:lottery']['loss_amount']);
  720. $this->assertNull($details['self:lottery']['rule_id']);
  721. $this->assertSame(2, (new RewardBaseSyncService())->run()['backflow']);
  722. $row = DB::table('backflow')->where('id', $id)->first();
  723. $this->assertEquals(68, $row->amount);
  724. $this->assertSame($preview['details'], json_decode($row->details, true));
  725. DB::statement("CREATE TRIGGER backflow_remark_limit BEFORE INSERT ON bot_balance_logs
  726. WHEN length(NEW.remark) > 255 BEGIN SELECT RAISE(ABORT, 'remark too long'); END");
  727. $this->enable('backflow');
  728. $this->assertSame(1, $this->runRewards()['backflow']['granted']);
  729. $this->assertSame(0, $this->runRewards()['backflow']['granted']);
  730. $this->assertEquals(88, DB::table('wallets')->value('available_balance'));
  731. $this->assertEquals(143, DB::table('wallet_bonus')->value('need_flow'));
  732. $this->assertSame('日期:2026-09;亏损:1605.00;档位明细见回水记录;本次发放:68.00;累计发放:68.00', DB::table('balance_logs')->value('remark'));
  733. $this->assertEquals($paidSnapshot, DB::table('backflow')->where('id', $paid)->first());
  734. }
  735. public function test_backflow_can_be_granted_again_for_the_remainder_until_the_month_is_closed(): void
  736. {
  737. // now = 2026-10-25 14:30 上海;当月 2026-10。钱包初始 20。
  738. DB::table('backflow_rules')->insert([
  739. ['category' => 'self:lottery:pc28', 'min_loss_amount' => 10, 'reward_amount' => 8, 'turnover_multiple' => 1],
  740. ['category' => 'self:lottery:pc28', 'min_loss_amount' => 300, 'reward_amount' => 50, 'turnover_multiple' => 1],
  741. ]);
  742. $id = $this->backflow(['date' => '2026-10', 'recharge_amount' => '50.00', 'withdrawal_amount' => '0']); // 亏 50
  743. $this->enable('backflow');
  744. $this->assertSame(0, $this->runRewards()['backflow']['granted'], '自动发放不发当月');
  745. // 第一次手动发放:当月亏 50 → 8,记为部分发放
  746. $this->assertTrue(RewardGrantService::grant('backflow', $id, 'operator-one'));
  747. $row = DB::table('backflow')->where('id', $id)->first();
  748. $this->assertSame([2, '8.00', '8.00'], [(int) $row->status, bcadd($row->amount, 0, 2), bcadd($row->granted_amount, 0, 2)]);
  749. $this->assertEquals(28, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
  750. $this->assertSame(['amount' => '8.00', 'granted_amount' => '8.00', 'pending_amount' => '0.00', 'can_grant' => false, 'status_text' => '部分发放'],
  751. BackflowService::grantFields((array) $row));
  752. // 没有差额时当月不能再发
  753. try { RewardGrantService::grant('backflow', $id, 'operator-one'); $this->fail('expected exception'); }
  754. catch (\RuntimeException $e) { $this->assertSame('暂无可发放金额(按当前规则应发 8.00,已发放 8.00)', $e->getMessage()); }
  755. // 继续亏到 350:同步后应发 50,已发 8,待发 42
  756. $this->lotteryFact('1001', 5, '2026-10-20 12:00:00', '300', '-300');
  757. (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20');
  758. $fields = BackflowService::grantFields((array) DB::table('backflow')->where('id', $id)->first());
  759. $this->assertSame(['50.00', '8.00', '42.00', true], [$fields['amount'], $fields['granted_amount'], $fields['pending_amount'], $fields['can_grant']]);
  760. $this->assertTrue(RewardGrantService::grant('backflow', $id, 'operator-two'));
  761. $row = DB::table('backflow')->where('id', $id)->first();
  762. $this->assertSame([2, '50.00', '50.00'], [(int) $row->status, bcadd($row->granted_amount, 0, 2), bcadd($row->granted_need_flow, 0, 2)]);
  763. $this->assertEquals(70, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
  764. $this->assertSame(['8.0000000000', '42.0000000000'], DB::table('balance_logs')->where('change_type', '回水')->orderBy('id')->pluck('amount')
  765. ->map(fn ($v) => bcadd($v, 0, 10))->all());
  766. $this->assertStringContainsString('本次发放:42.00;累计发放:50.00', DB::table('balance_logs')->where('change_type', '回水')->orderByDesc('id')->value('remark'));
  767. $contents = DB::table('site_message_translations')->where('language', 'zh')->orderBy('id')->pluck('content')->all();
  768. $this->assertCount(2, $contents);
  769. $this->assertStringContainsString('本次已发放 8.00', $contents[0]);
  770. $this->assertStringContainsString('本次已发放 42.00', $contents[1]);
  771. // 下个月自动发放:无差额,只结清为已发放,不重复入账
  772. Carbon::setTestNow(Carbon::parse('2026-11-01 14:30:00', 'Asia/Shanghai'));
  773. $this->assertSame(1, $this->runRewards()['backflow']['granted']);
  774. $this->assertSame(1, (int) DB::table('backflow')->where('id', $id)->value('status'));
  775. $this->assertEquals(70, DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
  776. $this->assertSame(2, DB::table('balance_logs')->where('change_type', '回水')->count());
  777. $this->assertSame(2, DB::table('site_messages')->count(), 'Closing without another credit must not send a notice');
  778. // 未来日期仍不能提前发放。
  779. $rebate = $this->rebate(['date' => Carbon::now('America/New_York')->addDay()->format('Y-m-d')]);
  780. $this->expectExceptionMessage('未到发放时间');
  781. RewardGrantService::grant('rebate', $rebate, 'operator-one');
  782. }
  783. #[RunInSeparateProcess]
  784. #[PreserveGlobalState(false)]
  785. public function test_daily_backflow_uses_the_configured_time_and_keeps_the_cutover_month_separate(): void
  786. {
  787. $this->enable('backflow', '00:15');
  788. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_190000_start_daily_backflow.php';
  789. $migration->up();
  790. $this->assertSame('2026-10-26', BackflowPeriod::dailyFrom());
  791. $this->lotteryFact('1001', 5, '2026-10-25 23:59:59', '120', '-120');
  792. $this->lotteryFact('1001', 5, '2026-10-26 00:00:00', '100', '-100');
  793. $unsettledAt = '2026-10-26 23:58:00';
  794. $this->lotteryFact('1001', 5, $unsettledAt, '0', '0', ['bet_amount' => '30', 'settled' => 0]);
  795. Carbon::setTestNow(Carbon::parse('2026-10-26 00:14:59', 'Asia/Shanghai'));
  796. $migration->up();
  797. $this->assertSame('2026-10-26', BackflowPeriod::dailyFrom(), 'Retry keeps the original cutoff');
  798. $this->assertSame('00:15', RewardGrantSettingsService::get('backflow')['grant_time']);
  799. $this->assertSame('2026-10-26', BackflowService::updateOrCreate('1001', 25)->date);
  800. $this->assertSame(2, (new RewardBaseSyncService())->run()['backflow']);
  801. $this->assertSame(['2026-10', '2026-10-26'], DB::table('backflow')->orderBy('date')->pluck('date')->all());
  802. $this->assertEquals(25, DB::table('backflow')->where('date', '2026-10-26')->value('recharge_amount'));
  803. $this->assertSame(0, $this->runRewards()['backflow']['granted']);
  804. Carbon::setTestNow(Carbon::parse('2026-10-26 00:15:00', 'Asia/Shanghai'));
  805. $this->assertSame(1, $this->runRewards()['backflow']['granted']);
  806. $this->assertEquals(120, DB::table('backflow')->where('date', '2026-10')->value('loss_amount'));
  807. $this->assertSame(0, (int) DB::table('backflow')->where('date', '2026-10-26')->value('status'));
  808. Carbon::setTestNow(Carbon::parse('2026-10-27 00:15:00', 'Asia/Shanghai'));
  809. $this->lotteryFact('1001', 5, '2026-10-27 00:00:00', '500', '500');
  810. (new RewardBaseSyncService())->run();
  811. $this->assertSame(1, $this->runRewards()['backflow']['skipped']);
  812. $this->assertEquals(55, DB::table('wallets')->value('available_balance'));
  813. $this->request('/admin/backflow', ['page' => 1, 'limit' => 10, 'date' => '2026-10-26']);
  814. PaginationState::resolveUsing($this->app);
  815. $list = (new BackflowController())->index()->getData(true);
  816. $this->assertSame(0, $list['code'], $list['msg']);
  817. $this->assertSame(1, $list['data']['total']);
  818. $this->assertFalse($list['data']['data'][0]['can_grant']);
  819. DB::table('share_bet_facts')->where('bet_at', $unsettledAt)
  820. ->update(['settled' => 1, 'effective_amount' => '30', 'net_profit' => '-30', 'updated_at' => now()]);
  821. (new RewardBaseSyncService())->run();
  822. $this->assertSame(1, $this->runRewards()['backflow']['granted']);
  823. $this->assertSame(0, $this->runRewards()['backflow']['granted']);
  824. $this->assertEquals(130, DB::table('backflow')->where('date', '2026-10-26')->value('loss_amount'));
  825. $this->assertEquals(90, DB::table('wallets')->value('available_balance'));
  826. $this->assertSame(0, (int) DB::table('backflow')->where('date', '2026-10-27')->value('status'));
  827. $this->assertSame(2, DB::table('balance_logs')->where('change_type', '回水')->count());
  828. }
  829. public function test_manual_rebate_pays_3033_basis_then_differences_and_auto_finishes_after_day_end(): void
  830. {
  831. DB::table('rebate_rules')->update(['percentage' => 1, 'turnover_multiple' => 2]);
  832. $id = $this->rebate(['date' => '2026-10-25']);
  833. $this->fact('1001', 'self:lottery', '2026-10-25 13:00:00', '3033', '-3033');
  834. $this->enable('rebate');
  835. $this->assertSame(0, $this->runRewards()['rebate']['granted'], 'Automatic payout still waits for day end');
  836. $this->request('/admin/rebate/submit', ['id' => $id], 'POST');
  837. $controller = new RebateController();
  838. $this->assertSame(0, $controller->store()->getData(true)['code']);
  839. $row = DB::table('rebates')->where('id', $id)->first();
  840. $this->assertSame(2, (int) $row->status);
  841. $this->assertEquals(30.33, $row->granted_amount);
  842. $this->assertEquals(60.66, $row->granted_need_flow);
  843. $this->assertEquals(50.33, DB::table('wallets')->value('available_balance'));
  844. $this->assertFalse(RebateService::grantFields((array) $row)['can_grant']);
  845. $this->assertSame(-3, $controller->store()->getData(true)['code'], 'No difference cannot pay twice');
  846. $this->fact('1001', 'self:lottery', '2026-10-25 14:00:00', '1000', '-1000');
  847. (new RewardBaseSyncService())->run('2026-10-25', '2026-10-25');
  848. $fields = RebateService::grantFields((array) DB::table('rebates')->where('id', $id)->first());
  849. $this->assertSame(['40.33', '30.33', '10.00', true, '部分发放'], [$fields['amount'], $fields['granted_amount'], $fields['pending_amount'], $fields['can_grant'], $fields['status_text']]);
  850. DB::statement("CREATE TRIGGER reject_partial_notice BEFORE INSERT ON bot_site_message_recipients BEGIN SELECT RAISE(ABORT, 'notice unavailable'); END");
  851. $this->assertNotSame(0, $controller->store()->getData(true)['code']);
  852. $this->assertEquals(50.33, DB::table('wallets')->value('available_balance'));
  853. $this->assertEquals(30.33, DB::table('rebates')->where('id', $id)->value('granted_amount'));
  854. $this->assertSame(1, DB::table('site_messages')->count());
  855. $this->assertSame(1, DB::table('wallet_bonus')->count());
  856. DB::statement('DROP TRIGGER reject_partial_notice');
  857. $this->assertSame(0, $controller->store()->getData(true)['code']);
  858. $this->assertEquals(60.33, DB::table('wallets')->value('available_balance'));
  859. $this->assertEquals(80.66, DB::table('wallet_bonus')->sum('need_flow'));
  860. $this->assertSame(['30.33', '10.00'], DB::table('balance_logs')->orderBy('id')->pluck('amount')->map(fn ($v) => bcadd($v, '0', 2))->all());
  861. $contents = DB::table('site_message_translations')->where('language', 'zh')->orderBy('id')->pluck('content')->all();
  862. $this->assertStringContainsString('本次已发放 30.33', $contents[0]);
  863. $this->assertStringContainsString('本次已发放 10.00', $contents[1]);
  864. $this->fact('1001', 'self:lottery', '2026-10-25 14:20:00', '333', '-333');
  865. (new RewardBaseSyncService())->run('2026-10-25', '2026-10-25');
  866. Carbon::setTestNow(Carbon::parse('2026-10-26 14:30:00', 'Asia/Shanghai'));
  867. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  868. $row = DB::table('rebates')->where('id', $id)->first();
  869. $this->assertSame(1, (int) $row->status);
  870. $this->assertEquals(43.66, $row->granted_amount);
  871. $this->assertEquals(87.32, $row->granted_need_flow);
  872. $this->assertEquals(63.66, DB::table('wallets')->value('available_balance'));
  873. $this->assertSame(3, DB::table('site_messages')->count());
  874. $this->assertSame(0, $this->runRewards()['rebate']['granted']);
  875. $this->assertSame(3, DB::table('balance_logs')->count());
  876. }
  877. public function test_rebate_day_end_without_a_difference_does_not_credit_or_notify_again(): void
  878. {
  879. $id = $this->rebate(['date' => '2026-10-25']);
  880. $this->assertTrue(RewardGrantService::grant('rebate', $id, 'operator-one'));
  881. DB::table('rebate_rules')->update(['percentage' => '0.1', 'turnover_multiple' => 3]);
  882. Carbon::setTestNow(Carbon::parse('2026-10-26 14:30:00', 'Asia/Shanghai'));
  883. $this->enable('rebate');
  884. $this->assertSame(1, $this->runRewards()['rebate']['granted']);
  885. $row = DB::table('rebates')->where('id', $id)->first();
  886. $this->assertSame(1, (int) $row->status);
  887. $this->assertEquals(4, $row->granted_amount, 'A lower calculation never reclaims a previous credit');
  888. $this->assertEquals(0, $row->granted_need_flow, 'Unpaid turnover must not be counted as granted');
  889. $this->assertEquals(24, DB::table('wallets')->value('available_balance'));
  890. $this->assertSame(1, DB::table('site_messages')->count());
  891. $this->assertSame(1, DB::table('balance_logs')->count());
  892. $this->assertFalse(RewardGrantService::grant('rebate', $id, 'operator-one', true));
  893. }
  894. public function test_rebate_granted_amount_migration_preserves_paid_history_and_partial_totals(): void
  895. {
  896. $paid = $this->rebate(['date' => '2026-09-01', 'status' => 1, 'amount' => 30.33, 'need_flow' => 60.66]);
  897. $pending = $this->rebate(['date' => '2026-09-02', 'amount' => 99]);
  898. $partial = $this->rebate(['date' => '2026-10-25', 'status' => 2, 'amount' => 50, 'granted_amount' => 30.33, 'granted_need_flow' => 60.66]);
  899. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php';
  900. $migration->up();
  901. $this->assertEquals(30.33, DB::table('rebates')->where('id', $paid)->value('granted_amount'));
  902. $this->assertEquals(60.66, DB::table('rebates')->where('id', $paid)->value('granted_need_flow'));
  903. $this->assertEquals(0, DB::table('rebates')->where('id', $pending)->value('granted_amount'));
  904. $this->assertEquals(30.33, DB::table('rebates')->where('id', $partial)->value('granted_amount'));
  905. $migration->down();
  906. $this->assertSame(1, (int) DB::table('rebates')->where('id', $partial)->value('status'));
  907. $this->assertFalse(Schema::hasColumn('rebates', 'granted_amount'));
  908. }
  909. public function test_granted_amount_migration_backfills_paid_rows(): void
  910. {
  911. $paid = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-08', 'status' => 1, 'amount' => '35', 'need_flow' => '70', 'granted_amount' => 0]);
  912. $open = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-07', 'status' => 0, 'amount' => '35', 'granted_amount' => 0]);
  913. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php')->up();
  914. $this->assertEquals([35, 70], array_values((array) DB::table('backflow')->where('id', $paid)->first(['granted_amount', 'granted_need_flow'])));
  915. $this->assertEquals(0, DB::table('backflow')->where('id', $open)->value('granted_amount'));
  916. }
  917. public function test_sync_cursor_only_revisits_changed_facts_and_backfill_range_creates_missing_rows(): void
  918. {
  919. $this->fact('3001', 'third:1', '2026-10-20 13:00:00', '100', '-100', ['updated_at' => '2026-10-20 13:00:00']);
  920. $this->assertSame(0, (new RewardBaseSyncService())->run()['rebates']);
  921. $this->assertSame(['rebates' => 1, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20'));
  922. $this->assertEquals(100, DB::table('rebates')->where('member_id', '3001')->value('effective_betting_amount'));
  923. $this->assertNotNull(DB::table('share_system_state')->where('key', 'reward_bases_cursor')->value('value'));
  924. $this->expectException(\InvalidArgumentException::class);
  925. (new RewardBaseSyncService())->run('2026-10-20', null);
  926. }
  927. public function test_rules_migration_removes_the_three_global_settings_and_rollback_restores_them(): void
  928. {
  929. $this->assertSame(['huishui_restriction'], DB::table('config')->pluck('field')->all());
  930. $this->assertTrue(Schema::hasColumns('rebates', ['details', 'need_flow']));
  931. $this->rulesMigration()->down();
  932. $this->assertEqualsCanonicalizing(['rebate', 'huishui_restriction', 'huishui_percentage'], DB::table('config')->pluck('field')->all());
  933. $this->assertSame('0.005', DB::table('config')->where('field', 'rebate')->value('val'));
  934. $this->assertFalse(Schema::hasTable('rebate_rules'));
  935. $this->assertFalse(Schema::hasColumn('backflow', 'loss_amount'));
  936. }
  937. }