doge 2 дней назад
Родитель
Сommit
a7f25da065

+ 41 - 33
app/Http/Controllers/admin/Operation.php

@@ -9,6 +9,8 @@ use App\Models\FundsRecord;
 use App\Models\Order;
 use App\Models\Operation as OperationModel;
 use App\Constants\HttpStatus;
+use App\Services\PlayedGameService;
+use Carbon\Carbon;
 
 class Operation extends Controller
 {
@@ -27,6 +29,8 @@ class Operation extends Controller
                 'member_id' => ['nullable'],
                 'account' => ['nullable', 'string', 'max:64'],
                 'first_name' => ['nullable'],
+                'sort_field' => ['nullable', 'string', 'in:recharge,withdraw,withdraw_recharge_difference,money'],
+                'sort_order' => ['nullable', 'string', 'in:asc,desc'],
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 15);
@@ -48,47 +52,51 @@ class Operation extends Controller
                 $query = $query->where('users.first_name', 'like', "%{$params['first_name']}%");
             }
 
-            $count = $query->count();
-            $list = $query->join('wallets', 'users.member_id', '=', 'wallets.member_id')
-                ->select(['users.id', 'users.user_id','users.member_id', 'users.account', 'users.first_name', 'wallets.available_balance as money'])
-                ->forPage($page, $limit)
-                ->orderByDesc("users.created_at")
-                ->get()->toArray();
-            
             $start = !empty($params['start_time']) ? "{$params['start_time']} 00:00:00" : null;
             $end = !empty($params['end_time']) ? "{$params['end_time']} 23:59:59" : null;
+            $query->join('wallets', 'users.member_id', '=', 'wallets.member_id');
+            $count = (clone $query)->count();
 
