ReportingTest.php 98 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Services\Reporting\ReportAdmin;
  4. use App\Services\Reporting\ReportReader;
  5. use App\Services\Reporting\ReportSync;
  6. use App\Services\Sharing\ShareDefaults;
  7. use App\Services\ThirdGameCommissionService;
  8. use Carbon\Carbon;
  9. use Illuminate\Config\Repository;
  10. use Illuminate\Database\Capsule\Manager;
  11. use Illuminate\Database\Schema\Blueprint;
  12. use Illuminate\Events\Dispatcher;
  13. use Illuminate\Foundation\Application;
  14. use Illuminate\Http\JsonResponse;
  15. use Illuminate\Http\Request;
  16. use Illuminate\Routing\Router;
  17. use Illuminate\Support\Facades\DB;
  18. use Illuminate\Support\Facades\Facade;
  19. use Illuminate\Support\Facades\Schema;
  20. use Illuminate\Translation\ArrayLoader;
  21. use Illuminate\Translation\Translator;
  22. use Illuminate\Validation\Factory;
  23. use Illuminate\Validation\ValidationException;
  24. use PHPUnit\Framework\Attributes\DataProvider;
  25. use PHPUnit\Framework\TestCase;
  26. /** Real source adapters, financial reconciliation, filters and aggregation; isolated SQLite only. */
  27. class ReportingTest extends TestCase
  28. {
  29. private Application $app;
  30. private ReportSync $sync;
  31. private ReportReader $reader;
  32. private ReportAdmin $admin;
  33. protected function setUp(): void
  34. {
  35. Carbon::setTestNow(Carbon::parse('2026-10-26 16:00:00', 'Asia/Shanghai'));
  36. Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2));
  37. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']])); Facade::setFacadeApplication($this->app);
  38. $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  39. $db->setEventDispatcher(new Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
  40. $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app));
  41. $this->app['validator']->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
  42. $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  43. Request::macro('validate', function ($rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  44. $request = Request::create('/admin/reporting/summary'); $request->user = (object) ['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request);
  45. foreach ([
  46. 'users' => 'id INTEGER PRIMARY KEY,member_id TEXT,user_id TEXT,username TEXT,first_name TEXT,agent_id INTEGER,"from" INTEGER,created_at TEXT',
  47. 'agents' => 'id INTEGER PRIMARY KEY,username TEXT',
  48. 'share_members' => 'user_id INTEGER PRIMARY KEY,parent_user_id INTEGER,bound_at TEXT',
  49. 'share_policies' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,effective_date TEXT,enabled INTEGER DEFAULT 0,benefit_enabled INTEGER DEFAULT 0,valid_member_min_deposit TEXT,valid_member_min_effective_bet TEXT,rules TEXT',
  50. 'share_system_state' => '"key" TEXT PRIMARY KEY,value TEXT',
  51. 'share_bet_facts' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,member_id TEXT,category TEXT,bet_date TEXT,effective_amount TEXT,settled INTEGER DEFAULT 1,voided INTEGER DEFAULT 0,currency TEXT DEFAULT "CNY"',
  52. 'share_accounts' => 'user_id INTEGER PRIMARY KEY,earned_amount TEXT DEFAULT "0",paid_amount TEXT DEFAULT "0",reserved_amount TEXT DEFAULT "0"',
  53. 'share_settlements' => 'id INTEGER PRIMARY KEY,user_id INTEGER,bet_date TEXT,category TEXT,direct_commission TEXT,indirect_commission TEXT',
  54. 'balance_logs' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,amount TEXT,change_type TEXT,related_id INTEGER,type INTEGER DEFAULT 1,created_at TEXT,updated_at TEXT,before_balance TEXT,after_balance TEXT,remark TEXT',
  55. 'withdraws' => 'id INTEGER PRIMARY KEY,member_id TEXT,amount TEXT,to_account TEXT,service_charge TEXT,exchange_rate TEXT,status INTEGER,created_at TEXT,updated_at TEXT',
  56. 'payment_orders' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,amount TEXT,fee TEXT,status INTEGER,created_at TEXT,updated_at TEXT',
  57. 'bets' => 'id INTEGER PRIMARY KEY,member_id TEXT,amount TEXT,profit TEXT,status INTEGER,created_at TEXT,updated_at TEXT',
  58. 'share_claims' => 'id INTEGER PRIMARY KEY,user_id INTEGER,status TEXT,credited_log_id INTEGER',
  59. 'share_ledger' => 'id INTEGER PRIMARY KEY,user_id INTEGER,amount TEXT,bet_date TEXT,category TEXT,created_at TEXT',
  60. 'agent_balance_logs' => 'id INTEGER PRIMARY KEY,agent_id INTEGER,type TEXT,amount TEXT,reference_type TEXT,reference_id INTEGER,created_at TEXT',
  61. 'agent_rebate_records' => 'id INTEGER PRIMARY KEY,agent_id INTEGER,platform TEXT,game_type INTEGER,settlement_date TEXT,flow_commission TEXT,profit_commission TEXT,flow_status TEXT,profit_status TEXT,created_at TEXT,updated_at TEXT',
  62. 'operation_audits' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,resource_type TEXT,resource_id INTEGER,action TEXT,changes TEXT,operator_id INTEGER,operator_name TEXT,created_at TEXT DEFAULT CURRENT_TIMESTAMP',
  63. ] as $name => $columns) DB::statement('CREATE TABLE bot_' . $name . ' (' . $columns . ')');
  64. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_140000_create_reporting_tables.php')->up();
  65. Schema::table('reporting_orders', fn (Blueprint $table) => $table->text('third_game_commission')->nullable());
  66. DB::table('reporting_settings')->where('id', 1)->update(['activity_started_at' => '2026-10-25 00:00:00']);
  67. DB::table('share_policies')->insert(['effective_date' => '2026-10-01', 'valid_member_min_deposit' => '20', 'valid_member_min_effective_bet' => '100', 'rules' => json_encode(ShareDefaults::rules())]);
  68. DB::table('share_system_state')->insert(['key' => 'initialized_at', 'value' => '2026-10-01 00:00:00']);
  69. DB::table('agents')->insert([['id' => 10, 'username' => 'agent10'], ['id' => 20, 'username' => 'agent20']]);
  70. foreach ([[1, '100', null, 1], [2, '200', 10, 1], [3, '300', 20, 1], [4, '400', 10, 2], [5, '500', null, -1]] as [$id, $member, $agent, $from]) {
  71. DB::table('users')->insert(['id' => $id, 'member_id' => $member, 'user_id' => 'u' . $member, 'username' => 'member' . $id, 'first_name' => '昵称' . $id, 'agent_id' => $agent, 'from' => $from, 'created_at' => '2026-10-01 00:00:00']);
  72. DB::table('share_members')->insert(['user_id' => $id, 'parent_user_id' => in_array($id, [2, 3]) ? 1 : null, 'bound_at' => '2026-10-01 00:00:00']);
  73. }
  74. $this->sync = new ReportSync(); $this->reader = new ReportReader(); $this->admin = new ReportAdmin();
  75. $this->ready();
  76. }
  77. protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
  78. private function today(array $filters = []): array { return $filters + ['start_date' => now('Asia/Shanghai')->toDateString(), 'end_date' => now('Asia/Shanghai')->toDateString()]; }
  79. private function ready(): void { DB::table('reporting_sync_state')->updateOrInsert(['source' => '__run__'], ['state' => '{"complete":true}', 'updated_at' => now()]); }
  80. private function context(string $source, int $id, int $user = 2, string $platform = 'H5', ?string $success = null): void
  81. {
  82. DB::table('reporting_contexts')->insert(['source' => $source, 'source_id' => (string) $id, 'user_id' => $user, 'agent_id' => $user === 2 ? 10 : null,
  83. 'inviter_id' => $user === 2 ? 1 : null, 'client_platform' => $platform, 'occurred_at' => '2026-10-26 09:00:00', 'succeeded_at' => $success]);
  84. }
  85. private function bet(int $id = 1, array $extra = []): void
  86. {
  87. DB::table('bets')->insert($extra + ['id' => $id, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2, 'created_at' => '2026-10-26 09:00:00', 'updated_at' => '2026-10-26 10:00:00']);
  88. $this->context('bets', $id, ($extra['member_id'] ?? '200') === '500' ? 5 : 2, 'H5', '2026-10-26 10:00:00'); $this->sync->publish('bets', $id);
  89. }
  90. private function deposit(int $id = 1, string $amount = '1000', string $member = '200'): int
  91. {
  92. DB::table('balance_logs')->insert(['id' => $id, 'member_id' => $member, 'amount' => $amount, 'change_type' => '充值', 'created_at' => '2026-10-26 09:00:00', 'updated_at' => '2026-10-26 09:00:00']);
  93. $this->sync->publish('balance_logs', $id); return (int) DB::table('reporting_events')->where('source', 'balance_logs')->where('source_id', (string) $id)->value('id');
  94. }
  95. private function verify(int $id, string $amount = '1000', string $fee = '2'): void
  96. {
  97. $this->admin->reconcile(['event_id' => $id, 'actual_amount' => $amount, 'external_fee' => $fee, 'occurred_at' => '2026-10-26 10:00:00', 'currency' => 'CNY', 'evidence' => '到账凭据#' . $id]);
  98. }
  99. private function qualificationFact(string $amount = '100', string $date = '2026-10-26', int $user = 2, array $extra = []): int
  100. {
  101. return DB::table('share_bet_facts')->insertGetId($extra + ['user_id' => $user, 'member_id' => (string) ($user * 100),
  102. 'category' => 'third:2', 'bet_date' => $date, 'effective_amount' => $amount]);
  103. }
  104. public function test_source_projection_is_idempotent_and_cross_day_metrics_use_actual_events(): void
  105. {
  106. $this->bet(1, ['created_at' => '2026-10-25 23:00:00']); $this->sync->publish('bets', 1);
  107. $this->assertSame(1, DB::table('reporting_orders')->count());
  108. $row = $this->reader->summary($this->today())['summary'];
  109. $this->assertSame('0.0000000000', $row['bet_amount']); $this->assertSame('100.0000000000', $row['settled_bet_amount']);
  110. $this->assertSame('60.0000000000', $row['payout_amount']); $this->assertSame('40.0000000000', $row['game_gross_profit']);
  111. $this->assertSame('40.0000000000', $row['company_profit']);
  112. $yesterday = $this->reader->summary(['start_date' => '2026-10-25', 'end_date' => '2026-10-25'])['summary'];
  113. $this->assertSame('100.0000000000', $yesterday['bet_amount']); $this->assertSame('0.0000000000', $yesterday['payout_amount']);
  114. }
  115. private function f1LotteryWithoutCompletionContext(): void
  116. {
  117. DB::statement('CREATE TABLE bot_lhc_order (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,amount TEXT,total_amount TEXT,win_amount TEXT,type INTEGER,lottery_status INTEGER,is_faker INTEGER DEFAULT 0,created_at INTEGER,updated_at INTEGER)');
  118. DB::statement('CREATE TABLE bot_lhc_order_item (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,lottery_status INTEGER,win_amount TEXT,updated_at INTEGER)');
  119. $betAt = Carbon::parse('2026-10-26 09:00:00', 'Asia/Shanghai')->timestamp;
  120. DB::table('lhc_order')->insert(['id' => 1, 'member_id' => '200', 'ordernum' => 'F1-live', 'amount' => '50', 'total_amount' => '100',
  121. 'win_amount' => '190', 'type' => 6, 'lottery_status' => 2, 'created_at' => $betAt, 'updated_at' => $betAt + 3600]);
  122. DB::table('lhc_order_item')->insert([
  123. ['id' => 1, 'member_id' => '200', 'ordernum' => 'F1-live', 'lottery_status' => 2, 'win_amount' => '190', 'updated_at' => $betAt + 3600],
  124. ['id' => 2, 'member_id' => '200', 'ordernum' => 'F1-live', 'lottery_status' => 1, 'win_amount' => '0', 'updated_at' => $betAt + 3600],
  125. ]);
  126. $this->context('lhc_order', 1);
  127. }
  128. private function f1PayoutReceipt(array $extra = []): void
  129. {
  130. DB::table('balance_logs')->insert($extra + ['member_id' => '200', 'type' => 1, 'related_id' => 1, 'amount' => '190',
  131. 'before_balance' => '100', 'after_balance' => '290', 'change_type' => '极速28中奖', 'created_at' => '2026-10-26 10:00:00']);
  132. }
  133. public function test_f1_wallet_receipt_restores_funds_business_category_and_sharing_settlement_totals(): void
  134. {
  135. $this->f1LotteryWithoutCompletionContext(); $this->f1PayoutReceipt();
  136. $before = DB::table('lhc_order')->get()->toJson();
  137. $this->sync->publish('lhc_order', 1); $this->sync->publish('lhc_order', 1);
  138. $funds = $this->reader->funds($this->today());
  139. $business = $this->reader->summary($this->today());
  140. $category = collect($business['categories'])->keyBy('group_id')['self:lottery'];
  141. $sharing = $this->reader->summary($this->today(['report_type' => 'sharing']));
  142. foreach ([$funds['summary'], $funds['data'][0], $business['summary'], $category, $sharing['summary']] as $row) {
  143. $this->assertSame('100.0000000000', $row['bet_amount']);
  144. $this->assertSame('100.0000000000', $row['settled_bet_amount']);
  145. $this->assertSame('190.0000000000', $row['payout_amount']);
  146. $this->assertSame('-90.0000000000', $row['game_gross_profit']);
  147. }
  148. $this->assertSame('F1彩票', $category['label']);
  149. $groups = $this->reader->details($this->today(['report_type' => 'sharing']))['data'];
  150. $this->assertSame('1', $groups[0]['group_id']);
  151. $this->assertSame('190.0000000000', $groups[0]['payout_amount']);
  152. $this->assertSame(2, DB::table('reporting_orders')->count());
  153. $this->assertSame('2026-10-26 10:00:00', DB::table('reporting_orders')->value('settled_at'));
  154. $this->assertSame($before, DB::table('lhc_order')->get()->toJson());
  155. }
  156. public function test_bulk_settlement_capture_preserves_event_attribution_and_records_actual_transition_once(): void
  157. {
  158. $this->f1LotteryWithoutCompletionContext();
  159. $this->sync->publish('lhc_order', 1);
  160. $this->assertSame('0.0000000000', $this->reader->funds($this->today())['summary']['payout_amount']);
  161. $before = DB::table('lhc_order')->get()->toJson();
  162. DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
  163. \App\Services\Reporting\ReportContext::lotterySettled(1);
  164. Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai'));
  165. \App\Services\Reporting\ReportContext::lotterySettled(1);
  166. $context = DB::table('reporting_contexts')->where('source', 'lhc_order')->first();
  167. $this->assertSame('2026-10-26 16:00:00', $context->succeeded_at);
  168. $this->assertSame(10, (int) $context->agent_id); $this->assertSame(1, (int) $context->inviter_id);
  169. $this->assertSame('H5', $context->client_platform);
  170. $this->sync->publish('lhc_order', 1);
  171. $row = $this->reader->funds($this->today())['summary'];
  172. $this->assertSame('100.0000000000', $row['settled_bet_amount']);
  173. $this->assertSame('190.0000000000', $row['payout_amount']);
  174. $this->assertSame('-90.0000000000', $row['game_gross_profit']);
  175. $this->assertSame($before, DB::table('lhc_order')->get()->toJson());
  176. }
  177. public static function invalidF1Receipts(): array
  178. {
  179. return [
  180. [['related_id' => 999]], [['member_id' => '100']], [['type' => 2]],
  181. [['amount' => '191', 'after_balance' => '291']], [['after_balance' => '999']],
  182. [['created_at' => '2026-10-25 10:00:00']], [['change_type' => '充值']],
  183. ];
  184. }
  185. #[DataProvider('invalidF1Receipts')]
  186. public function test_unrelated_or_unbalanced_receipts_do_not_fabricate_lottery_settlement_time(array $receipt): void
  187. {
  188. $this->f1LotteryWithoutCompletionContext(); $this->f1PayoutReceipt($receipt);
  189. $this->sync->publish('lhc_order', 1);
  190. $this->assertNull(DB::table('reporting_orders')->value('settled_at'));
  191. $row = $this->reader->funds($this->today())['summary'];
  192. $this->assertSame('100.0000000000', $row['bet_amount']);
  193. $this->assertSame('0.0000000000', $row['payout_amount']);
  194. $this->assertGreaterThan(0, $row['quality']['missing_time']);
  195. }
  196. public function test_verified_lottery_time_takes_priority_over_payout_receipt_time(): void
  197. {
  198. $this->f1LotteryWithoutCompletionContext(); $this->f1PayoutReceipt();
  199. DB::table('reporting_contexts')->where('source', 'lhc_order')->update(['settlement_verified' => 1, 'succeeded_at' => '2026-10-26 11:30:00']);
  200. $this->sync->publish('lhc_order', 1);
  201. $this->assertSame('2026-10-26 11:30:00', DB::table('reporting_orders')->value('settled_at'));
  202. }
  203. public function test_bulk_lottery_loss_is_counted_as_settled_gross_profit_without_a_prize_receipt(): void
  204. {
  205. $this->f1LotteryWithoutCompletionContext();
  206. DB::table('lhc_order')->where('id', 1)->update(['lottery_status' => 1, 'win_amount' => '0']);
  207. DB::table('lhc_order_item')->update(['lottery_status' => 1, 'win_amount' => '0']);
  208. \App\Services\Reporting\ReportContext::lotterySettled(1);
  209. $this->sync->publish('lhc_order', 1);
  210. $row = $this->reader->funds($this->today())['summary'];
  211. $this->assertSame('100.0000000000', $row['settled_bet_amount']);
  212. $this->assertSame('0.0000000000', $row['payout_amount']);
  213. $this->assertSame('100.0000000000', $row['game_gross_profit']);
  214. }
  215. public function test_missing_fees_and_success_time_never_produce_fabricated_net_values(): void
  216. {
  217. $event = $this->deposit(); $this->bet();
  218. $row = $this->reader->summary($this->today())['summary']; $this->assertNull($row['net_cash_flow']); $this->assertNull($row['company_profit']);
  219. $this->assertSame(1, $row['quality']['unverified_fees']); $this->assertSame('1000.0000000000', $row['deposit_amount']);
  220. $this->assertSame('1000.0000000000', $row['known_net_cash_flow']);
  221. $this->assertSame('40.0000000000', $row['known_company_profit']);
  222. $this->verify($event); $row = $this->reader->summary($this->today())['summary'];
  223. $this->assertSame('998.0000000000', $row['net_cash_flow']); $this->assertSame('38.0000000000', $row['company_profit']);
  224. $before = DB::table('balance_logs')->get()->toArray(); $this->verify($event, '1000', '3'); $this->sync->publish('balance_logs', 1);
  225. $this->assertSame(1, DB::table('reporting_reconciliations')->count()); $this->assertEquals($before, DB::table('balance_logs')->get()->toArray());
  226. $this->assertSame('997.0000000000', $this->reader->summary($this->today())['summary']['net_cash_flow']);
  227. }
  228. public function test_chain_withdraw_uses_recipient_amount_and_customer_fee_is_not_an_expense(): void
  229. {
  230. DB::table('withdraws')->insert(['id' => 1, 'member_id' => '200', 'amount' => '100', 'to_account' => '98', 'service_charge' => '2', 'exchange_rate' => '7', 'status' => 1, 'created_at' => '2026-10-25 08:00:00', 'updated_at' => '2026-10-26 10:00:00']);
  231. $this->sync->publish('withdraws', 1); $row = $this->reader->summary($this->today())['summary'];
  232. $this->assertSame(1, $row['quality']['missing_time']); $this->assertNull($row['net_cash_flow']);
  233. $event = (int) DB::table('reporting_events')->value('id'); $this->verify($event, '686', '1');
  234. $row = $this->reader->summary($this->today())['summary'];
  235. $this->assertSame('686.0000000000', $row['withdraw_amount']); $this->assertSame('14.0000000000', $row['customer_fee_income']);
  236. $this->assertSame('1.0000000000', $row['withdraw_fee']); $this->assertSame('-687.0000000000', $row['net_cash_flow']);
  237. DB::table('withdraws')->where('id', 1)->update(['status' => 2]); $result = $this->sync->sync(true);
  238. $this->assertSame(1, $result['errors']); $this->assertNull($this->reader->summary($this->today())['summary']['net_cash_flow']);
  239. $this->verify($event, '0', '1'); $this->sync->sync(true);
  240. $this->assertSame('0.0000000000', $this->reader->summary($this->today())['summary']['withdraw_amount']);
  241. }
  242. public function test_real_member_agent_sharing_overlap_is_not_added_twice_and_details_are_direct(): void
  243. {
  244. $this->bet(); $event = $this->deposit(); $this->verify($event);
  245. foreach (['business', 'promotion', 'sharing'] as $type) {
  246. $row = $this->reader->summary(['report_type' => $type])['summary']; $this->assertSame('100.0000000000', $row['bet_amount']);
  247. $this->assertSame('1000.0000000000', $row['deposit_amount']);
  248. }
  249. $this->assertSame('2', $this->reader->details($this->today())['data'][0]['group_id']);
  250. $this->assertSame('10', $this->reader->details(['report_type' => 'promotion'])['data'][0]['group_id']);
  251. $this->assertSame('1', $this->reader->details(['report_type' => 'sharing'])['data'][0]['group_id']);
  252. }
  253. public function test_test_agent_members_are_left_out_of_reports_and_follow_the_current_flag(): void
  254. {
  255. // 测试代理标记必须在第一次读统计前就存在(StatsScope 按连接缓存是否启用)
  256. Schema::table('agents', fn (Blueprint $table) => $table->unsignedTinyInteger('is_test')->default(0));
  257. $this->bet(); $this->verify($this->deposit());
  258. $row = $this->reader->summary([])['summary'];
  259. $this->assertSame(['100.0000000000', '1000.0000000000'], [$row['bet_amount'], $row['deposit_amount']]);
  260. DB::table('agents')->where('id', 10)->update(['is_test' => 1]); // 会员 200 属于代理 10
  261. $row = $this->reader->summary([])['summary'];
  262. $this->assertSame(0, bccomp($row['bet_amount'], '0', 10), 'test member bets are not counted');
  263. $this->assertSame(0, bccomp($row['deposit_amount'], '0', 10), 'test member deposits are not counted');
  264. $this->assertNotContains('2', array_column($this->reader->details($this->today())['data'], 'group_id'));
  265. DB::table('agents')->where('id', 10)->update(['is_test' => 0]);
  266. $this->assertSame('100.0000000000', $this->reader->summary([])['summary']['bet_amount'], 'switching back restores history');
  267. }
  268. public function test_category_column_options_only_hide_fields_not_applicable_to_game_categories(): void
  269. {
  270. $this->bet();
  271. $this->verify($this->deposit());
  272. $hidden = $this->admin->options()['category_hidden_fields'];
  273. $this->assertEqualsCanonicalizing([
  274. 'deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee',
  275. 'customer_fee_income', 'configured_fee', 'bonus_amount', 'manual_credit', 'manual_debit',
  276. 'net_cash_flow', 'company_profit', 'known_net_cash_flow', 'known_company_profit',
  277. ], $hidden);
  278. $report = $this->reader->summary($this->today());
  279. $category = collect($report['categories'])->firstWhere('group_id', 'self:lottery');
  280. $details = $this->reader->details($this->today(['category' => 'self:lottery']))['data'][0];
  281. foreach ($hidden as $field) {
  282. $this->assertNotNull($report['summary'][$field]);
  283. $this->assertNull($category[$field]);
  284. $this->assertNull($details[$field]);
  285. }
  286. $this->assertSame('100.0000000000', $category['bet_amount']);
  287. $this->assertSame('100.0000000000', $details['bet_amount']);
  288. }
  289. public function test_valid_members_use_both_sharing_thresholds_inclusively(): void
  290. {
  291. $this->bet(); $this->deposit(1, '19.99'); $fact = $this->qualificationFact('100');
  292. $this->assertSame(0, $this->reader->summary($this->today())['summary']['valid_member_count']);
  293. DB::table('balance_logs')->where('id', 1)->update(['amount' => '20']);
  294. DB::table('share_bet_facts')->where('id', $fact)->update(['effective_amount' => '99.99']);
  295. $this->assertSame(0, $this->reader->summary($this->today())['summary']['valid_member_count']);
  296. DB::table('share_bet_facts')->where('id', $fact)->update(['effective_amount' => '100']);
  297. $report = $this->reader->summary($this->today());
  298. $this->assertSame(1, $report['summary']['valid_member_count']);
  299. $this->assertSame('20.0000000000', $report['valid_deposit_min']);
  300. $this->assertSame('100.0000000000', $report['valid_effective_bet_min']);
  301. $this->assertSame(['id' => 1, 'effective_date' => '2026-10-01', 'min_deposit' => '20.0000000000',
  302. 'min_effective_bet' => '100.0000000000', 'date' => '2026-10-26', 'available' => true, 'unavailable_reasons' => []], $report['valid_member_policy']);
  303. // Presentation switches and the retired report-only value do not change qualification.
  304. DB::table('reporting_settings')->where('id', 1)->update(['valid_deposit_min' => '9999']);
  305. DB::table('share_policies')->update(['enabled' => 0, 'benefit_enabled' => 0]);
  306. $this->assertSame(1, $this->reader->summary($this->today())['summary']['valid_member_count']);
  307. }
  308. public function test_qualification_is_cumulative_before_start_and_independent_of_report_filters_and_payment_verification(): void
  309. {
  310. $this->bet(); $this->bet(2, ['member_id' => '500']);
  311. DB::table('balance_logs')->insert([
  312. ['member_id' => '200', 'type' => 1, 'amount' => '8', 'change_type' => '充值', 'created_at' => '2026-10-01 10:00:00'],
  313. ['member_id' => '200', 'type' => 1, 'amount' => '12', 'change_type' => '三方充值', 'created_at' => '2026-10-25 10:00:00'],
  314. ]);
  315. $this->qualificationFact('50', '2026-10-01'); $this->qualificationFact('50', '2026-10-25', 2, ['category' => 'self:sport']);
  316. // Report events and their manual reconciliation are not the sharing deposit source.
  317. $event = $this->deposit(10, '1'); $this->verify($event, '0', '0');
  318. $input = ['platform' => 'H5', 'category' => 'self:lottery', 'settlement_status' => 'settled'];
  319. $report = $this->reader->summary($input);
  320. $this->assertSame(1, $report['summary']['valid_member_count']);
  321. $this->assertSame(1, collect($report['categories'])->keyBy('group_id')['self:lottery']['valid_member_count']);
  322. $this->assertSame(1, $this->reader->details($input + ['member_account' => 'member2'])['data'][0]['valid_member_count']);
  323. $this->assertSame(1, $this->reader->details($input + ['report_type' => 'promotion', 'agent_account' => 'agent10'])['data'][0]['valid_member_count']);
  324. $this->assertSame(1, $this->reader->details($input + ['report_type' => 'sharing', 'inviter_account' => 'member1'])['data'][0]['valid_member_count']);
  325. $this->assertSame(0, $this->reader->summary(['platform' => 'PC'])['summary']['valid_member_count']);
  326. }
  327. public function test_qualification_excludes_future_non_deposit_and_ineligible_facts(): void
  328. {
  329. $this->bet();
  330. foreach ([[1, '充值', '19', '2026-10-26 23:59:59'], [1, '充值', '1000', '2026-10-27 00:00:00'],
  331. [2, '充值', '1000', '2026-10-25 10:00:00'], [1, '人工加款', '1000', '2026-10-25 10:00:00'],
  332. [1, '充值', '-1000', '2026-10-25 10:00:00']] as [$type, $kind, $amount, $at]) {
  333. DB::table('balance_logs')->insert(['member_id' => '200', 'type' => $type, 'change_type' => $kind, 'amount' => $amount, 'created_at' => $at]);
  334. }
  335. $fact = $this->qualificationFact('99');
  336. $this->qualificationFact('1000', '2026-10-27');
  337. $this->qualificationFact('1000', '2026-10-25', 2, ['settled' => 0]);
  338. $this->qualificationFact('1000', '2026-10-25', 2, ['voided' => 1]);
  339. $this->qualificationFact('1000', '2026-10-25', 2, ['currency' => 'USDT']);
  340. $this->assertSame(0, $this->reader->summary($this->today())['summary']['valid_member_count']);
  341. DB::table('balance_logs')->insert(['member_id' => '200', 'type' => 1, 'change_type' => '三方充值', 'amount' => '1', 'created_at' => '2026-10-26 23:59:59']);
  342. $this->assertSame(0, $this->reader->summary($this->today())['summary']['valid_member_count']);
  343. DB::table('share_bet_facts')->where('id', $fact)->update(['effective_amount' => '100']);
  344. $this->assertSame(1, $this->reader->summary($this->today())['summary']['valid_member_count']);
  345. }
  346. public function test_pending_sharing_policy_never_applies_before_its_effective_date(): void
  347. {
  348. $this->bet(); $this->deposit(1, '20'); $this->qualificationFact();
  349. DB::table('share_policies')->insert(['effective_date' => '2026-10-27', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '1000', 'rules' => '{}']);
  350. $report = $this->reader->summary($this->today());
  351. $this->assertSame(1, $report['summary']['valid_member_count']); $this->assertSame(1, $report['valid_member_policy']['id']);
  352. $this->assertSame('20.0000000000', $this->admin->settings()['valid_deposit_min']);
  353. Carbon::setTestNow(Carbon::parse('2026-10-27 16:00:00', 'Asia/Shanghai'));
  354. $report = $this->reader->summary(['start_date' => '2026-10-26', 'end_date' => '2026-10-27']);
  355. $this->assertSame(0, $report['summary']['valid_member_count']); $this->assertSame(2, $report['valid_member_policy']['id']);
  356. $historical = $this->reader->summary(['start_date' => '2026-10-26', 'end_date' => '2026-10-26']);
  357. $this->assertSame(1, $historical['summary']['valid_member_count']); $this->assertSame(1, $historical['valid_member_policy']['id']);
  358. }
  359. public function test_zero_sharing_thresholds_are_preserved_and_candidate_population_is_unchanged(): void
  360. {
  361. $this->bet(1, ['status' => 1, 'profit' => '0']);
  362. DB::table('share_policies')->update(['valid_member_min_deposit' => '0', 'valid_member_min_effective_bet' => '0']);
  363. $report = $this->reader->summary($this->today());
  364. $this->assertSame(1, $report['summary']['valid_member_count']); $this->assertSame(4, $report['summary']['member_count']);
  365. $this->assertSame('0.0000000000', $report['valid_deposit_min']); $this->assertSame('0.0000000000', $report['valid_effective_bet_min']);
  366. $this->assertSame(0, $this->reader->summary(['settlement_status' => 'settled'])['summary']['valid_member_count']);
  367. }
  368. public function test_missing_policy_threshold_and_sharing_data_produce_null_not_an_independent_default(): void
  369. {
  370. $this->bet(); $this->deposit(); $this->qualificationFact();
  371. DB::table('share_policies')->update(['valid_member_min_effective_bet' => null]);
  372. $report = $this->reader->summary($this->today());
  373. $this->assertNull($report['summary']['valid_member_count']); $this->assertNull($report['valid_effective_bet_min']);
  374. $this->assertFalse($report['valid_member_policy']['available']);
  375. $this->assertContains('incomplete_qualification_policy', $report['valid_member_policy']['unavailable_reasons']);
  376. DB::table('share_policies')->delete();
  377. $report = $this->reader->summary($this->today());
  378. $this->assertNull($report['summary']['valid_member_count']); $this->assertNull($report['valid_deposit_min']);
  379. $this->assertContains('no_effective_policy', $report['valid_member_policy']['unavailable_reasons']);
  380. DB::table('share_policies')->insert(['effective_date' => '2026-10-01', 'valid_member_min_deposit' => '20', 'valid_member_min_effective_bet' => '100', 'rules' => '{}']);
  381. DB::table('share_system_state')->delete();
  382. $report = $this->reader->summary($this->today());
  383. $this->assertNull($report['summary']['valid_member_count']);
  384. $this->assertContains('relations_not_initialized', $report['valid_member_policy']['unavailable_reasons']);
  385. DB::table('share_system_state')->insert(['key' => 'initialized_at', 'value' => '2026-10-01']);
  386. Schema::drop('share_bet_facts');
  387. $report = $this->reader->summary($this->today());
  388. $this->assertNull($report['summary']['valid_member_count']);
  389. $this->assertContains('missing_table:share_bet_facts', $report['valid_member_policy']['unavailable_reasons']);
  390. }
  391. public function test_report_qualification_reads_do_not_settle_or_write_and_old_settings_post_is_rejected(): void
  392. {
  393. $this->bet(); $this->deposit(1, '20'); $this->qualificationFact();
  394. $tables = ['reporting_settings', 'reporting_orders', 'reporting_events', 'share_policies', 'share_system_state', 'share_bet_facts',
  395. 'balance_logs', 'share_accounts', 'share_settlements', 'share_ledger', 'share_claims', 'operation_audits'];
  396. $before = []; foreach ($tables as $table) $before[$table] = DB::table($table)->get()->toJson();
  397. for ($i = 0; $i < 2; ++$i) {
  398. $this->assertSame(1, $this->reader->summary($this->today())['summary']['valid_member_count']);
  399. $this->assertSame(1, $this->reader->details($this->today())['data'][0]['valid_member_count']);
  400. $settings = $this->admin->settings();
  401. $this->assertSame('20.0000000000', $settings['valid_deposit_min']);
  402. $this->assertSame('100.0000000000', $settings['valid_effective_bet_min']);
  403. $this->assertSame('2026-10-25 00:00:00', $settings['activity_started_at']);
  404. }
  405. try { $this->admin->settings(['valid_deposit_min' => '19.99']); $this->fail('Expected read-only settings rejection'); }
  406. catch (ValidationException $e) { $this->assertStringContainsString('/admin/shareEarning/settings/save', $e->validator->errors()->first()); }
  407. foreach ($tables as $table) $this->assertSame($before[$table], DB::table($table)->get()->toJson(), $table . ' must remain unchanged');
  408. }
  409. public function test_platform_settlement_filters_tg_virtual_exclusion_and_rank_pagination(): void
  410. {
  411. $this->bet(); $this->bet(2, ['member_id' => '500', 'amount' => '200']);
  412. $this->bet(3, ['status' => 1, 'profit' => '0']);
  413. DB::table('bets')->insert(['id' => 4, 'member_id' => '400', 'amount' => '99999', 'profit' => '0', 'status' => 1, 'created_at' => '2026-10-26 09:00:00']); $this->sync->publish('bets', 4);
  414. $this->assertSame(3, DB::table('reporting_orders')->count());
  415. $this->assertSame('0.0000000000', $this->reader->summary(['platform' => 'PC'])['summary']['bet_amount']);
  416. $this->assertSame('100.0000000000', $this->reader->summary(['settlement_status' => 'pending'])['summary']['bet_amount']);
  417. $this->assertNull($this->reader->summary(['settlement_status' => 'pending'])['summary']['company_profit']);
  418. $ranks = $this->reader->details(['metric' => 'bet_amount', 'page' => 2, 'limit' => 1], true);
  419. $this->assertSame(2, $ranks['total']); $this->assertSame(2, $ranks['data'][0]['rank']);
  420. }
  421. public function test_activity_intervals_merge_devices_split_idle_and_history_is_explicitly_missing(): void
  422. {
  423. foreach ([['a', '10:00:00'], ['b', '10:00:00'], ['a', '10:01:00'], ['b', '10:01:00'], ['a', '10:20:00'], ['a', '10:21:00']] as [$session, $at]) {
  424. DB::table('reporting_activity')->insert(['user_id' => 2, 'agent_id' => 10, 'inviter_id' => 1, 'client_platform' => 'H5', 'session_hash' => $session, 'observed_at' => '2026-10-26 ' . $at]);
  425. }
  426. $row = $this->reader->summary($this->today())['summary'];
  427. $this->assertSame(2, $row['activity_minutes']); $this->assertSame(1, $row['active_member_count']); $this->assertSame(4, $row['member_count']);
  428. $this->assertSame('0.250000', $row['active_rate']);
  429. $this->assertSame('0.250000', $row['known_active_rate']);
  430. foreach ([[], ['start_date' => '2026-10-24', 'end_date' => '2026-10-26']] as $filters) {
  431. $partial = $this->reader->summary($filters)['summary'];
  432. $this->assertNull($partial['activity_minutes']);
  433. $this->assertNull($partial['active_rate']);
  434. $this->assertSame(2, $partial['known_activity_minutes']);
  435. $this->assertSame('0.250000', $partial['known_active_rate']);
  436. }
  437. $beforeCollection = $this->reader->summary(['start_date' => '2026-10-24', 'end_date' => '2026-10-24'])['summary'];
  438. $this->assertSame(0, $beforeCollection['active_member_count']);
  439. $this->assertNull($beforeCollection['known_active_rate']);
  440. }
  441. public function test_full_sync_and_repeated_sync_do_not_change_source_data_or_double_count(): void
  442. {
  443. $this->bet(); $this->deposit(); $before = DB::table('balance_logs')->get()->toArray();
  444. $first = $this->sync->sync(true); $second = $this->sync->sync(true);
  445. $this->assertSame(0, $first['errors']); $this->assertSame(0, $second['errors']);
  446. $this->assertSame(1, DB::table('reporting_orders')->count()); $this->assertSame(1, DB::table('reporting_events')->count());
  447. $this->assertEquals($before, DB::table('balance_logs')->get()->toArray());
  448. }
  449. public function test_lottery_parent_is_published_once_across_child_chunks_and_parent_scan(): void
  450. {
  451. $this->f1LotteryWithoutCompletionContext();
  452. for ($id = 3; $id <= 230; ++$id) DB::table('lhc_order_item')->insert([
  453. 'id' => $id, 'member_id' => '200', 'ordernum' => 'F1-live', 'lottery_status' => 1, 'win_amount' => '0', 'updated_at' => now()->timestamp,
  454. ]);
  455. DB::table('lhc_order')->where('id', 1)->update(['total_amount' => '11500']);
  456. $result = $this->sync->sync();
  457. $this->assertSame(0, $result['errors']);
  458. $this->assertSame(1, $result['published']);
  459. $this->assertSame(230, DB::table('reporting_orders')->count());
  460. Carbon::setTestNow(now('Asia/Shanghai')->addMinutes(10));
  461. DB::table('lhc_order_item')->where('id', 230)->update(['win_amount' => '68', 'lottery_status' => 2, 'updated_at' => now()->timestamp]);
  462. $this->assertSame(1, $this->sync->sync()['published']);
  463. $this->assertSame('68', (string) DB::table('reporting_orders')->where('source_id', '230')->value('payout_amount'));
  464. }
  465. public function test_source_cursor_advances_past_errors_and_failed_rows_retry_without_replaying_good_history(): void
  466. {
  467. foreach (range(1, 4) as $id) DB::table('bets')->insert(['id' => $id, 'member_id' => $id === 4 ? 'missing' : '200',
  468. 'amount' => '100', 'profit' => '60', 'status' => 2, 'created_at' => '2026-10-01 09:00:00', 'updated_at' => '2026-10-01 10:00:00']);
  469. $this->assertSame(1, $this->sync->sync()['errors']);
  470. $cursor = json_decode(DB::table('reporting_sync_state')->where('source', 'bets')->value('state'), true);
  471. $this->assertSame(4, $cursor['last_id']);
  472. Carbon::setTestNow(now('Asia/Shanghai')->addHour());
  473. DB::table('bets')->insert(['id' => 5, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2,
  474. 'created_at' => '2026-10-01 09:00:00', 'updated_at' => '2026-10-01 10:00:00']);
  475. $result = $this->sync->sync();
  476. $this->assertSame(2, $result['scanned']);
  477. $this->assertSame(1, $result['errors']);
  478. $this->assertSame('errors', $result['sources']['bets']);
  479. $this->assertSame(4, DB::table('reporting_orders')->count());
  480. DB::table('bets')->where('id', 4)->update(['member_id' => '200']);
  481. Carbon::setTestNow(now('Asia/Shanghai')->addMinute());
  482. $result = $this->sync->sync();
  483. $this->assertSame(1, $result['scanned']);
  484. $this->assertSame(0, $result['errors']);
  485. $this->assertSame(5, DB::table('reporting_orders')->count());
  486. $this->assertSame(0, DB::table('reporting_errors')->count());
  487. }
  488. public function test_missing_timestamps_use_bounded_rotating_checks_and_still_apply_old_corrections(): void
  489. {
  490. DB::statement('CREATE TABLE bot_jisu_game_order (id INTEGER PRIMARY KEY,member_id TEXT,amount TEXT,win_amount TEXT,status INTEGER,created_at TEXT)');
  491. foreach (range(1, 405) as $id) {
  492. DB::table('bets')->insert(['id' => $id, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2,
  493. 'created_at' => '2026-10-01 09:00:00', 'updated_at' => null]);
  494. DB::table('jisu_game_order')->insert(['id' => $id, 'member_id' => '200', 'amount' => '100', 'win_amount' => '60', 'status' => 2, 'created_at' => '2026-10-01 09:00:00']);
  495. }
  496. $this->assertSame(810, $this->sync->sync()['scanned']);
  497. DB::table('bets')->where('id', 405)->update(['profit' => '25']);
  498. DB::table('jisu_game_order')->where('id', 405)->update(['win_amount' => '35']);
  499. foreach ([400, 400, 10, 400] as $expected) {
  500. Carbon::setTestNow(now('Asia/Shanghai')->addMinute());
  501. $result = $this->sync->sync();
  502. $this->assertSame($expected, $result['scanned']);
  503. $this->assertSame(0, $result['errors']);
  504. }
  505. $this->assertSame('25', (string) DB::table('reporting_orders')->where('source', 'bets')->where('source_id', '405')->value('payout_amount'));
  506. $this->assertSame('35', (string) DB::table('reporting_orders')->where('source', 'jisu_game_order')->where('source_id', '405')->value('payout_amount'));
  507. }
  508. public function test_missing_source_errors_do_not_starve_later_retries_and_keep_run_incomplete(): void
  509. {
  510. DB::table('bets')->insert(['id' => 205, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2,
  511. 'created_at' => '2026-10-01 09:00:00', 'updated_at' => '2026-10-01 10:00:00']);
  512. DB::table('reporting_sync_state')->insert(['source' => 'bets', 'state' => json_encode(['last_id' => 205, 'checked_at' => now()->toDateTimeString()]), 'updated_at' => now()]);
  513. foreach (range(1, 205) as $id) DB::table('reporting_errors')->insert(['source' => 'bets', 'source_id' => (string) $id,
  514. 'reason' => 'previous error', 'created_at' => '2026-10-01 10:00:00', 'updated_at' => '2026-10-01 10:00:00']);
  515. $first = $this->sync->sync();
  516. $this->assertSame(0, $first['published']);
  517. $this->assertSame(205, $first['errors']);
  518. Carbon::setTestNow(now('Asia/Shanghai')->addMinute());
  519. $second = $this->sync->sync();
  520. $this->assertSame(1, $second['published']);
  521. $this->assertSame(204, $second['errors']);
  522. $this->assertSame('errors', $second['sources']['bets']);
  523. $state = json_decode(DB::table('reporting_sync_state')->where('source', '__run__')->value('state'), true);
  524. $this->assertFalse($state['complete']);
  525. $this->assertSame(1, DB::table('reporting_orders')->count());
  526. }
  527. public function test_unchanged_projections_keep_timestamps_and_skip_updates(): void
  528. {
  529. $this->bet(); $this->deposit();
  530. $beforeOrders = DB::table('reporting_orders')->get()->toArray();
  531. $beforeEvents = DB::table('reporting_events')->get()->toArray();
  532. Carbon::setTestNow(now('Asia/Shanghai')->addMinutes(10));
  533. DB::table('bets')->where('id', 1)->update(['amount' => '100.0000000000']);
  534. DB::enableQueryLog(); DB::flushQueryLog();
  535. try {
  536. $this->assertSame(0, $this->sync->sync(true)['errors']);
  537. $updates = array_filter(DB::getQueryLog(), fn ($q) => preg_match('/^update "bot_reporting_(orders|events)"/', $q['query']));
  538. $this->assertSame([], array_values($updates));
  539. } finally { DB::disableQueryLog(); DB::flushQueryLog(); }
  540. $this->assertEquals($beforeOrders, DB::table('reporting_orders')->get()->toArray());
  541. $this->assertEquals($beforeEvents, DB::table('reporting_events')->get()->toArray());
  542. }
  543. public function test_sync_index_migration_is_repeatable_and_preserves_existing_indexes_and_data(): void
  544. {
  545. $this->bet();
  546. Schema::table('bets', fn (Blueprint $t) => $t->index(['updated_at', 'id'], 'existing_bets_update_index'));
  547. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_130000_add_sync_performance_indexes.php';
  548. $migration->up(); $migration->up();
  549. $plan = DB::select('EXPLAIN QUERY PLAN DELETE FROM bot_reporting_orders WHERE parent_source = ? AND parent_id = ?', ['lhc_order', '1']);
  550. $this->assertStringContainsString('reporting_orders_parent_lookup', json_encode($plan));
  551. $this->assertTrue(Schema::hasIndex('reporting_errors', 'reporting_errors_retry_lookup'));
  552. $migration->down();
  553. $this->assertTrue(Schema::hasIndex('bets', 'existing_bets_update_index'));
  554. $this->assertFalse(Schema::hasIndex('reporting_orders', 'reporting_orders_parent_lookup'));
  555. $this->assertSame(1, DB::table('reporting_orders')->count());
  556. }
  557. public function test_command_lock_skips_overlap_and_releases_after_failure(): void
  558. {
  559. $lock = new \App\Support\SyncCommandLock();
  560. $name = 'test:' . bin2hex(random_bytes(8));
  561. $this->assertSame(7, $lock->run($name, function () use ($lock, $name) {
  562. $this->assertNull($lock->run($name, function () { $this->fail('Overlapping synchronization ran'); }));
  563. $this->assertSame(8, $lock->run($name . ':other', fn () => 8));
  564. return 7;
  565. }));
  566. try { $lock->run($name, fn () => throw new \RuntimeException('expected failure')); }
  567. catch (\RuntimeException $e) { $this->assertSame('expected failure', $e->getMessage()); }
  568. $this->assertSame(9, $lock->run($name, fn () => 9));
  569. }
  570. private function activityObservation(string $at, ?int $agent = 10, ?int $inviter = 1, string $session = 'a'): void
  571. {
  572. DB::table('reporting_activity')->insert(['user_id' => 2, 'agent_id' => $agent, 'inviter_id' => $inviter,
  573. 'client_platform' => 'H5', 'session_hash' => $session, 'observed_at' => $at]);
  574. }
  575. public function test_activity_snapshot_survives_agent_reassignment_in_summary_details_and_filters(): void
  576. {
  577. $this->activityObservation('2026-10-26 10:00:00'); $this->activityObservation('2026-10-26 10:02:00');
  578. $before = $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent10']))['summary'];
  579. DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
  580. $after = $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent10']))['summary'];
  581. $this->assertSame(2, $before['activity_minutes']); $this->assertSame(2, $after['activity_minutes']);
  582. $this->assertSame(1, $after['active_member_count']); $this->assertFalse($after['activity_population_complete']); $this->assertNull($after['active_rate']);
  583. $this->assertNull($after['known_active_rate']);
  584. $other = $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent20']))['summary'];
  585. $this->assertSame(0, $other['activity_minutes']); $this->assertSame(0, $other['active_member_count']);
  586. $details = $this->reader->details($this->today(['report_type' => 'promotion']));
  587. $this->assertSame(1, $details['total']); $this->assertSame('10', $details['data'][0]['group_id']); $this->assertSame(2, $details['data'][0]['activity_minutes']);
  588. $byGroup = $this->reader->summary($this->today(['report_type' => 'promotion', 'group_id' => '10']))['summary'];
  589. $this->assertSame(2, $byGroup['activity_minutes']);
  590. $business = $this->reader->summary($this->today(['agent_account' => 'agent10']))['summary']; $this->assertSame(2, $business['activity_minutes']);
  591. $this->activityObservation('2026-10-26 11:00:00', 20); $this->activityObservation('2026-10-26 11:03:00', 20);
  592. $groups = collect($this->reader->details($this->today(['report_type' => 'promotion']))['data'])->keyBy('group_id');
  593. $this->assertSame(2, $groups[10]['activity_minutes']); $this->assertSame(3, $groups[20]['activity_minutes']);
  594. $this->assertSame('0.500000', $groups[20]['active_rate']);
  595. $all = $this->reader->summary($this->today())['summary']; $this->assertSame(5, $all['activity_minutes']); $this->assertSame(1, $all['active_member_count']);
  596. $this->assertSame('0.250000', $all['active_rate']);
  597. }
  598. public function test_agent_transition_with_overlapping_devices_counts_each_second_only_once(): void
  599. {
  600. $this->activityObservation('2026-10-26 10:00:00', 10, 1, 'a');
  601. $this->activityObservation('2026-10-26 10:01:00', 20, 1, 'b');
  602. $this->activityObservation('2026-10-26 10:02:00', 20, 1, 'a');
  603. $this->activityObservation('2026-10-26 10:03:00', 20, 1, 'b');
  604. DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
  605. $groups = collect($this->reader->details($this->today(['report_type' => 'promotion']))['data'])->keyBy('group_id');
  606. $this->assertSame(1, $groups[10]['activity_minutes']); $this->assertSame(2, $groups[20]['activity_minutes']);
  607. $all = $this->reader->summary($this->today())['summary']; $this->assertSame(3, $all['activity_minutes']); $this->assertSame(1, $all['active_member_count']);
  608. $this->assertSame(1, $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent10']))['summary']['activity_minutes']);
  609. $this->assertSame(2, $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent20']))['summary']['activity_minutes']);
  610. }
  611. public function test_activity_uses_inviter_snapshot_and_does_not_inherit_current_null_attribution(): void
  612. {
  613. $this->activityObservation('2026-10-26 10:00:00'); $this->activityObservation('2026-10-26 10:04:00');
  614. DB::table('share_members')->where('user_id', 2)->update(['parent_user_id' => 3]);
  615. $groups = collect($this->reader->details($this->today(['report_type' => 'sharing']))['data'])->keyBy('group_id');
  616. $this->assertSame(4, $groups[1]['activity_minutes']); $this->assertArrayNotHasKey(3, $groups->all());
  617. $this->assertSame(4, $this->reader->summary($this->today(['report_type' => 'sharing', 'inviter_account' => 'member1']))['summary']['activity_minutes']);
  618. $this->assertSame(0, $this->reader->summary($this->today(['report_type' => 'sharing', 'inviter_account' => 'member3']))['summary']['activity_minutes']);
  619. DB::table('reporting_activity')->delete();
  620. $this->activityObservation('2026-10-26 11:00:00', null, null); $this->activityObservation('2026-10-26 11:02:00', null, null);
  621. $this->assertSame(2, $this->reader->summary($this->today())['summary']['activity_minutes']);
  622. $this->assertSame(0, $this->reader->summary($this->today(['report_type' => 'promotion']))['summary']['activity_minutes']);
  623. $this->assertSame(0, $this->reader->summary($this->today(['report_type' => 'sharing']))['summary']['activity_minutes']);
  624. }
  625. public function test_activity_interval_crossing_query_start_keeps_the_starting_snapshot(): void
  626. {
  627. $this->activityObservation('2026-10-25 23:59:00', 10); $this->activityObservation('2026-10-26 00:01:00', 20);
  628. DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
  629. $groups = collect($this->reader->details($this->today(['report_type' => 'promotion']))['data'])->keyBy('group_id');
  630. $this->assertSame(1, $groups[10]['activity_minutes']); $this->assertSame(0, $groups[20]['activity_minutes']);
  631. $this->assertSame(1, $this->reader->summary($this->today())['summary']['activity_minutes']);
  632. }
  633. public function test_order_time_reconciliation_survives_reimport_and_does_not_settle_source(): void
  634. {
  635. DB::table('bets')->insert(['id' => 1, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2, 'created_at' => '2026-10-25 09:00:00', 'updated_at' => '2026-10-26 10:00:00']);
  636. $this->sync->publish('bets', 1); $this->assertNull($this->reader->summary($this->today())['summary']['company_profit']);
  637. $id = (int) DB::table('reporting_orders')->value('id'); $before = (array) DB::table('bets')->first();
  638. $this->admin->reconcileOrder(['id' => $id, 'settled_at' => '2026-10-26 10:00:00', 'evidence' => '开奖结算日志']); $this->sync->publish('bets', 1);
  639. $this->assertSame('40.0000000000', $this->reader->summary($this->today())['summary']['company_profit']);
  640. $this->assertSame($before, (array) DB::table('bets')->first());
  641. }
  642. public function test_new_routes_have_admin_auth_and_only_operators_can_write_reconciliations(): void
  643. {
  644. $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
  645. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  646. foreach (['options', 'leaderboard', 'fund-flow', 'summary', 'details', 'orders', 'settings', 'payment-reconciliation'] as $action) {
  647. $r = $router->getRoutes()->match(Request::create('/admin/reporting/' . $action));
  648. $this->assertContains('admin.jwt', $r->middleware()); $this->assertContains('check.button.uri', $r->middleware());
  649. }
  650. foreach (['settings', 'payment-reconciliation', 'order-reconciliation'] as $action) $this->assertContains('operations.admin', $router->getRoutes()->match(Request::create('/admin/reporting/' . $action, 'POST'))->middleware());
  651. }
  652. public function test_share_accrual_and_claim_are_separate_and_agent_cost_only_counts_actual_credit(): void
  653. {
  654. $this->bet();
  655. DB::table('share_ledger')->insert([['id' => 1, 'user_id' => 1, 'amount' => '20', 'bet_date' => '2026-10-26', 'category' => 'self:lottery', 'created_at' => '2026-10-26 10:00:00'],
  656. ['id' => 2, 'user_id' => 1, 'amount' => '-3', 'bet_date' => '2026-10-26', 'category' => 'self:lottery', 'created_at' => '2026-10-26 11:00:00']]);
  657. DB::table('share_claims')->insert(['id' => 1, 'user_id' => 1, 'status' => 'approved', 'credited_log_id' => 10]);
  658. DB::table('balance_logs')->insert(['id' => 10, 'member_id' => '100', 'amount' => '5', 'change_type' => '分享佣金领取', 'created_at' => '2026-10-26 11:00:00']);
  659. DB::table('agent_rebate_records')->insert(['id' => 1, 'agent_id' => 10, 'platform' => 'pc28', 'game_type' => 3, 'settlement_date' => '2026-10-26', 'flow_commission' => '7', 'profit_commission' => '2', 'flow_status' => 'credited', 'profit_status' => 'pending', 'created_at' => '2026-10-26 10:00:00']);
  660. DB::table('agent_balance_logs')->insert(['id' => 1, 'agent_id' => 10, 'type' => 'commission', 'amount' => '7', 'reference_type' => 'agent_rebate_record', 'reference_id' => 1, 'created_at' => '2026-10-26 11:00:00']);
  661. foreach ([['share_ledger', 1], ['share_ledger', 2], ['balance_logs', 10], ['agent_rebate_records', 1], ['agent_balance_logs', 1]] as [$source, $id]) $this->sync->publish($source, $id);
  662. $row = $this->reader->summary($this->today())['summary'];
  663. $this->assertSame('17.0000000000', $row['share_accrual']); $this->assertSame('5.0000000000', $row['share_commission']);
  664. $this->assertSame('9.0000000000', $row['agent_accrual']); $this->assertSame('7.0000000000', $row['agent_commission']);
  665. $this->assertSame('28.0000000000', $row['company_profit']);
  666. foreach ([false, true] as $ranking) {
  667. $details = $this->reader->details($this->today(), $ranking);
  668. // Agent-only costs belong to the separate metadata bucket, not a synthetic member row.
  669. $this->assertSame('7.0000000000', $details['unallocated']['agent_commission']);
  670. $this->assertSame('9.0000000000', $details['unallocated']['agent_accrual']);
  671. $this->assertNotContains('unallocated', array_column($details['data'], 'group_id'));
  672. $this->assertSame(2, $details['total']);
  673. }
  674. $this->assertSame('2.0000000000', $this->reader->summary($this->today())['commission_balances']['agent_unpaid']);
  675. $this->assertSame('7.0000000000', $this->reader->summary($this->today())['summary']['agent_commission']);
  676. $this->assertSame('28.0000000000', $this->reader->summary($this->today())['summary']['company_profit']);
  677. $this->assertSame('5.0000000000', $this->reader->summary(['report_type' => 'sharing'])['summary']['share_commission']);
  678. }
  679. public function test_business_summary_survives_rebate_records_without_flow_columns(): void
  680. {
  681. DB::table('agent_rebate_records')->insert(['id' => 1, 'agent_id' => 10, 'platform' => 'self:lottery', 'game_type' => 8,
  682. 'settlement_date' => '2026-10-26', 'profit_commission' => '4.5', 'profit_status' => 'pending', 'created_at' => '2026-10-26 10:00:00']);
  683. DB::statement('ALTER TABLE bot_agent_rebate_records DROP COLUMN flow_commission');
  684. DB::statement('ALTER TABLE bot_agent_rebate_records DROP COLUMN flow_status');
  685. set_error_handler(static function (int $severity, string $message): bool {
  686. throw new \ErrorException($message, 0, $severity);
  687. });
  688. try {
  689. $balances = $this->reader->summary([])['commission_balances'];
  690. } finally {
  691. restore_error_handler();
  692. }
  693. $this->assertSame('4.5000000000', $balances['agent_unpaid']);
  694. }
  695. public function test_daily_total_is_for_all_pages_and_source_errors_are_visible_until_fixed(): void
  696. {
  697. $this->bet(1, ['created_at' => '2026-10-25 09:00:00']);
  698. $r = $this->reader->funds(['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1, 'page' => 2]);
  699. $this->assertSame(2, $r['total']); $this->assertSame('2026-10-25', $r['data'][0]['group_id']);
  700. $this->assertSame('100.0000000000', $r['summary']['settled_bet_amount']);
  701. DB::table('bets')->insert(['id' => 2, 'member_id' => 'missing', 'amount' => '10', 'status' => 1, 'created_at' => '2026-10-26 10:00:00']);
  702. $r = $this->sync->sync(true); $this->assertSame(1, $r['errors']);
  703. $this->assertNull($this->reader->summary($this->today())['summary']['company_profit']); $this->assertSame(1, DB::table('reporting_errors')->count());
  704. DB::table('bets')->where('id', 2)->update(['member_id' => '200']); $this->sync->sync(true);
  705. $this->assertSame(0, DB::table('reporting_errors')->count()); $this->assertNotNull($this->reader->summary($this->today())['summary']['company_profit']);
  706. }
  707. public function test_eloquent_context_observer_records_status_transition_once_and_tg_activity_is_deduplicated(): void
  708. {
  709. $withdraw = new \App\Models\Withdraw(); $withdraw->forceFill(['id' => 1, 'member_id' => '200', 'amount' => '10', 'to_account' => '9', 'service_charge' => '1', 'exchange_rate' => '7', 'status' => 0]); $withdraw->save();
  710. $context = DB::table('reporting_contexts')->where('source', 'withdraws')->first(); $this->assertNotNull($context); $this->assertNull($context->succeeded_at);
  711. $withdraw->status = 1; $withdraw->save(); $this->assertSame('2026-10-26 16:00:00', DB::table('reporting_contexts')->where('source', 'withdraws')->value('succeeded_at'));
  712. Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai')); $withdraw->amount = '11'; $withdraw->save();
  713. $this->assertSame('2026-10-26 16:00:00', DB::table('reporting_contexts')->where('source', 'withdraws')->value('succeeded_at'));
  714. $request = Request::create('/api/onMessage', 'POST'); $this->app->instance('request', $request); $user = (object) ['id' => 5, 'agent_id' => null, 'from' => -1];
  715. \App\Services\Reporting\ReportActivity::telegram($user); \App\Services\Reporting\ReportActivity::telegram($user);
  716. $this->assertSame(1, DB::table('reporting_activity')->count()); $this->assertSame('unknown', DB::table('reporting_activity')->value('client_platform'));
  717. }
  718. public function test_current_game_adapters_cover_parent_children_third_party_and_partial_sports_refunds(): void
  719. {
  720. DB::statement('CREATE TABLE bot_lhc_order (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,amount TEXT,total_amount TEXT,type INTEGER,lottery_status INTEGER,is_faker INTEGER,created_at INTEGER,updated_at INTEGER)');
  721. DB::statement('CREATE TABLE bot_lhc_order_item (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,lottery_status INTEGER,win_amount TEXT,updated_at INTEGER)');
  722. DB::statement('CREATE TABLE bot_sport_order (id INTEGER PRIMARY KEY,user_id TEXT,amount TEXT,win_amount TEXT,return_amount TEXT,status INTEGER,pay_status INTEGER,settlement_status INTEGER,return_status INTEGER,create_time INTEGER,update_time INTEGER,settlement_time INTEGER)');
  723. DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,platform TEXT DEFAULT "pg",player_id TEXT DEFAULT "u2",game_type INTEGER,bet_amount TEXT,settled_amount TEXT,status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
  724. $betAt = Carbon::parse('2026-10-26 09:00:00', 'Asia/Shanghai')->timestamp; $settledAt = $betAt + 3600;
  725. DB::table('lhc_order')->insert(['id' => 1, 'member_id' => '200', 'ordernum' => 'L1', 'amount' => '10', 'total_amount' => '20', 'type' => 6, 'lottery_status' => 2, 'is_faker' => 0, 'created_at' => $betAt, 'updated_at' => $settledAt]);
  726. DB::table('lhc_order_item')->insert([['id' => 1, 'member_id' => '200', 'ordernum' => 'L1', 'lottery_status' => 1, 'win_amount' => '0', 'updated_at' => $settledAt], ['id' => 2, 'member_id' => '200', 'ordernum' => 'L1', 'lottery_status' => 2, 'win_amount' => '18', 'updated_at' => $settledAt]]);
  727. $this->context('lhc_order', 1, 2, 'PC', '2026-10-26 10:00:00'); $this->sync->publish('lhc_order', 1);
  728. DB::table('sport_order')->insert(['id' => 1, 'user_id' => '200', 'amount' => '100', 'win_amount' => '0', 'return_amount' => '50', 'status' => 1, 'pay_status' => 1, 'settlement_status' => 1, 'return_status' => 1, 'create_time' => $betAt, 'update_time' => $settledAt, 'settlement_time' => $settledAt]);
  729. $this->context('sport_order', 1, 2, 'APP'); $this->sync->publish('sport_order', 1);
  730. DB::table('third_game_orders')->insert(['id' => 1, 'order_key' => str_repeat('a', 64), 'member_id' => '200', 'user_id' => 2, 'game_type' => 4, 'bet_amount' => '30', 'settled_amount' => '-30', 'status' => 1, 'currency' => 'CNY', 'bet_time' => '2026-10-26 09:00:00', 'last_update_time' => '2026-10-26 10:00:00', 'created_at' => '2026-10-26 09:00:00', 'updated_at' => '2026-10-26 10:00:00']);
  731. $this->sync->publish('third_game_orders', 1);
  732. $r = $this->reader->summary($this->today())['summary'];
  733. $this->assertSame(3, $r['order_count']); $this->assertSame('150.0000000000', $r['bet_amount']);
  734. $this->assertSame('100.0000000000', $r['settled_bet_amount']); $this->assertSame('18.0000000000', $r['payout_amount']);
  735. $this->assertSame('50.0000000000', $r['refund_amount']); $this->assertSame('82.0000000000', $r['game_gross_profit']);
  736. $unknown = $this->reader->summary(['platform' => 'unknown'])['summary']; $this->assertSame('30.0000000000', $unknown['bet_amount']);
  737. $this->assertSame(0, DB::table('share_bet_facts')->count()); // Reports did not publish sharing facts.
  738. $this->assertSame(0, $this->sync->sync(true)['errors']);
  739. Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai'));
  740. DB::table('lhc_order_item')->where('id', 2)->update(['win_amount' => '68', 'updated_at' => now()->timestamp]);
  741. $this->assertSame(0, $this->sync->sync()['errors']);
  742. $this->assertSame('32.0000000000', $this->reader->summary($this->today())['summary']['game_gross_profit']);
  743. $this->assertSame(4, DB::table('reporting_orders')->count());
  744. }
  745. public static function thirdPartyReportCases(): array
  746. {
  747. $cases = [];
  748. foreach (range(1, 7) as $type) foreach ([
  749. 'win' => [1, '80', '100', '180', '0', '-80'],
  750. 'loss' => [1, '-100', '100', '0', '0', '100'],
  751. 'push' => [1, '0', '100', '100', '0', '0'],
  752. 'pending' => [0, '0', '0', '0', '0', '0'],
  753. 'void' => [2, '0', '0', '0', '100', '0'],
  754. 'cancelled' => [3, '0', '0', '0', '100', '0'],
  755. ] as $name => $values) $cases[$type . '-' . $name] = [$type, ...$values];
  756. return $cases;
  757. }
  758. private function commissionOrders(): void
  759. {
  760. DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,
  761. platform TEXT,player_id TEXT,game_type INTEGER,game_name TEXT,bet_amount TEXT,valid_amount TEXT,settled_amount TEXT,
  762. status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT,
  763. third_game_commission TEXT)');
  764. }
  765. private function commissionOrder(int $id, array $extra = []): void
  766. {
  767. $user = $extra['user_id'] ?? 2;
  768. $order = $extra + ['id' => $id, 'order_key' => hash('sha256', 'commission-' . $id),
  769. 'member_id' => (string) ($user * 100), 'user_id' => $user, 'platform' => 'pg', 'player_id' => 'u' . $user,
  770. 'game_type' => 2, 'game_name' => 'game-' . $id, 'bet_amount' => '100', 'valid_amount' => '95',
  771. 'settled_amount' => '-100', 'status' => 1, 'currency' => 'CNY', 'bet_time' => '2026-10-25 23:00:00',
  772. 'last_update_time' => '2026-10-26 10:00:00', 'created_at' => '2026-10-25 23:00:00', 'updated_at' => '2026-10-26 10:00:00'];
  773. $ratio = ['pg' => '0.09', 'ag' => '0.20', 'free' => '0'][strtolower($order['platform'])] ?? null;
  774. $amount = ThirdGameCommissionService::result($order['status'], $order['settled_amount']) === 'loss' && $ratio !== null
  775. ? ThirdGameCommissionService::charge($order['settled_amount'], $ratio) : null;
  776. DB::table('third_game_orders')->insert($order + ['third_game_commission' => $amount]);
  777. $this->sync->publish('third_game_orders', $id);
  778. }
  779. public function test_platform_commissions_sum_multiple_games_by_category_and_respect_all_three_report_scopes(): void
  780. {
  781. $this->commissionOrders();
  782. $this->commissionOrder(1);
  783. $this->commissionOrder(2, ['settled_amount' => '40']);
  784. $this->commissionOrder(3, ['platform' => 'AG', 'game_type' => 1, 'settled_amount' => '-50']);
  785. $this->commissionOrder(4, ['platform' => 'ag', 'user_id' => 3, 'settled_amount' => '-20']);
  786. $this->commissionOrder(5, ['user_id' => 1, 'settled_amount' => '-10']);
  787. $this->bet();
  788. $business = $this->reader->summary($this->today());
  789. $this->assertSame('23.9000000000', $business['summary']['third_game_commission']);
  790. $this->assertSame('156.1000000000', $business['summary']['company_profit']);
  791. $categories = collect($business['categories'])->keyBy('group_id');
  792. $this->assertSame('13.9000000000', $categories['third:2']['third_game_commission']);
  793. $this->assertSame('76.1000000000', $categories['third:2']['game_net_profit']);
  794. $this->assertSame('10.0000000000', $categories['third:1']['third_game_commission']);
  795. $this->assertSame('0.0000000000', $categories['self:lottery']['third_game_commission']);
  796. foreach (['promotion', 'sharing'] as $type) {
  797. $report = $this->reader->summary($this->today(['report_type' => $type]));
  798. $this->assertSame('23.0000000000', $report['summary']['third_game_commission']);
  799. $this->assertSame(9, count($report['categories']));
  800. }
  801. $this->assertSame('19.0000000000', $this->reader->details($this->today(['report_type' => 'promotion', 'group_id' => '10']))['data'][0]['third_game_commission']);
  802. $range = ['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1];
  803. $funds = $this->reader->funds($range);
  804. $this->assertSame('23.9000000000', $funds['summary']['third_game_commission']);
  805. $this->assertSame('23.9000000000', $funds['data'][0]['third_game_commission']);
  806. $this->assertCount(9, $funds['summary']['categories']);
  807. $this->assertSame('76.1000000000', collect($funds['data'][0]['categories'])->keyBy('group_id')['third:2']['game_net_profit']);
  808. $previous = $this->reader->funds($range + ['page' => 2]);
  809. $this->assertSame($funds['summary'], $previous['summary']);
  810. $this->assertSame('0.0000000000', collect($previous['data'][0]['categories'])->keyBy('group_id')['third:2']['third_game_commission']);
  811. // Bet on 10-25, settled on 10-26: game result is counted on the settlement day only, never on both days.
  812. $this->assertSame('0.0000000000', collect($previous['data'][0]['categories'])->keyBy('group_id')['third:2']['game_gross_profit']);
  813. $this->assertSame('0.0000000000', $previous['data'][0]['game_gross_profit']);
  814. $this->assertSame('90.0000000000', collect($funds['data'][0]['categories'])->keyBy('group_id')['third:2']['game_gross_profit']);
  815. $this->assertSame($funds['summary']['game_gross_profit'], $funds['data'][0]['game_gross_profit']);
  816. $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
  817. $expense = collect($finance['expense'])->keyBy('key');
  818. $this->assertSame('23.90', $expense['third_game_commission']['amount']);
  819. $this->assertSame(3, $expense['third_game_commission']['people']);
  820. $this->assertSame('23.90', $finance['expense_total']);
  821. $this->assertSame('116.10', $finance['third_game_net_profit']);
  822. $this->assertSame('0.00', $expense['third_party_fee']['amount']);
  823. }
  824. public function test_fee_summary_separates_customer_fees_from_channel_costs(): void
  825. {
  826. DB::statement('CREATE TABLE bot_agent_payment_orders (id INTEGER PRIMARY KEY,agent_id INTEGER,type INTEGER,amount TEXT,fee TEXT,status INTEGER,created_at TEXT,updated_at TEXT)');
  827. DB::statement('CREATE TABLE bot_agent_withdrawals (id INTEGER PRIMARY KEY,agent_id INTEGER,amount TEXT,fee TEXT,actual_amount TEXT,channel_fee TEXT,status TEXT,paid_at TEXT,created_at TEXT,updated_at TEXT)');
  828. $at = '2026-10-26 10:00:00';
  829. DB::table('payment_orders')->insert([
  830. ['id' => 1, 'member_id' => '200', 'type' => 1, 'amount' => '1000', 'fee' => '20', 'status' => 2, 'created_at' => $at, 'updated_at' => $at],
  831. ['id' => 2, 'member_id' => '200', 'type' => 2, 'amount' => '500', 'fee' => '7', 'status' => 2, 'created_at' => $at, 'updated_at' => $at],
  832. // 历史人工提现记录过 fee,但并没有向会员收取,不能算作收入。
  833. ['id' => 3, 'member_id' => '300', 'type' => 4, 'amount' => '200', 'fee' => '200', 'status' => 2, 'created_at' => $at, 'updated_at' => $at],
  834. ['id' => 4, 'member_id' => '300', 'type' => 1, 'amount' => '900', 'fee' => '50', 'status' => 3, 'created_at' => $at, 'updated_at' => $at],
  835. ]);
  836. DB::table('withdraws')->insert(['id' => 1, 'member_id' => '200', 'amount' => '100', 'to_account' => '98', 'service_charge' => '2', 'exchange_rate' => '7', 'status' => 1, 'created_at' => $at, 'updated_at' => $at]);
  837. DB::table('balance_logs')->insert(['member_id' => '200', 'amount' => '-700', 'change_type' => '提现', 'related_id' => 1, 'created_at' => $at, 'updated_at' => $at]);
  838. DB::table('agent_payment_orders')->insert(['id' => 1, 'agent_id' => 10, 'type' => 1, 'amount' => '300', 'fee' => '3', 'status' => 2, 'created_at' => $at, 'updated_at' => $at]);
  839. DB::table('agent_withdrawals')->insert([
  840. ['id' => 1, 'agent_id' => 10, 'amount' => '100', 'fee' => '1', 'actual_amount' => '99', 'channel_fee' => '2.99', 'status' => 'paid', 'paid_at' => $at, 'created_at' => $at, 'updated_at' => $at],
  841. ['id' => 2, 'agent_id' => 20, 'amount' => '500', 'fee' => '5', 'actual_amount' => '495', 'channel_fee' => null, 'status' => 'paid', 'paid_at' => $at, 'created_at' => $at, 'updated_at' => $at],
  842. ['id' => 3, 'agent_id' => 20, 'amount' => '500', 'fee' => '5', 'actual_amount' => '495', 'channel_fee' => '3', 'status' => 'pending', 'paid_at' => null, 'created_at' => $at, 'updated_at' => $at],
  843. ]);
  844. $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
  845. $this->assertSame('14.00', collect($finance['income'])->keyBy('key')['member_withdraw_deduction']['amount'], 'only USDT service charges are deducted from members');
  846. // 平台承担的三方手续费(会员和代理的充值、提现)都计入支出。
  847. $expense = collect($finance['expense'])->keyBy('key');
  848. $this->assertSame(['20.00', '7.00', '3.00', '2.99'], [$expense['third_party_fee']['amount'], $expense['third_party_auto_withdrawal_fee']['amount'],
  849. $expense['agent_deposit_channel_fee']['amount'], $expense['agent_payout_channel_fee']['amount']]);
  850. $this->assertSame([1, 1], [$expense['agent_deposit_channel_fee']['people'], $expense['agent_payout_channel_fee']['people']]);
  851. $fees = $finance['fee_summary'];
  852. $this->assertSame(['14.00', '6.00'], array_column($fees['income'], 'amount'));
  853. $this->assertSame(['20.00', '7.00', '3.00', '2.99'], array_column($fees['cost'], 'amount'));
  854. $this->assertSame(['20.00', '32.99', '-12.99', 1], [$fees['income_total'], $fees['cost_total'], $fees['net'], $fees['unrecorded_agent_payout_count']]);
  855. }
  856. public function test_missing_platform_rates_are_unknown_zero_rates_are_valid_and_cash_flow_is_preserved(): void
  857. {
  858. $this->commissionOrders();
  859. $this->commissionOrder(1, ['platform' => 'free']);
  860. $this->assertSame('0.0000000000', $this->reader->summary($this->today())['summary']['third_game_commission']);
  861. $this->commissionOrder(2, ['platform' => 'missing']);
  862. $this->commissionOrder(3);
  863. $report = $this->reader->summary($this->today());
  864. $row = $report['summary'];
  865. $this->assertNull($row['third_game_commission']);
  866. $this->assertSame('9.0000000000', $row['known_third_game_commission']);
  867. $this->assertNull($row['company_profit']);
  868. $this->assertNull($row['game_net_profit']);
  869. $this->assertSame('0.0000000000', $row['net_cash_flow']);
  870. $this->assertSame(1, $row['quality']['missing_platform_ratio']);
  871. $funds = $this->reader->funds($this->today());
  872. $category = collect($funds['data'][0]['categories'])->keyBy('group_id')['third:2'];
  873. $this->assertNull($category['third_game_commission']);
  874. $this->assertNull($category['game_net_profit']);
  875. $this->assertSame('9.0000000000', $category['known_third_game_commission']);
  876. $this->assertSame(1, $category['quality']['missing_platform_ratio']);
  877. $finance = \App\Services\FundsSummaryService::summary();
  878. $this->assertNull(collect($finance['expense'])->keyBy('key')['third_game_commission']['amount']);
  879. $this->assertNull($finance['expense_total']);
  880. $this->assertNull($finance['third_game_net_profit']);
  881. $this->assertSame('9.00', $finance['known_expense_total']);
  882. $this->assertSame('9.0000000000', $finance['third_game_commission_meta']['known_amount']);
  883. }
  884. public function test_commission_excludes_pending_void_virtual_and_foreign_orders_and_uses_settlement_date(): void
  885. {
  886. $this->commissionOrders();
  887. $this->commissionOrder(1);
  888. $this->commissionOrder(2, ['status' => 0]);
  889. $this->commissionOrder(3, ['status' => 2]);
  890. $this->commissionOrder(4, ['status' => 3]);
  891. $this->commissionOrder(5, ['user_id' => 4]);
  892. $this->commissionOrder(6, ['last_update_time' => '2026-10-25 23:30:00']);
  893. $this->assertSame('9.0000000000', $this->reader->summary($this->today())['summary']['third_game_commission']);
  894. $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
  895. $this->assertSame('9.00', $finance['expense_total']);
  896. $this->commissionOrder(7, ['currency' => 'USD']);
  897. $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
  898. $this->assertNull($finance['expense_total']);
  899. $this->assertSame(1, $finance['third_game_commission_meta']['quality']['foreign_currency']);
  900. $this->assertSame('9.0000000000', $finance['third_game_commission_meta']['known_amount']);
  901. }
  902. public function test_report_and_finance_agree_on_verified_dates_skip_foreign_players_and_withhold_profit_on_sync_errors(): void
  903. {
  904. $this->commissionOrders();
  905. $this->commissionOrder(1);
  906. // An old snapshot of the other environment's player, never given a commission.
  907. $this->commissionOrder(2, ['player_id' => 'tu2', 'platform' => 'unknown', 'settled_amount' => '-50']);
  908. $summary = $this->reader->summary($this->today())['summary'];
  909. $this->assertSame('9.0000000000', $summary['third_game_commission'], 'foreign snapshot is not in the report');
  910. $this->assertSame('91.0000000000', $summary['game_net_profit']);
  911. $this->assertSame('9.00', \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26')['expense_total']);
  912. // 结算日核对 moves the order to 10-25 in the report; finance must follow the same date.
  913. $id = (int) DB::table('reporting_orders')->where('parent_source', 'third_game_orders')->where('parent_id', '1')->value('id');
  914. $this->admin->reconcileOrder(['id' => $id, 'settled_at' => '2026-10-25 23:30:00', 'evidence' => '对账单']);
  915. $this->assertSame('0.0000000000', $this->reader->summary($this->today())['summary']['third_game_commission']);
  916. $this->assertSame('0.00', \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26')['expense_total']);
  917. $this->assertSame('9.00', \App\Services\FundsSummaryService::summary('2026-10-25', '2026-10-25')['expense_total']);
  918. DB::table('reporting_errors')->insert(['source' => 'third_game_orders', 'source_id' => '1', 'reason' => 'sync failed',
  919. 'created_at' => now(), 'updated_at' => now()]);
  920. $range = ['start_date' => '2026-10-25', 'end_date' => '2026-10-26'];
  921. $this->assertNull($this->reader->summary($range)['summary']['game_net_profit']);
  922. $funds = $this->reader->funds($range);
  923. $this->assertNull($funds['summary']['game_net_profit']);
  924. $this->assertNull(collect($funds['data'][0]['categories'])->keyBy('group_id')['third:2']['game_net_profit']);
  925. }
  926. public function test_reports_and_finance_read_the_saved_commission_without_requesting_quota(): void
  927. {
  928. $this->commissionOrders(); $this->commissionOrder(1);
  929. $this->app->instance(\Illuminate\Http\Client\Factory::class, new \Illuminate\Http\Client\Factory());
  930. config(['third_game.api_url' => 'https://supplier.test', 'third_game.sn' => 'test', 'third_game.key' => 'test']);
  931. \Illuminate\Support\Facades\Http::preventStrayRequests();
  932. \Illuminate\Support\Facades\Http::fake([]);
  933. foreach (['business', 'promotion', 'sharing'] as $type) {
  934. $report = $this->reader->summary($this->today(['report_type' => $type]));
  935. $this->assertSame('9.0000000000', $report['summary']['third_game_commission']);
  936. }
  937. $this->assertSame('9.0000000000', $this->reader->funds($this->today())['summary']['third_game_commission']);
  938. $this->assertSame('9.00', \App\Services\FundsSummaryService::summary()['expense_total']);
  939. \Illuminate\Support\Facades\Http::assertNothingSent();
  940. }
  941. public function test_finance_commission_keeps_large_decimal_precision_and_reports_missing_source_data(): void
  942. {
  943. $this->commissionOrders();
  944. $this->commissionOrder(1, ['bet_amount' => '99999999999999.1234567891', 'settled_amount' => '-99999999999999.1234567891']);
  945. $this->assertSame('8999999999999.92', \App\Services\FundsSummaryService::summary()['expense_total']);
  946. DB::table('third_game_orders')->where('id', 1)->update(['settled_amount' => null]);
  947. $finance = \App\Services\FundsSummaryService::summary();
  948. $this->assertNull($finance['expense_total']);
  949. $this->assertSame(1, $finance['third_game_commission_meta']['quality']['missing_amount']);
  950. DB::table('third_game_orders')->where('id', 1)->update(['settled_amount' => '-100', 'last_update_time' => null]);
  951. $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
  952. $this->assertNull($finance['expense_total']);
  953. $this->assertSame(1, $finance['third_game_commission_meta']['quality']['missing_time']);
  954. }
  955. #[DataProvider('thirdPartyReportCases')]
  956. public function test_third_party_categories_use_provider_settlement_time_and_preserve_net_payout_math(int $type, int $status, string $net, string $stake, string $payout, string $refund, string $gross): void
  957. {
  958. DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,platform TEXT DEFAULT "pg",player_id TEXT DEFAULT "u2",game_type INTEGER,bet_amount TEXT,settled_amount TEXT,status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
  959. DB::table('third_game_orders')->insert(['id' => 1, 'order_key' => str_repeat('b', 64), 'member_id' => '200', 'user_id' => 2, 'game_type' => $type,
  960. 'bet_amount' => '100', 'settled_amount' => $net, 'status' => $status, 'currency' => 'CNY', 'bet_time' => '2026-10-25 23:00:00',
  961. 'last_update_time' => '2026-10-26 10:00:00', 'created_at' => '2026-10-26 12:00:00', 'updated_at' => '2026-10-26 13:00:00']);
  962. $this->sync->publish('third_game_orders', 1); $this->sync->publish('third_game_orders', 1);
  963. $day = $this->reader->funds($this->today())['summary'];
  964. $report = $this->reader->summary($this->today());
  965. $category = collect($report['categories'])->keyBy('group_id')['third:' . $type];
  966. foreach ([$day, $category, $this->reader->summary($this->today(['report_type' => 'sharing']))['summary']] as $row) {
  967. $this->assertSame('0.0000000000', $row['bet_amount']);
  968. $this->assertSame(bcadd($stake, '0', 10), $row['settled_bet_amount']);
  969. $this->assertSame(bcadd($payout, '0', 10), $row['payout_amount']);
  970. $this->assertSame(bcadd($refund, '0', 10), $row['refund_amount']);
  971. $this->assertSame(bcadd($gross, '0', 10), $row['game_gross_profit']);
  972. }
  973. $previous = $this->reader->summary(['start_date' => '2026-10-25', 'end_date' => '2026-10-25'])['summary'];
  974. $this->assertSame('100.0000000000', $previous['bet_amount']);
  975. $this->assertSame('0.0000000000', $previous['payout_amount']);
  976. $this->assertSame(1, DB::table('reporting_orders')->count());
  977. $this->assertSame($status === 0 ? null : '2026-10-26 10:00:00', DB::table('reporting_orders')->value('settled_at'));
  978. }
  979. public static function sportsReportCases(): array
  980. {
  981. return [
  982. 'win' => [1, 1, 0, '180', '0', '100', '180', '0', '-80'],
  983. 'loss' => [1, 1, 0, '0', '0', '100', '0', '0', '100'],
  984. 'half-loss' => [1, 1, 1, '0', '50', '50', '0', '50', '50'],
  985. 'push' => [1, 1, 1, '0', '100', '0', '0', '100', '0'],
  986. 'awaiting-payout' => [1, 2, 0, '180', '0', '0', '0', '0', '0'],
  987. 'void' => [2, 0, 2, '0', '100', '0', '0', '100', '0'],
  988. ];
  989. }
  990. #[DataProvider('sportsReportCases')]
  991. public function test_f1_sports_uses_official_settlement_or_refund_time(int $status, int $settlement, int $returnStatus, string $win, string $returned, string $stake, string $payout, string $refund, string $gross): void
  992. {
  993. DB::statement('CREATE TABLE bot_sport_order (id INTEGER PRIMARY KEY,user_id TEXT,amount TEXT,win_amount TEXT,return_amount TEXT,status INTEGER,pay_status INTEGER,settlement_status INTEGER,return_status INTEGER,create_time INTEGER,update_time INTEGER,settlement_time INTEGER,return_operation_time INTEGER)');
  994. DB::table('sport_order')->insert(['id' => 1, 'user_id' => '200', 'amount' => '100', 'win_amount' => $win, 'return_amount' => $returned,
  995. 'status' => $status, 'pay_status' => 1, 'settlement_status' => $settlement, 'return_status' => $returnStatus,
  996. 'create_time' => Carbon::parse('2026-10-25 23:00:00', 'Asia/Shanghai')->timestamp,
  997. 'update_time' => Carbon::parse('2026-10-26 13:00:00', 'Asia/Shanghai')->timestamp,
  998. 'settlement_time' => $status === 2 ? null : Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai')->timestamp,
  999. 'return_operation_time' => $status === 2 ? Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')->timestamp : null]);
  1000. $this->sync->publish('sport_order', 1);
  1001. $category = collect($this->reader->summary($this->today())['categories'])->keyBy('group_id')['self:sport'];
  1002. foreach ([$category, $this->reader->funds($this->today())['summary']] as $row) {
  1003. $this->assertSame(bcadd($stake, '0', 10), $row['settled_bet_amount']);
  1004. $this->assertSame(bcadd($payout, '0', 10), $row['payout_amount']);
  1005. $this->assertSame(bcadd($refund, '0', 10), $row['refund_amount']);
  1006. $this->assertSame(bcadd($gross, '0', 10), $row['game_gross_profit']);
  1007. }
  1008. $this->assertSame($settlement === 2 ? null : ($status === 2 ? '2026-10-26 11:00:00' : '2026-10-26 10:00:00'), DB::table('reporting_orders')->value('settled_at'));
  1009. }
  1010. public function test_legacy_sport_game_job_captures_losses_as_well_as_wins_for_reporting(): void
  1011. {
  1012. DB::statement('CREATE TABLE bot_sport_game_order (id INTEGER PRIMARY KEY,member_id TEXT,gameplay_id INTEGER,amount TEXT,odds TEXT,status INTEGER,win_amount TEXT,profit_and_loss TEXT,created_at TEXT,updated_at TEXT)');
  1013. DB::statement('CREATE TABLE bot_wallets (id INTEGER PRIMARY KEY,member_id TEXT,available_balance TEXT,frozen_balance TEXT,created_at TEXT,updated_at TEXT)');
  1014. DB::table('wallets')->insert(['id' => 1, 'member_id' => '200', 'available_balance' => '100', 'frozen_balance' => '0']);
  1015. DB::table('sport_game_order')->insert([
  1016. ['id' => 1, 'member_id' => '200', 'gameplay_id' => 1, 'amount' => '100', 'odds' => '1.8', 'status' => 0, 'created_at' => '2026-10-26 09:00:00'],
  1017. ['id' => 2, 'member_id' => '200', 'gameplay_id' => 2, 'amount' => '50', 'odds' => '1.8', 'status' => 0, 'created_at' => '2026-10-26 09:00:00'],
  1018. ]);
  1019. $this->context('sport_game_order', 1); $this->context('sport_game_order', 2);
  1020. (new \App\Jobs\SportGameOrderJob(1))->handle();
  1021. $this->sync->publish('sport_game_order', 1); $this->sync->publish('sport_game_order', 2);
  1022. $row = $this->reader->funds($this->today())['summary'];
  1023. $this->assertSame('150.0000000000', $row['settled_bet_amount']);
  1024. $this->assertSame('180.0000000000', $row['payout_amount']);
  1025. $this->assertSame('-30.0000000000', $row['game_gross_profit']);
  1026. $this->assertSame('280.00', bcadd((string) DB::table('wallets')->where('id', 1)->value('available_balance'), '0', 2));
  1027. $this->assertSame(0, DB::transactionLevel());
  1028. }
  1029. public function test_audit_failure_rolls_back_reconciliation_and_foreign_currency_is_not_summed(): void
  1030. {
  1031. $event = $this->deposit();
  1032. DB::statement("CREATE TRIGGER reject_reporting_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END");
  1033. try { $this->verify($event); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); }
  1034. $this->assertSame(0, DB::table('reporting_reconciliations')->count());
  1035. $this->bet(); DB::table('reporting_orders')->update(['currency' => 'USDT']);
  1036. $r = $this->reader->summary($this->today())['summary']; $this->assertSame('0.0000000000', $r['bet_amount']); $this->assertSame(1, $r['quality']['foreign_currency']); $this->assertNull($r['company_profit']);
  1037. }
  1038. public function test_verified_attribution_is_a_reporting_snapshot_and_survives_later_imports(): void
  1039. {
  1040. $event = $this->deposit(); $this->bet();
  1041. $this->admin->reconcile(['event_id' => $event, 'actual_amount' => '1000', 'external_fee' => '0', 'occurred_at' => '2026-10-26 10:00:00',
  1042. 'currency' => 'CNY', 'evidence' => '核对原绑定及装置', 'agent_id' => 20, 'inviter_id' => 3, 'client_platform' => 'APP']);
  1043. $this->sync->publish('balance_logs', 1);
  1044. $e = DB::table('reporting_events')->where('id', $event)->first(); $this->assertSame(20, $e->agent_id); $this->assertSame('verified', $e->attribution_quality);
  1045. $this->assertSame('APP', $e->client_platform); $this->assertSame(10, DB::table('users')->where('id', 2)->value('agent_id'));
  1046. $id = (int) DB::table('reporting_orders')->value('id');
  1047. $this->admin->reconcileOrder(['id' => $id, 'agent_id' => 20, 'evidence' => '原始推广凭据']); $this->sync->publish('bets', 1);
  1048. $this->assertSame(20, DB::table('reporting_orders')->value('agent_id'));
  1049. $this->assertSame('1000.0000000000', $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent20'])['summary']['deposit_amount']);
  1050. $this->assertSame(1, $this->reader->events(['group_id' => '2', 'kind' => 'deposit'])['total']);
  1051. $this->assertSame(0, $this->admin->quality([])['total']);
  1052. $this->assertNotEmpty($this->admin->quality([])['sources']);
  1053. }
  1054. private function historicalBet(int $id = 1, string $betAt = '2024-01-01 09:00:00', string $settledAt = '2024-01-02 10:00:00'): void
  1055. {
  1056. $this->bet($id, ['created_at' => $betAt, 'amount' => '40', 'profit' => '20']);
  1057. DB::table('reporting_contexts')->where('source', 'bets')->where('source_id', (string) $id)->update(['succeeded_at' => $settledAt]);
  1058. $this->sync->publish('bets', $id);
  1059. }
  1060. public function test_no_dates_default_to_all_history_and_dates_alone_select_a_period(): void
  1061. {
  1062. $this->historicalBet(); $this->bet(2);
  1063. $default = $this->reader->summary([]);
  1064. $this->assertSame(1, $default['filters']['all_time']); $this->assertSame('2024-01-01', $default['filters']['start_date']);
  1065. $this->assertSame('2026-10-26', $default['filters']['end_date']);
  1066. $this->assertSame('140.0000000000', $default['summary']['bet_amount']);
  1067. $this->assertSame('80.0000000000', $default['summary']['payout_amount']);
  1068. $blank = $this->reader->summary(['start_date' => '', 'end_date' => null]);
  1069. $this->assertSame($default['filters'], $blank['filters']); $this->assertSame($default['summary'], $blank['summary']);
  1070. $range = $this->reader->summary($this->today());
  1071. $this->assertSame(0, $range['filters']['all_time']); $this->assertSame('2026-10-26', $range['filters']['start_date']);
  1072. $this->assertSame('100.0000000000', $range['summary']['bet_amount']);
  1073. $this->assertSame('2026-10-26', $default['valid_member_policy']['date']);
  1074. $this->assertSame($default['summary'], $this->reader->summary(['all_time' => 0])['summary']);
  1075. $this->assertSame($range['summary'], $this->reader->summary($this->today(['all_time' => 1]))['summary']);
  1076. $bounded = $this->reader->params(['start_date' => now('Asia/Shanghai')->subDays(365)->toDateString()]);
  1077. $this->assertSame(now('Asia/Shanghai')->subDays(365)->toDateString(), $bounded['start_date']);
  1078. $this->assertSame(366, $this->admin->options()['max_days']);
  1079. }
  1080. public function test_all_time_funds_paginate_actual_days_and_keep_the_complete_summary(): void
  1081. {
  1082. $this->historicalBet(); $this->bet(2); $event = $this->deposit(); $this->verify($event);
  1083. $first = $this->reader->funds(['limit' => 1]);
  1084. $days = (int) Carbon::parse('2024-01-01')->diffInDays(Carbon::parse('2026-10-27'));
  1085. $this->assertSame($days, $first['total']); $this->assertCount(1, $first['data']); $this->assertSame('2026-10-26', $first['data'][0]['date']);
  1086. $this->assertSame('140.0000000000', $first['summary']['bet_amount']); $this->assertSame('80.0000000000', $first['summary']['payout_amount']);
  1087. $this->assertSame('100.0000000000', $first['data'][0]['bet_amount']); $this->assertSame('60.0000000000', $first['data'][0]['payout_amount']);
  1088. $this->assertSame('998.0000000000', $first['summary']['net_cash_flow']);
  1089. $settled = $this->reader->funds(['limit' => 1, 'page' => $days - 1]);
  1090. $this->assertSame($first['summary'], $settled['summary']); $this->assertSame('2024-01-02', $settled['data'][0]['date']);
  1091. $this->assertSame('0.0000000000', $settled['data'][0]['bet_amount']); $this->assertSame('20.0000000000', $settled['data'][0]['payout_amount']);
  1092. $bet = $this->reader->funds(['limit' => 1, 'page' => $days]);
  1093. $this->assertSame('2024-01-01', $bet['data'][0]['date']); $this->assertSame('40.0000000000', $bet['data'][0]['bet_amount']);
  1094. $this->assertSame('0.0000000000', $bet['data'][0]['payout_amount']);
  1095. $outside = $this->reader->funds(['limit' => 1, 'page' => $days + 1]);
  1096. $this->assertSame([], $outside['data']); $this->assertSame($first['summary'], $outside['summary']); $this->assertSame($days, $outside['total']);
  1097. $bounded = $this->reader->funds(['start_date' => '2026-10-25', 'limit' => 1, 'page' => 2]);
  1098. $this->assertSame(2, $bounded['total']); $this->assertSame('2026-10-25', $bounded['data'][0]['date']);
  1099. $this->assertSame('100.0000000000', $bounded['summary']['bet_amount']);
  1100. }
  1101. public function test_all_time_uses_the_effective_reconciliation_date_and_amount(): void
  1102. {
  1103. $event = $this->deposit();
  1104. DB::table('balance_logs')->where('id', 1)->update(['created_at' => '2019-01-01 09:00:00']); $this->sync->publish('balance_logs', 1);
  1105. $this->admin->reconcile(['event_id' => $event, 'actual_amount' => '500', 'external_fee' => '3', 'occurred_at' => '2022-02-03 10:00:00',
  1106. 'currency' => 'CNY', 'evidence' => '历史到账凭据']);
  1107. $all = $this->reader->summary([]);
  1108. $this->assertSame('2022-02-03', $all['filters']['start_date']); $this->assertSame('500.0000000000', $all['summary']['deposit_amount']);
  1109. $this->assertSame('497.0000000000', $all['summary']['net_cash_flow']);
  1110. $this->assertSame('0.0000000000', $this->reader->summary($this->today())['summary']['deposit_amount']);
  1111. $funds = $this->reader->funds(['limit' => 1,
  1112. 'page' => (int) Carbon::parse('2022-02-03')->diffInDays(Carbon::parse('2026-10-26')) + 1]);
  1113. $this->assertSame('2022-02-03', $funds['data'][0]['date']); $this->assertSame('500.0000000000', $funds['data'][0]['deposit_amount']);
  1114. $events = $this->reader->events([]);
  1115. $this->assertSame(1, $events['total']); $this->assertSame('2022-02-03 10:00:00', $events['data'][0]['verified_at']);
  1116. $this->assertSame($all['filters']['start_date'], $events['filters']['start_date']); $this->assertSame(1, $events['filters']['all_time']);
  1117. }
  1118. public function test_all_time_does_not_substitute_the_application_date_for_unknown_payment_time(): void
  1119. {
  1120. DB::table('withdraws')->insert(['id' => 1, 'member_id' => '200', 'amount' => '100', 'to_account' => '98', 'service_charge' => '2',
  1121. 'exchange_rate' => '7', 'status' => 1, 'created_at' => '2001-01-01 08:00:00']);
  1122. $this->sync->publish('withdraws', 1);
  1123. $all = $this->reader->summary([]);
  1124. $this->assertSame('2026-10-01', $all['filters']['start_date']); $this->assertSame(1, $all['summary']['quality']['missing_time']);
  1125. $this->assertSame('0.0000000000', $all['summary']['withdraw_amount']); $this->assertNull($all['summary']['net_cash_flow']);
  1126. $funds = $this->reader->funds(['limit' => 100]);
  1127. $this->assertSame(26, $funds['total']); $this->assertCount(26, $funds['data']);
  1128. foreach ($funds['data'] as $day) {
  1129. $this->assertSame('0.0000000000', $day['withdraw_amount']); $this->assertSame(1, $day['quality']['missing_time']); $this->assertNull($day['net_cash_flow']);
  1130. }
  1131. $events = $this->reader->events([]);
  1132. $this->assertSame(1, $events['total']); $this->assertNull($events['data'][0]['occurred_at']);
  1133. $this->assertSame('2001-01-01 08:00:00', $events['data'][0]['candidate_at']);
  1134. }
  1135. public function test_all_time_first_day_uses_real_members_and_activity_and_empty_data_falls_back_to_today(): void
  1136. {
  1137. DB::table('users')->update(['created_at' => '2026-10-26 00:00:00']);
  1138. DB::table('users')->where('from', 2)->update(['created_at' => '1999-01-01 00:00:00']);
  1139. $this->assertSame('2026-10-26', $this->reader->params([])['start_date']);
  1140. DB::table('users')->where('id', 2)->update(['created_at' => '2023-01-01 00:00:00']);
  1141. $this->assertSame('2023-01-01', $this->reader->params([])['start_date']);
  1142. $this->activityObservation('2022-01-01 10:00:00'); $this->activityObservation('2022-01-01 10:02:00');
  1143. $all = $this->reader->summary([]);
  1144. $this->assertSame('2022-01-01', $all['filters']['start_date']); $this->assertSame(2, $all['summary']['known_activity_minutes']);
  1145. $this->assertNull($all['summary']['activity_minutes']);
  1146. DB::table('reporting_activity')->delete(); DB::table('users')->delete();
  1147. $empty = $this->reader->funds([]);
  1148. $this->assertSame('2026-10-26', $empty['filters']['start_date']); $this->assertSame(1, $empty['total']); $this->assertCount(1, $empty['data']);
  1149. $this->assertSame('0.0000000000', $empty['summary']['bet_amount']);
  1150. }
  1151. public function test_all_time_preserves_account_platform_settlement_and_ranking_filters(): void
  1152. {
  1153. $this->historicalBet(); $this->bet(2, ['member_id' => '500', 'amount' => '200']);
  1154. $this->bet(3, ['status' => 1, 'amount' => '30', 'profit' => '0']); $this->deposit();
  1155. foreach (['member_account' => 'member2', 'agent_account' => 'agent10', 'inviter_account' => 'member1'] as $filter => $value) {
  1156. $this->assertSame('70.0000000000', $this->reader->summary([$filter => $value])['summary']['bet_amount']);
  1157. }
  1158. $this->assertSame('40.0000000000', $this->reader->summary(['member_account' => 'member2', 'settlement_status' => 'settled'])['summary']['bet_amount']);
  1159. $this->assertSame('0.0000000000', $this->reader->summary(['platform' => 'APP'])['summary']['bet_amount']);
  1160. $this->assertSame('0.0000000000', $this->reader->summary(['category' => 'self:sport'])['summary']['bet_amount']);
  1161. $ranks = $this->reader->details(['metric' => 'bet_amount', 'page' => 2, 'limit' => 1], true);
  1162. $this->assertSame(2, $ranks['total']); $this->assertSame('2', $ranks['data'][0]['group_id']); $this->assertSame(2, $ranks['data'][0]['rank']);
  1163. $orders = $this->reader->orders(['member_account' => 'member2', 'page' => 2, 'limit' => 1]);
  1164. $this->assertSame(2, $orders['total']); $this->assertSame('2024-01-01 09:00:00', $orders['data'][0]['bet_at']);
  1165. $this->assertSame(1, $orders['filters']['all_time']); $this->assertSame('2024-01-01', $orders['filters']['start_date']);
  1166. $this->assertSame(0, $this->reader->events(['member_account' => 'member5'])['total']);
  1167. $this->assertSame(1, $this->reader->events(['member_account' => 'member2', 'kind' => 'deposit'])['total']);
  1168. }
  1169. #[DataProvider('invalidQueries')]
  1170. public function test_invalid_filters_do_not_query_unbounded_dates(array $input): void { $this->expectException(ValidationException::class); $this->reader->summary($input); }
  1171. public static function invalidQueries(): array { return [[['start_date' => '2026-02-30']], [['end_date' => '2026-10-27']], [['start_date' => '2020-01-01']],
  1172. [['platform' => 'ios']], [['settlement_status' => 'paid']], [['limit' => 999]], [['start_date' => []]], [['end_date' => []]],
  1173. [['start_date' => '2026-10-26', 'end_date' => '2026-10-25']], [['end_date' => '2026-02-30']],
  1174. [['start_date' => '2025-01-01', 'end_date' => '2026-10-26']], [['start_date' => 'not-a-date', 'end_date' => '2026-10-26']]]; }
  1175. }