Browse Source

三方游戏抽成:按订单保存费率与抽成,报表与财务读取保存值

- 同步三方注单时为亏损单保存当日费率与抽成,修正单沿用已保存费率;
  上线前的历史订单按上线费率快照补录
- third-game:backfill-commission 每 30 分钟补录缺费率订单;迁移未执行时跳过
- 费率查询失败 30 分钟后重试,不再整天缺费率
- 没有更新时间的订单版本不能覆盖已结算/已取消的记录
- 报表、资金流动新增 game_net_profit 与按日分类;同步失败或数据缺失时返回 null
- 财务汇总的结算日期与报表一致(优先使用后台结算日核对时间)
- 另一环境玩家的旧订单快照不参与补录和报表
- 迁移对表和字段做存在判断

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doge 12 hours ago
parent
commit
224b1a7d5f

+ 46 - 0
app/Console/Commands/ThirdGameCommissionBackfill.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\ThirdGameCommissionService;
+use App\Support\SyncCommandLock;
+use Illuminate\Console\Command;
+
+class ThirdGameCommissionBackfill extends Command
+{
+    protected $signature = 'third-game:backfill-commission {--retry-missing : 同时按当前费率补录上线后仍缺费率的订单}';
+
+    protected $description = '在本地补录三方游戏抽成,已保存的费率保持固定';
+
+    public function handle(ThirdGameCommissionService $service, SyncCommandLock $lock): int
+    {
+        if (!$service->migrated()) {
+            // 定时任务在迁移前也会触发;未迁移时没有可补录的范围,跳过而不是每次报错。
+            $this->info('三方抽成迁移尚未执行,跳过补录');
+
+            return self::SUCCESS;
+        }
+        $exit = $lock->run('third-game:backfill-commission', function () use ($service) {
+            // 历史订单(上线前)按上线费率快照补录;定时任务也会执行,直到历史订单补完,部署后无需手动运行。
+            $exit = self::SUCCESS;
+            try {
+                $history = $service->backfill(false);
+                $this->info(sprintf('历史订单:已补录 %d 笔,仍缺费率 %d 笔', $history['updated'], $history['remaining']));
+            } catch (\RuntimeException $e) {
+                $this->error('历史订单:' . $e->getMessage());
+                $exit = self::FAILURE;
+            }
+            if ($this->option('retry-missing')) {
+                $result = $service->backfill(true);
+                $this->info(sprintf('上线后订单:已补录 %d 笔,仍缺费率 %d 笔', $result['updated'], $result['remaining']));
+            }
+
+            return $exit;
+        });
+        if ($exit === null) {
+            $this->info('三方抽成补录正在运行');
+        }
+
+        return $exit ?? self::SUCCESS;
+    }
+}

+ 3 - 0
app/Console/Kernel.php

@@ -34,6 +34,9 @@ class Kernel extends ConsoleKernel
         $schedule->command('third-game:sync-orders')
             ->everyMinute()
             ->withoutOverlapping(5);
+        $schedule->command('third-game:backfill-commission --retry-missing')
+            ->everyThirtyMinutes()
+            ->withoutOverlapping(60);
         $schedule->command('agent:settle')
             ->dailyAt('04:10')
             ->withoutOverlapping(180);

+ 2 - 0
app/Models/ThirdGameOrder.php

@@ -38,6 +38,8 @@ class ThirdGameOrder extends BaseModel
         'bet_amount' => 'decimal:10',
         'valid_amount' => 'decimal:10',
         'settled_amount' => 'decimal:10',
+        'third_game_commission_ratio' => 'decimal:10',
+        'third_game_commission' => 'decimal:10',
         'status' => 'integer',
         'bet_time' => 'datetime',
         'last_update_time' => 'datetime',

+ 1 - 0
app/Services/FundsSummaryService.php

@@ -84,6 +84,7 @@ class FundsSummaryService
             'expense_total' => $commission['complete'] ? self::sumItems($expense) : null,
             'known_expense_total' => bcadd(self::sumItems($expense),
                 $commission['complete'] ? '0.00' : self::commissionMoney($commission['known_amount']), 2),
+            'third_game_net_profit' => $commission['net_profit'] === null ? null : self::commissionMoney($commission['net_profit']),
             'third_game_commission_meta' => $commission,
         ];
     }

+ 4 - 2
app/Services/Reporting/ReportAdmin.php

@@ -4,6 +4,7 @@ namespace App\Services\Reporting;
 
 use App\Services\OperationAuditService;
 use App\Services\Sharing\ShareMemberQualification;
+use App\Services\ThirdGameCommissionService;
 use App\Support\MaintenancePolicy;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Validator;
@@ -19,7 +20,7 @@ class ReportAdmin
             'platforms' => ['all', 'H5', 'PC', 'APP', 'unknown'], 'settlement_statuses' => ['all', 'pending', 'settled'],
             'ranking_metrics' => ['deposit_amount', 'bet_amount', 'payout_amount', 'withdraw_amount', 'activity_minutes'],
             'timezone' => 'Asia/Shanghai', 'currency' => (new ReportSync())->currency(), 'max_days' => 366,
-            'rules' => ['profit' => '已结算游戏毛利-实发优惠-代理佣金-分享佣金-实际平台手续费',
+            'rules' => ['profit' => '已结算游戏毛利-三方游戏平台抽成-实发优惠-代理佣金-分享佣金-实际平台手续费',
                 'net_cash_flow' => '成功收款-实际对外打款-实际平台手续费', 'valid_member' => '范围内期间非撤销投注会员,按查询结束日已生效的分享赚钱规则,累计成功存款及累计已结算非撤销同币种有效投注均大于或等于对应门槛;累计包含查询开始日前记录,不受装置及游戏分类筛选影响',
                 'online_rate' => '期间活跃会员/范围内期末真实会员', 'public_costs' => '公共资金和成本只在所有订单展示,未知值为null']];
     }
@@ -106,7 +107,8 @@ class ReportAdmin
         $p = Validator::make($input, ['page' => ['integer', 'min:1', 'max:1000000'], 'limit' => ['integer', 'min:1', 'max:100']])->validate();
         return ['total' => DB::table('reporting_errors')->count(), 'data' => DB::table('reporting_errors')->orderByDesc('id')
             ->forPage((int) ($p['page'] ?? 1), (int) ($p['limit'] ?? 20))->get()->map(fn ($r) => (array) $r)->all(),
-            'sources' => DB::table('reporting_sync_state')->orderBy('source')->get()->map(fn ($r) => ['source' => $r->source, 'state' => json_decode($r->state, true), 'updated_at' => $r->updated_at])->all()];
+            'sources' => DB::table('reporting_sync_state')->where('source', '<>', ThirdGameCommissionService::BACKFILL_STATE)
+                ->orderBy('source')->get()->map(fn ($r) => ['source' => $r->source, 'state' => json_decode($r->state, true), 'updated_at' => $r->updated_at])->all()];
     }
 
     private function attributionRules(): array

+ 68 - 22
app/Services/Reporting/ReportReader.php

@@ -78,7 +78,7 @@ class ReportReader
         $p = $this->params($input + ['report_type' => 'business']); $p['report_type'] = 'business'; $p['category'] = 'all'; $p['settlement_status'] = 'all';
         $result = $this->compute($p, 'daily'); $all = $result['groups']['all']; unset($result['groups']['all']);
         $days = array_values($result['groups']); usort($days, fn ($a, $b) => strcmp($b['group_id'], $a['group_id']));
-        $keep = array_flip(array_merge(['group_id', 'label', 'net_cash_flow', 'company_profit', 'known_net_cash_flow', 'known_company_profit', 'known_third_game_commission', 'complete', 'quality'], self::AMOUNTS));
+        $keep = array_flip(array_merge(['group_id', 'label', 'net_cash_flow', 'company_profit', 'known_net_cash_flow', 'known_company_profit', 'known_third_game_commission', 'game_net_profit', 'complete', 'quality', 'categories'], self::AMOUNTS));
         $all = array_intersect_key($all, $keep);
         $days = array_map(fn ($row) => ['date' => $row['group_id']] + array_intersect_key($row, $keep), $days);
         return $result['meta'] + ['summary' => $all, 'total' => (int) Carbon::parse($p['start'])->diffInDays(Carbon::parse($p['end'])), 'data' => $days];
@@ -88,6 +88,11 @@ class ReportReader
     {
         $p = $this->params($input); [$users, $agents] = $this->people($p); $query = $this->orderQuery($p);
         $rows = []; $total = 0;
+        if (Schema::hasTable('third_game_orders')) {
+            $query->selectSub(DB::table('third_game_orders')->select('platform')
+                ->whereColumn('third_game_orders.id', 'reporting_orders.parent_id')
+                ->where('reporting_orders.parent_source', 'third_game_orders')->limit(1), 'provider_platform');
+        }
         foreach ($query->orderByDesc('bet_at')->orderByDesc('id')->cursor() as $record) {
             $row = (array) $record;
             if (!isset($users[$row['user_id']]) || !$this->scope($row, $p, $users, $agents)) continue;
@@ -118,7 +123,8 @@ class ReportReader
     {
         [$users, $agents] = $this->people($p); $settings = (new ReportAdmin())->settings();
         $qualification = (new ShareMemberQualification())->read($p['end_date'], $mode !== 'daily'); $policy = $qualification['policy_meta'];
-        $groups = []; $unallocated = null; $now = now('Asia/Shanghai')->toDateTimeString(); $rates = null;
+        $groups = []; $unallocated = null; $now = now('Asia/Shanghai')->toDateTimeString();
+        $dailyCategories = [];
         if ($mode === 'summary') foreach (['all' => '所有订单'] + MaintenancePolicy::CATEGORIES as $key => $label) $groups[$key] = $this->empty($key, $label);
         if ($mode === 'daily') {
             $groups['all'] = $this->empty('all', '总计');
@@ -145,7 +151,12 @@ class ReportReader
                 if ($key === null) continue;
                 if (!isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
                 $window = $mode === 'daily' && $key !== 'all' ? ['start' => $key . ' 00:00:00', 'end' => Carbon::parse($key)->addDay()->toDateTimeString()] + $p : $p;
-                $this->addOrder($groups[$key], $row, $window, $rates);
+                $this->addOrder($groups[$key], $row, $window);
+                if ($mode === 'daily' && isset(MaintenancePolicy::CATEGORIES[$row['category']])) {
+                    $category = $row['category'];
+                    $dailyCategories[$key][$category] ??= $this->emptyGameCategory($category);
+                    $this->addGameResult($dailyCategories[$key][$category], $row, $window);
+                }
                 if ($p['report_type'] === 'promotion' && $row['attribution_quality'] === 'historical_current_agent') ++$groups[$key]['quality']['unverified_attribution'];
             }
         }
@@ -205,10 +216,9 @@ class ReportReader
             if ($mode === 'daily') $value['quality']['missing_time'] += $key === 'all' ? 0 : $globalMissingTime;
             $complete = !array_sum(array_map('intval', $value['quality'])); $value['complete'] = $complete;
             $value['known_net_cash_flow'] = bcsub(bcsub($value['deposit_amount'], $value['withdraw_amount'], 10), bcadd($value['deposit_fee'], $value['withdraw_fee'], 10), 10);
-            $value['known_third_game_commission'] = $value['third_game_commission'];
             $cost = '0'; foreach (['bonus_amount', 'agent_commission', 'share_commission', 'deposit_fee', 'withdraw_fee', 'third_game_commission'] as $f) $cost = bcadd($cost, $value[$f], 10);
             $value['known_company_profit'] = bcsub($value['game_gross_profit'], $cost, 10);
-            if ($value['quality']['missing_platform_ratio']) $value['third_game_commission'] = null;
+            $this->finishGameResult($value);
             $cashComplete = !array_sum(array_map('intval', array_diff_key($value['quality'], ['missing_platform_ratio' => true])));
             $value['net_cash_flow'] = $cashComplete ? $value['known_net_cash_flow'] : null;
             $value['company_profit'] = $complete && $p['settlement_status'] !== 'pending' ? $value['known_company_profit'] : null;
@@ -217,6 +227,17 @@ class ReportReader
                 $value['net_cash_flow'] = $value['company_profit'] = $value['known_net_cash_flow'] = $value['known_company_profit'] = null;
             }
             if ($mode === 'daily' && $key !== 'all') { $value['activity_minutes'] = $value['active_rate'] = null; }
+            if ($mode === 'daily') {
+                $value['categories'] = [];
+                foreach (MaintenancePolicy::CATEGORIES as $category => $label) {
+                    $game = $dailyCategories[$key][$category] ?? $this->emptyGameCategory($category);
+                    // Sync state is global: a failed or unfinished sync makes every category of the day uncertain.
+                    $game['quality']['sync_errors'] = $value['quality']['sync_errors'];
+                    $game['quality']['not_synchronized'] = $value['quality']['not_synchronized'];
+                    $this->finishGameResult($game);
+                    $value['categories'][] = $game;
+                }
+            }
         } unset($value);
         if (isset($groups['unallocated'])) { $unallocated = $groups['unallocated']; unset($groups['unallocated']); }
         return ['groups' => $groups, 'unallocated' => $unallocated, 'meta' => ['filters' => array_diff_key($p, ['start' => 1, 'end' => 1]),
@@ -224,8 +245,7 @@ class ReportReader
             'activity_started_at' => $settings['activity_started_at'], 'valid_deposit_min' => $policy['min_deposit'],
             'valid_effective_bet_min' => $policy['min_effective_bet'], 'valid_member_policy' => $policy,
             'commission_balances' => $this->commissionBalances($p, $users, $agents),
-            'third_game_commission_meta' => ['basis' => ThirdGameCommissionService::BASIS, 'estimated' => true,
-                'rate_date' => $rates['date'] ?? null, 'rates_fetched_at' => $rates['fetched_at'] ?? null], 'server_time' => $now]];
+            'third_game_commission_meta' => ThirdGameCommissionService::meta(), 'server_time' => $now]];
     }
 
     private function commissionBalances(array $p, array $users, array $agents): array