-            foreach ($list as &$item) {
-                $item['recharge'] = number_format(FundsRecord::where('member_id', $item['member_id'])
-                    ->whereIn('change_type', ['充值','人工充值','三方充值'])
-                    ->where(function ($query1) use ($start, $end) {
-                        if ($start && $end) {
-                            $query1->where('created_at', '>=', $start)
-                                ->where('created_at', '<=', $end);
-                        }
-                    })
-                    ->sum('amount'), 2);
+            // Aggregate before pagination so sorting covers all filtered members, using numeric amounts.
+            $funds = FundsRecord::query()->select('member_id')
+                ->whereIn('member_id', (clone $query)->select('users.member_id'))
+                ->whereIn('change_type', ['充值', '人工充值', '三方充值', '提现', '三方提现'])
+                ->selectRaw("SUM(CASE WHEN change_type IN ('充值', '人工充值', '三方充值') THEN amount ELSE 0 END) AS recharge_total")
+                ->selectRaw("SUM(CASE WHEN change_type IN ('提现', '三方提现') THEN amount ELSE 0 END) AS withdraw_change")
+                ->groupBy('member_id');
+            if ($start && $end) $funds->whereBetween('created_at', [$start, $end]);
 
-                $item['withdraw'] = number_format(FundsRecord::where('member_id', $item['member_id'])
-                    ->whereIn('change_type', ['提现','三方提现'])
-                    ->where(function ($query1) use ($start, $end) {
-                        if ($start && $end) {
-                            $query1->where('created_at', '>=', $start)
-                                ->where('created_at', '<=', $end);
-                        }
-                    })
-                    ->sum('amount'), 2);
+            $grammar = $query->getQuery()->getGrammar();
+            $recharge = 'COALESCE(' . $grammar->wrap('exchange_funds.recharge_total') . ', 0)';
+            $withdrawChange = 'COALESCE(' . $grammar->wrap('exchange_funds.withdraw_change') . ', 0)';
+            // 提现流水为负、退款为正;盈亏 = 充值 - 提现净额 = 充值 + 提现流水合计。
+            $query->leftJoinSub($funds, 'exchange_funds', 'users.member_id', '=', 'exchange_funds.member_id')
+                ->select(['users.id', 'users.user_id', 'users.member_id', 'users.account', 'users.first_name', 'wallets.available_balance as money'])
+                ->selectRaw("{$recharge} AS recharge, -({$withdrawChange}) AS withdraw, {$recharge} + {$withdrawChange} AS withdraw_recharge_difference");
+            if (!empty($params['sort_field'])) {
+                $query->orderBy($params['sort_field'], $params['sort_order'] ?? 'desc');
+            }
+            $list = $query->orderByDesc('users.created_at')->orderByDesc('users.id')
+                ->forPage($page, $limit)->get()->toArray();
+
+            $playedGames = PlayedGameService::forMembers(array_column($list, 'member_id'), $start, $end);
+            $startTimestamp = $start ? Carbon::parse($start, 'Asia/Shanghai')->timestamp : null;
+            $endTimestamp = $end ? Carbon::parse($end, 'Asia/Shanghai')->timestamp : null;
+
+            foreach ($list as &$item) {
+                $item['recharge'] = number_format($item['recharge'], 2);
+                $item['withdraw'] = number_format($item['withdraw'], 2);
+                $item['withdraw_recharge_difference'] = number_format($item['withdraw_recharge_difference'], 2);
+                $item['played_games'] = $playedGames[$item['member_id']] ?? [];
                 //订单总额
                 $item['order_amount'] = number_format(Order::where('user_id', $item['user_id'])
                     ->where('status', 1)
                     ->where('pay_status', 1)
                     ->where('return_status', 0)
-                    ->where(function ($query1) use ($start, $end) {
-                        if ($start && $end) {
-                            $start = strtotime($start.' 00:00:00');
-                            $end = strtotime($end.' 23:59:59');
-                            $query1->where('create_time', '>=', $start)
-                                ->where('create_time', '<=', $end);
+                    ->where(function ($query1) use ($startTimestamp, $endTimestamp) {
+                        if ($startTimestamp && $endTimestamp) {
+                            $query1->where('create_time', '>=', $startTimestamp)
+                                ->where('create_time', '<=', $endTimestamp);
                         }
                     })
                     ->sum('amount'), 2);
@@ -163,4 +171,4 @@ class Operation extends Controller
         }
         return $this->success(['total' => $count, 'data' => $list, 'count' => $data]);
     }
-}
+}

+ 64 - 0
app/Services/PlayedGameService.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Services;
+
+use App\Services\Sharing\ShareDefaults;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+
+/** Read actual orders so historical and not-yet-settled play does not depend on commission synchronization. */
+class PlayedGameService
+{
+    public static function forMembers(array $memberIds, ?string $start = null, ?string $end = null): array
+    {
+        if ($memberIds === []) return [];
+
+        $played = [];
+        $third = DB::table('third_game_orders as o')->join('users as u', function ($join) {
+            $join->on('o.user_id', '=', 'u.id')->orWhere(function ($join) {
+                $join->whereNull('o.user_id')->on('o.member_id', '=', 'u.member_id');
+            });
+        })->whereIn('u.member_id', $memberIds)
+            ->where(function ($query) {
+                $query->whereNull('o.member_id')->orWhere('o.member_id', '')->orWhereColumn('o.member_id', 'u.member_id');
+            })
+            ->whereIn('o.game_type', range(1, 7))->whereIn('o.status', [0, 1])->where('o.bet_amount', '>', 0);
+        if ($start && $end) $third->whereBetween('o.bet_time', [$start, $end]);
+        foreach ($third->distinct()->get(['u.member_id', 'o.game_type']) as $row) {
+            $played[$row->member_id]['third:' . $row->game_type] = true;
+        }
+
+        // sport_order.user_id stores the member ID, unlike third_game_orders.user_id (users.id).
+        $sources = [
+            ['sport_order', 'user_id', 'create_time', 'self:sport', 'status', [1], true],
+            ['sport_game_order', 'member_id', 'created_at', 'self:sport', 'status', [0, 1, 2], false],
+            ['lhc_order', 'member_id', 'created_at', 'self:lottery', 'lottery_status', [0, 1, 2], true],
+            ['bets', 'member_id', 'created_at', 'self:lottery', 'status', [1, 2], false],
+            ['jisu_game_order', 'member_id', 'created_at', 'self:lottery', 'status', [0, 1, 2], false],
+        ];
+        $startTimestamp = $start ? Carbon::parse($start, ShareDefaults::TIMEZONE)->timestamp : null;
+        $endTimestamp = $end ? Carbon::parse($end, ShareDefaults::TIMEZONE)->timestamp : null;
+        foreach ($sources as [$table, $memberColumn, $timeColumn, $category, $statusColumn, $statuses, $timestamp]) {
+            $query = DB::table($table)->whereIn($memberColumn, $memberIds)
+                ->whereIn($statusColumn, $statuses)->where('amount', '>', 0);
+            if ($timestamp) $query->where('is_faker', 0);
+            if ($table === 'sport_order') $query->where('pay_status', 1)->where('return_status', '<>', 2);
+            if ($start && $end) {
+                $query->whereBetween($timeColumn, $timestamp
+                    ? [$startTimestamp, $endTimestamp]
+                    : [$start, $end]);
+            }
+            foreach ($query->distinct()->pluck($memberColumn) as $memberId) $played[$memberId][$category] = true;
+        }
+
+        $result = [];
+        foreach ($played as $memberId => $categories) {
+            // Keep the same names and stable ordering as commission categories.
+            foreach (ShareDefaults::categories() as $category => $label) {
+                if (isset($categories[$category])) $result[$memberId][] = ['category' => $category, 'label' => $label];
+            }
+        }
+
+        return $result;
+    }
+}

+ 1 - 0
docs/前端交付/bot-28/后台/README.md

@@ -18,6 +18,7 @@
 | 报表汇总:营业/推广/分享页签及明细 | [报表汇总-前端接入](报表汇总-前端接入.md) |
 | 提现打码量与冻结金额:会员列表打码量、上下分打码量、冻结/解冻、冻结记录(2026-11-04) | [打码量与冻结金额-后台接口](打码量与冻结金额-后台接口.md) |
 | 资金流动:区间总计与每日列表 | [资金流动-前端接入](资金流动-前端接入.md) |
+| 用户报表:玩过的游戏大类、盈亏(充值-提现)与充值/提现/盈亏/余额排序(2026-11-04) | [用户报表-游戏分类、盈亏与排序](用户报表-游戏分类与提充差值.md) |
 | 龙虎榜:五类排名与分页 | [龙虎榜-前端接入](龙虎榜-前端接入.md) |
 | 三份报表接入文档目录 | [报表文档目录](龙虎榜资金流动与报表-接口.md) |
 

+ 7 - 0
docs/前端交付/bot-28/后台/用户报表-游戏分类与提充差值.md

@@ -0,0 +1,7 @@
+# 用户报表:玩过的游戏、盈亏与金额排序
+
+完整接入说明:[用户报表-游戏分类、盈亏与排序](../../../总后台/用户报表-游戏分类与提充差值.md)。
+
+`GET /admin/operation/exchangeList` 的列表行新增 `played_games` 和 `withdraw_recharge_difference`。请增加“玩过的游戏”“盈亏”两列,盈亏按 **充值-提现** 展示后端返回值。
+
+充值、提现、盈亏、余额四列使用服务端排序:传 `sort_field=recharge/withdraw/withdraw_recharge_difference/money` 与 `sort_order=asc/desc`,切换排序时重置到第 1 页。默认方向为 `desc`;全量筛选结果排序后再分页。

+ 89 - 0
docs/总后台/用户报表-游戏分类与提充差值.md

@@ -0,0 +1,89 @@
+# 用户报表:玩过的游戏、盈亏与金额排序
+
+接口:`GET /admin/operation/exchangeList`,沿用后台管理员认证及原有权限。
+
+列表增加两列:**玩过的游戏**、**盈亏**,并支持充值、提现、盈亏、余额排序。分页结构和原有会员筛选方式不变。
+
+## 新增字段
+
+字段位于响应 `data.data[]`:
+
+| 字段 | 类型 | 展示方式 |
+| --- | --- | --- |
+| `played_games` | array | 显示每项 `label`,可用标签或顿号分隔;`category` 用作稳定标识 |
+| `withdraw_recharge_difference` | string | 盈亏,充值金额减提现净额;支持负数,保留两位小数,沿用列表千分位格式。字段名沿用原名 |
+
+```json
+{
+  "code": 0,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "id": 1,
+        "user_id": "10001",
+        "member_id": "10001",
+        "account": "alice",
+        "first_name": "示例会员",
+        "money": "88.00",
+        "recharge": "1,500.66",
+        "withdraw": "2,000.44",
+        "withdraw_recharge_difference": "-499.78",
+        "played_games": [
+          {"category": "third:1", "label": "真人"},
+          {"category": "self:lottery", "label": "F1彩票"}
+        ],
+        "order_amount": "0.00"
+      }
+    ]
+  }
+}
+```
+
+未玩过游戏返回 `played_games: []`,前端可显示 `—`;没有充值提现时金额和差值均为 `"0.00"`。不要对已带千分位的金额字段直接做减法,直接使用后端返回的差值。
+
+## 排序参数
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| `sort_field` | 否 | `recharge` 充值、`withdraw` 提现、`withdraw_recharge_difference` 盈亏、`money` 余额 |
+| `sort_order` | 否 | `asc` 从小到大;`desc` 从大到小;指定排序字段时默认 `desc` |
+
+例如盈亏从大到小:
+
+```http
+GET /admin/operation/exchangeList?page=1&limit=15&sort_field=withdraw_recharge_difference&sort_order=desc
+```
+
+前端四列使用服务端排序,点击表头时传对应字段及方向并重置到第 1 页。排序作用于全部符合筛选条件的会员,再分页;按金额数值比较,包含负数和无交易的 `0.00`。日期筛选影响充值、提现、盈亏的统计及排序,余额按当前钱包余额排序。
+
+金额相同则按会员创建时间倒序、会员记录 ID 倒序排列,保证分页稳定。不传 `sort_field` 时仍按会员创建时间倒序排列。非法排序字段或方向返回非零 `code`。
+
+## 游戏大类
+
+复用返佣的分类键与名称,按以下顺序返回并去重:
+
+| category | label |
+| --- | --- |
+| `third:1` | 真人 |
+| `third:2` | 电子 |
+| `third:3` | 彩票 |
+| `third:4` | 体育 |
+| `third:5` | 电竞 |
+| `third:6` | 捕鱼 |
+| `third:7` | 棋牌 |
+| `self:sport` | F1体育 |
+| `self:lottery` | F1彩票 |
+
+直接查询用户的第三方游戏、体育、足球游戏、六合彩/极速彩票、旧 PC28 等投注记录,不依赖返佣是否已发放或事实同步是否完成。计入正金额的有效下单记录,包含待结算订单;取消、退款、未付款的体育订单及虚假订单不计入。旧 PC28 和六合彩等统一归为 F1彩票,不展开具体游戏。
+
+## 统计范围与金额方向
+
+- `start_time`、`end_time` 仍为成对的 `Y-m-d` 日期参数。传入后,充值、提现、差值、玩过的游戏和原有订单金额都按该日期区间统计,包含首尾自然日,时区为北京时间;不传则统计全部历史。
+- 充值沿用 `充值`、`人工充值`、`三方充值` 余额流水合计;提现沿用 `提现`、`三方提现` 流水。
+- **`withdraw` 的金额方向修正为提现净额**:提现扣款流水为负数,退款流水为正数,先对流水求和再取反。例如扣款 `-500`、退款 `+500`,提现净额为 `0.00`。前端直接展示该字段,不再自行取反。
+- 盈亏为 `充值金额-提现净额`,复用列表充值、提现的同一组统计,不另外查询。正值表示充值较多,负值表示提现较多。例如充值 `200.00`、提现 `80.00`,盈亏为 `120.00`。
+- 沿用原有余额流水口径:提现申请扣款已计入,失败/拒绝退回在退回发生日抵扣,因此此处并非仅统计已成功出款订单。会员钱包余额 `money` 仍为当前余额。
+
+本次不需要新增数据库字段或执行数据迁移。

+ 326 - 0
tests/Integration/OperationExchangeListTest.php

@@ -0,0 +1,326 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Operation;
+use App\Services\Sharing\ShareDefaults;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+/** Real controller and source orders, isolated from the application database and .env. */
+class OperationExchangeListTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
+        Request::macro('validate', function (array $rules) {
+            return app('validator')->make($this->all(), $rules)->validate();
+        });
+        foreach ([
+            'users' => 'id INTEGER PRIMARY KEY, user_id TEXT, member_id TEXT, account TEXT, first_name TEXT, "from" INTEGER DEFAULT 1, created_at TEXT',
+            'wallets' => 'id INTEGER PRIMARY KEY, member_id TEXT, available_balance NUMERIC DEFAULT 0',
+            'balance_logs' => 'id INTEGER PRIMARY KEY, member_id TEXT, change_type TEXT, amount NUMERIC, created_at TEXT',
+            'third_game_orders' => 'id INTEGER PRIMARY KEY, user_id INTEGER, member_id TEXT, game_type INTEGER, status INTEGER DEFAULT 1, bet_amount NUMERIC DEFAULT 10, bet_time TEXT',
+            'sport_order' => 'id INTEGER PRIMARY KEY, user_id TEXT, amount NUMERIC DEFAULT 10, status INTEGER DEFAULT 1, pay_status INTEGER DEFAULT 1, return_status INTEGER DEFAULT 0, is_faker INTEGER DEFAULT 0, create_time INTEGER',
+            'sport_game_order' => 'id INTEGER PRIMARY KEY, member_id TEXT, amount NUMERIC DEFAULT 10, status INTEGER DEFAULT 1, created_at TEXT',
+            'lhc_order' => 'id INTEGER PRIMARY KEY, member_id TEXT, amount NUMERIC DEFAULT 10, lottery_status INTEGER DEFAULT 1, is_faker INTEGER DEFAULT 0, created_at INTEGER',
+            'bets' => 'id INTEGER PRIMARY KEY, member_id TEXT, amount NUMERIC DEFAULT 10, status INTEGER DEFAULT 2, created_at TEXT',
+            'jisu_game_order' => 'id INTEGER PRIMARY KEY, member_id TEXT, amount NUMERIC DEFAULT 10, status INTEGER DEFAULT 1, created_at TEXT',
+        ] as $table => $columns) {
+            DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
+        }
+        foreach ([['100', 'alice', 1], ['200', 'bob', 1], ['300', 'robot', 2]] as $i => [$member, $account, $from]) {
+            DB::table('users')->insert(['id' => $i + 1, 'user_id' => $member, 'member_id' => $member,
+                'account' => $account, 'first_name' => $account, 'from' => $from, 'created_at' => '2026-11-0' . ($i + 1) . ' 12:00:00']);
+            DB::table('wallets')->insert(['member_id' => $member, 'available_balance' => 88]);
+        }
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function response(array $params = []): array
+    {
+        $this->app->instance('request', Request::create('/admin/operation/exchangeList', 'GET', $params));
+        return (new Operation())->exchangeList()->getData(true);
+    }
+
+    private function rows(array $params = []): array
+    {
+        $body = $this->response($params);
+        $this->assertSame(0, $body['code'], $body['msg']);
+        return array_column($body['data']['data'], null, 'member_id');
+    }
+
+    private function order(string $table, string $member = '100', array $extra = [], string $at = '2026-11-04 12:00:00'): void
+    {
+        $timestamp = in_array($table, ['sport_order', 'lhc_order'], true);
+        $memberColumn = $table === 'sport_order' ? 'user_id' : 'member_id';
+        $timeColumn = $table === 'sport_order' ? 'create_time' : ($table === 'third_game_orders' ? 'bet_time' : 'created_at');
+        DB::table($table)->insert($extra + [$memberColumn => $member,
+            $timeColumn => $timestamp ? Carbon::parse($at, 'Asia/Shanghai')->timestamp : $at]);
+    }
+
+    private function funds(string $type, string $amount, string $member = '100', string $at = '2026-11-04 12:00:00'): void
+    {
+        DB::table('balance_logs')->insert(['member_id' => $member, 'change_type' => $type, 'amount' => $amount, 'created_at' => $at]);
+    }
+
+    public function test_all_nine_commission_categories_are_deduplicated_and_old_orders_are_included(): void
+    {
+        foreach (range(1, 7) as $type) $this->order('third_game_orders', extra: ['game_type' => $type]);
+        $this->order('third_game_orders', extra: ['game_type' => 1]);
+        $this->order('sport_order');
+        $this->order('sport_game_order');
+        // No sharing facts, child orders or legacy ID cutoff are needed to recognize historical play.
+        $this->order('lhc_order', extra: ['id' => 1]);
+        $this->order('bets');
+        $this->order('jisu_game_order');
+
+        $rows = $this->rows();
+        $this->assertSame(ShareDefaults::categories(), array_column($rows['100']['played_games'], 'label', 'category'));
+        $this->assertSame([], $rows['200']['played_games']);
+        $this->assertArrayNotHasKey('300', $rows);
+    }
+
+    public function test_cancelled_unpaid_fake_zero_stake_and_unknown_games_do_not_count_as_play(): void
+    {
+        $this->order('third_game_orders', extra: ['game_type' => 1, 'status' => 2]);
+        $this->order('third_game_orders', extra: ['game_type' => 2, 'status' => 3]);
+        $this->order('third_game_orders', extra: ['game_type' => 8]);
+        $this->order('third_game_orders', extra: ['game_type' => 3, 'bet_amount' => 0]);
+        $this->order('sport_order', extra: ['pay_status' => 0]);
+        $this->order('sport_order', extra: ['return_status' => 2]);
+        $this->order('sport_order', extra: ['is_faker' => 1]);
+        $this->order('sport_game_order', extra: ['status' => 3]);
+        $this->order('lhc_order', extra: ['lottery_status' => 3]);
+        $this->order('lhc_order', extra: ['lottery_status' => 4]);
+        $this->order('lhc_order', extra: ['is_faker' => 1]);
+        $this->order('bets', extra: ['status' => 3]);
+        $this->order('jisu_game_order', extra: ['status' => 3]);
+        $this->assertSame([], $this->rows()['100']['played_games']);
+    }
+
+    public function test_pending_bets_count_without_waiting_for_commission_settlement(): void
+    {
+        $this->order('third_game_orders', extra: ['game_type' => 1, 'status' => 0]);
+        $this->order('sport_order');
+        $this->order('sport_game_order', extra: ['status' => 0]);
+        $this->order('lhc_order', extra: ['lottery_status' => 0]);
+        $this->order('bets', extra: ['status' => 1]);
+        $this->order('jisu_game_order', extra: ['status' => 0]);
+        $this->assertSame(['third:1', 'self:sport', 'self:lottery'], array_column($this->rows()['100']['played_games'], 'category'));
+    }
+
+    public function test_third_party_identity_supports_user_id_only_and_does_not_mix_members(): void
+    {
+        $this->order('third_game_orders', extra: ['user_id' => 1, 'member_id' => null, 'game_type' => 1]);
+        $this->order('third_game_orders', '200', ['user_id' => 2, 'game_type' => 2]);
+        $this->order('third_game_orders', '100', ['user_id' => 2, 'game_type' => 3]); // conflicting identity
+        $rows = $this->rows();
+        $this->assertSame(['third:1'], array_column($rows['100']['played_games'], 'category'));
+        $this->assertSame(['third:2'], array_column($rows['200']['played_games'], 'category'));
+    }
+
+    public static function gameSources(): array
+    {
+        return array_map(fn ($table) => [$table], ['third_game_orders', 'sport_order', 'sport_game_order', 'lhc_order', 'bets', 'jisu_game_order']);
+    }
+
+    #[DataProvider('gameSources')]
+    public function test_each_game_source_respects_the_selected_dates(string $table): void
+    {
+        $extra = $table === 'third_game_orders' ? ['game_type' => 1] : [];
+        $this->order($table, extra: $extra, at: '2026-11-03 23:59:59');
+        $this->order($table, extra: $extra, at: '2026-11-05 00:00:00');
+        $params = ['start_time' => '2026-11-04', 'end_time' => '2026-11-04'];
+        $this->assertSame([], $this->rows($params)['100']['played_games']);
+        $this->order($table, extra: $extra, at: '2026-11-04 00:00:00');
+        $this->assertCount(1, $this->rows($params)['100']['played_games']);
+    }
+
+    public function test_profit_is_recharge_minus_withdraw_and_deducts_refunded_withdrawals(): void
+    {
+        $this->funds('充值', '1000.11');
+        $this->funds('人工充值', '200.22');
+        $this->funds('三方充值', '300.33');
+        $this->funds('提现', '-2000.44');
+        $this->funds('三方提现', '-500.55');
+        $this->funds('三方提现', '500.55'); // failed withdrawal returned in full
+        $this->funds('优惠活动', '9999');
+        $this->funds('人工扣款', '-9999');
+        $this->funds('充值', '8888', '300');
+        $row = $this->rows()['100'];
+        $this->assertSame('1,500.66', $row['recharge']);
+        $this->assertSame('2,000.44', $row['withdraw']);
+        $this->assertSame('-499.78', $row['withdraw_recharge_difference']);
+
+        $this->funds('提现', '-100', '200');
+        $this->funds('充值', '500', '200');
+        $this->assertSame('400.00', $this->rows()['200']['withdraw_recharge_difference']);
+    }
+
+    public function test_date_filters_apply_to_money_categories_and_existing_order_amount_at_day_boundaries(): void
+    {
+        foreach (['2026-11-04 00:00:00', '2026-11-04 23:59:59'] as $at) {
+            $this->order('sport_order', at: $at);
+            $this->order('lhc_order', at: $at);
+            $this->funds('充值', '100', at: $at);
+        }
+        $this->order('third_game_orders', extra: ['game_type' => 1], at: '2026-11-03 23:59:59');
+        $this->order('third_game_orders', extra: ['game_type' => 2], at: '2026-11-05 00:00:00');
+        $this->order('sport_order', at: '2026-11-03 23:59:59');
+        $this->order('sport_order', at: '2026-11-05 00:00:00');
+        $this->funds('充值', '999', at: '2026-11-03 23:59:59');
+        $this->funds('提现', '-999', at: '2026-11-05 00:00:00');
+        $this->funds('提现', '-80');
+        $row = $this->rows(['start_time' => '2026-11-04', 'end_time' => '2026-11-04'])['100'];
+        $this->assertSame('200.00', $row['recharge']);
+        $this->assertSame('80.00', $row['withdraw']);
+        $this->assertSame('120.00', $row['withdraw_recharge_difference']);
+        $this->assertSame('20.00', $row['order_amount']);
+        $this->assertSame(['self:sport', 'self:lottery'], array_column($row['played_games'], 'category'));
+        $this->assertCount(4, $this->rows()['100']['played_games']);
+    }
+
+    public function test_empty_users_default_to_zero_and_filters_and_pagination_keep_the_response_shape(): void
+    {
+        $row = $this->rows(['account' => 'lic'])['100'];
+        foreach (['recharge', 'withdraw', 'withdraw_recharge_difference', 'order_amount'] as $key) $this->assertSame('0.00', $row[$key]);
+        $this->assertSame([], $row['played_games']);
+        $this->assertSame([], $this->rows(['account' => 'missing']));
+        $body = $this->response(['page' => 2, 'limit' => 1]);
+        $this->assertSame(2, $body['data']['total']);
+        $this->assertSame(['100'], array_column($body['data']['data'], 'member_id'));
+        $this->assertNotSame(0, $this->response(['start_time' => '2026-11-04'])['code']);
+    }
+
+    private function sortingMembers(): void
+    {
+        foreach (['400' => 'carol', '500' => 'dave'] as $member => $account) {
+            DB::table('users')->insert(['user_id' => (string) $member, 'member_id' => (string) $member,
+                'account' => $account, 'first_name' => $account, 'created_at' => '2026-11-04 12:00:00']);
+            DB::table('wallets')->insert(['member_id' => (string) $member]);
+        }
+        $this->funds('充值', '2000.10');
+        $this->funds('提现', '-50.10');
+        $this->funds('三方充值', '100.00', '200');
+        $this->funds('三方提现', '-1500.01', '200');
+        $this->funds('三方提现', '-9000', '200');
+        $this->funds('三方提现', '9000', '200'); // refunded, must not raise the withdrawal ranking
+        $this->funds('人工充值', '900', '400');
+        $this->funds('提现', '-100', '400');
+        $this->funds('充值', '999999', '300'); // virtual user
+        foreach (['100' => '9', '200' => '1000', '400' => '90', '500' => '0'] as $member => $balance) {
+            DB::table('wallets')->where('member_id', (string) $member)->update(['available_balance' => $balance]);
+        }
+    }
+
+    public static function amountSorts(): array
+    {
+        $cases = [];
+        foreach ([
+            'recharge' => ['500', '200', '400', '100'],
+            'withdraw' => ['500', '100', '400', '200'],
+            'withdraw_recharge_difference' => ['200', '500', '400', '100'],
+            'money' => ['500', '100', '400', '200'],
+        ] as $field => $ascending) {
+            $cases[$field . '_asc'] = [$field, 'asc', $ascending];
+            $cases[$field . '_desc'] = [$field, 'desc', array_reverse($ascending)];
+        }
+        return $cases;
+    }
+
+    #[DataProvider('amountSorts')]
+    public function test_amount_sorting_is_numeric_and_precedes_pagination(string $field, string $direction, array $expected): void
+    {
+        $this->sortingMembers();
+        $actual = [];
+        foreach ([1, 2] as $page) {
+            $body = $this->response(['sort_field' => $field, 'sort_order' => $direction, 'limit' => 2, 'page' => $page]);
+            $this->assertSame(0, $body['code'], $body['msg']);
+            $this->assertSame(4, $body['data']['total']);
+            array_push($actual, ...array_column($body['data']['data'], 'member_id'));
+        }
+        $this->assertSame($expected, $actual);
+    }
+
+    public function test_amount_sorting_uses_selected_dates_and_member_filters(): void
+    {
+        $this->sortingMembers();
+        $this->funds('充值', '100000', '200', '2026-11-03 12:00:00');
+        $this->funds('提现', '-100000', '100', '2026-11-05 12:00:00');
+        $dates = ['start_time' => '2026-11-04', 'end_time' => '2026-11-04'];
+        foreach (['recharge' => '100', 'withdraw' => '200', 'withdraw_recharge_difference' => '100'] as $field => $member) {
+            $body = $this->response($dates + ['sort_field' => $field, 'limit' => 1]);
+            $this->assertSame(0, $body['code'], $body['msg']);
+            $this->assertSame($member, $body['data']['data'][0]['member_id']);
+        }
+        $body = $this->response(['sort_field' => 'recharge', 'limit' => 1]);
+        $this->assertSame('200', $body['data']['data'][0]['member_id']);
+        $this->assertSame(['400'], array_column($this->response($dates + ['sort_field' => 'recharge', 'account' => 'car'])['data']['data'], 'member_id'));
+    }
+
+    public function test_equal_amounts_have_stable_order_across_pages_and_default_order_is_preserved(): void
+    {
+        DB::table('users')->insert(['id' => 4, 'user_id' => '400', 'member_id' => '400',
+            'account' => 'carol', 'first_name' => 'carol', 'created_at' => '2026-11-02 12:00:00']);
+        DB::table('wallets')->insert(['member_id' => '400', 'available_balance' => 88]);
+        foreach (['recharge', 'withdraw', 'withdraw_recharge_difference', 'money'] as $field) {
+            $members = [];
+            foreach ([1, 2, 3] as $page) {
+                $body = $this->response(['sort_field' => $field, 'sort_order' => 'asc', 'limit' => 1, 'page' => $page]);
+                $this->assertSame(0, $body['code'], $body['msg']);
+                $members[] = $body['data']['data'][0]['member_id'];
+            }
+            $this->assertSame(['400', '200', '100'], $members);
+        }
+        $this->assertSame(['400', '200', '100'], array_column($this->response()['data']['data'], 'member_id'));
+    }
+
+    public static function invalidSorts(): array
+    {
+        return [
+            [['sort_field' => 'created_at']],
+            [['sort_field' => 'money desc, users.id']],
+            [['sort_field' => ['money']]],
+            [['sort_field' => 'money', 'sort_order' => 'descending']],
+            [['sort_field' => 'money', 'sort_order' => ['asc']]],
+        ];
+    }
+
+    #[DataProvider('invalidSorts')]
+    public function test_invalid_sort_fields_and_directions_are_rejected(array $params): void
+    {
+        $this->assertNotSame(0, $this->response($params)['code']);
+    }
+}