doge 18 часов назад
Родитель
Сommit
5a11a25b57

+ 6 - 1
app/Jobs/SportGameOrderJob.php

@@ -50,7 +50,12 @@ class SportGameOrderJob implements ShouldQueue
         Log::error('开始执行足球游戏订单开奖结算任务: ' . now());
         
         //先把未中奖的标记为未中奖
-        SportGameOrder::where('gameplay_id', '<>', $this->gameplayId)->where('status', 0)->update(['status' => 1]);
+        DB::transaction(function () {
+            $query = SportGameOrder::where('gameplay_id', '<>', $this->gameplayId)->where('status', 0);
+            $ids = (clone $query)->lockForUpdate()->pluck('id')->all();
+            $query->update(['status' => 1]);
+            \App\Services\Reporting\ReportContext::sportGamesSettled($ids);
+        });
 
         $list = SportGameOrder::where('gameplay_id', $this->gameplayId)->where('status', 0)->get();
         foreach ($list as $item) {

+ 30 - 0
app/Services/Reporting/ReportContext.php

@@ -31,6 +31,36 @@ class ReportContext
         }
     }
 
+    /** Capture the loss batch while the settlement job still holds its source locks. */
+    public static function sportGamesSettled(array $orderIds): void
+    {
+        try {
+            if (!$orderIds || !Schema::hasTable('reporting_contexts')) return;
+            $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+            foreach (array_chunk($orderIds, 200) as $chunk) {
+                $rows = DB::table('sport_game_order')->whereIn('id', $chunk)->where('status', 1)->get();
+                $ids = $rows->map(fn ($row) => (string) $row->id)->all();
+                $known = DB::table('reporting_contexts')->where('source', 'sport_game_order')->whereIn('source_id', $ids)->pluck('source_id')->all();
+                foreach ($rows as $row) {
+                    if (in_array((string) $row->id, $known, true)) continue;
+                    try {
+                        $at = \App\Services\Sharing\EffectiveBet::time($row->created_at)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+                        $identity = self::identity((array) $row, $at, 'sport_game_order');
+                        DB::table('reporting_contexts')->insertOrIgnore($identity + [
+                            'source' => 'sport_game_order', 'source_id' => (string) $row->id, 'occurred_at' => $at, 'succeeded_at' => null,
+                        ]);
+                    } catch (\Throwable $e) {
+                        \Illuminate\Support\Facades\Log::error('reporting_sport_settlement_identity_failed', ['order_id' => $row->id, 'exception' => get_class($e)]);
+                    }
+                }
+                DB::table('reporting_contexts')->where('source', 'sport_game_order')->whereIn('source_id', $ids)
+                    ->whereNull('succeeded_at')->update(['succeeded_at' => $now]);
+            }
+        } catch (\Throwable $e) {
+            \Illuminate\Support\Facades\Log::error('reporting_sport_settlement_failed', ['exception' => get_class($e)]);
+        }
+    }
+
     public static function captureModel($model): void
     {
         $source = $model->getTable();

+ 6 - 0
docs/后端/F1彩票报表结算统计修正.md

@@ -15,3 +15,9 @@ php artisan reporting:sync --full
 该命令只刷新报表数据,不重结算、不发钱。核对输出中的 `errors`,非0时通过 `/admin/reporting/quality` 查看具体异常。
 
 核对同一日期的 `/admin/reporting/fund-flow`、`/admin/reporting/summary` 营业及 `report_type=sharing` 视图。F1彩票分类键为 `self:lottery`;分享视图按订单保存的邀请人归属统计,有效分享佣金仍从已有应计/实发记录读取。本次不更改前端字段,也没有新增数据库迁移。
+
+## 第三方与 F1 体育补充核对
+
+第三方七类使用供应商 `last_update_time`,F1 体育主链路使用 `settlement_time`/`return_operation_time`。已覆盖中奖、未中奖、待派奖、撤单及部分退款的统计验证。
+
+旧足球小游戏 `sport_game_order` 的未中奖批量更新也会绕过模型钩子,已在该批状态变更的同一事务内补记完成时间;不改变赔率、派奖金额或输赢判定。该补丁在 bot-28 发布。没有可靠完成时间的旧未中奖记录仍保留待核对,不用更新时间猜测日期。

+ 92 - 0
tests/Integration/ReportingTest.php

@@ -597,6 +597,98 @@ class ReportingTest extends TestCase
         $this->assertSame(4, DB::table('reporting_orders')->count());
     }
 
