doge 23 godzin temu
rodzic
commit
f38e0cea71

+ 2 - 0
app/Services/BetService.php

@@ -790,6 +790,7 @@ class BetService extends BaseService
 
             //下注项状态更新为未中奖
             LhcOrderItem::where('ordernum', $v['ordernum'])->update(['lottery_status' => self::model()::STATUS_LOSS]);
+            \App\Services\Reporting\ReportContext::lotterySettled((int) $v['id']);
         }
 
     }
@@ -955,6 +956,7 @@ class BetService extends BaseService
             } else {
                 LhcOrder::where("id", $v['id'])->update(['lottery_status' => '1', 'win_amount' => 0]);
             }
+            \App\Services\Reporting\ReportContext::lotterySettled((int) $v['id']);
         }
         $openList = array_merge($openList, $fakeOpenData['list']);
 

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

@@ -11,6 +11,26 @@ class ReportContext
 {
     public const SOURCES = ['bets', 'lhc_order', 'jisu_game_order', 'sport_game_order', 'sport_order', 'recharges', 'withdraws', 'payment_orders', 'balance_logs', 'agent_withdrawals', 'share_claims'];
 
+    /** Bulk lottery updates bypass model events. Call at the actual terminal transition. */
+    public static function lotterySettled(int $orderId): void
+    {
+        try {
+            if (!Schema::hasTable('reporting_contexts')) return;
+            $order = DB::table('lhc_order')->where('id', $orderId)->first();
+            if (!$order || (int) ($order->is_faker ?? 0) === 1 || !in_array((int) $order->lottery_status, [1, 2, 3, 4], true)) return;
+            $at = \App\Services\Sharing\EffectiveBet::time($order->created_at)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+            $identity = self::identity((array) $order, $at, 'lhc_order');
+            DB::table('reporting_contexts')->insertOrIgnore($identity + [
+                'source' => 'lhc_order', 'source_id' => (string) $orderId, 'occurred_at' => $at, 'succeeded_at' => null,
+            ]);
+            DB::table('reporting_contexts')->where('source', 'lhc_order')->where('source_id', (string) $orderId)
+                ->whereNull('succeeded_at')->update(['succeeded_at' => now('Asia/Shanghai')->format('Y-m-d H:i:s')]);
+        } catch (\Throwable $e) {
+            // Reporting must never change whether an already settled bet pays out.
+            \Illuminate\Support\Facades\Log::error('reporting_lottery_settlement_failed', ['order_id' => $orderId, 'exception' => get_class($e)]);
+        }
+    }
+
     public static function captureModel($model): void
     {
         $source = $model->getTable();

+ 32 - 1
app/Services/Reporting/ReportSync.php

@@ -92,12 +92,15 @@ class ReportSync
                 'category' => 'self:lottery', 'bet_at' => $at, 'bet_amount' => $bet, 'settled' => $settled, 'voided' => $void,
                 'net_profit' => $settled ? bcsub($payout, $bet, 10) : Money::zero(), 'currency' => $this->currency()];
         } else $facts = (new BetFactSynchronizer())->projectForReporting($source, (object) $row);
+        $context = DB::table('reporting_contexts')->where('source', $source)->where('source_id', (string) $row['id'])->first();
+        $lotteryPayoutAt = $source === 'lhc_order' && empty($context->succeeded_at)
+            ? $this->lotteryPayoutTime($row, $facts) : null;
         $keys = [];
         foreach ($facts as $fact) {
             $identity = ReportContext::identity($row + ['report_user_id' => $fact['user_id']], $fact['bet_at'], $source);
-            $context = DB::table('reporting_contexts')->where('source', $source)->where('source_id', (string) $row['id'])->first();
             $settledAt = $context->succeeded_at ?? null;
             if (!(bool) ($context->settlement_verified ?? false)) {
+                if ($source === 'lhc_order') $settledAt = $settledAt ?? $lotteryPayoutAt;
                 if ($source === 'third_game_orders') $settledAt = $this->time($row['last_update_time'] ?? null);
                 if ($source === 'sport_order') $settledAt = $this->time($row['settlement_time'] ?? null) ?? $this->time($row['return_operation_time'] ?? null) ?? $settledAt;
             }
@@ -124,6 +127,34 @@ class ReportSync
         DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $row['id'])->when($keys, fn ($q) => $q->whereNotIn('source_id', $keys))->delete();
     }
 