@@ -265,13 +285,7 @@ class ReportReader
         });
         if ($p['settlement_status'] === 'settled') $q->where('settled', 1)->where('voided', 0);
         if ($p['settlement_status'] === 'pending') $q->where('settled', 0)->where('voided', 0);
-        $q->select('reporting_orders.*');
-        if (Schema::hasTable('third_game_orders')) {
-            $q->selectSub(DB::table('third_game_orders')->select('platform')
-                ->whereColumn('third_game_orders.id', 'reporting_orders.parent_id')
-                ->where('reporting_orders.parent_source', 'third_game_orders')->limit(1), 'provider_platform');
-        }
-        return $q;
+        return $q->select('reporting_orders.*');
     }
 
     private function eventQuery(array $p)
@@ -312,7 +326,7 @@ class ReportReader
         return ['group_id' => $key, 'label' => $label] + array_fill_keys(self::AMOUNTS, Money::zero()) + ['_orders' => [], '_bettors' => [],
             'quality' => ['missing_time' => 0, 'missing_amount' => 0, 'unverified_fees' => 0, 'foreign_currency' => 0, 'unverified_attribution' => 0, 'missing_platform_ratio' => 0]];
     }
-    private function addOrder(array &$value, array $row, array $p, ?array &$rates): void
+    private function addOrder(array &$value, array $row, array $p): void
     {
         if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
         if ($this->within($row['bet_at'], $p)) {
@@ -325,16 +339,48 @@ class ReportReader
             elseif ($row['settled']) {
                 $this->add($value, 'settled_bet_amount', bcsub($row['bet_amount'], $row['refund_amount'], 10)); $this->add($value, 'payout_amount', $row['payout_amount']);
                 $this->add($value, 'refund_amount', $row['refund_amount']);
-                $this->add($value, 'game_gross_profit', bcsub('0', $row['net_profit'], 10));
-                if ($row['parent_source'] === 'third_game_orders' && bccomp($row['net_profit'], '0', 10) !== 0) {
-                    $service = app(ThirdGameCommissionService::class); $rates ??= $service->rates();
-                    $amount = $service->amount((string) ($row['provider_platform'] ?? ''), bcsub('0', $row['net_profit'], 10), $rates);
-                    if ($amount === null) ++$value['quality']['missing_platform_ratio'];
-                    else $this->add($value, 'third_game_commission', $amount);
-                }
+                $this->addGameResult($value, $row, $p);
             }
         }
     }
+
+    private function emptyGameCategory(string $category): array
+    {
+        return [
+            'group_id' => $category, 'label' => MaintenancePolicy::CATEGORIES[$category],
+            'game_gross_profit' => Money::zero(), 'third_game_commission' => Money::zero(),
+            'quality' => ['missing_time' => 0, 'foreign_currency' => 0, 'missing_platform_ratio' => 0],
+        ];
+    }
+
+    private function addGameResult(array &$value, array $row, array $p): void
+    {
+        // addOrder() counts these gaps itself before calling here; per-category totals rely on this check.
+        if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
+        if ($row['voided'] || !$row['settled']) return;
+        if ($row['settled_at'] === null) { ++$value['quality']['missing_time']; return; }
+        if (!$this->within($row['settled_at'], $p)) return;
+
+        $this->add($value, 'game_gross_profit', bcsub('0', $row['net_profit'], 10));
+        if ($row['parent_source'] === 'third_game_orders' && bccomp($row['net_profit'], '0', 10) < 0) {
+            $amount = $row['third_game_commission'];
+            if ($amount === null) {
+                ++$value['quality']['missing_platform_ratio'];
+            } else {
+                $this->add($value, 'third_game_commission', $amount);
+            }
+        }
+    }
+
+    /** Game profit is only definite when synced data is current and no settled order was left out for a missing rate, time, amount or currency. */
+    private function finishGameResult(array &$value): void
+    {
+        $value['known_third_game_commission'] = $value['third_game_commission'];
+        if ($value['quality']['missing_platform_ratio']) $value['third_game_commission'] = null;
+        $gaps = array_intersect_key($value['quality'], array_flip(['missing_platform_ratio', 'missing_time', 'missing_amount', 'foreign_currency', 'sync_errors', 'not_synchronized']));
+        $value['game_net_profit'] = array_sum(array_map('intval', $gaps)) || $value['third_game_commission'] === null
+            ? null : bcsub($value['game_gross_profit'], $value['third_game_commission'], 10);
+    }
     private function addEvent(array &$value, array $row): void
     {
         if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }

+ 7 - 2
app/Services/Reporting/ReportSync.php

@@ -4,6 +4,7 @@ namespace App\Services\Reporting;
 
 use App\Services\Sharing\BetFactSynchronizer;
 use App\Services\Sharing\EffectiveBet as Money;
+use App\Services\ThirdGameBalanceService;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
@@ -131,7 +132,8 @@ class ReportSync
     private function orders(string $source, array $row): void
     {
         $at = $this->time($row['created_at'] ?? $row['create_time'] ?? null);
-        if ((int) ($row['is_faker'] ?? 0) === 1) { DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $row['id'])->delete(); return; }
+        $foreign = $source === 'third_game_orders' && !app(ThirdGameBalanceService::class)->ownsPlayer((string) ($row['player_id'] ?? ''));
+        if ((int) ($row['is_faker'] ?? 0) === 1 || $foreign) { DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $row['id'])->delete(); return; }
         $facts = [];
         if (in_array($source, ['bets', 'jisu_game_order'], true)) {
             $identity = ReportContext::identity($row, $at ?? now()->toDateTimeString(), $source);
@@ -172,6 +174,9 @@ class ReportSync
                 'bet_amount' => $fact['bet_amount'], 'payout_amount' => $payout, 'net_profit' => $fact['net_profit'],
                 'refund_amount' => $refund, 'settled' => (int) $fact['settled'], 'voided' => (int) $fact['voided'],
                 'time_quality' => (!$fact['settled'] && !$fact['voided']) || $settledAt !== null ? 'confirmed' : 'missing_settlement_time'];
+            if ($source === 'third_game_orders') {
+                $values['third_game_commission'] = $row['third_game_commission'] ?? null;
+            }
             $this->writeProjection('reporting_orders', $key, $values);
         }
         DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $row['id'])->when($keys, fn ($q) => $q->whereNotIn('source_id', $keys))->delete();