+    public static function thirdPartyReportCases(): array
+    {
+        $cases = [];
+        foreach (range(1, 7) as $type) foreach ([
+            'win' => [1, '80', '100', '180', '0', '-80'],
+            'loss' => [1, '-100', '100', '0', '0', '100'],
+            'push' => [1, '0', '100', '100', '0', '0'],
+            'pending' => [0, '0', '0', '0', '0', '0'],
+            'void' => [2, '0', '0', '0', '100', '0'],
+            'cancelled' => [3, '0', '0', '0', '100', '0'],
+        ] as $name => $values) $cases[$type . '-' . $name] = [$type, ...$values];
+        return $cases;
+    }
+
+    /** @dataProvider thirdPartyReportCases */
+    public function test_third_party_categories_use_provider_settlement_time_and_preserve_net_payout_math(int $type, int $status, string $net, string $stake, string $payout, string $refund, string $gross): void
+    {
+        DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,game_type INTEGER,bet_amount TEXT,settled_amount TEXT,status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
+        DB::table('third_game_orders')->insert(['id' => 1, 'order_key' => str_repeat('b', 64), 'member_id' => '200', 'user_id' => 2, 'game_type' => $type,
+            'bet_amount' => '100', 'settled_amount' => $net, 'status' => $status, 'currency' => 'CNY', 'bet_time' => '2026-10-25 23:00:00',
+            'last_update_time' => '2026-10-26 10:00:00', 'created_at' => '2026-10-26 12:00:00', 'updated_at' => '2026-10-26 13:00:00']);
+        $this->sync->publish('third_game_orders', 1); $this->sync->publish('third_game_orders', 1);
+        $day = $this->reader->funds($this->today())['summary'];
+        $report = $this->reader->summary($this->today());
+        $category = collect($report['categories'])->keyBy('group_id')['third:' . $type];
+        foreach ([$day, $category, $this->reader->summary($this->today(['report_type' => 'sharing']))['summary']] as $row) {
+            $this->assertSame('0.0000000000', $row['bet_amount']);
+            $this->assertSame(bcadd($stake, '0', 10), $row['settled_bet_amount']);
+            $this->assertSame(bcadd($payout, '0', 10), $row['payout_amount']);
+            $this->assertSame(bcadd($refund, '0', 10), $row['refund_amount']);
+            $this->assertSame(bcadd($gross, '0', 10), $row['game_gross_profit']);
+        }
+        $previous = $this->reader->summary(['start_date' => '2026-10-25', 'end_date' => '2026-10-25'])['summary'];
+        $this->assertSame('100.0000000000', $previous['bet_amount']);
+        $this->assertSame('0.0000000000', $previous['payout_amount']);
+        $this->assertSame(1, DB::table('reporting_orders')->count());
+        $this->assertSame($status === 0 ? null : '2026-10-26 10:00:00', DB::table('reporting_orders')->value('settled_at'));
+    }
+
+    public static function sportsReportCases(): array
+    {
+        return [
+            'win' => [1, 1, 0, '180', '0', '100', '180', '0', '-80'],
+            'loss' => [1, 1, 0, '0', '0', '100', '0', '0', '100'],
+            'half-loss' => [1, 1, 1, '0', '50', '50', '0', '50', '50'],
+            'push' => [1, 1, 1, '0', '100', '0', '0', '100', '0'],
+            'awaiting-payout' => [1, 2, 0, '180', '0', '0', '0', '0', '0'],
+            'void' => [2, 0, 2, '0', '100', '0', '0', '100', '0'],
+        ];
+    }
+
+    /** @dataProvider sportsReportCases */
+    public function test_f1_sports_uses_official_settlement_or_refund_time(int $status, int $settlement, int $returnStatus, string $win, string $returned, string $stake, string $payout, string $refund, string $gross): void
+    {
+        DB::statement('CREATE TABLE bot_sport_order (id INTEGER PRIMARY KEY,user_id TEXT,amount TEXT,win_amount TEXT,return_amount TEXT,status INTEGER,pay_status INTEGER,settlement_status INTEGER,return_status INTEGER,create_time INTEGER,update_time INTEGER,settlement_time INTEGER,return_operation_time INTEGER)');
+        DB::table('sport_order')->insert(['id' => 1, 'user_id' => '200', 'amount' => '100', 'win_amount' => $win, 'return_amount' => $returned,
+            'status' => $status, 'pay_status' => 1, 'settlement_status' => $settlement, 'return_status' => $returnStatus,
+            'create_time' => Carbon::parse('2026-10-25 23:00:00', 'Asia/Shanghai')->timestamp,
+            'update_time' => Carbon::parse('2026-10-26 13:00:00', 'Asia/Shanghai')->timestamp,
+            'settlement_time' => $status === 2 ? null : Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai')->timestamp,
+            'return_operation_time' => $status === 2 ? Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')->timestamp : null]);
+        $this->sync->publish('sport_order', 1);
+        $category = collect($this->reader->summary($this->today())['categories'])->keyBy('group_id')['self:sport'];
+        foreach ([$category, $this->reader->funds($this->today())['summary']] as $row) {
+            $this->assertSame(bcadd($stake, '0', 10), $row['settled_bet_amount']);
+            $this->assertSame(bcadd($payout, '0', 10), $row['payout_amount']);
+            $this->assertSame(bcadd($refund, '0', 10), $row['refund_amount']);
+            $this->assertSame(bcadd($gross, '0', 10), $row['game_gross_profit']);
+        }
+        $this->assertSame($settlement === 2 ? null : ($status === 2 ? '2026-10-26 11:00:00' : '2026-10-26 10:00:00'), DB::table('reporting_orders')->value('settled_at'));
+    }
+
+    public function test_legacy_sport_game_job_captures_losses_as_well_as_wins_for_reporting(): void
+    {
+        DB::statement('CREATE TABLE bot_sport_game_order (id INTEGER PRIMARY KEY,member_id TEXT,gameplay_id INTEGER,amount TEXT,odds TEXT,status INTEGER,win_amount TEXT,profit_and_loss TEXT,created_at TEXT,updated_at TEXT)');
+        DB::statement('CREATE TABLE bot_wallets (id INTEGER PRIMARY KEY,member_id TEXT,available_balance TEXT,frozen_balance TEXT,created_at TEXT,updated_at TEXT)');
+        DB::table('wallets')->insert(['id' => 1, 'member_id' => '200', 'available_balance' => '100', 'frozen_balance' => '0']);
+        DB::table('sport_game_order')->insert([
+            ['id' => 1, 'member_id' => '200', 'gameplay_id' => 1, 'amount' => '100', 'odds' => '1.8', 'status' => 0, 'created_at' => '2026-10-26 09:00:00'],
+            ['id' => 2, 'member_id' => '200', 'gameplay_id' => 2, 'amount' => '50', 'odds' => '1.8', 'status' => 0, 'created_at' => '2026-10-26 09:00:00'],
+        ]);
+        $this->context('sport_game_order', 1); $this->context('sport_game_order', 2);
+        (new \App\Jobs\SportGameOrderJob(1))->handle();
+        $this->sync->publish('sport_game_order', 1); $this->sync->publish('sport_game_order', 2);
+        $row = $this->reader->funds($this->today())['summary'];
+        $this->assertSame('150.0000000000', $row['settled_bet_amount']);
+        $this->assertSame('180.0000000000', $row['payout_amount']);
+        $this->assertSame('-30.0000000000', $row['game_gross_profit']);
+        $this->assertSame('280.00', bcadd((string) DB::table('wallets')->where('id', 1)->value('available_balance'), '0', 2));
+        $this->assertSame(0, DB::transactionLevel());
+    }
+
     public function test_audit_failure_rolls_back_reconciliation_and_foreign_currency_is_not_summed(): void
     {
         $event = $this->deposit();