+    /** Restore missing win times only from matching, balanced wallet-credit receipts. */
+    private function lotteryPayoutTime(array $order, array $facts): ?string
+    {
+        if (!$facts || !Schema::hasTable('balance_logs') || !isset($order['win_amount'])) return null;
+        $expected = Money::zero();
+        foreach ($facts as $fact) {
+            if (!$fact['settled'] || $fact['voided']) return null;
+            $expected = bcadd($expected, bcadd($fact['bet_amount'], $fact['net_profit'], 10), 10);
+        }
+        if (bccomp($expected, '0', 10) <= 0 || bccomp($expected, (string) $order['win_amount'], 10) !== 0) return null;
+        $name = \App\Models\LhcOrder::getRemark((int) ($order['type'] ?? 0));
+        if ($name === '') return null;
+        $types = [$name . '中奖'];
+        if (in_array((int) $order['type'], [5, 6], true)) $types[] = '中奖';
+        $logs = DB::table('balance_logs')->where('member_id', (string) $order['member_id'])
+            ->where('related_id', $order['id'])->where('type', 1)->whereIn('change_type', $types)->where('amount', '>', 0)->get();
+        $credited = Money::zero(); $last = null;
+        foreach ($logs as $log) {
+            $at = $this->time($log->created_at);
+            if ($at === null || $at < $facts[0]['bet_at'] || $at > now('Asia/Shanghai')->format('Y-m-d H:i:s')) return null;
+            if ($log->before_balance === null || $log->after_balance === null
+                || bccomp(bcsub((string) $log->after_balance, (string) $log->before_balance, 10), (string) $log->amount, 10) !== 0) return null;
+            $credited = bcadd($credited, (string) $log->amount, 10);
+            $last = $last === null ? $at : max($last, $at);
+        }
+        return bccomp($credited, $expected, 10) === 0 ? $last : null;
+    }
+
     private function money(string $source, array $row): void
     {
         $candidate = $this->time($row['created_at'] ?? null);

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

@@ -0,0 +1,17 @@
+# F1彩票报表结算统计修正
+
+F1彩票部分结算使用查询更新,未触发模型的报表钩子,导致已结算订单缺少 `succeeded_at`。投注能统计,但结算净本金、派奖及毛利会保留为0并标记缺失时间。
+
+本次在 bot-28 的28类结算及 melbet_sport-api 的六合彩、极速结算完成处补记实际完成时间,保留投注时记录的装置、代理和邀请人。报表采集失败不改变游戏结算结果;采集记录与原结算事务一起提交或回滚。
+
+历史中奖订单只有在对应会员、订单、派奖类型、全部子注单派奖总额及钱包账变差额一致时,才按实发流水的创建时间恢复结算日期。人工核对过的时间优先;未中奖且无完成记录、退款或无法完整匹配的历史流水不猜测时间,仍需按凭据核对。
+
+发布两个项目后,在 bot-28 执行一次:
+
+```bash
+php artisan reporting:sync --full
+```
+
+该命令只刷新报表数据,不重结算、不发钱。核对输出中的 `errors`,非0时通过 `/admin/reporting/quality` 查看具体异常。
+
+核对同一日期的 `/admin/reporting/fund-flow`、`/admin/reporting/summary` 营业及 `report_type=sharing` 视图。F1彩票分类键为 `self:lottery`;分享视图按订单保存的邀请人归属统计,有效分享佣金仍从已有应计/实发记录读取。本次不更改前端字段,也没有新增数据库迁移。

+ 111 - 1
tests/Integration/ReportingTest.php

@@ -117,6 +117,114 @@ class ReportingTest extends TestCase
         $this->assertSame('100.0000000000', $yesterday['bet_amount']); $this->assertSame('0.0000000000', $yesterday['payout_amount']);
     }
 
