app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setEventDispatcher(new Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema()); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app)); $this->app['validator']->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager())); $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class { public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); } }); Request::macro('validate', function ($rules) { return app('validator')->make($this->all(), $rules)->validate(); }); $request = Request::create('/admin/reporting/summary'); $request->user = (object) ['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request); foreach ([ 'users' => 'id INTEGER PRIMARY KEY,member_id TEXT,user_id TEXT,username TEXT,first_name TEXT,agent_id INTEGER,"from" INTEGER,created_at TEXT', 'agents' => 'id INTEGER PRIMARY KEY,username TEXT', 'share_members' => 'user_id INTEGER PRIMARY KEY,parent_user_id INTEGER,bound_at TEXT', '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', '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', 'payment_orders' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,amount TEXT,fee TEXT,status INTEGER,created_at TEXT,updated_at TEXT', 'bets' => 'id INTEGER PRIMARY KEY,member_id TEXT,amount TEXT,profit TEXT,status INTEGER,created_at TEXT,updated_at TEXT', 'share_claims' => 'id INTEGER PRIMARY KEY,user_id INTEGER,status TEXT,credited_log_id INTEGER', 'share_ledger' => 'id INTEGER PRIMARY KEY,user_id INTEGER,amount TEXT,bet_date TEXT,category TEXT,created_at TEXT', 'agent_balance_logs' => 'id INTEGER PRIMARY KEY,agent_id INTEGER,type TEXT,amount TEXT,reference_type TEXT,reference_id INTEGER,created_at TEXT', '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', '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', ] as $name => $columns) DB::statement('CREATE TABLE bot_' . $name . ' (' . $columns . ')'); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_140000_create_reporting_tables.php')->up(); DB::table('reporting_settings')->where('id', 1)->update(['activity_started_at' => '2026-10-25 00:00:00']); DB::table('agents')->insert([['id' => 10, 'username' => 'agent10'], ['id' => 20, 'username' => 'agent20']]); 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]) { 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']); 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']); } $this->sync = new ReportSync(); $this->reader = new ReportReader(); $this->admin = new ReportAdmin(); $this->ready(); } protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function ready(): void { DB::table('reporting_sync_state')->updateOrInsert(['source' => '__run__'], ['state' => '{"complete":true}', 'updated_at' => now()]); } private function context(string $source, int $id, int $user = 2, string $platform = 'H5', ?string $success = null): void { DB::table('reporting_contexts')->insert(['source' => $source, 'source_id' => (string) $id, 'user_id' => $user, 'agent_id' => $user === 2 ? 10 : null, 'inviter_id' => $user === 2 ? 1 : null, 'client_platform' => $platform, 'occurred_at' => '2026-10-26 09:00:00', 'succeeded_at' => $success]); } private function bet(int $id = 1, array $extra = []): void { 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']); $this->context('bets', $id, ($extra['member_id'] ?? '200') === '500' ? 5 : 2, 'H5', '2026-10-26 10:00:00'); $this->sync->publish('bets', $id); } private function deposit(int $id = 1, string $amount = '1000', string $member = '200'): int { 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']); $this->sync->publish('balance_logs', $id); return (int) DB::table('reporting_events')->where('source', 'balance_logs')->where('source_id', (string) $id)->value('id'); } private function verify(int $id, string $amount = '1000', string $fee = '2'): void { $this->admin->reconcile(['event_id' => $id, 'actual_amount' => $amount, 'external_fee' => $fee, 'occurred_at' => '2026-10-26 10:00:00', 'currency' => 'CNY', 'evidence' => '到账凭据#' . $id]); } public function test_source_projection_is_idempotent_and_cross_day_metrics_use_actual_events(): void { $this->bet(1, ['created_at' => '2026-10-25 23:00:00']); $this->sync->publish('bets', 1); $this->assertSame(1, DB::table('reporting_orders')->count()); $row = $this->reader->summary([])['summary']; $this->assertSame('0.0000000000', $row['bet_amount']); $this->assertSame('100.0000000000', $row['settled_bet_amount']); $this->assertSame('60.0000000000', $row['payout_amount']); $this->assertSame('40.0000000000', $row['game_gross_profit']); $this->assertSame('40.0000000000', $row['company_profit']); $yesterday = $this->reader->summary(['start_date' => '2026-10-25', 'end_date' => '2026-10-25'])['summary']; $this->assertSame('100.0000000000', $yesterday['bet_amount']); $this->assertSame('0.0000000000', $yesterday['payout_amount']); } public function test_missing_fees_and_success_time_never_produce_fabricated_net_values(): void { $event = $this->deposit(); $this->bet(); $row = $this->reader->summary([])['summary']; $this->assertNull($row['net_cash_flow']); $this->assertNull($row['company_profit']); $this->assertSame(1, $row['quality']['unverified_fees']); $this->assertSame('1000.0000000000', $row['deposit_amount']); $this->verify($event); $row = $this->reader->summary([])['summary']; $this->assertSame('998.0000000000', $row['net_cash_flow']); $this->assertSame('38.0000000000', $row['company_profit']); $before = DB::table('balance_logs')->get()->toArray(); $this->verify($event, '1000', '3'); $this->sync->publish('balance_logs', 1); $this->assertSame(1, DB::table('reporting_reconciliations')->count()); $this->assertEquals($before, DB::table('balance_logs')->get()->toArray()); $this->assertSame('997.0000000000', $this->reader->summary([])['summary']['net_cash_flow']); } public function test_chain_withdraw_uses_recipient_amount_and_customer_fee_is_not_an_expense(): void { 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']); $this->sync->publish('withdraws', 1); $row = $this->reader->summary([])['summary']; $this->assertSame(1, $row['quality']['missing_time']); $this->assertNull($row['net_cash_flow']); $event = (int) DB::table('reporting_events')->value('id'); $this->verify($event, '686', '1'); $row = $this->reader->summary([])['summary']; $this->assertSame('686.0000000000', $row['withdraw_amount']); $this->assertSame('14.0000000000', $row['customer_fee_income']); $this->assertSame('1.0000000000', $row['withdraw_fee']); $this->assertSame('-687.0000000000', $row['net_cash_flow']); DB::table('withdraws')->where('id', 1)->update(['status' => 2]); $result = $this->sync->sync(true); $this->assertSame(1, $result['errors']); $this->assertNull($this->reader->summary([])['summary']['net_cash_flow']); $this->verify($event, '0', '1'); $this->sync->sync(true); $this->assertSame('0.0000000000', $this->reader->summary([])['summary']['withdraw_amount']); } public function test_real_member_agent_sharing_overlap_is_not_added_twice_and_details_are_direct(): void { $this->bet(); $event = $this->deposit(); $this->verify($event); foreach (['business', 'promotion', 'sharing'] as $type) { $row = $this->reader->summary(['report_type' => $type])['summary']; $this->assertSame('100.0000000000', $row['bet_amount']); $this->assertSame('1000.0000000000', $row['deposit_amount']); } $this->assertSame('2', $this->reader->details([])['data'][0]['group_id']); $this->assertSame('10', $this->reader->details(['report_type' => 'promotion'])['data'][0]['group_id']); $this->assertSame('1', $this->reader->details(['report_type' => 'sharing'])['data'][0]['group_id']); $category = $this->reader->summary([])['categories'][8]; $this->assertNull($category['deposit_amount']); $this->assertNull($category['company_profit']); $this->assertSame('100.0000000000', $category['bet_amount']); } public function test_valid_member_threshold_is_strict_and_independent_from_sharing(): void { $this->bet(); $id = $this->deposit(1, '20'); $this->verify($id, '20', '0'); $this->assertSame(0, $this->reader->summary([])['summary']['valid_member_count']); $this->admin->settings(['valid_deposit_min' => '19.99']); $this->assertSame(1, $this->reader->summary([])['summary']['valid_member_count']); $this->assertSame(1, DB::table('operation_audits')->where('resource_type', 'reporting_settings')->count()); } public function test_platform_settlement_filters_tg_virtual_exclusion_and_rank_pagination(): void { $this->bet(); $this->bet(2, ['member_id' => '500', 'amount' => '200']); $this->bet(3, ['status' => 1, 'profit' => '0']); 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); $this->assertSame(3, DB::table('reporting_orders')->count()); $this->assertSame('0.0000000000', $this->reader->summary(['platform' => 'PC'])['summary']['bet_amount']); $this->assertSame('100.0000000000', $this->reader->summary(['settlement_status' => 'pending'])['summary']['bet_amount']); $this->assertNull($this->reader->summary(['settlement_status' => 'pending'])['summary']['company_profit']); $ranks = $this->reader->details(['metric' => 'bet_amount', 'page' => 2, 'limit' => 1], true); $this->assertSame(2, $ranks['total']); $this->assertSame(2, $ranks['data'][0]['rank']); } public function test_activity_intervals_merge_devices_split_idle_and_history_is_explicitly_missing(): void { 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]) { 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]); } $row = $this->reader->summary([])['summary']; $this->assertSame(2, $row['activity_minutes']); $this->assertSame(1, $row['active_member_count']); $this->assertSame(4, $row['member_count']); $this->assertSame('0.250000', $row['active_rate']); $old = $this->reader->summary(['start_date' => '2026-10-24', 'end_date' => '2026-10-26'])['summary']; $this->assertNull($old['activity_minutes']); $this->assertSame(2, $old['known_activity_minutes']); } public function test_full_sync_and_repeated_sync_do_not_change_source_data_or_double_count(): void { $this->bet(); $this->deposit(); $before = DB::table('balance_logs')->get()->toArray(); $first = $this->sync->sync(true); $second = $this->sync->sync(true); $this->assertSame(0, $first['errors']); $this->assertSame(0, $second['errors']); $this->assertSame(1, DB::table('reporting_orders')->count()); $this->assertSame(1, DB::table('reporting_events')->count()); $this->assertEquals($before, DB::table('balance_logs')->get()->toArray()); } private function activityObservation(string $at, ?int $agent = 10, ?int $inviter = 1, string $session = 'a'): void { DB::table('reporting_activity')->insert(['user_id' => 2, 'agent_id' => $agent, 'inviter_id' => $inviter, 'client_platform' => 'H5', 'session_hash' => $session, 'observed_at' => $at]); } public function test_activity_snapshot_survives_agent_reassignment_in_summary_details_and_filters(): void { $this->activityObservation('2026-10-26 10:00:00'); $this->activityObservation('2026-10-26 10:02:00'); $before = $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent10'])['summary']; DB::table('users')->where('id', 2)->update(['agent_id' => 20]); $after = $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent10'])['summary']; $this->assertSame(2, $before['activity_minutes']); $this->assertSame(2, $after['activity_minutes']); $this->assertSame(1, $after['active_member_count']); $this->assertFalse($after['activity_population_complete']); $this->assertNull($after['active_rate']); $other = $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent20'])['summary']; $this->assertSame(0, $other['activity_minutes']); $this->assertSame(0, $other['active_member_count']); $details = $this->reader->details(['report_type' => 'promotion']); $this->assertSame(1, $details['total']); $this->assertSame('10', $details['data'][0]['group_id']); $this->assertSame(2, $details['data'][0]['activity_minutes']); $byGroup = $this->reader->summary(['report_type' => 'promotion', 'group_id' => '10'])['summary']; $this->assertSame(2, $byGroup['activity_minutes']); $business = $this->reader->summary(['agent_account' => 'agent10'])['summary']; $this->assertSame(2, $business['activity_minutes']); $this->activityObservation('2026-10-26 11:00:00', 20); $this->activityObservation('2026-10-26 11:03:00', 20); $groups = collect($this->reader->details(['report_type' => 'promotion'])['data'])->keyBy('group_id'); $this->assertSame(2, $groups[10]['activity_minutes']); $this->assertSame(3, $groups[20]['activity_minutes']); $this->assertSame('0.500000', $groups[20]['active_rate']); $all = $this->reader->summary([])['summary']; $this->assertSame(5, $all['activity_minutes']); $this->assertSame(1, $all['active_member_count']); $this->assertSame('0.250000', $all['active_rate']); } public function test_agent_transition_with_overlapping_devices_counts_each_second_only_once(): void { $this->activityObservation('2026-10-26 10:00:00', 10, 1, 'a'); $this->activityObservation('2026-10-26 10:01:00', 20, 1, 'b'); $this->activityObservation('2026-10-26 10:02:00', 20, 1, 'a'); $this->activityObservation('2026-10-26 10:03:00', 20, 1, 'b'); DB::table('users')->where('id', 2)->update(['agent_id' => 20]); $groups = collect($this->reader->details(['report_type' => 'promotion'])['data'])->keyBy('group_id'); $this->assertSame(1, $groups[10]['activity_minutes']); $this->assertSame(2, $groups[20]['activity_minutes']); $all = $this->reader->summary([])['summary']; $this->assertSame(3, $all['activity_minutes']); $this->assertSame(1, $all['active_member_count']); $this->assertSame(1, $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent10'])['summary']['activity_minutes']); $this->assertSame(2, $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent20'])['summary']['activity_minutes']); } public function test_activity_uses_inviter_snapshot_and_does_not_inherit_current_null_attribution(): void { $this->activityObservation('2026-10-26 10:00:00'); $this->activityObservation('2026-10-26 10:04:00'); DB::table('share_members')->where('user_id', 2)->update(['parent_user_id' => 3]); $groups = collect($this->reader->details(['report_type' => 'sharing'])['data'])->keyBy('group_id'); $this->assertSame(4, $groups[1]['activity_minutes']); $this->assertArrayNotHasKey(3, $groups->all()); $this->assertSame(4, $this->reader->summary(['report_type' => 'sharing', 'inviter_account' => 'member1'])['summary']['activity_minutes']); $this->assertSame(0, $this->reader->summary(['report_type' => 'sharing', 'inviter_account' => 'member3'])['summary']['activity_minutes']); DB::table('reporting_activity')->delete(); $this->activityObservation('2026-10-26 11:00:00', null, null); $this->activityObservation('2026-10-26 11:02:00', null, null); $this->assertSame(2, $this->reader->summary([])['summary']['activity_minutes']); $this->assertSame(0, $this->reader->summary(['report_type' => 'promotion'])['summary']['activity_minutes']); $this->assertSame(0, $this->reader->summary(['report_type' => 'sharing'])['summary']['activity_minutes']); } public function test_activity_interval_crossing_query_start_keeps_the_starting_snapshot(): void { $this->activityObservation('2026-10-25 23:59:00', 10); $this->activityObservation('2026-10-26 00:01:00', 20); DB::table('users')->where('id', 2)->update(['agent_id' => 20]); $groups = collect($this->reader->details(['report_type' => 'promotion'])['data'])->keyBy('group_id'); $this->assertSame(1, $groups[10]['activity_minutes']); $this->assertSame(0, $groups[20]['activity_minutes']); $this->assertSame(1, $this->reader->summary([])['summary']['activity_minutes']); } public function test_order_time_reconciliation_survives_reimport_and_does_not_settle_source(): void { 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']); $this->sync->publish('bets', 1); $this->assertNull($this->reader->summary([])['summary']['company_profit']); $id = (int) DB::table('reporting_orders')->value('id'); $before = (array) DB::table('bets')->first(); $this->admin->reconcileOrder(['id' => $id, 'settled_at' => '2026-10-26 10:00:00', 'evidence' => '开奖结算日志']); $this->sync->publish('bets', 1); $this->assertSame('40.0000000000', $this->reader->summary([])['summary']['company_profit']); $this->assertSame($before, (array) DB::table('bets')->first()); } public function test_new_routes_have_admin_auth_and_only_operators_can_write_reconciliations(): void { $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router); $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; }); foreach (['options', 'leaderboard', 'fund-flow', 'summary', 'details', 'orders', 'settings', 'payment-reconciliation'] as $action) { $r = $router->getRoutes()->match(Request::create('/admin/reporting/' . $action)); $this->assertContains('admin.jwt', $r->middleware()); $this->assertContains('check.button.uri', $r->middleware()); } foreach (['settings', 'payment-reconciliation', 'order-reconciliation'] as $action) $this->assertContains('operations.admin', $router->getRoutes()->match(Request::create('/admin/reporting/' . $action, 'POST'))->middleware()); } public function test_share_accrual_and_claim_are_separate_and_agent_cost_only_counts_actual_credit(): void { $this->bet(); 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'], ['id' => 2, 'user_id' => 1, 'amount' => '-3', 'bet_date' => '2026-10-26', 'category' => 'self:lottery', 'created_at' => '2026-10-26 11:00:00']]); DB::table('share_claims')->insert(['id' => 1, 'user_id' => 1, 'status' => 'approved', 'credited_log_id' => 10]); DB::table('balance_logs')->insert(['id' => 10, 'member_id' => '100', 'amount' => '5', 'change_type' => '分享佣金领取', 'created_at' => '2026-10-26 11:00:00']); 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']); 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']); 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); $row = $this->reader->summary([])['summary']; $this->assertSame('17.0000000000', $row['share_accrual']); $this->assertSame('5.0000000000', $row['share_commission']); $this->assertSame('9.0000000000', $row['agent_accrual']); $this->assertSame('7.0000000000', $row['agent_commission']); $this->assertSame('28.0000000000', $row['company_profit']); $details = $this->reader->details([]); $this->assertSame('7.0000000000', $details['unallocated']['agent_commission']); $this->assertSame('5.0000000000', $this->reader->summary(['report_type' => 'sharing'])['summary']['share_commission']); } public function test_daily_total_is_for_all_pages_and_source_errors_are_visible_until_fixed(): void { $this->bet(1, ['created_at' => '2026-10-25 09:00:00']); $r = $this->reader->funds(['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1, 'page' => 2]); $this->assertSame(2, $r['total']); $this->assertSame('2026-10-25', $r['data'][0]['group_id']); $this->assertSame('100.0000000000', $r['summary']['settled_bet_amount']); DB::table('bets')->insert(['id' => 2, 'member_id' => 'missing', 'amount' => '10', 'status' => 1, 'created_at' => '2026-10-26 10:00:00']); $r = $this->sync->sync(true); $this->assertSame(1, $r['errors']); $this->assertNull($this->reader->summary([])['summary']['company_profit']); $this->assertSame(1, DB::table('reporting_errors')->count()); DB::table('bets')->where('id', 2)->update(['member_id' => '200']); $this->sync->sync(true); $this->assertSame(0, DB::table('reporting_errors')->count()); $this->assertNotNull($this->reader->summary([])['summary']['company_profit']); } public function test_eloquent_context_observer_records_status_transition_once_and_tg_activity_is_deduplicated(): void { $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(); $context = DB::table('reporting_contexts')->where('source', 'withdraws')->first(); $this->assertNotNull($context); $this->assertNull($context->succeeded_at); $withdraw->status = 1; $withdraw->save(); $this->assertSame('2026-10-26 16:00:00', DB::table('reporting_contexts')->where('source', 'withdraws')->value('succeeded_at')); Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai')); $withdraw->amount = '11'; $withdraw->save(); $this->assertSame('2026-10-26 16:00:00', DB::table('reporting_contexts')->where('source', 'withdraws')->value('succeeded_at')); $request = Request::create('/api/onMessage', 'POST'); $this->app->instance('request', $request); $user = (object) ['id' => 5, 'agent_id' => null, 'from' => -1]; \App\Services\Reporting\ReportActivity::telegram($user); \App\Services\Reporting\ReportActivity::telegram($user); $this->assertSame(1, DB::table('reporting_activity')->count()); $this->assertSame('unknown', DB::table('reporting_activity')->value('client_platform')); } public function test_current_game_adapters_cover_parent_children_third_party_and_partial_sports_refunds(): void { 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)'); 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)'); 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)'); 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)'); $betAt = Carbon::parse('2026-10-26 09:00:00', 'Asia/Shanghai')->timestamp; $settledAt = $betAt + 3600; 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]); 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]]); $this->context('lhc_order', 1, 2, 'PC', '2026-10-26 10:00:00'); $this->sync->publish('lhc_order', 1); 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]); $this->context('sport_order', 1, 2, 'APP'); $this->sync->publish('sport_order', 1); 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']); $this->sync->publish('third_game_orders', 1); $r = $this->reader->summary([])['summary']; $this->assertSame(3, $r['order_count']); $this->assertSame('150.0000000000', $r['bet_amount']); $this->assertSame('100.0000000000', $r['settled_bet_amount']); $this->assertSame('18.0000000000', $r['payout_amount']); $this->assertSame('50.0000000000', $r['refund_amount']); $this->assertSame('82.0000000000', $r['game_gross_profit']); $unknown = $this->reader->summary(['platform' => 'unknown'])['summary']; $this->assertSame('30.0000000000', $unknown['bet_amount']); $this->assertFalse(Schema::hasTable('share_bet_facts')); // Reports did not publish or create sharing facts. $this->assertSame(0, $this->sync->sync(true)['errors']); Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai')); DB::table('lhc_order_item')->where('id', 2)->update(['win_amount' => '68', 'updated_at' => now()->timestamp]); $this->assertSame(0, $this->sync->sync()['errors']); $this->assertSame('32.0000000000', $this->reader->summary([])['summary']['game_gross_profit']); $this->assertSame(4, DB::table('reporting_orders')->count()); } public function test_audit_failure_rolls_back_reconciliation_and_foreign_currency_is_not_summed(): void { $event = $this->deposit(); DB::statement("CREATE TRIGGER reject_reporting_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END"); try { $this->verify($event); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); } $this->assertSame(0, DB::table('reporting_reconciliations')->count()); $this->bet(); DB::table('reporting_orders')->update(['currency' => 'USDT']); $r = $this->reader->summary([])['summary']; $this->assertSame('0.0000000000', $r['bet_amount']); $this->assertSame(1, $r['quality']['foreign_currency']); $this->assertNull($r['company_profit']); } public function test_verified_attribution_is_a_reporting_snapshot_and_survives_later_imports(): void { $event = $this->deposit(); $this->bet(); $this->admin->reconcile(['event_id' => $event, 'actual_amount' => '1000', 'external_fee' => '0', 'occurred_at' => '2026-10-26 10:00:00', 'currency' => 'CNY', 'evidence' => '核对原绑定及装置', 'agent_id' => 20, 'inviter_id' => 3, 'client_platform' => 'APP']); $this->sync->publish('balance_logs', 1); $e = DB::table('reporting_events')->where('id', $event)->first(); $this->assertSame(20, $e->agent_id); $this->assertSame('verified', $e->attribution_quality); $this->assertSame('APP', $e->client_platform); $this->assertSame(10, DB::table('users')->where('id', 2)->value('agent_id')); $id = (int) DB::table('reporting_orders')->value('id'); $this->admin->reconcileOrder(['id' => $id, 'agent_id' => 20, 'evidence' => '原始推广凭据']); $this->sync->publish('bets', 1); $this->assertSame(20, DB::table('reporting_orders')->value('agent_id')); $this->assertSame('1000.0000000000', $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent20'])['summary']['deposit_amount']); $this->assertSame(1, $this->reader->events(['group_id' => '2', 'kind' => 'deposit'])['total']); $this->assertSame(0, $this->admin->quality([])['total']); $this->assertNotEmpty($this->admin->quality([])['sources']); } /** @dataProvider invalidQueries */ public function test_invalid_filters_do_not_query_unbounded_dates(array $input): void { $this->expectException(ValidationException::class); $this->reader->summary($input); } public static function invalidQueries(): array { return [[['start_date' => '2026-02-30']], [['end_date' => '2026-10-27']], [['start_date' => '2020-01-01']], [['platform' => 'ios']], [['settlement_status' => 'paid']], [['limit' => 999]]]; } }