@@ -279,7 +284,7 @@ class ReportSync
     {
         $old = DB::table($table)->where($key)->first();
         if (!$old) { DB::table($table)->insert($key + $values + ['created_at' => now(), 'updated_at' => now()]); return; }
-        $decimals = ['bet_amount', 'payout_amount', 'net_profit', 'refund_amount', 'amount', 'customer_fee', 'configured_fee'];
+        $decimals = ['bet_amount', 'payout_amount', 'net_profit', 'refund_amount', 'third_game_commission', 'amount', 'customer_fee', 'configured_fee'];
         foreach ($values as $column => $value) {
             $previous = $old->$column;
             $same = $value === null || $previous === null ? $value === $previous

+ 6 - 0
app/Services/ThirdGameBalanceService.php

@@ -188,6 +188,12 @@ class ThirdGameBalanceService
         return $prefix . str_pad((string) $userId, 4, '0', STR_PAD_LEFT);
     }
 
+    /** Test players start with tu; each environment owns only its own namespace (old foreign snapshots may exist). */
+    public function ownsPlayer(string $playerId): bool
+    {
+        return ($this->playerPrefix() === 'tu') === str_starts_with(trim($playerId), 'tu');
+    }
+
     public function playerPrefix(): string
     {
         $prefix = trim((string) config('third_game.player_prefix', 'u'));

+ 165 - 23
app/Services/ThirdGameCommissionService.php

@@ -2,59 +2,200 @@
 
 namespace App\Services;
 
+use App\Services\AdminRealtime\AdminRealtime;
+use App\Services\Reporting\ReportSync;
 use App\Services\Sharing\EffectiveBet as Money;
 use Illuminate\Contracts\Cache\LockTimeoutException;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
+use RuntimeException;
 
 class ThirdGameCommissionService
 {
-    public const BASIS = 'game_gross_profit';
+    public const BACKFILL_STATE = '__third_game_commission_backfill__';
+    /** A failed rate lookup is retried after this long instead of blocking the rest of the day. */
+    private const FAILED_RATES_TTL_MINUTES = 30;
 
-    public function amount(string $platform, string $grossProfit, array $rates): ?string
+    public static function result(int $status, ?string $net): ?string
     {
-        if (bccomp($grossProfit, '0', 10) === 0) return Money::zero();
-        $ratio = $rates['ratios'][strtolower(trim($platform))] ?? null;
-        return $ratio === null ? null : bcmul($grossProfit, $ratio, 10);
+        if ($status !== 1 || $net === null) {
+            return null;
+        }
+
+        return match (bccomp($net, '0', 10)) {
+            -1 => 'loss',
+            1 => 'win',
+            0 => 'draw',
+        };
+    }
+
+    public static function charge(string $net, string $ratio): string
+    {
+        return bcmul(bcsub('0', $net, 10), $ratio, 10);
+    }
+
+    public static function currency(): string
+    {
+        return strtoupper(trim((string) config('third_game.currency', 'CNY')));
+    }
+
+    public static function meta(): array
+    {
+        return ['basis' => 'player_net_loss', 'estimated' => true, 'rate_date' => null, 'rates_fetched_at' => null];
+    }
+
+    /** Whether the commission migration has run: the columns exist and the launch range was recorded. */
+    public function migrated(): bool
+    {
+        return Schema::hasTable('third_game_orders') && Schema::hasColumn('third_game_orders', 'third_game_commission_ratio')
+            && $this->backfillState() !== [];
+    }
+
+    public function backfillState(): array
+    {
+        if (!Schema::hasTable('reporting_sync_state')) return [];
+        $state = DB::table('reporting_sync_state')->where('source', self::BACKFILL_STATE)->value('state');
+
+        return $state === null ? [] : json_decode($state, true, 512, JSON_THROW_ON_ERROR);
+    }
+
+    public function isHistorical(?int $id, ?string $settledAt, array $state): bool
+    {
+        return $id !== null && $id <= ($state['max_id'] ?? 0)
+            && ($settledAt === null || $settledAt <= $state['cutoff_at']);
+    }
+
+    public function backfill(bool $retryMissing): array
+    {
+        $state = $this->backfillState();
+        if ($state === []) {
+            throw new RuntimeException('缺少三方抽成回填范围,请先运行迁移');
+        }
+
+        $orders = DB::table('third_game_orders')->where('status', 1)
+            ->where('settled_amount', '<', 0)->whereNull('third_game_commission_ratio')
+            ->whereRaw('UPPER(TRIM(currency)) = ?', [self::currency()])
+            // Old snapshots of the other environment's players stay untouched, like the order list and finance summary.
+            ->where('player_id', $this->testPlayerNamespace() ? 'like' : 'not like', 'tu%');
+        if ($retryMissing) {
+            $orders->where(function ($query) use ($state) {
+                $query->where('id', '>', $state['max_id'])->orWhere('last_update_time', '>', $state['cutoff_at']);
+            });
+            if (!$orders->exists()) {
+                return ['updated' => 0, 'remaining' => 0];
+            }
+            $snapshot = $this->rates();
+        } else {
+            $orders->where('id', '<=', $state['max_id'])->where(function ($query) use ($state) {
+                $query->whereNull('last_update_time')->orWhere('last_update_time', '<=', $state['cutoff_at']);
+            });
+            if (!$orders->exists()) {
+                return ['updated' => 0, 'remaining' => 0];
+            }
+            $snapshot = $state['snapshot'];
+            if ($snapshot === null) {
+                $snapshot = $this->rates();
+                if (!$snapshot['available'] || $snapshot['ratios'] === []) {
+                    throw new RuntimeException('未取得上线费率,请在费率可用时重试历史回填');
+                }
+                $state['snapshot'] = $snapshot;
+                $state['currency'] = self::currency();
+                DB::table('reporting_sync_state')->where('source', self::BACKFILL_STATE)->update([
+                    'state' => json_encode($state, JSON_THROW_ON_ERROR),
+                    'updated_at' => now('Asia/Shanghai')->toDateTimeString(),
+                ]);
+            } elseif ($state['currency'] !== self::currency()) {
+                throw new RuntimeException('当前三方币种与历史回填费率币种不一致');
+            }
+        }
+
+        $updated = 0;
+        $ratios = $snapshot['ratios'];
+        if ($ratios !== []) {
+            $candidates = (clone $orders)->whereIn(DB::raw('LOWER(TRIM(platform))'), array_keys($ratios));
+            $candidates->select('id')->chunkById(200, function ($batch) use ($candidates, $ratios, &$updated) {
+                $ids = DB::transaction(function () use ($candidates, $batch, $ratios) {
+                    $ids = [];
+                    $locked = (clone $candidates)->whereIn('id', $batch->pluck('id'))
+                        ->select('id', 'platform', 'settled_amount')->orderBy('id')->lockForUpdate()->get();
+                    $now = now('Asia/Shanghai')->toDateTimeString();
+                    foreach ($locked as $order) {
+                        $ratio = $ratios[strtolower(trim($order->platform))];
+                        DB::table('third_game_orders')->where('id', $order->id)->update([
+                            'third_game_commission_ratio' => $ratio,
+                            'third_game_commission' => self::charge(Money::decimal($order->settled_amount), $ratio),
+                            'updated_at' => $now,
+                        ]);
+                        $ids[] = $order->id;
+                    }
+
+                    return $ids;
+                }, 3);
+                $updated += count($ids);
+                AdminRealtime::record('third_game_orders', $ids);
+            });
+        }
+
+        return ['updated' => $updated, 'remaining' => $orders->count()];
+    }
+
+    private function testPlayerNamespace(): bool
+    {
+        return app(ThirdGameBalanceService::class)->playerPrefix() === 'tu';
     }
 
     /** Finance reads original orders, independently of the reporting projection's sync schedule. */
     public function summary(?string $start, ?string $end): array
     {
-        $known = Money::zero(); $people = []; $rates = null;
+        $known = Money::zero(); $gross = Money::zero(); $people = [];
+        $currency = (new ReportSync())->currency();
         $quality = ['missing_ratio' => 0, 'missing_time' => 0, 'missing_amount' => 0, 'foreign_currency' => 0];
         if (Schema::hasTable('third_game_orders')) {
             $query = DB::table('third_game_orders as o')->join('users as u', 'u.id', '=', 'o.user_id')
                 ->where(function ($q) { $q->whereNull('u.from')->orWhere('u.from', '<>', 2); })
-                ->where('o.status', 1)->select('o.*');
-            $query->where('o.player_id', config('third_game.player_prefix', 'u') === 'tu' ? 'like' : 'not like', 'tu%');
+                ->where('o.status', 1);
+            // Same settlement date as the report: an admin-verified time (结算日核对) wins over the provider time.
+            $grammar = $query->getGrammar();
+            $settledAt = $grammar->wrap('o.last_update_time');
+            if (Schema::hasTable('reporting_contexts')) {
+                $query->leftJoin('reporting_contexts as c', function ($join) use ($grammar) {
+                    // source_id is a string; cast the order id so the (source, source_id) index is usable.
+                    $join->where('c.source', 'third_game_orders')
+                        ->whereRaw($grammar->wrap('c.source_id') . ' = CAST(' . $grammar->wrap('o.id') . ' AS CHAR)')
+                        ->where('c.settlement_verified', 1);
+                });
+                $settledAt = 'COALESCE(' . $grammar->wrap('c.succeeded_at') . ', ' . $settledAt . ')';
+            }
+            $query->select('o.*')->selectRaw($settledAt . ' AS report_settled_at');
+            $query->where('o.player_id', $this->testPlayerNamespace() ? 'like' : 'not like', 'tu%');
             if ($start !== null && $end !== null) {
-                $query->where(function ($q) use ($start, $end) {
-                    $q->whereBetween('o.last_update_time', [$start, $end])
-                        ->orWhere(function ($q) use ($end) { $q->whereNull('o.last_update_time')->where('o.bet_time', '<=', $end); });
+                $query->where(function ($q) use ($start, $end, $settledAt) {
+                    $q->whereRaw($settledAt . ' BETWEEN ? AND ?', [$start, $end])
+                        ->orWhere(function ($q) use ($end, $settledAt) { $q->whereRaw($settledAt . ' IS NULL')->where('o.bet_time', '<=', $end); });
                 });
             }
             foreach ($query->orderBy('o.id')->cursor() as $row) {
-                if ($row->last_update_time === null) { ++$quality['missing_time']; continue; }
-                if (strtoupper(trim((string) $row->currency)) !== config('reporting.currency', 'CNY')) { ++$quality['foreign_currency']; continue; }
+                if ($row->report_settled_at === null) { ++$quality['missing_time']; continue; }
+                if (strtoupper(trim((string) $row->currency)) !== $currency) { ++$quality['foreign_currency']; continue; }
                 if ($row->settled_amount === null) { ++$quality['missing_amount']; continue; }
-                $gross = bcsub('0', (string) $row->settled_amount, 10);
-                if (bccomp($gross, '0', 10) === 0) continue;
-                $rates ??= $this->rates();
-                $amount = $this->amount((string) $row->platform, $gross, $rates);
+                $net = Money::decimal($row->settled_amount);
+                $gross = bcsub($gross, $net, 10);
+                if (self::result(1, $net) !== 'loss') continue;
+                $amount = $row->third_game_commission;
                 if ($amount === null) { ++$quality['missing_ratio']; continue; }
+                $amount = Money::decimal($amount);
                 $known = bcadd($known, $amount, 10);
                 if (bccomp($amount, '0', 10) !== 0) $people[$row->user_id] = true;
             }
         }
         $complete = array_sum($quality) === 0;
         return ['amount' => $complete ? $known : null, 'known_amount' => $known, 'people' => count($people),
-            'complete' => $complete, 'quality' => $quality, 'basis' => self::BASIS, 'estimated' => true,
-            'rate_date' => $rates['date'] ?? null, 'rates_fetched_at' => $rates['fetched_at'] ?? null];
+            'complete' => $complete, 'quality' => $quality, 'net_profit' => $complete ? bcsub($gross, $known, 10) : null]
+            + self::meta();
     }
 
-    /** One quota request per merchant/currency/Beijing day, including failed requests. */
+    /** One successful quota request per merchant/currency/Beijing day; failures are retried every 30 minutes. */
     public function rates(): array
     {
         $now = now('Asia/Shanghai');
@@ -72,12 +213,13 @@ class ThirdGameCommissionService
             return Cache::lock($key . ':lock', max(30, (int) config('third_game.timeout', 10) + 10))->block(2, function () use ($key, $now, $unavailable) {
                 $cached = Cache::get($key);
                 if (is_array($cached)) return $cached;
-                // Reserve the day before HTTP so concurrent requests and failures never retry in a loop.
-                Cache::put($key, $unavailable, $now->copy()->endOfDay());
+                // Reserve before HTTP so concurrent requests never retry in a loop; a failure is retried after a short pause.
+                $retryAt = $now->copy()->addMinutes(self::FAILED_RATES_TTL_MINUTES)->min($now->copy()->endOfDay());
+                Cache::put($key, $unavailable, $retryAt);
                 $result = app(ThirdGameBalanceService::class)->quotaRatios();
                 $snapshot = ['date' => $now->toDateString(), 'fetched_at' => now('Asia/Shanghai')->toDateTimeString(),
                     'available' => (bool) $result['ok'], 'ratios' => $result['ratios']];
-                Cache::put($key, $snapshot, $now->copy()->endOfDay());
+                Cache::put($key, $snapshot, $snapshot['available'] ? $now->copy()->endOfDay() : $retryAt);
                 return $snapshot;
             });
         } catch (LockTimeoutException $e) {

+ 145 - 63
app/Services/ThirdGameOrderService.php

@@ -4,7 +4,9 @@ namespace App\Services;
 
 use App\Models\ThirdGameOrder as ThirdGameOrderModel;
 use App\Services\AdminRealtime\AdminRealtime;
+use App\Services\Sharing\EffectiveBet as Money;
 use Carbon\Carbon;
+use Illuminate\Database\UniqueConstraintViolationException;
 use Illuminate\Support\Facades\DB;
 
 class ThirdGameOrderService
@@ -40,16 +42,16 @@ class ThirdGameOrderService
         int $limit = 2000
     ): array {
         return $this->persistProviderResult(
-            $this->provider->historyOrders($startTime, $endTime, $page, $limit)
+            $this->provider->historyOrders($startTime, $endTime, $page, $limit), $page, $limit
         );
     }
 
     public function syncRealtime(int $page = 1, int $limit = 2000): array
     {
-        return $this->persistProviderResult($this->provider->realtimeOrders($page, $limit));
+        return $this->persistProviderResult($this->provider->realtimeOrders($page, $limit), $page, $limit);
     }
 
-    private function persistProviderResult(array $result): array
+    private function persistProviderResult(array $result, int $page = 1, int $limit = 2000): array
     {
         if (empty($result['ok'])) {
             return $result;
@@ -66,10 +68,8 @@ class ThirdGameOrderService
             static fn(array $order): string => trim((string) ($order['player_id'] ?? '')),
             $orders
         )))));
-        $existing = $this->existingSnapshots($orders);
         $now = Carbon::now(config('app.timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
         $rows = [];
-        $matchedUserIds = [];
 
         foreach ($orders as $order) {
             $platform = trim((string) ($order['platform'] ?? ''));
@@ -80,19 +80,15 @@ class ThirdGameOrderService
 
             $playerId = trim((string) ($order['player_id'] ?? ''));
             $identity = $this->identity($platform, $playerId, $gameOrderId);
-            $user = $users[$playerId] ?? null;
-            $previous = $existing[$identity] ?? null;
-            $userId = $user->id ?? $previous->user_id ?? null;
-            if ($userId !== null) {
-                $matchedUserIds[(int) $userId] = true;
+            $updated = $this->dateTime($order['last_update_time'] ?? null);
+            $status = min(255, max(0, (int) ($order['status'] ?? 0)));
+            if (isset($rows[$identity])
+                && $this->isStale($updated, $rows[$identity]['last_update_time'], $rows[$identity]['status'])) {
+                continue;
             }
 
             $rows[$identity] = [
                 'order_key' => $identity,
-                'user_id' => $userId,
-                'member_id' => $user->member_id ?? $previous->member_id ?? null,
-                'username' => (string) ($user->username ?? $previous->username ?? ''),
-                'first_name' => (string) ($user->first_name ?? $previous->first_name ?? ''),
                 'player_id' => $playerId,
                 'platform' => $platform,
                 'currency' => trim((string) ($order['currency'] ?? '')),
@@ -105,29 +101,42 @@ class ThirdGameOrderService
                 'valid_amount' => $this->decimal($order['valid_amount'] ?? null),
                 'settled_amount' => $this->decimal($order['settled_amount'] ?? null),
                 'bet_content' => $this->text($order['bet_content'] ?? ''),
-                'status' => min(255, max(0, (int) ($order['status'] ?? 0))),
+                'status' => $status,
                 'game_order_id' => $gameOrderId,
                 'bet_time' => $this->dateTime($order['bet_time'] ?? null),
-                'last_update_time' => $this->dateTime($order['last_update_time'] ?? null),
-                'created_at' => $previous->created_at ?? $now,
+                'last_update_time' => $updated,
+                'created_at' => $now,
                 'updated_at' => $now,
             ];
         }
 
+        $synced = 0;
+        $unmatched = 0;
+        $matchedUserIds = [];
         if ($rows !== []) {
-            $changedKeys = array_keys(array_filter($rows, fn(array $row, string $key): bool
-                => $this->snapshotChanged($existing[$key] ?? null, $row), ARRAY_FILTER_USE_BOTH));
-            ThirdGameOrderModel::query()->upsert(
-                array_values($rows),
-                ['order_key'],
-                [
-                    'user_id', 'member_id', 'username', 'first_name', 'player_id', 'currency',
-                    'game_type', 'game_name', 'round_no', 'table_no', 'seat', 'bet_amount',
-                    'valid_amount', 'settled_amount', 'bet_content', 'status', 'bet_time',
-                    'last_update_time', 'updated_at',
-                ]
-            );
-            $this->recordRealtime($changedKeys, $existing);
+            $commission = app(ThirdGameCommissionService::class);
+            $history = $commission->backfillState();
+            $rates = [];
+            foreach ($rows as $row) {
+                if (strtoupper($row['currency']) === $commission::currency()
+                    && $commission::result($row['status'], $row['settled_amount']) === 'loss') {
+                    $rates = $commission->rates()['ratios'];
+                    break;
+                }
+            }
+
+            ksort($rows);
+            foreach (array_chunk($rows, 200, true) as $batch) {
+                $written = $this->writeOrders($batch, $users, $commission, $rates, $history);
+                $synced += count($written);
+                foreach ($written as $row) {
+                    if ($row['user_id'] === null) {
+                        ++$unmatched;
+                    } else {
+                        $matchedUserIds[(int) $row['user_id']] = true;
+                    }
+                }
+            }
         }
 
         $pageNo = (int) ($result['page_no'] ?? $page);
@@ -137,9 +146,9 @@ class ThirdGameOrderService
         return [
             'ok' => true,
             'received' => $received,
-            'synced' => count($rows),
+            'synced' => $synced,
             'matched_users' => count($matchedUserIds),
-            'unmatched_orders' => count(array_filter($rows, static fn(array $row): bool => $row['user_id'] === null)),
+            'unmatched_orders' => $unmatched,
             'total' => $total,
             'page_no' => $pageNo,
             'page_size' => $pageSize,
@@ -147,6 +156,100 @@ class ThirdGameOrderService
         ];
     }
 
+    private function writeOrders(array $batch, array $users, ThirdGameCommissionService $commission, array $rates, array $history): array
+    {
+        for ($attempt = 1; ; ++$attempt) {
+            try {
+                [$written, $existing] = DB::transaction(function () use ($batch, $users, $commission, $rates, $history) {
+                    $existing = ThirdGameOrderModel::query()->whereIn('order_key', array_keys($batch))
+                        ->orderBy('order_key')->lockForUpdate()->get([
+                            'id', 'order_key', 'user_id', 'member_id', 'username', 'first_name', 'currency',
+                            'status', 'settled_amount', 'last_update_time', 'third_game_commission_ratio', 'third_game_commission',
+                        ])->keyBy('order_key')->all();
+                    $new = [];
+                    $updates = [];
+                    foreach ($batch as $key => $row) {
+                        $previous = $existing[$key] ?? null;
+                        if ($previous !== null && $this->isStale($row['last_update_time'],
+                            $previous->getRawOriginal('last_update_time'), (int) $previous->getRawOriginal('status'))) {
+                            continue;
+                        }
+
+                        $user = $users[$row['player_id']] ?? null;
+                        $row['user_id'] = $user->id ?? $previous->user_id ?? null;
+                        $row['member_id'] = $user->member_id ?? $previous->member_id ?? null;
+                        $row['username'] = (string) ($user->username ?? $previous->username ?? '');
+                        $row['first_name'] = (string) ($user->first_name ?? $previous->first_name ?? '');
+                        $row = $this->applyCommission($row, $previous, $commission, $rates, $history);
+                        if ($previous === null) {
+                            $new[$key] = $row;
+                        } else {
+                            $updates[$key] = $row;
+                        }
+                    }
+
+                    if ($new !== []) {
+                        // A concurrent insert must be re-read before applying a fee snapshot.
+                        ThirdGameOrderModel::query()->insert(array_values($new));
+                    }
+                    if ($updates !== []) {
+                        ThirdGameOrderModel::query()->upsert(array_values($updates), ['order_key'], [
+                            'user_id', 'member_id', 'username', 'first_name', 'player_id', 'currency',
+                            'game_type', 'game_name', 'round_no', 'table_no', 'seat', 'bet_amount',
+                            'valid_amount', 'settled_amount', 'bet_content', 'status', 'bet_time',
+                            'last_update_time', 'updated_at', 'third_game_commission_ratio', 'third_game_commission',
+                        ]);
+                    }
+
+                    return [$new + $updates, $existing];
+                }, 3);
+                break;
+            } catch (UniqueConstraintViolationException $e) {
+                if ($attempt === 3) {
+                    throw $e;
+                }
+            }
+        }
+
+        $changedKeys = array_keys(array_filter($written, fn (array $row, string $key): bool
+            => $this->snapshotChanged($existing[$key] ?? null, $row), ARRAY_FILTER_USE_BOTH));
+        $this->recordRealtime($changedKeys, $existing);
+
+        return $written;
+    }
+
+    /**
+     * Whether an incoming version is older than the stored one. Versions are ordered by the provider update time;
+     * a version without one cannot be ordered, so it may only replace an unfinished version, never a finished
+     * (settled/cancelled) one. A timed version always replaces an untimed one.
+     */
+    private function isStale(?string $candidateTime, ?string $currentTime, int $currentStatus): bool
+    {
+        if ($candidateTime === null) return $currentStatus !== 0;
+        return $currentTime !== null && $candidateTime < $currentTime;
+    }
+
+    private function applyCommission(array $row, ?ThirdGameOrderModel $previous, ThirdGameCommissionService $commission, array $rates, array $history): array
+    {
+        $currency = strtoupper($row['currency']);
+        $ratio = $previous !== null && $currency === strtoupper(trim((string) $previous->currency))
+            ? $previous->third_game_commission_ratio : null;
+        $result = $commission::result($row['status'], $row['settled_amount']);
+        if ($result === 'loss') {
+            if ($ratio === null && $currency === $commission::currency()) {
+                if ($commission->isHistorical($previous?->id, $row['last_update_time'], $history)) {
+                    $rates = ($history['currency'] ?? null) === $currency ? $history['snapshot']['ratios'] : [];
+                }
+                $ratio = $rates[strtolower($row['platform'])] ?? null;
+            }
+            $amount = $ratio === null ? null : $commission::charge($row['settled_amount'], $ratio);
+        } else {
+            $amount = $row['status'] === 1 && $result === null ? null : Money::zero();
+        }
+
+        return $row + ['third_game_commission_ratio' => $ratio, 'third_game_commission' => $amount];
+    }
+
     public function syncForList(array $params): array
     {
         if ((int) ($params['page'] ?? 1) > 1) {
@@ -374,7 +477,7 @@ class ThirdGameOrderService
     /**
      * @return array<string, ThirdGameOrderModel>
      */
-    /** 同步每分钟全量 upsert,只有新订单或状态、结算、会员归属变化才推送后台 */
+    /** 只推送新订单及状态、结算、抽成、会员归属等有实质变化的订单。 */
     private function snapshotChanged($previous, array $row): bool
     {
         if ($previous === null) {
@@ -386,6 +489,9 @@ class ThirdGameOrderService
             : $settled !== $row['settled_amount'];
 
         return $settledChanged
+            || $previous->third_game_commission_ratio !== $row['third_game_commission_ratio']
+            || $previous->third_game_commission !== $row['third_game_commission']
+            || $previous->currency !== $row['currency']
             || (int) $previous->getRawOriginal('status') !== (int) $row['status']
             || (string) $previous->getRawOriginal('last_update_time') !== (string) $row['last_update_time']
             || (string) $previous->getRawOriginal('user_id') !== (string) $row['user_id'];
@@ -406,36 +512,6 @@ class ThirdGameOrderService
         }
     }
 
-    private function existingSnapshots(array $orders): array
-    {
-        $orderKeys = [];
-        foreach ($orders as $order) {
-            $gameOrderId = trim((string) ($order['game_order_id'] ?? ''));
-            if ($gameOrderId === '') {
-                continue;
-            }
-            $orderKeys[] = $this->identity(
-                trim((string) ($order['platform'] ?? '')),
-                trim((string) ($order['player_id'] ?? '')),
-                $gameOrderId
-            );
-        }
-        $orderKeys = array_values(array_unique($orderKeys));
-        if ($orderKeys === []) {
-            return [];
-        }
-
-        $rows = ThirdGameOrderModel::query()
-            ->whereIn('order_key', $orderKeys)
-            ->get(['id', 'order_key', 'user_id', 'member_id', 'username', 'first_name', 'status', 'settled_amount', 'last_update_time', 'created_at']);
-        $result = [];
-        foreach ($rows as $row) {
-            $result[(string) $row->order_key] = $row;
-        }
-
-        return $result;
-    }
-
     private function format(ThirdGameOrderModel $order): array
     {
         $userId = $order->current_user_id ?? $order->user_id;
@@ -444,6 +520,9 @@ class ThirdGameOrderService
         $firstName = $order->current_first_name ?? $order->first_name;
         $gameType = (int) $order->game_type;
         $status = (int) $order->status;
+        $result = ThirdGameCommissionService::result($status, $order->settled_amount);
+        $commission = $result === 'loss' ? $order->third_game_commission
+            : ($status === 1 && $result === null ? null : Money::zero());
 
         return [
             'id' => (int) $order->id,
@@ -464,6 +543,9 @@ class ThirdGameOrderService
             'bet_amount' => $this->number($order->bet_amount),
             'valid_amount' => $this->number($order->valid_amount),
             'settled_amount' => $this->number($order->settled_amount),
+            'bet_result' => $result,
+            'third_game_commission_ratio' => $result === 'loss' ? $order->third_game_commission_ratio : null,
+            'third_game_commission' => $commission,
             'bet_content' => (string) ($order->bet_content ?? ''),
             'status' => $status,
             'status_text' => self::STATUS_NAMES[$status] ?? '未知',

+ 52 - 0
database/migrations/2026_11_05_150000_add_third_game_order_commission.php

@@ -0,0 +1,52 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    private const BACKFILL_STATE = '__third_game_commission_backfill__';
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('third_game_orders')) return;
+        Schema::table('third_game_orders', function (Blueprint $table) {
+            if (!Schema::hasColumn('third_game_orders', 'third_game_commission_ratio')) $table->decimal('third_game_commission_ratio', 11, 10)->nullable();
+            if (!Schema::hasColumn('third_game_orders', 'third_game_commission')) $table->decimal('third_game_commission', 30, 10)->nullable();
+        });
+        if (Schema::hasTable('reporting_orders') && !Schema::hasColumn('reporting_orders', 'third_game_commission')) {
+            Schema::table('reporting_orders', function (Blueprint $table) {
+                $table->decimal('third_game_commission', 30, 10)->nullable();
+            });
+        }
+
+        // Without the reporting tables there is no launch range; every order then uses the rate of its sync day.
+        if (!Schema::hasTable('reporting_sync_state')) return;
+        $cutoff = now('Asia/Shanghai')->toDateTimeString();
+        DB::table('reporting_sync_state')->insertOrIgnore([
+            'source' => self::BACKFILL_STATE,
+            'state' => json_encode([
+                'max_id' => (int) DB::table('third_game_orders')->max('id'),
+                'cutoff_at' => $cutoff,
+                'snapshot' => null,
+            ], JSON_THROW_ON_ERROR),
+            'updated_at' => $cutoff,
+        ]);
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasTable('reporting_sync_state')) DB::table('reporting_sync_state')->where('source', self::BACKFILL_STATE)->delete();
+        if (Schema::hasTable('reporting_orders') && Schema::hasColumn('reporting_orders', 'third_game_commission')) {
+            Schema::table('reporting_orders', function (Blueprint $table) {
+                $table->dropColumn('third_game_commission');
+            });
+        }
+        foreach (['third_game_commission_ratio', 'third_game_commission'] as $column) {
+            if (Schema::hasTable('third_game_orders') && Schema::hasColumn('third_game_orders', $column)) {
+                Schema::table('third_game_orders', fn (Blueprint $table) => $table->dropColumn($column));
+            }
+        }
+    }
+};

+ 3 - 3
docs/admin-finance-api.md

@@ -52,11 +52,11 @@ POST 请求使用 `Content-Type: application/json`。统一响应:`code = 0` 
 
 前端直接遍历 `income`、`expense`。每项固定返回 `key`、`label`、`amount`、`people`。
 
-`expense` 新增 `third_game_commission`,金额为两位小数字符串,计入 `expense_total`,`people` 为已知非零抽成订单的去重会员数。与支付手续费 `third_party_fee` 分开显示。
+`expense` 的 `third_game_commission` 为两位小数字符串,计入 `expense_total`。`people` 为已知非零抽成订单的去重会员数。支付手续费使用独立字段 `third_party_fee`。
 
-按已完成订单结算日期统计:玩家净输金额(`-settled_amount`)乘 AG、PG 等平台对应ratio。玩家净输100元、ratio=0.09时抽成9元;玩家净赢时保留负数抵减。未完成、取消、撤单不计。平台比例三个接口共用,每天查询一次;历史金额使用查询时缓存比例估算。
+按结算日期汇总订单保存的抽成,结算日期与报表一致:后台做过结算日核对的订单以核对时间为准,否则用三方订单更新时间。玩家净输 100 元、费率为 0.09 时抽成 9 元。玩家赢或打和时抽成为零。`third_game_net_profit` 为三方游戏毛利扣除抽成后的收益,返回两位小数字符串。
 
-`third_game_commission_meta` 返回 `amount`、`known_amount`(10位小数)、`people`、`complete`、`quality`、`basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`。日期无可用比例时可为 `null`。比例或订单数据缺失时,抽成项 `amount`、`expense_total` 返回 `null`,显示“—”;`known_expense_total` 返回已知支出合计。真实零比例正常返回0。
+`third_game_commission_meta` 提供 `known_amount`(10 位小数)和 `quality.missing_ratio`。费率或订单数据缺失时,抽成项 `amount`、`expense_total` 和 `third_game_net_profit` 返回 `null`,显示“—”。`known_expense_total` 返回已知支出合计。真实零费率按零统计。
 
 不传日期时统计全部历史数据,返回的 `start_date`、`end_date` 均为 `null`。
 

+ 9 - 0
docs/admin-third-game-order-api.md

@@ -27,6 +27,12 @@ Authorization: Bearer <token>
 | `status` | 否 | `0`未完成、`1`已完成、`2`已取消、`3`已撤单 |
 | `start_time` / `end_time` | 否 | 订单更新时间范围,必须同时传,格式 `Y-m-d H:i:s` |
 
+| 返回字段 | 类型 | 含义 |
+| --- | --- | --- |
+| `bet_result` | string \| null | `win` 玩家赢、`loss` 玩家输、`draw` 打和。其余情况返回 `null`,用 `status` 显示订单状态 |
+| `third_game_commission_ratio` | string \| null | 亏损单保存的费率,10 位小数 |
+| `third_game_commission` | string \| null | 平台支付的抽成,10 位小数。赢、打和、未完成、取消、撤单返回零。缺少输赢金额或亏损单缺少费率时显示“—” |
+
 返回:
 
 ```json
@@ -56,6 +62,9 @@ Authorization: Bearer <token>
         "bet_amount": 100,
         "valid_amount": 95,
         "settled_amount": -100,
+        "bet_result": "loss",
+        "third_game_commission_ratio": "0.0900000000",
+        "third_game_commission": "9.0000000000",
         "bet_content": "Banker",
         "status": 1,
         "status_text": "已完成",

+ 2 - 4
docs/前端交付/bot-28/后台/报表汇总-前端接入.md

@@ -292,11 +292,9 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 
 优惠、内部佣金、应计金额不直接作为外部现金再次从净入金扣除。客户内扣手续费收入和配置参考费用不额外加入上述公式。
 
-第三方游戏平台抽成使用 `third_game_commission`(10位小数字符串,可为 `null`),`summary` 及每个 `categories[]` 都返回,营业/推广/分享三种 `report_type` 均支持。按 AG、PG 等平台匹配比例,将平台旗下各游戏订单的抽成归入真人、电子、体育等游戏大类;自营游戏为零。
+营业、推广、分享三种 `report_type` 的 `summary` 和 `categories[]` 都返回 `third_game_commission`(三方抽成)和 `game_net_profit`(扣除三方抽成后的游戏收益),金额为 10 位小数字符串。抽成按结算日汇总订单保存值。玩家净输 100 元、费率为 0.09 时抽成 9 元,玩家赢、打和及自营游戏的抽成为零。
 
-当前计算口径为已结算订单的 `-settled_amount × 平台 ratio`,例如玩家净输100元、ratio=0.09,抽成9元;玩家净赢时保留负值并抵减合计。未完成、取消、撤单不计。按结算日期归属。比例每天共用一次查询;历史区间按查询时缓存的比例估算,不代表历史账单实扣。
-
-`third_game_commission_meta` 返回 `basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`。无相关结算订单时日期可为 `null`。缺少平台比例时,受影响行的 `third_game_commission`、`company_profit` 为 `null`,`known_third_game_commission` 为已知部分,`quality.missing_platform_ratio` 为缺失订单数;真实零比例按零计算。缺失比例不影响原有实际净入金。
+亏损单缺少费率时,受影响行的抽成、`game_net_profit` 和 `company_profit` 为 `null`。`known_third_game_commission` 为已知部分,`quality.missing_platform_ratio` 为缺失订单数。真实零费率按零统计。已结算订单缺少结算时间、输赢金额或币种不符时,`game_net_profit` 同样为 `null`(对应 `quality.missing_time` / `missing_amount` / `foreign_currency` 计数)。
 
 ### 6.2 总卡与分类卡的差异
 

+ 4 - 3
docs/前端交付/bot-28/后台/资金流动-前端接入.md

@@ -137,9 +137,9 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 
 分页使用请求值或 `data.filters.page`、`data.filters.limit`;顶部总计直接取 `data.summary`。
 
-区间 `summary` 和每日 `data[]` 均新增 `third_game_commission`,10位小数字符串;按结算日期统计,仅包含已完成订单。玩家净输100元、平台ratio=0.09时抽成9元,玩家净赢时以负数抵减。三个接口共用每日平台比例,历史区间使用查询时缓存比例估算。`third_game_commission_meta` 提供 `basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`,日期可为 `null`。
+区间 `summary` 和每日 `data[]` 都返回 `third_game_commission`(三方抽成)、`game_net_profit`(扣除三方抽成后的游戏收益)和 `categories[]`。分类沿用营业报表的九个分类,每行包含 `group_id`、`label`、`game_gross_profit`、`third_game_commission`、`game_net_profit`、`known_third_game_commission` 和 `quality`(`missing_platform_ratio`、`missing_time`、`foreign_currency`)。区间分类覆盖全部选定日期,每日分类随对应日期行返回。
 
-缺少平台比例时,受影响行的抽成及 `company_profit` 返回 `null`,`known_third_game_commission` 保留已知金额,`quality.missing_platform_ratio` 为缺失订单数。真实零比例按零统计;实际净入金不因缺少游戏抽成比例而置空。
+抽成按结算日汇总亏损单保存值,玩家净输 100 元、费率为 0.09 时抽成 9 元。亏损单缺少费率时,受影响行的抽成、`game_net_profit` 和 `company_profit` 为 `null`。`known_third_game_commission` 保留已知金额,`quality.missing_platform_ratio` 为缺失订单数。真实零费率按零统计。已结算订单缺少结算时间、输赢金额或币种不符时,`game_net_profit` 同样为 `null`(对应 `quality.missing_time` / `missing_amount` / `foreign_currency` 计数)。
 
 ## 4. 金额字段与展示
 
@@ -160,7 +160,8 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 | `payout_amount` | 派奖总额 | 期间实际结算的派奖,不含退款 |
 | `refund_amount` | 退款总额 | 期间结算或撤销所产生的退款 |
 | `game_gross_profit` | 游戏毛利 | 已结算投注净额减派奖,平台盈利为正 |
-| `third_game_commission` | 第三方游戏平台抽成 | 期间已结算游戏毛利乘 AG、PG 等平台对应比例,保留正负值 |
+| `third_game_commission` | 第三方游戏平台抽成 | 期间结算的三方亏损单抽成合计 |
+| `game_net_profit` | 扣除抽成后的游戏收益 | 游戏毛利减第三方游戏平台抽成 |
 | `bonus_amount` | 实发优惠 | 实发笔笔返、回水、可用奖励、利息及冻结奖励实际解冻等 |
 | `agent_commission` | 实发代理佣金 | 已实际计入代理余额的佣金 |
 | `share_commission` | 实发分享佣金 | 已审核通过并实际计入会员钱包的分享佣金 |

+ 2 - 4
docs/总后台/报表汇总-前端接入.md

@@ -292,11 +292,9 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 
 优惠、内部佣金、应计金额不直接作为外部现金再次从净入金扣除。客户内扣手续费收入和配置参考费用不额外加入上述公式。
 
-第三方游戏平台抽成使用 `third_game_commission`(10位小数字符串,可为 `null`),`summary` 及每个 `categories[]` 都返回,营业/推广/分享三种 `report_type` 均支持。按 AG、PG 等平台匹配比例,将平台旗下各游戏订单的抽成归入真人、电子、体育等游戏大类;自营游戏为零。
+营业、推广、分享三种 `report_type` 的 `summary` 和 `categories[]` 都返回 `third_game_commission`(三方抽成)和 `game_net_profit`(扣除三方抽成后的游戏收益),金额为 10 位小数字符串。抽成按结算日汇总订单保存值。玩家净输 100 元、费率为 0.09 时抽成 9 元,玩家赢、打和及自营游戏的抽成为零。
 
-当前计算口径为已结算订单的 `-settled_amount × 平台 ratio`,例如玩家净输100元、ratio=0.09,抽成9元;玩家净赢时保留负值并抵减合计。未完成、取消、撤单不计。按结算日期归属。比例每天共用一次查询;历史区间按查询时缓存的比例估算,不代表历史账单实扣。
-
-`third_game_commission_meta` 返回 `basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`。无相关结算订单时日期可为 `null`。缺少平台比例时,受影响行的 `third_game_commission`、`company_profit` 为 `null`,`known_third_game_commission` 为已知部分,`quality.missing_platform_ratio` 为缺失订单数;真实零比例按零计算。缺失比例不影响原有实际净入金。
+亏损单缺少费率时,受影响行的抽成、`game_net_profit` 和 `company_profit` 为 `null`。`known_third_game_commission` 为已知部分,`quality.missing_platform_ratio` 为缺失订单数。真实零费率按零统计。已结算订单缺少结算时间、输赢金额或币种不符时,`game_net_profit` 同样为 `null`(对应 `quality.missing_time` / `missing_amount` / `foreign_currency` 计数)。
 
 ### 6.2 总卡与分类卡的差异
 

+ 4 - 3
docs/总后台/资金流动-前端接入.md

@@ -137,9 +137,9 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 
 分页使用请求值或 `data.filters.page`、`data.filters.limit`;顶部总计直接取 `data.summary`。
 
-区间 `summary` 和每日 `data[]` 均新增 `third_game_commission`,10位小数字符串;按结算日期统计,仅包含已完成订单。玩家净输100元、平台ratio=0.09时抽成9元,玩家净赢时以负数抵减。三个接口共用每日平台比例,历史区间使用查询时缓存比例估算。`third_game_commission_meta` 提供 `basis=game_gross_profit`、`estimated=true`、`rate_date`、`rates_fetched_at`,日期可为 `null`。
+区间 `summary` 和每日 `data[]` 都返回 `third_game_commission`(三方抽成)、`game_net_profit`(扣除三方抽成后的游戏收益)和 `categories[]`。分类沿用营业报表的九个分类,每行包含 `group_id`、`label`、`game_gross_profit`、`third_game_commission`、`game_net_profit`、`known_third_game_commission` 和 `quality`(`missing_platform_ratio`、`missing_time`、`foreign_currency`)。区间分类覆盖全部选定日期,每日分类随对应日期行返回。
 
-缺少平台比例时,受影响行的抽成及 `company_profit` 返回 `null`,`known_third_game_commission` 保留已知金额,`quality.missing_platform_ratio` 为缺失订单数。真实零比例按零统计;实际净入金不因缺少游戏抽成比例而置空。
+抽成按结算日汇总亏损单保存值,玩家净输 100 元、费率为 0.09 时抽成 9 元。亏损单缺少费率时,受影响行的抽成、`game_net_profit` 和 `company_profit` 为 `null`。`known_third_game_commission` 保留已知金额,`quality.missing_platform_ratio` 为缺失订单数。真实零费率按零统计。已结算订单缺少结算时间、输赢金额或币种不符时,`game_net_profit` 同样为 `null`(对应 `quality.missing_time` / `missing_amount` / `foreign_currency` 计数)。
 
 ## 4. 金额字段与展示
 
@@ -160,7 +160,8 @@ GET /admin/reporting/fund-flow?page=1&limit=20
 | `payout_amount` | 派奖总额 | 期间实际结算的派奖,不含退款 |
 | `refund_amount` | 退款总额 | 期间结算或撤销所产生的退款 |
 | `game_gross_profit` | 游戏毛利 | 已结算投注净额减派奖,平台盈利为正 |
-| `third_game_commission` | 第三方游戏平台抽成 | 期间已结算游戏毛利乘 AG、PG 等平台对应比例,保留正负值 |
+| `third_game_commission` | 第三方游戏平台抽成 | 期间结算的三方亏损单抽成合计 |
+| `game_net_profit` | 扣除抽成后的游戏收益 | 游戏毛利减第三方游戏平台抽成 |
 | `bonus_amount` | 实发优惠 | 实发笔笔返、回水、可用奖励、利息及冻结奖励实际解冻等 |
 | `agent_commission` | 实发代理佣金 | 已实际计入代理余额的佣金 |
 | `share_commission` | 实发分享佣金 | 已审核通过并实际计入会员钱包的分享佣金 |

+ 71 - 25
tests/Integration/ReportingTest.php

@@ -6,6 +6,7 @@ use App\Services\Reporting\ReportAdmin;
 use App\Services\Reporting\ReportReader;
 use App\Services\Reporting\ReportSync;
 use App\Services\Sharing\ShareDefaults;
+use App\Services\ThirdGameCommissionService;
 use Carbon\Carbon;
 use Illuminate\Config\Repository;
 use Illuminate\Database\Capsule\Manager;
@@ -65,6 +66,7 @@ class ReportingTest extends TestCase
             '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();
+        Schema::table('reporting_orders', fn (Blueprint $table) => $table->text('third_game_commission')->nullable());
         DB::table('reporting_settings')->where('id', 1)->update(['activity_started_at' => '2026-10-25 00:00:00']);
         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())]);
         DB::table('share_system_state')->insert(['key' => 'initialized_at', 'value' => '2026-10-01 00:00:00']);
@@ -742,21 +744,22 @@ class ReportingTest extends TestCase
     {
         DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,
             platform TEXT,player_id TEXT,game_type INTEGER,game_name TEXT,bet_amount TEXT,valid_amount TEXT,settled_amount TEXT,
-            status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
-        $this->app->instance(\App\Services\ThirdGameCommissionService::class, new class extends \App\Services\ThirdGameCommissionService {
-            public function rates(): array { return ['date' => '2026-10-26', 'fetched_at' => '2026-10-26 08:00:00',
-                'available' => true, 'ratios' => ['pg' => '0.09', 'ag' => '0.20', 'free' => '0']]; }
-        });
+            status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT,
+            third_game_commission TEXT)');
     }
 
     private function commissionOrder(int $id, array $extra = []): void
     {
         $user = $extra['user_id'] ?? 2;
-        DB::table('third_game_orders')->insert($extra + ['id' => $id, 'order_key' => hash('sha256', 'commission-' . $id),
+        $order = $extra + ['id' => $id, 'order_key' => hash('sha256', 'commission-' . $id),
             'member_id' => (string) ($user * 100), 'user_id' => $user, 'platform' => 'pg', 'player_id' => 'u' . $user,
             'game_type' => 2, 'game_name' => 'game-' . $id, 'bet_amount' => '100', 'valid_amount' => '95',
             'settled_amount' => '-100', 'status' => 1, 'currency' => 'CNY', 'bet_time' => '2026-10-25 23:00:00',
-            'last_update_time' => '2026-10-26 10:00:00', 'created_at' => '2026-10-25 23:00:00', 'updated_at' => '2026-10-26 10:00:00']);
+            'last_update_time' => '2026-10-26 10:00:00', 'created_at' => '2026-10-25 23:00:00', 'updated_at' => '2026-10-26 10:00:00'];
+        $ratio = ['pg' => '0.09', 'ag' => '0.20', 'free' => '0'][strtolower($order['platform'])] ?? null;
+        $amount = ThirdGameCommissionService::result($order['status'], $order['settled_amount']) === 'loss' && $ratio !== null
+            ? ThirdGameCommissionService::charge($order['settled_amount'], $ratio) : null;
+        DB::table('third_game_orders')->insert($order + ['third_game_commission' => $amount]);
         $this->sync->publish('third_game_orders', $id);
     }
 
@@ -770,27 +773,39 @@ class ReportingTest extends TestCase
         $this->commissionOrder(5, ['user_id' => 1, 'settled_amount' => '-10']);
         $this->bet();
         $business = $this->reader->summary($this->today());
-        $this->assertSame('20.3000000000', $business['summary']['third_game_commission']);
-        $this->assertSame('159.7000000000', $business['summary']['company_profit']);
+        $this->assertSame('23.9000000000', $business['summary']['third_game_commission']);
+        $this->assertSame('156.1000000000', $business['summary']['company_profit']);
         $categories = collect($business['categories'])->keyBy('group_id');
-        $this->assertSame('10.3000000000', $categories['third:2']['third_game_commission']);
+        $this->assertSame('13.9000000000', $categories['third:2']['third_game_commission']);
+        $this->assertSame('76.1000000000', $categories['third:2']['game_net_profit']);
         $this->assertSame('10.0000000000', $categories['third:1']['third_game_commission']);
         $this->assertSame('0.0000000000', $categories['self:lottery']['third_game_commission']);
         foreach (['promotion', 'sharing'] as $type) {
             $report = $this->reader->summary($this->today(['report_type' => $type]));
-            $this->assertSame('19.4000000000', $report['summary']['third_game_commission']);
+            $this->assertSame('23.0000000000', $report['summary']['third_game_commission']);
             $this->assertSame(9, count($report['categories']));
         }
-        $this->assertSame('15.4000000000', $this->reader->details($this->today(['report_type' => 'promotion', 'group_id' => '10']))['data'][0]['third_game_commission']);
-        $funds = $this->reader->funds(['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1]);
-        $this->assertSame('20.3000000000', $funds['summary']['third_game_commission']);
-        $this->assertSame('20.3000000000', $funds['data'][0]['third_game_commission']);
-        $this->assertSame('0.0000000000', $this->reader->funds(['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1, 'page' => 2])['data'][0]['third_game_commission']);
+        $this->assertSame('19.0000000000', $this->reader->details($this->today(['report_type' => 'promotion', 'group_id' => '10']))['data'][0]['third_game_commission']);
+        $range = ['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1];
+        $funds = $this->reader->funds($range);
+        $this->assertSame('23.9000000000', $funds['summary']['third_game_commission']);
+        $this->assertSame('23.9000000000', $funds['data'][0]['third_game_commission']);
+        $this->assertCount(9, $funds['summary']['categories']);
+        $this->assertSame('76.1000000000', collect($funds['data'][0]['categories'])->keyBy('group_id')['third:2']['game_net_profit']);
+        $previous = $this->reader->funds($range + ['page' => 2]);
+        $this->assertSame($funds['summary'], $previous['summary']);
+        $this->assertSame('0.0000000000', collect($previous['data'][0]['categories'])->keyBy('group_id')['third:2']['third_game_commission']);
+        // Bet on 10-25, settled on 10-26: game result is counted on the settlement day only, never on both days.
+        $this->assertSame('0.0000000000', collect($previous['data'][0]['categories'])->keyBy('group_id')['third:2']['game_gross_profit']);
+        $this->assertSame('0.0000000000', $previous['data'][0]['game_gross_profit']);
+        $this->assertSame('90.0000000000', collect($funds['data'][0]['categories'])->keyBy('group_id')['third:2']['game_gross_profit']);
+        $this->assertSame($funds['summary']['game_gross_profit'], $funds['data'][0]['game_gross_profit']);
         $finance = \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26');
         $expense = collect($finance['expense'])->keyBy('key');
-        $this->assertSame('20.30', $expense['third_game_commission']['amount']);
+        $this->assertSame('23.90', $expense['third_game_commission']['amount']);
         $this->assertSame(3, $expense['third_game_commission']['people']);
-        $this->assertSame('20.30', $finance['expense_total']);
+        $this->assertSame('23.90', $finance['expense_total']);
+        $this->assertSame('116.10', $finance['third_game_net_profit']);
         $this->assertSame('0.00', $expense['third_party_fee']['amount']);
     }
 
@@ -806,11 +821,19 @@ class ReportingTest extends TestCase
         $this->assertNull($row['third_game_commission']);
         $this->assertSame('9.0000000000', $row['known_third_game_commission']);
         $this->assertNull($row['company_profit']);
+        $this->assertNull($row['game_net_profit']);
         $this->assertSame('0.0000000000', $row['net_cash_flow']);
         $this->assertSame(1, $row['quality']['missing_platform_ratio']);
+        $funds = $this->reader->funds($this->today());
+        $category = collect($funds['data'][0]['categories'])->keyBy('group_id')['third:2'];
+        $this->assertNull($category['third_game_commission']);
+        $this->assertNull($category['game_net_profit']);
+        $this->assertSame('9.0000000000', $category['known_third_game_commission']);
+        $this->assertSame(1, $category['quality']['missing_platform_ratio']);
         $finance = \App\Services\FundsSummaryService::summary();
         $this->assertNull(collect($finance['expense'])->keyBy('key')['third_game_commission']['amount']);
         $this->assertNull($finance['expense_total']);
+        $this->assertNull($finance['third_game_net_profit']);
         $this->assertSame('9.00', $finance['known_expense_total']);
         $this->assertSame('9.0000000000', $finance['third_game_commission_meta']['known_amount']);
     }
@@ -834,24 +857,47 @@ class ReportingTest extends TestCase
         $this->assertSame('9.0000000000', $finance['third_game_commission_meta']['known_amount']);
     }
 
-    public function test_all_report_types_and_finance_share_one_daily_supplier_request(): void
+    public function test_report_and_finance_agree_on_verified_dates_skip_foreign_players_and_withhold_profit_on_sync_errors(): void
+    {
+        $this->commissionOrders();
+        $this->commissionOrder(1);
+        // An old snapshot of the other environment's player, never given a commission.
+        $this->commissionOrder(2, ['player_id' => 'tu2', 'platform' => 'unknown', 'settled_amount' => '-50']);
+        $summary = $this->reader->summary($this->today())['summary'];
+        $this->assertSame('9.0000000000', $summary['third_game_commission'], 'foreign snapshot is not in the report');
+        $this->assertSame('91.0000000000', $summary['game_net_profit']);
+        $this->assertSame('9.00', \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26')['expense_total']);
+
+        // 结算日核对 moves the order to 10-25 in the report; finance must follow the same date.
+        $id = (int) DB::table('reporting_orders')->where('parent_source', 'third_game_orders')->where('parent_id', '1')->value('id');
+        $this->admin->reconcileOrder(['id' => $id, 'settled_at' => '2026-10-25 23:30:00', 'evidence' => '对账单']);
+        $this->assertSame('0.0000000000', $this->reader->summary($this->today())['summary']['third_game_commission']);
+        $this->assertSame('0.00', \App\Services\FundsSummaryService::summary('2026-10-26', '2026-10-26')['expense_total']);
+        $this->assertSame('9.00', \App\Services\FundsSummaryService::summary('2026-10-25', '2026-10-25')['expense_total']);
+
+        DB::table('reporting_errors')->insert(['source' => 'third_game_orders', 'source_id' => '1', 'reason' => 'sync failed',
+            'created_at' => now(), 'updated_at' => now()]);
+        $range = ['start_date' => '2026-10-25', 'end_date' => '2026-10-26'];
+        $this->assertNull($this->reader->summary($range)['summary']['game_net_profit']);
+        $funds = $this->reader->funds($range);
+        $this->assertNull($funds['summary']['game_net_profit']);
+        $this->assertNull(collect($funds['data'][0]['categories'])->keyBy('group_id')['third:2']['game_net_profit']);
+    }
+
+    public function test_reports_and_finance_read_the_saved_commission_without_requesting_quota(): void
     {
         $this->commissionOrders(); $this->commissionOrder(1);
-        $this->app->instance(\App\Services\ThirdGameCommissionService::class, new \App\Services\ThirdGameCommissionService());
-        $this->app->instance('cache', new \Illuminate\Cache\Repository(new \Illuminate\Cache\ArrayStore()));
         $this->app->instance(\Illuminate\Http\Client\Factory::class, new \Illuminate\Http\Client\Factory());
         config(['third_game.api_url' => 'https://supplier.test', 'third_game.sn' => 'test', 'third_game.key' => 'test']);
         \Illuminate\Support\Facades\Http::preventStrayRequests();
-        \Illuminate\Support\Facades\Http::fake(['https://supplier.test/api/server/quota' => \Illuminate\Support\Facades\Http::response([
-            'code' => 10000, 'data' => ['ratios' => [['platfrom' => 'pg', 'ratio' => 0.09]]],
-        ])]);
+        \Illuminate\Support\Facades\Http::fake([]);
         foreach (['business', 'promotion', 'sharing'] as $type) {
             $report = $this->reader->summary($this->today(['report_type' => $type]));
             $this->assertSame('9.0000000000', $report['summary']['third_game_commission']);
         }
         $this->assertSame('9.0000000000', $this->reader->funds($this->today())['summary']['third_game_commission']);
         $this->assertSame('9.00', \App\Services\FundsSummaryService::summary()['expense_total']);
-        \Illuminate\Support\Facades\Http::assertSentCount(1);
+        \Illuminate\Support\Facades\Http::assertNothingSent();
     }
 
     public function test_finance_commission_keeps_large_decimal_precision_and_reports_missing_source_data(): void

+ 211 - 6
tests/Integration/ThirdGamePlayerIdentityTest.php

@@ -3,12 +3,18 @@
 namespace Tests\Integration;
 
 use App\Services\ThirdGameBalanceService;
+use App\Services\ThirdGameCommissionService;
 use App\Services\ThirdGameOrderService;
+use Carbon\Carbon;
+use Illuminate\Cache\ArrayStore;
+use Illuminate\Cache\Repository as CacheRepository;
 use Illuminate\Config\Repository;
 use Illuminate\Database\Capsule\Manager;
 use Illuminate\Foundation\Application;
+use Illuminate\Http\Client\Factory;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Http;
 use PHPUnit\Framework\TestCase;
 
 /** Provider order identities are resolved against an isolated SQLite database only. */
@@ -23,12 +29,12 @@ class ThirdGamePlayerIdentityTest extends TestCase
         $db = new Manager($app);
         $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
         $db->setAsGlobal(); $db->bootEloquent(); $app->instance('db', $db->getDatabaseManager());
+        $app->bind('db.schema', fn () => $db->schema());
+        DB::connection()->getPdo()->sqliteCreateFunction('MD5', md5(...), 1);
         DB::statement('CREATE TABLE bot_users (id INTEGER PRIMARY KEY,member_id TEXT,username TEXT,account TEXT,first_name TEXT)');
-        DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY AUTOINCREMENT,order_key TEXT UNIQUE,
-            user_id INTEGER,member_id TEXT,username TEXT,first_name TEXT,player_id TEXT,platform TEXT,currency TEXT,
-            game_type INTEGER,game_name TEXT,round_no TEXT,table_no TEXT,seat TEXT,bet_amount TEXT,valid_amount TEXT,
-            settled_amount TEXT,bet_content TEXT,status INTEGER,game_order_id TEXT,bet_time TEXT,last_update_time TEXT,
-            created_at TEXT,updated_at TEXT)');
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_08_21_120000_create_third_game_orders_table.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_140000_create_reporting_tables.php')->up();
+        $this->commissionMigration()->up();
         DB::table('users')->insert([
             ['id' => 1, 'member_id' => 'm1', 'username' => 'one', 'first_name' => ''],
             ['id' => 106, 'member_id' => 'm106', 'username' => 'member106', 'first_name' => ''],
@@ -38,7 +44,7 @@ class ThirdGamePlayerIdentityTest extends TestCase
 
     protected function tearDown(): void
     {
-        DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
+        Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
     }
 
     private function lookup(array $playerIds): array
@@ -108,4 +114,203 @@ class ThirdGamePlayerIdentityTest extends TestCase
         $this->assertSame(2, DB::table('third_game_orders')->count());
     }
 
+    public function test_order_sync_snapshots_only_losses_and_keeps_the_rate_during_corrections(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
+        $this->fakeQuota([
+            [['platfrom' => 'ag', 'ratio' => '0.09'], ['platfrom' => 'free', 'ratio' => '0']],
+            [['platfrom' => 'ag', 'ratio' => '0.20']],
+        ]);
+        $provider = new class extends ThirdGameBalanceService {
+            public array $orders;
+
+            public function realtimeOrders(int $page = 1, int $pageSize = 2000): array
+            {
+                return ['ok' => true, 'list' => $this->orders];
+            }
+
+            public function historyOrders(string $startTime, string $endTime, int $page = 1, int $pageSize = 2000): array
+            {
+                return ['ok' => true, 'list' => $this->orders];
+            }
+        };
+        $service = new ThirdGameOrderService($provider);
+        $loss = $this->order('loss');
+        $provider->orders = [
+            $loss,
+            $this->order('win', ['settled_amount' => '20']),
+            $this->order('draw', ['settled_amount' => '0']),
+            $this->order('pending', ['status' => 0]),
+            $this->order('missing-net', ['settled_amount' => null]),
+            $this->order('missing-rate', ['platform' => 'pg']),
+            $this->order('zero-rate', ['platform' => 'free']),
+            $this->order('foreign', ['currency' => 'USD']),
+        ];
+        $this->assertSame(8, $service->syncRealtime()['synced']);
+        $byOrder = fn () => collect($service->paginate([])['list'])->keyBy('game_order_id');
+        $rows = $byOrder();
+        $this->assertSame('loss', $rows['loss']['bet_result']);
+        $this->assertSame('0.0900000000', $rows['loss']['third_game_commission_ratio']);
+        $this->assertSame('9.0000000000', $rows['loss']['third_game_commission']);
+        $this->assertSame('win', $rows['win']['bet_result']);
+        $this->assertSame('draw', $rows['draw']['bet_result']);
+        $this->assertNull($rows['pending']['bet_result']);
+        foreach (['win', 'draw', 'pending', 'zero-rate'] as $order) {
+            $this->assertSame('0.0000000000', $rows[$order]['third_game_commission']);
+        }
+        foreach (['missing-net', 'missing-rate', 'foreign'] as $order) {
+            $this->assertNull($rows[$order]['third_game_commission']);
+        }
+        $this->assertSame('0.0000000000', $rows['zero-rate']['third_game_commission_ratio']);
+        $service->syncRealtime();
+        Http::assertSentCount(1);
+
+        Carbon::setTestNow(Carbon::parse('2026-11-06 12:00:00', 'Asia/Shanghai'));
+        $correction = ['last_update_time' => '2026-11-06 10:00:00', 'settled_amount' => '-50'] + $loss;
+        $provider->orders = [$correction, $this->order('new', ['last_update_time' => '2026-11-06 10:00:00'])];
+        $service->syncHistory('2026-11-06 09:00:00', '2026-11-06 11:00:00');
+        $this->assertSame('4.5000000000', $byOrder()['loss']['third_game_commission']);
+        $this->assertSame('20.0000000000', $byOrder()['new']['third_game_commission']);
+        $provider->orders = [$loss];
+        $this->assertSame(0, $service->syncRealtime()['synced']);
+        $this->assertSame('4.5000000000', $byOrder()['loss']['third_game_commission']);
+
+        $provider->orders = [['status' => 3, 'last_update_time' => '2026-11-06 11:00:00'] + $loss];
+        $service->syncRealtime();
+        $this->assertSame('0.0000000000', $byOrder()['loss']['third_game_commission']);
+        $this->assertNull($byOrder()['loss']['bet_result']);
+        $this->assertNull($byOrder()['loss']['third_game_commission_ratio']);
+        Http::assertSentCount(2);
+    }
+
+    public function test_backfill_freezes_the_launch_range_and_daily_retry_only_fills_new_orders(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
+        $migration = $this->commissionMigration();
+        $migration->down();
+        $this->insertOrder('old-ag');
+        $this->insertOrder('old-pg', ['platform' => 'pg']);
+        $this->insertOrder('old-win', ['settled_amount' => '10']);
+        $migration->up();
+        $this->insertOrder('new-pg', ['platform' => 'pg', 'last_update_time' => '2026-11-05 13:00:00']);
+        $this->fakeQuota([
+            [['platfrom' => 'ag', 'ratio' => '0.09']],
+            [['platfrom' => 'ag', 'ratio' => '0.20'], ['platfrom' => 'pg', 'ratio' => '0.30']],
+        ]);
+        $service = new ThirdGameCommissionService();
+        $this->assertSame(['updated' => 1, 'remaining' => 1], $service->backfill(false));
+        Carbon::setTestNow(Carbon::parse('2026-11-06 12:00:00', 'Asia/Shanghai'));
+        $this->assertSame(['updated' => 0, 'remaining' => 1], $service->backfill(false));
+        Http::assertSentCount(1);
+        $this->assertSame(['updated' => 1, 'remaining' => 0], $service->backfill(true));
+        $rows = collect((new ThirdGameOrderService(new ThirdGameBalanceService()))->paginate([])['list'])->keyBy('game_order_id');
+        $this->assertSame('9.0000000000', $rows['old-ag']['third_game_commission']);
+        $this->assertNull($rows['old-pg']['third_game_commission']);
+        $this->assertSame('0.0000000000', $rows['old-win']['third_game_commission']);
+        $this->assertSame('30.0000000000', $rows['new-pg']['third_game_commission']);
+        Http::assertSentCount(2);
+    }
+
+    public function test_failed_rate_lookup_is_retried_the_same_day_and_rows_without_update_time_still_update(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
+        $this->fakeQuota([[['platfrom' => 'ag', 'ratio' => '0.10']]], [['code' => 500, 'msg' => 'busy']]);
+        $provider = new class extends ThirdGameBalanceService {
+            public array $orders;
+
+            public function realtimeOrders(int $page = 1, int $pageSize = 2000): array
+            {
+                return ['ok' => true, 'list' => $this->orders];
+            }
+        };
+        $service = new ThirdGameOrderService($provider);
+        $byOrder = fn () => collect($service->paginate([])['list'])->keyBy('game_order_id');
+
+        $provider->orders = [$this->order('loss'), $this->order('later', ['status' => 0, 'settled_amount' => null])];
+        $service->syncRealtime();
+        $this->assertNull($byOrder()['loss']['third_game_commission'], 'rate lookup failed');
+        Carbon::setTestNow(Carbon::parse('2026-11-05 12:20:00', 'Asia/Shanghai'));
+        $service->syncRealtime();
+        Http::assertSentCount(1);
+
+        Carbon::setTestNow(Carbon::parse('2026-11-05 12:31:00', 'Asia/Shanghai'));
+        $provider->orders = [$this->order('loss'), $this->order('later', ['last_update_time' => null])];
+        $service->syncRealtime();
+        Http::assertSentCount(2);
+        $this->assertSame('10.0000000000', $byOrder()['loss']['third_game_commission'], 'retried after the failure pause');
+        $this->assertSame(1, $byOrder()['later']['status'], 'a row without update time is not treated as stale');
+        $this->assertSame('10.0000000000', $byOrder()['later']['third_game_commission']);
+
+        // An untimed version cannot be ordered, so it never replaces a finished one.
+        $provider->orders = [$this->order('loss', ['status' => 0, 'settled_amount' => null, 'last_update_time' => null])];
+        $service->syncRealtime();
+        $this->assertSame(1, $byOrder()['loss']['status']);
+        $this->assertEquals(-100, $byOrder()['loss']['settled_amount']);
+        $this->assertSame('2026-11-05 10:00:00', $byOrder()['loss']['last_update_time']);
+    }
+
+    public function test_scheduled_backfill_skips_quietly_until_the_migration_has_run(): void
+    {
+        $migration = $this->commissionMigration();
+        $migration->down();
+        $this->assertFalse((new ThirdGameCommissionService())->migrated());
+        $command = new \App\Console\Commands\ThirdGameCommissionBackfill();
+        $command->setLaravel(app());
+        $tester = new \Symfony\Component\Console\Tester\CommandTester($command);
+        $this->assertSame(0, $tester->execute(['--retry-missing' => true]));
+        $this->assertStringContainsString('跳过', $tester->getDisplay());
+        $migration->up();
+        $this->assertTrue((new ThirdGameCommissionService())->migrated());
+    }
+
+    public function test_backfill_leaves_old_snapshots_of_the_other_environment_untouched(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
+        $migration = $this->commissionMigration();
+        $migration->down();
+        $this->insertOrder('own');
+        $this->insertOrder('foreign', ['player_id' => 'tu0106']);
+        $migration->up();
+        $this->fakeQuota([[['platfrom' => 'ag', 'ratio' => '0.10']]]);
+        $this->assertSame(['updated' => 1, 'remaining' => 0], (new ThirdGameCommissionService())->backfill(false));
+        $this->assertNull(DB::table('third_game_orders')->where('game_order_id', 'foreign')->value('third_game_commission'));
+    }
+
+    private function commissionMigration(): object
+    {
+        return require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_150000_add_third_game_order_commission.php';
+    }
+
+    private function order(string $id, array $extra = []): array
+    {
+        return $extra + [
+            'player_id' => 'u0106', 'game_order_id' => $id, 'platform' => 'AG', 'currency' => 'CNY',
+            'game_type' => 1, 'status' => 1, 'bet_amount' => '100', 'settled_amount' => '-100',
+            'bet_time' => '2026-11-05 09:00:00', 'last_update_time' => '2026-11-05 10:00:00',
+        ];
+    }
+
+    private function insertOrder(string $id, array $extra = []): void
+    {
+        DB::table('third_game_orders')->insert($this->order($id, $extra) + ['order_key' => hash('sha256', $id), 'user_id' => 106]);
+    }
+
+    /** @param list<array> $before raw responses (e.g. failures) returned before the daily ratio lists */
+    private function fakeQuota(array $days, array $before = []): void
+    {
+        app()->instance('cache', new CacheRepository(new ArrayStore()));
+        app()->instance(Factory::class, new Factory());
+        app()->instance('log', new \Psr\Log\NullLogger());
+        config(['third_game.api_url' => 'https://supplier.test', 'third_game.sn' => 'test', 'third_game.key' => 'test']);
+        Http::preventStrayRequests();
+        $sequence = Http::sequence();
+        foreach ($before as $response) {
+            $sequence->push($response);
+        }
+        foreach ($days as $ratios) {
+            $sequence->push(['code' => 10000, 'data' => ['ratios' => $ratios]]);
+        }
+        Http::fake(['https://supplier.test/api/server/quota' => $sequence]);
+    }
+
 }