+    private function f1LotteryWithoutCompletionContext(): void
+    {
+        DB::statement('CREATE TABLE bot_lhc_order (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,amount TEXT,total_amount TEXT,win_amount TEXT,type INTEGER,lottery_status INTEGER,is_faker INTEGER DEFAULT 0,created_at INTEGER,updated_at INTEGER)');
+        DB::statement('CREATE TABLE bot_lhc_order_item (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,lottery_status INTEGER,win_amount TEXT,updated_at INTEGER)');
+        $betAt = Carbon::parse('2026-10-26 09:00:00', 'Asia/Shanghai')->timestamp;
+        DB::table('lhc_order')->insert(['id' => 1, 'member_id' => '200', 'ordernum' => 'F1-live', 'amount' => '50', 'total_amount' => '100',
+            'win_amount' => '190', 'type' => 6, 'lottery_status' => 2, 'created_at' => $betAt, 'updated_at' => $betAt + 3600]);
+        DB::table('lhc_order_item')->insert([
+            ['id' => 1, 'member_id' => '200', 'ordernum' => 'F1-live', 'lottery_status' => 2, 'win_amount' => '190', 'updated_at' => $betAt + 3600],
+            ['id' => 2, 'member_id' => '200', 'ordernum' => 'F1-live', 'lottery_status' => 1, 'win_amount' => '0', 'updated_at' => $betAt + 3600],
+        ]);
+        $this->context('lhc_order', 1);
+    }
+
+    private function f1PayoutReceipt(array $extra = []): void
+    {
+        DB::table('balance_logs')->insert($extra + ['member_id' => '200', 'type' => 1, 'related_id' => 1, 'amount' => '190',
+            'before_balance' => '100', 'after_balance' => '290', 'change_type' => '极速28中奖', 'created_at' => '2026-10-26 10:00:00']);
+    }
+
+    public function test_f1_wallet_receipt_restores_funds_business_category_and_sharing_settlement_totals(): void
+    {
+        $this->f1LotteryWithoutCompletionContext(); $this->f1PayoutReceipt();
+        $before = DB::table('lhc_order')->get()->toJson();
+        $this->sync->publish('lhc_order', 1); $this->sync->publish('lhc_order', 1);
+        $funds = $this->reader->funds($this->today());
+        $business = $this->reader->summary($this->today());
+        $category = collect($business['categories'])->keyBy('group_id')['self:lottery'];
+        $sharing = $this->reader->summary($this->today(['report_type' => 'sharing']));
+        foreach ([$funds['summary'], $funds['data'][0], $business['summary'], $category, $sharing['summary']] as $row) {
+            $this->assertSame('100.0000000000', $row['bet_amount']);
+            $this->assertSame('100.0000000000', $row['settled_bet_amount']);
+            $this->assertSame('190.0000000000', $row['payout_amount']);
+            $this->assertSame('-90.0000000000', $row['game_gross_profit']);
+        }
+        $this->assertSame('F1彩票', $category['label']);
+        $groups = $this->reader->details($this->today(['report_type' => 'sharing']))['data'];
+        $this->assertSame('1', $groups[0]['group_id']);
+        $this->assertSame('190.0000000000', $groups[0]['payout_amount']);
+        $this->assertSame(2, DB::table('reporting_orders')->count());
+        $this->assertSame('2026-10-26 10:00:00', DB::table('reporting_orders')->value('settled_at'));
+        $this->assertSame($before, DB::table('lhc_order')->get()->toJson());
+    }
+
+    public function test_bulk_settlement_capture_preserves_event_attribution_and_records_actual_transition_once(): void
+    {
+        $this->f1LotteryWithoutCompletionContext();
+        $this->sync->publish('lhc_order', 1);
+        $this->assertSame('0.0000000000', $this->reader->funds($this->today())['summary']['payout_amount']);
+        $before = DB::table('lhc_order')->get()->toJson();
+        DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
+        \App\Services\Reporting\ReportContext::lotterySettled(1);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai'));
+        \App\Services\Reporting\ReportContext::lotterySettled(1);
+        $context = DB::table('reporting_contexts')->where('source', 'lhc_order')->first();
+        $this->assertSame('2026-10-26 16:00:00', $context->succeeded_at);
+        $this->assertSame(10, (int) $context->agent_id); $this->assertSame(1, (int) $context->inviter_id);
+        $this->assertSame('H5', $context->client_platform);
+        $this->sync->publish('lhc_order', 1);
+        $row = $this->reader->funds($this->today())['summary'];
+        $this->assertSame('100.0000000000', $row['settled_bet_amount']);
+        $this->assertSame('190.0000000000', $row['payout_amount']);
+        $this->assertSame('-90.0000000000', $row['game_gross_profit']);
+        $this->assertSame($before, DB::table('lhc_order')->get()->toJson());
+    }
+
+    public static function invalidF1Receipts(): array
+    {
+        return [
+            [['related_id' => 999]], [['member_id' => '100']], [['type' => 2]],
+            [['amount' => '191', 'after_balance' => '291']], [['after_balance' => '999']],
+            [['created_at' => '2026-10-25 10:00:00']], [['change_type' => '充值']],
+        ];
+    }
+
+    /** @dataProvider invalidF1Receipts */
+    public function test_unrelated_or_unbalanced_receipts_do_not_fabricate_lottery_settlement_time(array $receipt): void
+    {
+        $this->f1LotteryWithoutCompletionContext(); $this->f1PayoutReceipt($receipt);
+        $this->sync->publish('lhc_order', 1);
+        $this->assertNull(DB::table('reporting_orders')->value('settled_at'));
+        $row = $this->reader->funds($this->today())['summary'];
+        $this->assertSame('100.0000000000', $row['bet_amount']);
+        $this->assertSame('0.0000000000', $row['payout_amount']);
+        $this->assertGreaterThan(0, $row['quality']['missing_time']);
+    }
+
+    public function test_verified_lottery_time_takes_priority_over_payout_receipt_time(): void
+    {
+        $this->f1LotteryWithoutCompletionContext(); $this->f1PayoutReceipt();
+        DB::table('reporting_contexts')->where('source', 'lhc_order')->update(['settlement_verified' => 1, 'succeeded_at' => '2026-10-26 11:30:00']);
+        $this->sync->publish('lhc_order', 1);
+        $this->assertSame('2026-10-26 11:30:00', DB::table('reporting_orders')->value('settled_at'));
+    }
+
+    public function test_bulk_lottery_loss_is_counted_as_settled_gross_profit_without_a_prize_receipt(): void
+    {
+        $this->f1LotteryWithoutCompletionContext();
+        DB::table('lhc_order')->where('id', 1)->update(['lottery_status' => 1, 'win_amount' => '0']);
+        DB::table('lhc_order_item')->update(['lottery_status' => 1, 'win_amount' => '0']);
+        \App\Services\Reporting\ReportContext::lotterySettled(1);
+        $this->sync->publish('lhc_order', 1);
+        $row = $this->reader->funds($this->today())['summary'];
+        $this->assertSame('100.0000000000', $row['settled_bet_amount']);
+        $this->assertSame('0.0000000000', $row['payout_amount']);
+        $this->assertSame('100.0000000000', $row['game_gross_profit']);
+    }
+
     public function test_missing_fees_and_success_time_never_produce_fabricated_net_values(): void
     {
         $event = $this->deposit(); $this->bet();
@@ -425,7 +533,9 @@ class ReportingTest extends TestCase
         $this->assertSame('28.0000000000', $row['company_profit']);
         foreach ([false, true] as $ranking) {
             $details = $this->reader->details($this->today(), $ranking);
-            $this->assertArrayNotHasKey('unallocated', $details);
+            // Agent-only costs belong to the separate metadata bucket, not a synthetic member row.
+            $this->assertSame('7.0000000000', $details['unallocated']['agent_commission']);
+            $this->assertSame('9.0000000000', $details['unallocated']['agent_accrual']);
             $this->assertNotContains('unallocated', array_column($details['data'], 'group_id'));
             $this->assertSame(2, $details['total']);
         }