ReportingTest.php 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  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 Carbon\Carbon;
  8. use Illuminate\Config\Repository;
  9. use Illuminate\Database\Capsule\Manager;
  10. use Illuminate\Database\Schema\Blueprint;
  11. use Illuminate\Events\Dispatcher;
  12. use Illuminate\Foundation\Application;
  13. use Illuminate\Http\JsonResponse;
  14. use Illuminate\Http\Request;
  15. use Illuminate\Routing\Router;
  16. use Illuminate\Support\Facades\DB;
  17. use Illuminate\Support\Facades\Facade;
  18. use Illuminate\Support\Facades\Schema;
  19. use Illuminate\Translation\ArrayLoader;
  20. use Illuminate\Translation\Translator;
  21. use Illuminate\Validation\Factory;
  22. use Illuminate\Validation\ValidationException;
  23. use PHPUnit\Framework\TestCase;
  24. /** Real source adapters, financial reconciliation, filters and aggregation; isolated SQLite only. */
  25. class ReportingTest extends TestCase
  26. {
  27. private Application $app;
  28. private ReportSync $sync;
  29. private ReportReader $reader;
  30. private ReportAdmin $admin;
  31. protected function setUp(): void
  32. {
  33. Carbon::setTestNow(Carbon::parse('2026-10-26 16:00:00', 'Asia/Shanghai'));
  34. Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2));
  35. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']])); Facade::setFacadeApplication($this->app);
  36. $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  37. $db->setEventDispatcher(new Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
  38. $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app));
  39. $this->app['validator']->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
  40. $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
  41. public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
  42. });
  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. DB::table('reporting_settings')->where('id', 1)->update(['activity_started_at' => '2026-10-25 00:00:00']);
  66. 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())]);
  67. DB::table('share_system_state')->insert(['key' => 'initialized_at', 'value' => '2026-10-01 00:00:00']);
  68. DB::table('agents')->insert([['id' => 10, 'username' => 'agent10'], ['id' => 20, 'username' => 'agent20']]);
  69. 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]) {
  70. 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']);
  71. 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']);
  72. }
  73. $this->sync = new ReportSync(); $this->reader = new ReportReader(); $this->admin = new ReportAdmin();
  74. $this->ready();
  75. }
  76. protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
  77. private function today(array $filters = []): array { return $filters + ['start_date' => now('Asia/Shanghai')->toDateString(), 'end_date' => now('Asia/Shanghai')->toDateString()]; }
  78. private function ready(): void { DB::table('reporting_sync_state')->updateOrInsert(['source' => '__run__'], ['state' => '{"complete":true}', 'updated_at' => now()]); }
  79. private function context(string $source, int $id, int $user = 2, string $platform = 'H5', ?string $success = null): void
  80. {
  81. DB::table('reporting_contexts')->insert(['source' => $source, 'source_id' => (string) $id, 'user_id' => $user, 'agent_id' => $user === 2 ? 10 : null,
  82. 'inviter_id' => $user === 2 ? 1 : null, 'client_platform' => $platform, 'occurred_at' => '2026-10-26 09:00:00', 'succeeded_at' => $success]);
  83. }
  84. private function bet(int $id = 1, array $extra = []): void
  85. {
  86. 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']);
  87. $this->context('bets', $id, ($extra['member_id'] ?? '200') === '500' ? 5 : 2, 'H5', '2026-10-26 10:00:00'); $this->sync->publish('bets', $id);
  88. }
  89. private function deposit(int $id = 1, string $amount = '1000', string $member = '200'): int
  90. {
  91. 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']);
  92. $this->sync->publish('balance_logs', $id); return (int) DB::table('reporting_events')->where('source', 'balance_logs')->where('source_id', (string) $id)->value('id');
  93. }
  94. private function verify(int $id, string $amount = '1000', string $fee = '2'): void
  95. {
  96. $this->admin->reconcile(['event_id' => $id, 'actual_amount' => $amount, 'external_fee' => $fee, 'occurred_at' => '2026-10-26 10:00:00', 'currency' => 'CNY', 'evidence' => '到账凭据#' . $id]);
  97. }
  98. private function qualificationFact(string $amount = '100', string $date = '2026-10-26', int $user = 2, array $extra = []): int
  99. {
  100. return DB::table('share_bet_facts')->insertGetId($extra + ['user_id' => $user, 'member_id' => (string) ($user * 100),
  101. 'category' => 'third:2', 'bet_date' => $date, 'effective_amount' => $amount]);
  102. }
  103. public function test_source_projection_is_idempotent_and_cross_day_metrics_use_actual_events(): void
  104. {
  105. $this->bet(1, ['created_at' => '2026-10-25 23:00:00']); $this->sync->publish('bets', 1);
  106. $this->assertSame(1, DB::table('reporting_orders')->count());
  107. $row = $this->reader->summary($this->today())['summary'];
  108. $this->assertSame('0.0000000000', $row['bet_amount']); $this->assertSame('100.0000000000', $row['settled_bet_amount']);
  109. $this->assertSame('60.0000000000', $row['payout_amount']); $this->assertSame('40.0000000000', $row['game_gross_profit']);
  110. $this->assertSame('40.0000000000', $row['company_profit']);
  111. $yesterday = $this->reader->summary(['start_date' => '2026-10-25', 'end_date' => '2026-10-25'])['summary'];
  112. $this->assertSame('100.0000000000', $yesterday['bet_amount']); $this->assertSame('0.0000000000', $yesterday['payout_amount']);
  113. }
  114. public function test_missing_fees_and_success_time_never_produce_fabricated_net_values(): void
  115. {
  116. $event = $this->deposit(); $this->bet();
  117. $row = $this->reader->summary($this->today())['summary']; $this->assertNull($row['net_cash_flow']); $this->assertNull($row['company_profit']);
  118. $this->assertSame(1, $row['quality']['unverified_fees']); $this->assertSame('1000.0000000000', $row['deposit_amount']);
  119. $this->verify($event); $row = $this->reader->summary($this->today())['summary'];
  120. $this->assertSame('998.0000000000', $row['net_cash_flow']); $this->assertSame('38.0000000000', $row['company_profit']);
  121. $before = DB::table('balance_logs')->get()->toArray(); $this->verify($event, '1000', '3'); $this->sync->publish('balance_logs', 1);
  122. $this->assertSame(1, DB::table('reporting_reconciliations')->count()); $this->assertEquals($before, DB::table('balance_logs')->get()->toArray());
  123. $this->assertSame('997.0000000000', $this->reader->summary($this->today())['summary']['net_cash_flow']);
  124. }
  125. public function test_chain_withdraw_uses_recipient_amount_and_customer_fee_is_not_an_expense(): void
  126. {
  127. 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']);
  128. $this->sync->publish('withdraws', 1); $row = $this->reader->summary($this->today())['summary'];
  129. $this->assertSame(1, $row['quality']['missing_time']); $this->assertNull($row['net_cash_flow']);
  130. $event = (int) DB::table('reporting_events')->value('id'); $this->verify($event, '686', '1');
  131. $row = $this->reader->summary($this->today())['summary'];
  132. $this->assertSame('686.0000000000', $row['withdraw_amount']); $this->assertSame('14.0000000000', $row['customer_fee_income']);
  133. $this->assertSame('1.0000000000', $row['withdraw_fee']); $this->assertSame('-687.0000000000', $row['net_cash_flow']);
  134. DB::table('withdraws')->where('id', 1)->update(['status' => 2]); $result = $this->sync->sync(true);
  135. $this->assertSame(1, $result['errors']); $this->assertNull($this->reader->summary($this->today())['summary']['net_cash_flow']);
  136. $this->verify($event, '0', '1'); $this->sync->sync(true);
  137. $this->assertSame('0.0000000000', $this->reader->summary($this->today())['summary']['withdraw_amount']);
  138. }
  139. public function test_real_member_agent_sharing_overlap_is_not_added_twice_and_details_are_direct(): void
  140. {
  141. $this->bet(); $event = $this->deposit(); $this->verify($event);
  142. foreach (['business', 'promotion', 'sharing'] as $type) {
  143. $row = $this->reader->summary(['report_type' => $type])['summary']; $this->assertSame('100.0000000000', $row['bet_amount']);
  144. $this->assertSame('1000.0000000000', $row['deposit_amount']);
  145. }
  146. $this->assertSame('2', $this->reader->details($this->today())['data'][0]['group_id']);
  147. $this->assertSame('10', $this->reader->details(['report_type' => 'promotion'])['data'][0]['group_id']);
  148. $this->assertSame('1', $this->reader->details(['report_type' => 'sharing'])['data'][0]['group_id']);
  149. $category = $this->reader->summary($this->today())['categories'][8]; $this->assertNull($category['deposit_amount']); $this->assertNull($category['company_profit']);
  150. $this->assertSame('100.0000000000', $category['bet_amount']);
  151. }
  152. public function test_valid_members_use_both_sharing_thresholds_inclusively(): void
  153. {
  154. $this->bet(); $this->deposit(1, '19.99'); $fact = $this->qualificationFact('100');
  155. $this->assertSame(0, $this->reader->summary($this->today())['summary']['valid_member_count']);
  156. DB::table('balance_logs')->where('id', 1)->update(['amount' => '20']);
  157. DB::table('share_bet_facts')->where('id', $fact)->update(['effective_amount' => '99.99']);
  158. $this->assertSame(0, $this->reader->summary($this->today())['summary']['valid_member_count']);
  159. DB::table('share_bet_facts')->where('id', $fact)->update(['effective_amount' => '100']);
  160. $report = $this->reader->summary($this->today());
  161. $this->assertSame(1, $report['summary']['valid_member_count']);
  162. $this->assertSame('20.0000000000', $report['valid_deposit_min']);
  163. $this->assertSame('100.0000000000', $report['valid_effective_bet_min']);
  164. $this->assertSame(['id' => 1, 'effective_date' => '2026-10-01', 'min_deposit' => '20.0000000000',
  165. 'min_effective_bet' => '100.0000000000', 'date' => '2026-10-26', 'available' => true, 'unavailable_reasons' => []], $report['valid_member_policy']);
  166. // Presentation switches and the retired report-only value do not change qualification.
  167. DB::table('reporting_settings')->where('id', 1)->update(['valid_deposit_min' => '9999']);
  168. DB::table('share_policies')->update(['enabled' => 0, 'benefit_enabled' => 0]);
  169. $this->assertSame(1, $this->reader->summary($this->today())['summary']['valid_member_count']);
  170. }
  171. public function test_qualification_is_cumulative_before_start_and_independent_of_report_filters_and_payment_verification(): void
  172. {
  173. $this->bet(); $this->bet(2, ['member_id' => '500']);
  174. DB::table('balance_logs')->insert([
  175. ['member_id' => '200', 'type' => 1, 'amount' => '8', 'change_type' => '充值', 'created_at' => '2026-10-01 10:00:00'],
  176. ['member_id' => '200', 'type' => 1, 'amount' => '12', 'change_type' => '三方充值', 'created_at' => '2026-10-25 10:00:00'],
  177. ]);
  178. $this->qualificationFact('50', '2026-10-01'); $this->qualificationFact('50', '2026-10-25', 2, ['category' => 'self:sport']);
  179. // Report events and their manual reconciliation are not the sharing deposit source.
  180. $event = $this->deposit(10, '1'); $this->verify($event, '0', '0');
  181. $input = ['platform' => 'H5', 'category' => 'self:lottery', 'settlement_status' => 'settled'];
  182. $report = $this->reader->summary($input);
  183. $this->assertSame(1, $report['summary']['valid_member_count']);
  184. $this->assertSame(1, collect($report['categories'])->keyBy('group_id')['self:lottery']['valid_member_count']);
  185. $this->assertSame(1, $this->reader->details($input + ['member_account' => 'member2'])['data'][0]['valid_member_count']);
  186. $this->assertSame(1, $this->reader->details($input + ['report_type' => 'promotion', 'agent_account' => 'agent10'])['data'][0]['valid_member_count']);
  187. $this->assertSame(1, $this->reader->details($input + ['report_type' => 'sharing', 'inviter_account' => 'member1'])['data'][0]['valid_member_count']);
  188. $this->assertSame(0, $this->reader->summary(['platform' => 'PC'])['summary']['valid_member_count']);
  189. }
  190. public function test_qualification_excludes_future_non_deposit_and_ineligible_facts(): void
  191. {
  192. $this->bet();
  193. foreach ([[1, '充值', '19', '2026-10-26 23:59:59'], [1, '充值', '1000', '2026-10-27 00:00:00'],
  194. [2, '充值', '1000', '2026-10-25 10:00:00'], [1, '人工加款', '1000', '2026-10-25 10:00:00'],
  195. [1, '充值', '-1000', '2026-10-25 10:00:00']] as [$type, $kind, $amount, $at]) {
  196. DB::table('balance_logs')->insert(['member_id' => '200', 'type' => $type, 'change_type' => $kind, 'amount' => $amount, 'created_at' => $at]);
  197. }
  198. $fact = $this->qualificationFact('99');
  199. $this->qualificationFact('1000', '2026-10-27');
  200. $this->qualificationFact('1000', '2026-10-25', 2, ['settled' => 0]);
  201. $this->qualificationFact('1000', '2026-10-25', 2, ['voided' => 1]);
  202. $this->qualificationFact('1000', '2026-10-25', 2, ['currency' => 'USDT']);
  203. $this->assertSame(0, $this->reader->summary($this->today())['summary']['valid_member_count']);
  204. DB::table('balance_logs')->insert(['member_id' => '200', 'type' => 1, 'change_type' => '三方充值', 'amount' => '1', 'created_at' => '2026-10-26 23:59:59']);
  205. $this->assertSame(0, $this->reader->summary($this->today())['summary']['valid_member_count']);
  206. DB::table('share_bet_facts')->where('id', $fact)->update(['effective_amount' => '100']);
  207. $this->assertSame(1, $this->reader->summary($this->today())['summary']['valid_member_count']);
  208. }
  209. public function test_pending_sharing_policy_never_applies_before_its_effective_date(): void
  210. {
  211. $this->bet(); $this->deposit(1, '20'); $this->qualificationFact();
  212. DB::table('share_policies')->insert(['effective_date' => '2026-10-27', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '1000', 'rules' => '{}']);
  213. $report = $this->reader->summary($this->today());
  214. $this->assertSame(1, $report['summary']['valid_member_count']); $this->assertSame(1, $report['valid_member_policy']['id']);
  215. $this->assertSame('20.0000000000', $this->admin->settings()['valid_deposit_min']);
  216. Carbon::setTestNow(Carbon::parse('2026-10-27 16:00:00', 'Asia/Shanghai'));
  217. $report = $this->reader->summary(['start_date' => '2026-10-26', 'end_date' => '2026-10-27']);
  218. $this->assertSame(0, $report['summary']['valid_member_count']); $this->assertSame(2, $report['valid_member_policy']['id']);
  219. $historical = $this->reader->summary(['start_date' => '2026-10-26', 'end_date' => '2026-10-26']);
  220. $this->assertSame(1, $historical['summary']['valid_member_count']); $this->assertSame(1, $historical['valid_member_policy']['id']);
  221. }
  222. public function test_zero_sharing_thresholds_are_preserved_and_candidate_population_is_unchanged(): void
  223. {
  224. $this->bet(1, ['status' => 1, 'profit' => '0']);
  225. DB::table('share_policies')->update(['valid_member_min_deposit' => '0', 'valid_member_min_effective_bet' => '0']);
  226. $report = $this->reader->summary($this->today());
  227. $this->assertSame(1, $report['summary']['valid_member_count']); $this->assertSame(4, $report['summary']['member_count']);
  228. $this->assertSame('0.0000000000', $report['valid_deposit_min']); $this->assertSame('0.0000000000', $report['valid_effective_bet_min']);
  229. $this->assertSame(0, $this->reader->summary(['settlement_status' => 'settled'])['summary']['valid_member_count']);
  230. }
  231. public function test_missing_policy_threshold_and_sharing_data_produce_null_not_an_independent_default(): void
  232. {
  233. $this->bet(); $this->deposit(); $this->qualificationFact();
  234. DB::table('share_policies')->update(['valid_member_min_effective_bet' => null]);
  235. $report = $this->reader->summary($this->today());
  236. $this->assertNull($report['summary']['valid_member_count']); $this->assertNull($report['valid_effective_bet_min']);
  237. $this->assertFalse($report['valid_member_policy']['available']);
  238. $this->assertContains('incomplete_qualification_policy', $report['valid_member_policy']['unavailable_reasons']);
  239. DB::table('share_policies')->delete();
  240. $report = $this->reader->summary($this->today());
  241. $this->assertNull($report['summary']['valid_member_count']); $this->assertNull($report['valid_deposit_min']);
  242. $this->assertContains('no_effective_policy', $report['valid_member_policy']['unavailable_reasons']);
  243. DB::table('share_policies')->insert(['effective_date' => '2026-10-01', 'valid_member_min_deposit' => '20', 'valid_member_min_effective_bet' => '100', 'rules' => '{}']);
  244. DB::table('share_system_state')->delete();
  245. $report = $this->reader->summary($this->today());
  246. $this->assertNull($report['summary']['valid_member_count']);
  247. $this->assertContains('relations_not_initialized', $report['valid_member_policy']['unavailable_reasons']);
  248. DB::table('share_system_state')->insert(['key' => 'initialized_at', 'value' => '2026-10-01']);
  249. Schema::drop('share_bet_facts');
  250. $report = $this->reader->summary($this->today());
  251. $this->assertNull($report['summary']['valid_member_count']);
  252. $this->assertContains('missing_table:share_bet_facts', $report['valid_member_policy']['unavailable_reasons']);
  253. }
  254. public function test_report_qualification_reads_do_not_settle_or_write_and_old_settings_post_is_rejected(): void
  255. {
  256. $this->bet(); $this->deposit(1, '20'); $this->qualificationFact();
  257. $tables = ['reporting_settings', 'reporting_orders', 'reporting_events', 'share_policies', 'share_system_state', 'share_bet_facts',
  258. 'balance_logs', 'share_accounts', 'share_settlements', 'share_ledger', 'share_claims', 'operation_audits'];
  259. $before = []; foreach ($tables as $table) $before[$table] = DB::table($table)->get()->toJson();
  260. for ($i = 0; $i < 2; ++$i) {
  261. $this->assertSame(1, $this->reader->summary($this->today())['summary']['valid_member_count']);
  262. $this->assertSame(1, $this->reader->details($this->today())['data'][0]['valid_member_count']);
  263. $settings = $this->admin->settings();
  264. $this->assertSame('20.0000000000', $settings['valid_deposit_min']);
  265. $this->assertSame('100.0000000000', $settings['valid_effective_bet_min']);
  266. $this->assertSame('2026-10-25 00:00:00', $settings['activity_started_at']);
  267. }
  268. try { $this->admin->settings(['valid_deposit_min' => '19.99']); $this->fail('Expected read-only settings rejection'); }
  269. catch (ValidationException $e) { $this->assertStringContainsString('/admin/shareEarning/settings/save', $e->validator->errors()->first()); }
  270. foreach ($tables as $table) $this->assertSame($before[$table], DB::table($table)->get()->toJson(), $table . ' must remain unchanged');
  271. }
  272. public function test_platform_settlement_filters_tg_virtual_exclusion_and_rank_pagination(): void
  273. {
  274. $this->bet(); $this->bet(2, ['member_id' => '500', 'amount' => '200']);
  275. $this->bet(3, ['status' => 1, 'profit' => '0']);
  276. 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);
  277. $this->assertSame(3, DB::table('reporting_orders')->count());
  278. $this->assertSame('0.0000000000', $this->reader->summary(['platform' => 'PC'])['summary']['bet_amount']);
  279. $this->assertSame('100.0000000000', $this->reader->summary(['settlement_status' => 'pending'])['summary']['bet_amount']);
  280. $this->assertNull($this->reader->summary(['settlement_status' => 'pending'])['summary']['company_profit']);
  281. $ranks = $this->reader->details(['metric' => 'bet_amount', 'page' => 2, 'limit' => 1], true);
  282. $this->assertSame(2, $ranks['total']); $this->assertSame(2, $ranks['data'][0]['rank']);
  283. }
  284. public function test_activity_intervals_merge_devices_split_idle_and_history_is_explicitly_missing(): void
  285. {
  286. 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]) {
  287. 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]);
  288. }
  289. $row = $this->reader->summary($this->today())['summary'];
  290. $this->assertSame(2, $row['activity_minutes']); $this->assertSame(1, $row['active_member_count']); $this->assertSame(4, $row['member_count']);
  291. $this->assertSame('0.250000', $row['active_rate']);
  292. $old = $this->reader->summary(['start_date' => '2026-10-24', 'end_date' => '2026-10-26'])['summary'];
  293. $this->assertNull($old['activity_minutes']); $this->assertSame(2, $old['known_activity_minutes']);
  294. }
  295. public function test_full_sync_and_repeated_sync_do_not_change_source_data_or_double_count(): void
  296. {
  297. $this->bet(); $this->deposit(); $before = DB::table('balance_logs')->get()->toArray();
  298. $first = $this->sync->sync(true); $second = $this->sync->sync(true);
  299. $this->assertSame(0, $first['errors']); $this->assertSame(0, $second['errors']);
  300. $this->assertSame(1, DB::table('reporting_orders')->count()); $this->assertSame(1, DB::table('reporting_events')->count());
  301. $this->assertEquals($before, DB::table('balance_logs')->get()->toArray());
  302. }
  303. private function activityObservation(string $at, ?int $agent = 10, ?int $inviter = 1, string $session = 'a'): void
  304. {
  305. DB::table('reporting_activity')->insert(['user_id' => 2, 'agent_id' => $agent, 'inviter_id' => $inviter,
  306. 'client_platform' => 'H5', 'session_hash' => $session, 'observed_at' => $at]);
  307. }
  308. public function test_activity_snapshot_survives_agent_reassignment_in_summary_details_and_filters(): void
  309. {
  310. $this->activityObservation('2026-10-26 10:00:00'); $this->activityObservation('2026-10-26 10:02:00');
  311. $before = $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent10']))['summary'];
  312. DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
  313. $after = $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent10']))['summary'];
  314. $this->assertSame(2, $before['activity_minutes']); $this->assertSame(2, $after['activity_minutes']);
  315. $this->assertSame(1, $after['active_member_count']); $this->assertFalse($after['activity_population_complete']); $this->assertNull($after['active_rate']);
  316. $other = $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent20']))['summary'];
  317. $this->assertSame(0, $other['activity_minutes']); $this->assertSame(0, $other['active_member_count']);
  318. $details = $this->reader->details($this->today(['report_type' => 'promotion']));
  319. $this->assertSame(1, $details['total']); $this->assertSame('10', $details['data'][0]['group_id']); $this->assertSame(2, $details['data'][0]['activity_minutes']);
  320. $byGroup = $this->reader->summary($this->today(['report_type' => 'promotion', 'group_id' => '10']))['summary'];
  321. $this->assertSame(2, $byGroup['activity_minutes']);
  322. $business = $this->reader->summary($this->today(['agent_account' => 'agent10']))['summary']; $this->assertSame(2, $business['activity_minutes']);
  323. $this->activityObservation('2026-10-26 11:00:00', 20); $this->activityObservation('2026-10-26 11:03:00', 20);
  324. $groups = collect($this->reader->details($this->today(['report_type' => 'promotion']))['data'])->keyBy('group_id');
  325. $this->assertSame(2, $groups[10]['activity_minutes']); $this->assertSame(3, $groups[20]['activity_minutes']);
  326. $this->assertSame('0.500000', $groups[20]['active_rate']);
  327. $all = $this->reader->summary($this->today())['summary']; $this->assertSame(5, $all['activity_minutes']); $this->assertSame(1, $all['active_member_count']);
  328. $this->assertSame('0.250000', $all['active_rate']);
  329. }
  330. public function test_agent_transition_with_overlapping_devices_counts_each_second_only_once(): void
  331. {
  332. $this->activityObservation('2026-10-26 10:00:00', 10, 1, 'a');
  333. $this->activityObservation('2026-10-26 10:01:00', 20, 1, 'b');
  334. $this->activityObservation('2026-10-26 10:02:00', 20, 1, 'a');
  335. $this->activityObservation('2026-10-26 10:03:00', 20, 1, 'b');
  336. DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
  337. $groups = collect($this->reader->details($this->today(['report_type' => 'promotion']))['data'])->keyBy('group_id');
  338. $this->assertSame(1, $groups[10]['activity_minutes']); $this->assertSame(2, $groups[20]['activity_minutes']);
  339. $all = $this->reader->summary($this->today())['summary']; $this->assertSame(3, $all['activity_minutes']); $this->assertSame(1, $all['active_member_count']);
  340. $this->assertSame(1, $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent10']))['summary']['activity_minutes']);
  341. $this->assertSame(2, $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent20']))['summary']['activity_minutes']);
  342. }
  343. public function test_activity_uses_inviter_snapshot_and_does_not_inherit_current_null_attribution(): void
  344. {
  345. $this->activityObservation('2026-10-26 10:00:00'); $this->activityObservation('2026-10-26 10:04:00');
  346. DB::table('share_members')->where('user_id', 2)->update(['parent_user_id' => 3]);
  347. $groups = collect($this->reader->details($this->today(['report_type' => 'sharing']))['data'])->keyBy('group_id');
  348. $this->assertSame(4, $groups[1]['activity_minutes']); $this->assertArrayNotHasKey(3, $groups->all());
  349. $this->assertSame(4, $this->reader->summary($this->today(['report_type' => 'sharing', 'inviter_account' => 'member1']))['summary']['activity_minutes']);
  350. $this->assertSame(0, $this->reader->summary($this->today(['report_type' => 'sharing', 'inviter_account' => 'member3']))['summary']['activity_minutes']);
  351. DB::table('reporting_activity')->delete();
  352. $this->activityObservation('2026-10-26 11:00:00', null, null); $this->activityObservation('2026-10-26 11:02:00', null, null);
  353. $this->assertSame(2, $this->reader->summary($this->today())['summary']['activity_minutes']);
  354. $this->assertSame(0, $this->reader->summary($this->today(['report_type' => 'promotion']))['summary']['activity_minutes']);
  355. $this->assertSame(0, $this->reader->summary($this->today(['report_type' => 'sharing']))['summary']['activity_minutes']);
  356. }
  357. public function test_activity_interval_crossing_query_start_keeps_the_starting_snapshot(): void
  358. {
  359. $this->activityObservation('2026-10-25 23:59:00', 10); $this->activityObservation('2026-10-26 00:01:00', 20);
  360. DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
  361. $groups = collect($this->reader->details($this->today(['report_type' => 'promotion']))['data'])->keyBy('group_id');
  362. $this->assertSame(1, $groups[10]['activity_minutes']); $this->assertSame(0, $groups[20]['activity_minutes']);
  363. $this->assertSame(1, $this->reader->summary($this->today())['summary']['activity_minutes']);
  364. }
  365. public function test_order_time_reconciliation_survives_reimport_and_does_not_settle_source(): void
  366. {
  367. 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']);
  368. $this->sync->publish('bets', 1); $this->assertNull($this->reader->summary($this->today())['summary']['company_profit']);
  369. $id = (int) DB::table('reporting_orders')->value('id'); $before = (array) DB::table('bets')->first();
  370. $this->admin->reconcileOrder(['id' => $id, 'settled_at' => '2026-10-26 10:00:00', 'evidence' => '开奖结算日志']); $this->sync->publish('bets', 1);
  371. $this->assertSame('40.0000000000', $this->reader->summary($this->today())['summary']['company_profit']);
  372. $this->assertSame($before, (array) DB::table('bets')->first());
  373. }
  374. public function test_new_routes_have_admin_auth_and_only_operators_can_write_reconciliations(): void
  375. {
  376. $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
  377. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  378. foreach (['options', 'leaderboard', 'fund-flow', 'summary', 'details', 'orders', 'settings', 'payment-reconciliation'] as $action) {
  379. $r = $router->getRoutes()->match(Request::create('/admin/reporting/' . $action));
  380. $this->assertContains('admin.jwt', $r->middleware()); $this->assertContains('check.button.uri', $r->middleware());
  381. }
  382. foreach (['settings', 'payment-reconciliation', 'order-reconciliation'] as $action) $this->assertContains('operations.admin', $router->getRoutes()->match(Request::create('/admin/reporting/' . $action, 'POST'))->middleware());
  383. }
  384. public function test_share_accrual_and_claim_are_separate_and_agent_cost_only_counts_actual_credit(): void
  385. {
  386. $this->bet();
  387. 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'],
  388. ['id' => 2, 'user_id' => 1, 'amount' => '-3', 'bet_date' => '2026-10-26', 'category' => 'self:lottery', 'created_at' => '2026-10-26 11:00:00']]);
  389. DB::table('share_claims')->insert(['id' => 1, 'user_id' => 1, 'status' => 'approved', 'credited_log_id' => 10]);
  390. DB::table('balance_logs')->insert(['id' => 10, 'member_id' => '100', 'amount' => '5', 'change_type' => '分享佣金领取', 'created_at' => '2026-10-26 11:00:00']);
  391. 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']);
  392. 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']);
  393. 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);
  394. $row = $this->reader->summary($this->today())['summary'];
  395. $this->assertSame('17.0000000000', $row['share_accrual']); $this->assertSame('5.0000000000', $row['share_commission']);
  396. $this->assertSame('9.0000000000', $row['agent_accrual']); $this->assertSame('7.0000000000', $row['agent_commission']);
  397. $this->assertSame('28.0000000000', $row['company_profit']);
  398. foreach ([false, true] as $ranking) {
  399. $details = $this->reader->details($this->today(), $ranking);
  400. $this->assertArrayNotHasKey('unallocated', $details);
  401. $this->assertNotContains('unallocated', array_column($details['data'], 'group_id'));
  402. $this->assertSame(2, $details['total']);
  403. }
  404. $this->assertSame('7.0000000000', $this->reader->summary($this->today())['summary']['agent_commission']);
  405. $this->assertSame('28.0000000000', $this->reader->summary($this->today())['summary']['company_profit']);
  406. $this->assertSame('5.0000000000', $this->reader->summary(['report_type' => 'sharing'])['summary']['share_commission']);
  407. }
  408. public function test_daily_total_is_for_all_pages_and_source_errors_are_visible_until_fixed(): void
  409. {
  410. $this->bet(1, ['created_at' => '2026-10-25 09:00:00']);
  411. $r = $this->reader->funds(['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1, 'page' => 2]);
  412. $this->assertSame(2, $r['total']); $this->assertSame('2026-10-25', $r['data'][0]['group_id']);
  413. $this->assertSame('100.0000000000', $r['summary']['settled_bet_amount']);
  414. DB::table('bets')->insert(['id' => 2, 'member_id' => 'missing', 'amount' => '10', 'status' => 1, 'created_at' => '2026-10-26 10:00:00']);
  415. $r = $this->sync->sync(true); $this->assertSame(1, $r['errors']);
  416. $this->assertNull($this->reader->summary($this->today())['summary']['company_profit']); $this->assertSame(1, DB::table('reporting_errors')->count());
  417. DB::table('bets')->where('id', 2)->update(['member_id' => '200']); $this->sync->sync(true);
  418. $this->assertSame(0, DB::table('reporting_errors')->count()); $this->assertNotNull($this->reader->summary($this->today())['summary']['company_profit']);
  419. }
  420. public function test_eloquent_context_observer_records_status_transition_once_and_tg_activity_is_deduplicated(): void
  421. {
  422. $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();
  423. $context = DB::table('reporting_contexts')->where('source', 'withdraws')->first(); $this->assertNotNull($context); $this->assertNull($context->succeeded_at);
  424. $withdraw->status = 1; $withdraw->save(); $this->assertSame('2026-10-26 16:00:00', DB::table('reporting_contexts')->where('source', 'withdraws')->value('succeeded_at'));
  425. Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai')); $withdraw->amount = '11'; $withdraw->save();
  426. $this->assertSame('2026-10-26 16:00:00', DB::table('reporting_contexts')->where('source', 'withdraws')->value('succeeded_at'));
  427. $request = Request::create('/api/onMessage', 'POST'); $this->app->instance('request', $request); $user = (object) ['id' => 5, 'agent_id' => null, 'from' => -1];
  428. \App\Services\Reporting\ReportActivity::telegram($user); \App\Services\Reporting\ReportActivity::telegram($user);
  429. $this->assertSame(1, DB::table('reporting_activity')->count()); $this->assertSame('unknown', DB::table('reporting_activity')->value('client_platform'));
  430. }
  431. public function test_current_game_adapters_cover_parent_children_third_party_and_partial_sports_refunds(): void
  432. {
  433. 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)');
  434. 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)');
  435. 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)');
  436. DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,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)');
  437. $betAt = Carbon::parse('2026-10-26 09:00:00', 'Asia/Shanghai')->timestamp; $settledAt = $betAt + 3600;
  438. 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]);
  439. 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]]);
  440. $this->context('lhc_order', 1, 2, 'PC', '2026-10-26 10:00:00'); $this->sync->publish('lhc_order', 1);
  441. 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]);
  442. $this->context('sport_order', 1, 2, 'APP'); $this->sync->publish('sport_order', 1);
  443. 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']);
  444. $this->sync->publish('third_game_orders', 1);
  445. $r = $this->reader->summary($this->today())['summary'];
  446. $this->assertSame(3, $r['order_count']); $this->assertSame('150.0000000000', $r['bet_amount']);
  447. $this->assertSame('100.0000000000', $r['settled_bet_amount']); $this->assertSame('18.0000000000', $r['payout_amount']);
  448. $this->assertSame('50.0000000000', $r['refund_amount']); $this->assertSame('82.0000000000', $r['game_gross_profit']);
  449. $unknown = $this->reader->summary(['platform' => 'unknown'])['summary']; $this->assertSame('30.0000000000', $unknown['bet_amount']);
  450. $this->assertSame(0, DB::table('share_bet_facts')->count()); // Reports did not publish sharing facts.
  451. $this->assertSame(0, $this->sync->sync(true)['errors']);
  452. Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai'));
  453. DB::table('lhc_order_item')->where('id', 2)->update(['win_amount' => '68', 'updated_at' => now()->timestamp]);
  454. $this->assertSame(0, $this->sync->sync()['errors']);
  455. $this->assertSame('32.0000000000', $this->reader->summary($this->today())['summary']['game_gross_profit']);
  456. $this->assertSame(4, DB::table('reporting_orders')->count());
  457. }
  458. public function test_audit_failure_rolls_back_reconciliation_and_foreign_currency_is_not_summed(): void
  459. {
  460. $event = $this->deposit();
  461. DB::statement("CREATE TRIGGER reject_reporting_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END");
  462. try { $this->verify($event); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); }
  463. $this->assertSame(0, DB::table('reporting_reconciliations')->count());
  464. $this->bet(); DB::table('reporting_orders')->update(['currency' => 'USDT']);
  465. $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']);
  466. }
  467. public function test_verified_attribution_is_a_reporting_snapshot_and_survives_later_imports(): void
  468. {
  469. $event = $this->deposit(); $this->bet();
  470. $this->admin->reconcile(['event_id' => $event, 'actual_amount' => '1000', 'external_fee' => '0', 'occurred_at' => '2026-10-26 10:00:00',
  471. 'currency' => 'CNY', 'evidence' => '核对原绑定及装置', 'agent_id' => 20, 'inviter_id' => 3, 'client_platform' => 'APP']);
  472. $this->sync->publish('balance_logs', 1);
  473. $e = DB::table('reporting_events')->where('id', $event)->first(); $this->assertSame(20, $e->agent_id); $this->assertSame('verified', $e->attribution_quality);
  474. $this->assertSame('APP', $e->client_platform); $this->assertSame(10, DB::table('users')->where('id', 2)->value('agent_id'));
  475. $id = (int) DB::table('reporting_orders')->value('id');
  476. $this->admin->reconcileOrder(['id' => $id, 'agent_id' => 20, 'evidence' => '原始推广凭据']); $this->sync->publish('bets', 1);
  477. $this->assertSame(20, DB::table('reporting_orders')->value('agent_id'));
  478. $this->assertSame('1000.0000000000', $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent20'])['summary']['deposit_amount']);
  479. $this->assertSame(1, $this->reader->events(['group_id' => '2', 'kind' => 'deposit'])['total']);
  480. $this->assertSame(0, $this->admin->quality([])['total']);
  481. $this->assertNotEmpty($this->admin->quality([])['sources']);
  482. }
  483. private function historicalBet(int $id = 1, string $betAt = '2024-01-01 09:00:00', string $settledAt = '2024-01-02 10:00:00'): void
  484. {
  485. $this->bet($id, ['created_at' => $betAt, 'amount' => '40', 'profit' => '20']);
  486. DB::table('reporting_contexts')->where('source', 'bets')->where('source_id', (string) $id)->update(['succeeded_at' => $settledAt]);
  487. $this->sync->publish('bets', $id);
  488. }
  489. public function test_no_dates_default_to_all_history_and_dates_alone_select_a_period(): void
  490. {
  491. $this->historicalBet(); $this->bet(2);
  492. $default = $this->reader->summary([]);
  493. $this->assertSame(1, $default['filters']['all_time']); $this->assertSame('2024-01-01', $default['filters']['start_date']);
  494. $this->assertSame('2026-10-26', $default['filters']['end_date']);
  495. $this->assertSame('140.0000000000', $default['summary']['bet_amount']);
  496. $this->assertSame('80.0000000000', $default['summary']['payout_amount']);
  497. $blank = $this->reader->summary(['start_date' => '', 'end_date' => null]);
  498. $this->assertSame($default['filters'], $blank['filters']); $this->assertSame($default['summary'], $blank['summary']);
  499. $range = $this->reader->summary($this->today());
  500. $this->assertSame(0, $range['filters']['all_time']); $this->assertSame('2026-10-26', $range['filters']['start_date']);
  501. $this->assertSame('100.0000000000', $range['summary']['bet_amount']);
  502. $this->assertSame('2026-10-26', $default['valid_member_policy']['date']);
  503. $this->assertSame($default['summary'], $this->reader->summary(['all_time' => 0])['summary']);
  504. $this->assertSame($range['summary'], $this->reader->summary($this->today(['all_time' => 1]))['summary']);
  505. $bounded = $this->reader->params(['start_date' => now('Asia/Shanghai')->subDays(365)->toDateString()]);
  506. $this->assertSame(now('Asia/Shanghai')->subDays(365)->toDateString(), $bounded['start_date']);
  507. $this->assertSame(366, $this->admin->options()['max_days']);
  508. }
  509. public function test_all_time_funds_paginate_actual_days_and_keep_the_complete_summary(): void
  510. {
  511. $this->historicalBet(); $this->bet(2); $event = $this->deposit(); $this->verify($event);
  512. $first = $this->reader->funds(['limit' => 1]);
  513. $days = (int) Carbon::parse('2024-01-01')->diffInDays(Carbon::parse('2026-10-27'));
  514. $this->assertSame($days, $first['total']); $this->assertCount(1, $first['data']); $this->assertSame('2026-10-26', $first['data'][0]['date']);
  515. $this->assertSame('140.0000000000', $first['summary']['bet_amount']); $this->assertSame('80.0000000000', $first['summary']['payout_amount']);
  516. $this->assertSame('100.0000000000', $first['data'][0]['bet_amount']); $this->assertSame('60.0000000000', $first['data'][0]['payout_amount']);
  517. $this->assertSame('998.0000000000', $first['summary']['net_cash_flow']);
  518. $settled = $this->reader->funds(['limit' => 1, 'page' => $days - 1]);
  519. $this->assertSame($first['summary'], $settled['summary']); $this->assertSame('2024-01-02', $settled['data'][0]['date']);
  520. $this->assertSame('0.0000000000', $settled['data'][0]['bet_amount']); $this->assertSame('20.0000000000', $settled['data'][0]['payout_amount']);
  521. $bet = $this->reader->funds(['limit' => 1, 'page' => $days]);
  522. $this->assertSame('2024-01-01', $bet['data'][0]['date']); $this->assertSame('40.0000000000', $bet['data'][0]['bet_amount']);
  523. $this->assertSame('0.0000000000', $bet['data'][0]['payout_amount']);
  524. $outside = $this->reader->funds(['limit' => 1, 'page' => $days + 1]);
  525. $this->assertSame([], $outside['data']); $this->assertSame($first['summary'], $outside['summary']); $this->assertSame($days, $outside['total']);
  526. $bounded = $this->reader->funds(['start_date' => '2026-10-25', 'limit' => 1, 'page' => 2]);
  527. $this->assertSame(2, $bounded['total']); $this->assertSame('2026-10-25', $bounded['data'][0]['date']);
  528. $this->assertSame('100.0000000000', $bounded['summary']['bet_amount']);
  529. }
  530. public function test_all_time_uses_the_effective_reconciliation_date_and_amount(): void
  531. {
  532. $event = $this->deposit();
  533. DB::table('balance_logs')->where('id', 1)->update(['created_at' => '2019-01-01 09:00:00']); $this->sync->publish('balance_logs', 1);
  534. $this->admin->reconcile(['event_id' => $event, 'actual_amount' => '500', 'external_fee' => '3', 'occurred_at' => '2022-02-03 10:00:00',
  535. 'currency' => 'CNY', 'evidence' => '历史到账凭据']);
  536. $all = $this->reader->summary([]);
  537. $this->assertSame('2022-02-03', $all['filters']['start_date']); $this->assertSame('500.0000000000', $all['summary']['deposit_amount']);
  538. $this->assertSame('497.0000000000', $all['summary']['net_cash_flow']);
  539. $this->assertSame('0.0000000000', $this->reader->summary($this->today())['summary']['deposit_amount']);
  540. $funds = $this->reader->funds(['limit' => 1,
  541. 'page' => (int) Carbon::parse('2022-02-03')->diffInDays(Carbon::parse('2026-10-26')) + 1]);
  542. $this->assertSame('2022-02-03', $funds['data'][0]['date']); $this->assertSame('500.0000000000', $funds['data'][0]['deposit_amount']);
  543. $events = $this->reader->events([]);
  544. $this->assertSame(1, $events['total']); $this->assertSame('2022-02-03 10:00:00', $events['data'][0]['verified_at']);
  545. $this->assertSame($all['filters']['start_date'], $events['filters']['start_date']); $this->assertSame(1, $events['filters']['all_time']);
  546. }
  547. public function test_all_time_does_not_substitute_the_application_date_for_unknown_payment_time(): void
  548. {
  549. DB::table('withdraws')->insert(['id' => 1, 'member_id' => '200', 'amount' => '100', 'to_account' => '98', 'service_charge' => '2',
  550. 'exchange_rate' => '7', 'status' => 1, 'created_at' => '2001-01-01 08:00:00']);
  551. $this->sync->publish('withdraws', 1);
  552. $all = $this->reader->summary([]);
  553. $this->assertSame('2026-10-01', $all['filters']['start_date']); $this->assertSame(1, $all['summary']['quality']['missing_time']);
  554. $this->assertSame('0.0000000000', $all['summary']['withdraw_amount']); $this->assertNull($all['summary']['net_cash_flow']);
  555. $funds = $this->reader->funds(['limit' => 100]);
  556. $this->assertSame(26, $funds['total']); $this->assertCount(26, $funds['data']);
  557. foreach ($funds['data'] as $day) {
  558. $this->assertSame('0.0000000000', $day['withdraw_amount']); $this->assertSame(1, $day['quality']['missing_time']); $this->assertNull($day['net_cash_flow']);
  559. }
  560. $events = $this->reader->events([]);
  561. $this->assertSame(1, $events['total']); $this->assertNull($events['data'][0]['occurred_at']);
  562. $this->assertSame('2001-01-01 08:00:00', $events['data'][0]['candidate_at']);
  563. }
  564. public function test_all_time_first_day_uses_real_members_and_activity_and_empty_data_falls_back_to_today(): void
  565. {
  566. DB::table('users')->update(['created_at' => '2026-10-26 00:00:00']);
  567. DB::table('users')->where('from', 2)->update(['created_at' => '1999-01-01 00:00:00']);
  568. $this->assertSame('2026-10-26', $this->reader->params([])['start_date']);
  569. DB::table('users')->where('id', 2)->update(['created_at' => '2023-01-01 00:00:00']);
  570. $this->assertSame('2023-01-01', $this->reader->params([])['start_date']);
  571. $this->activityObservation('2022-01-01 10:00:00'); $this->activityObservation('2022-01-01 10:02:00');
  572. $all = $this->reader->summary([]);
  573. $this->assertSame('2022-01-01', $all['filters']['start_date']); $this->assertSame(2, $all['summary']['known_activity_minutes']);
  574. $this->assertNull($all['summary']['activity_minutes']);
  575. DB::table('reporting_activity')->delete(); DB::table('users')->delete();
  576. $empty = $this->reader->funds([]);
  577. $this->assertSame('2026-10-26', $empty['filters']['start_date']); $this->assertSame(1, $empty['total']); $this->assertCount(1, $empty['data']);
  578. $this->assertSame('0.0000000000', $empty['summary']['bet_amount']);
  579. }
  580. public function test_all_time_preserves_account_platform_settlement_and_ranking_filters(): void
  581. {
  582. $this->historicalBet(); $this->bet(2, ['member_id' => '500', 'amount' => '200']);
  583. $this->bet(3, ['status' => 1, 'amount' => '30', 'profit' => '0']); $this->deposit();
  584. foreach (['member_account' => 'member2', 'agent_account' => 'agent10', 'inviter_account' => 'member1'] as $filter => $value) {
  585. $this->assertSame('70.0000000000', $this->reader->summary([$filter => $value])['summary']['bet_amount']);
  586. }
  587. $this->assertSame('40.0000000000', $this->reader->summary(['member_account' => 'member2', 'settlement_status' => 'settled'])['summary']['bet_amount']);
  588. $this->assertSame('0.0000000000', $this->reader->summary(['platform' => 'APP'])['summary']['bet_amount']);
  589. $this->assertSame('0.0000000000', $this->reader->summary(['category' => 'self:sport'])['summary']['bet_amount']);
  590. $ranks = $this->reader->details(['metric' => 'bet_amount', 'page' => 2, 'limit' => 1], true);
  591. $this->assertSame(2, $ranks['total']); $this->assertSame('2', $ranks['data'][0]['group_id']); $this->assertSame(2, $ranks['data'][0]['rank']);
  592. $orders = $this->reader->orders(['member_account' => 'member2', 'page' => 2, 'limit' => 1]);
  593. $this->assertSame(2, $orders['total']); $this->assertSame('2024-01-01 09:00:00', $orders['data'][0]['bet_at']);
  594. $this->assertSame(1, $orders['filters']['all_time']); $this->assertSame('2024-01-01', $orders['filters']['start_date']);
  595. $this->assertSame(0, $this->reader->events(['member_account' => 'member5'])['total']);
  596. $this->assertSame(1, $this->reader->events(['member_account' => 'member2', 'kind' => 'deposit'])['total']);
  597. }
  598. /** @dataProvider invalidQueries */
  599. public function test_invalid_filters_do_not_query_unbounded_dates(array $input): void { $this->expectException(ValidationException::class); $this->reader->summary($input); }
  600. public static function invalidQueries(): array { return [[['start_date' => '2026-02-30']], [['end_date' => '2026-10-27']], [['start_date' => '2020-01-01']],
  601. [['platform' => 'ios']], [['settlement_status' => 'paid']], [['limit' => 999]], [['start_date' => []]], [['end_date' => []]],
  602. [['start_date' => '2026-10-26', 'end_date' => '2026-10-25']], [['end_date' => '2026-02-30']],
  603. [['start_date' => '2025-01-01', 'end_date' => '2026-10-26']], [['start_date' => 'not-a-date', 'end_date' => '2026-10-26']]]; }
  604. }