doge 3 дней назад
Родитель
Сommit
d8c8af55bd

+ 1 - 0
app/Services/Reporting/ReportAdmin.php

@@ -17,6 +17,7 @@ class ReportAdmin
     {
         return ['report_types' => ['business' => '营业报表', 'promotion' => '推广报表', 'sharing' => '分享赚钱'],
             'categories' => ['all' => '所有订单'] + MaintenancePolicy::CATEGORIES,
+            'category_hidden_fields' => ReportReader::TOTAL_ONLY_FIELDS,
             '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,

+ 14 - 5
app/Services/Reporting/ReportReader.php

@@ -18,7 +18,11 @@ class ReportReader
     private const AMOUNTS = ['deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee',
         'customer_fee_income', 'configured_fee', 'bonus_amount', 'agent_commission', 'share_commission', 'agent_accrual', 'share_accrual', 'manual_credit', 'manual_debit',
         'bet_amount', 'settled_bet_amount', 'payout_amount', 'refund_amount', 'game_gross_profit', 'third_game_commission'];
-    private const CASH = ['deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee', 'customer_fee_income', 'configured_fee', 'bonus_amount', 'manual_credit', 'manual_debit'];
+    public const TOTAL_ONLY_FIELDS = [
+        'deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee',
+        'customer_fee_income', 'configured_fee', 'bonus_amount', 'manual_credit', 'manual_debit',
+        'net_cash_flow', 'company_profit', 'known_net_cash_flow', 'known_company_profit',
+    ];
 
     public function params(array $input): array
     {
@@ -184,6 +188,7 @@ class ReportReader
         $stateRows = DB::table('reporting_sync_state')->get(); $hasErrors = DB::table('reporting_errors')->where(function ($q) use ($p) { $q->whereNull('candidate_at')->orWhere('candidate_at', '<', $p['end']); })->count();
         $run = $stateRows->firstWhere('source', '__run__'); $ready = $run && (json_decode($run->state, true)['complete'] ?? false);
         $syncAt = $run->updated_at ?? null;
+        $covered = $p['start'] >= $settings['activity_started_at'];
         $scopePopulation = []; $groupPopulation = [];
         if ($mode !== 'daily') foreach ($users as $uid => $u) {
             $person = ['user_id' => $u['id'], 'agent_id' => $u['agent_id'] ?? null, 'inviter_id' => $u['inviter_id'] ?? null, 'kind' => 'activity', 'client_platform' => $p['platform']];
@@ -208,9 +213,14 @@ class ReportReader
             foreach ($active as $item) $seconds += $item['seconds'];
             // Current membership cannot prove a historical population after a reassignment.
             $value['activity_population_complete'] = !array_diff_key($active, $population);
-            $covered = $p['start'] >= $settings['activity_started_at'];
             $value['known_activity_minutes'] = intdiv($seconds, 60); $value['activity_minutes'] = $covered ? $value['known_activity_minutes'] : null;
-            $value['active_rate'] = $covered && $value['activity_population_complete'] ? ($value['member_count'] ? bcdiv((string) $value['active_member_count'], (string) $value['member_count'], 6) : '0.000000') : null;
+            $value['known_active_rate'] = null;
+            if (($covered || $active) && $value['activity_population_complete']) {
+                $value['known_active_rate'] = $value['member_count']
+                    ? bcdiv((string) $value['active_member_count'], (string) $value['member_count'], 6)
+                    : '0.000000';
+            }
+            $value['active_rate'] = $covered ? $value['known_active_rate'] : null;
             $value['order_count'] = count($value['_orders']); unset($value['_orders'], $value['_bettors']);
             $value['quality']['sync_errors'] = (int) $hasErrors; $value['quality']['not_synchronized'] = !$ready;
             if ($mode === 'daily') $value['quality']['missing_time'] += $key === 'all' ? 0 : $globalMissingTime;
@@ -223,8 +233,7 @@ class ReportReader
             $value['net_cash_flow'] = $cashComplete ? $value['known_net_cash_flow'] : null;
             $value['company_profit'] = $complete && $p['settlement_status'] !== 'pending' ? $value['known_company_profit'] : null;
             if (($mode === 'summary' && $key !== 'all') || $p['category'] !== 'all') {
-                foreach (self::CASH as $f) $value[$f] = null;
-                $value['net_cash_flow'] = $value['company_profit'] = $value['known_net_cash_flow'] = $value['known_company_profit'] = null;
+                foreach (self::TOTAL_ONLY_FIELDS as $field) $value[$field] = null;
             }
             if ($mode === 'daily' && $key !== 'all') { $value['activity_minutes'] = $value['active_rate'] = null; }
             if ($mode === 'daily') {

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

@@ -16,6 +16,7 @@
 | 转盘任务、概率配置与记录 | [转盘活动-后台接口](独立活动/转盘活动-后台接口.md) |
 | 报表页面修正:移除三个入口,统一有效会员条件 | [报表页面调整-移除三个入口](报表页面调整-移除三个入口.md) |
 | 报表汇总:营业/推广/分享页签及明细 | [报表汇总-前端接入](报表汇总-前端接入.md) |
+| 报表汇总:分类隐藏资金列、移除已采集时长及活动指标显示(2026-11-06) | [报表汇总列显示与活跃数据](报表汇总-列显示与活跃数据.md) |
 | 提现打码量与冻结金额:会员列表打码量、上下分打码量、冻结/解冻、冻结记录(2026-11-04) | [打码量与冻结金额-后台接口](打码量与冻结金额-后台接口.md) |
 | 会员彩金:统一变动类型筛选、变动前/变动金额/变动后、余额宝利息直接进余额,新增笔笔返、回水(2026-11-05) | [会员彩金-前端接入](会员彩金-前端接入.md) |
 | 活动列表:到期自动下架、状态筛选与列表展示(2026-11-05) | [活动列表-到期自动下架](活动列表-到期自动下架.md) |

+ 26 - 0
docs/前端交付/bot-28/后台/报表汇总-列显示与活跃数据.md

@@ -0,0 +1,26 @@
+# 报表汇总列显示与活跃数据
+
+适用于 `/admin/reporting/summary` 的营业报表、推广报表和分享赚钱页签。
+
+## 游戏分类隐藏资金列
+
+| 请求 | 读取字段 |
+|---|---|
+| `GET /admin/reporting/options` | `data.category_hidden_fields`,需隐藏的列绑定字段,类型为 `string[]` |
+| `GET /admin/reporting/summary?category=all` | 「所有订单」读取 `data.summary`,游戏分类读取 `data.categories[]` |
+| `GET /admin/reporting/details?category=self:lottery` | 用 `data.filters.category` 判断是否为游戏分类明细 |
+
+选择游戏分类卡(`group_id != "all"`)或查看游戏分类明细(`filters.category != "all"`)时,按 `category_hidden_fields` 从列定义里移除整列,包括列标题。涉及成功存款、实际提款及会员/代理分项、手续费及参考值、优惠、人工上下分、公司净收益、实际净入金和两项已知小计。
+
+打开明细时带上当前筛选。切回「所有订单」时恢复原列。总卡金额为 `null` 时显示「—」,金额为零时按金额格式显示。
+
+## 活跃率与活动时长
+
+移除「已采集时长」列。活跃率、活动时长按当前卡片或明细行的字段渲染。
+
+| 指标 | 展示顺序 |
+|---|---|
+| 活跃率 | 优先用 `active_rate`。为 `null` 时用 `known_active_rate`,例如 `"0.250000"` 显示为「25%(按已观测会员)」;两者均为 `null` 时显示「—」。 |
+| 活动时长 | 优先用 `activity_minutes`。为 `null` 且 `active_member_count > 0` 时,用 `known_activity_minutes`,例如「12 分钟(仅已采集区间)」;其余空值显示「—」。 |
+
+百分比和分钟数都按是否为 `null` 判断。完整值为零时显示「0%」或「0 分钟」。

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

@@ -97,6 +97,7 @@ GET /admin/reporting/options
 |---|---|---|
 | `report_types` | object<string,string> | 三个页签代码和名称 |
 | `categories` | object<string,string> | `all` 加九类游戏代码和名称 |
+| `category_hidden_fields` | string[] | 游戏分类卡和分类明细需隐藏的指标字段 |
 | `platforms` | string[] | 装置代码;`all` 展示“全部”,`unknown` 展示“未知” |
 | `settlement_statuses` | string[] | `all` 全部、`pending` 未结算、`settled` 已结算 |
 | `currency` | string | 金额格式使用的本位币 |
@@ -276,6 +277,7 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 | `manual_credit` / `manual_debit` | 人工上分/扣分,独立展示,不直接当作外部现金存提款 |
 | `company_profit` | string \| null,公司净收益,公式见下文;`settlement_status=pending` 时为 `null` |
 | `net_cash_flow` | string \| null,实际净入金,公式见下文 |
+| `known_company_profit` / `known_net_cash_flow` | 按已确认时间、金额及已核实外部费用汇总的已知小计;最终净值缺失时单列小计,并结合 `quality` 提示待核对项 |
 | `member_count` | number,当前人口范围内、查询结束日前的真实会员参考人数,不按注册装置拆分 |
 | `valid_member_count` | number \| null,本次投注候选人中满足累计两项门槛的去重人数;规则不可用时为 `null` |
 | `active_member_count` | number,本次已观测到的区间活跃会员去重人数;历史覆盖不足时仅代表已知观测 |
@@ -298,9 +300,9 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 
 ### 6.2 总卡与分类卡的差异
 
-分类卡保留可归属的游戏指标及分类佣金/应计。以下公共资金字段返回 `null`:`deposit_amount`、`withdraw_amount`、`member_withdraw_amount`、`agent_withdraw_amount`、`deposit_fee`、`withdraw_fee`、`customer_fee_income`、`configured_fee`、`bonus_amount`、`manual_credit`、`manual_debit`。分类卡的 `company_profit`、`net_cash_flow` 也均为 `null`。分类毛利用 `game_gross_profit`。
+选择游戏分类卡(`group_id != all`)或打开分类明细(`filters.category != all`)时,从列定义中移除 `options.category_hidden_fields` 列出的字段,连同列头隐藏。这些字段包括存提款、手续费、优惠、人工上下分、公司净收益、实际净入金及两项已知小计。「所有订单」总卡按原列展示,个别净值为 `null` 时仍保留该列。
 
-明细请求 `category != all` 时,同样将上述公共资金和净收益字段置为 `null`。分类佣金是已带分类归属的资金记录,不得按投注比例摊分公共成本,也不得把总卡金额复制给九类卡片。
+分类卡和分类明细的公共资金字段仍返回 `null`。分类卡保留可归属的游戏指标和分类佣金/应计,分类毛利用 `game_gross_profit`。分类佣金按资金记录的分类归属统计。
 
 分类明细优先展示可可靠归属的订单指标。`category` 限定游戏订单,明细中的佣金/应计可能包含同组其他分类记录,不能标为该分类金额,也不通过资金追溯在前端重算统计值。
 
@@ -319,8 +321,10 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 | `activity_minutes` | 使用整数分钟;0显示“0分钟”,`null` 显示“—” |
 | `active_member_count` | 区间活跃会员人数 |
 | `active_rate` | 按百分比展示,如 `"0.250000"` 显示25%;`null` 显示“—” |
+| `known_activity_minutes` | 完整活动时长为 `null` 且 `active_member_count > 0` 时,可显示观测到的分钟数,标注「仅已采集区间」 |
+| `known_active_rate` | 完整活跃率为 `null` 时,可显示此比例,标注「按已观测会员」;缺少观测或会员范围无法确认时为 `null` |
 
-总卡与分类卡分别读取对应返回值,活动指标不按分类相加。
+总卡与分类卡分别读取对应返回值,活动指标不按分类相加。报表页面去掉「已采集时长」列,`activity_started_at` 可用于说明活动统计的覆盖范围。
 
 ## 9. 分组明细与两类追溯
 

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

@@ -97,6 +97,7 @@ GET /admin/reporting/options
 |---|---|---|
 | `report_types` | object<string,string> | 三个页签代码和名称 |
 | `categories` | object<string,string> | `all` 加九类游戏代码和名称 |
+| `category_hidden_fields` | string[] | 游戏分类卡和分类明细需隐藏的指标字段 |
 | `platforms` | string[] | 装置代码;`all` 展示“全部”,`unknown` 展示“未知” |
 | `settlement_statuses` | string[] | `all` 全部、`pending` 未结算、`settled` 已结算 |
 | `currency` | string | 金额格式使用的本位币 |
@@ -276,6 +277,7 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 | `manual_credit` / `manual_debit` | 人工上分/扣分,独立展示,不直接当作外部现金存提款 |
 | `company_profit` | string \| null,公司净收益,公式见下文;`settlement_status=pending` 时为 `null` |
 | `net_cash_flow` | string \| null,实际净入金,公式见下文 |
+| `known_company_profit` / `known_net_cash_flow` | 按已确认时间、金额及已核实外部费用汇总的已知小计;最终净值缺失时单列小计,并结合 `quality` 提示待核对项 |
 | `member_count` | number,当前人口范围内、查询结束日前的真实会员参考人数,不按注册装置拆分 |
 | `valid_member_count` | number \| null,本次投注候选人中满足累计两项门槛的去重人数;规则不可用时为 `null` |
 | `active_member_count` | number,本次已观测到的区间活跃会员去重人数;历史覆盖不足时仅代表已知观测 |
@@ -298,9 +300,9 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 
 ### 6.2 总卡与分类卡的差异
 
-分类卡保留可归属的游戏指标及分类佣金/应计。以下公共资金字段返回 `null`:`deposit_amount`、`withdraw_amount`、`member_withdraw_amount`、`agent_withdraw_amount`、`deposit_fee`、`withdraw_fee`、`customer_fee_income`、`configured_fee`、`bonus_amount`、`manual_credit`、`manual_debit`。分类卡的 `company_profit`、`net_cash_flow` 也均为 `null`。分类毛利用 `game_gross_profit`。
+选择游戏分类卡(`group_id != all`)或打开分类明细(`filters.category != all`)时,从列定义中移除 `options.category_hidden_fields` 列出的字段,连同列头隐藏。这些字段包括存提款、手续费、优惠、人工上下分、公司净收益、实际净入金及两项已知小计。「所有订单」总卡按原列展示,个别净值为 `null` 时仍保留该列。
 
-明细请求 `category != all` 时,同样将上述公共资金和净收益字段置为 `null`。分类佣金是已带分类归属的资金记录,不得按投注比例摊分公共成本,也不得把总卡金额复制给九类卡片。
+分类卡和分类明细的公共资金字段仍返回 `null`。分类卡保留可归属的游戏指标和分类佣金/应计,分类毛利用 `game_gross_profit`。分类佣金按资金记录的分类归属统计。
 
 分类明细优先展示可可靠归属的订单指标。`category` 限定游戏订单,明细中的佣金/应计可能包含同组其他分类记录,不能标为该分类金额,也不通过资金追溯在前端重算统计值。
 
@@ -319,8 +321,10 @@ GET /admin/reporting/summary?report_type=business&start_date=2026-10-26&end_date
 | `activity_minutes` | 使用整数分钟;0显示“0分钟”,`null` 显示“—” |
 | `active_member_count` | 区间活跃会员人数 |
 | `active_rate` | 按百分比展示,如 `"0.250000"` 显示25%;`null` 显示“—” |
+| `known_activity_minutes` | 完整活动时长为 `null` 且 `active_member_count > 0` 时,可显示观测到的分钟数,标注「仅已采集区间」 |
+| `known_active_rate` | 完整活跃率为 `null` 时,可显示此比例,标注「按已观测会员」;缺少观测或会员范围无法确认时为 `null` |
 
-总卡与分类卡分别读取对应返回值,活动指标不按分类相加。
+总卡与分类卡分别读取对应返回值,活动指标不按分类相加。报表页面去掉「已采集时长」列,`activity_started_at` 可用于说明活动统计的覆盖范围。
 
 ## 9. 分组明细与两类追溯
 

+ 35 - 3
tests/Integration/ReportingTest.php

@@ -231,6 +231,8 @@ class ReportingTest extends TestCase
         $event = $this->deposit(); $this->bet();
         $row = $this->reader->summary($this->today())['summary']; $this->assertNull($row['net_cash_flow']); $this->assertNull($row['company_profit']);
         $this->assertSame(1, $row['quality']['unverified_fees']); $this->assertSame('1000.0000000000', $row['deposit_amount']);
+        $this->assertSame('1000.0000000000', $row['known_net_cash_flow']);
+        $this->assertSame('40.0000000000', $row['known_company_profit']);
         $this->verify($event); $row = $this->reader->summary($this->today())['summary'];
         $this->assertSame('998.0000000000', $row['net_cash_flow']); $this->assertSame('38.0000000000', $row['company_profit']);
         $before = DB::table('balance_logs')->get()->toArray(); $this->verify($event, '1000', '3'); $this->sync->publish('balance_logs', 1);
@@ -263,8 +265,28 @@ class ReportingTest extends TestCase
         $this->assertSame('2', $this->reader->details($this->today())['data'][0]['group_id']);
         $this->assertSame('10', $this->reader->details(['report_type' => 'promotion'])['data'][0]['group_id']);
         $this->assertSame('1', $this->reader->details(['report_type' => 'sharing'])['data'][0]['group_id']);
-        $category = $this->reader->summary($this->today())['categories'][8]; $this->assertNull($category['deposit_amount']); $this->assertNull($category['company_profit']);
+    }
+
+    public function test_category_column_options_only_hide_fields_not_applicable_to_game_categories(): void
+    {
+        $this->bet();
+        $this->verify($this->deposit());
+        $hidden = $this->admin->options()['category_hidden_fields'];
+        $this->assertEqualsCanonicalizing([
+            'deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee',
+            'customer_fee_income', 'configured_fee', 'bonus_amount', 'manual_credit', 'manual_debit',
+            'net_cash_flow', 'company_profit', 'known_net_cash_flow', 'known_company_profit',
+        ], $hidden);
+        $report = $this->reader->summary($this->today());
+        $category = collect($report['categories'])->firstWhere('group_id', 'self:lottery');
+        $details = $this->reader->details($this->today(['category' => 'self:lottery']))['data'][0];
+        foreach ($hidden as $field) {
+            $this->assertNotNull($report['summary'][$field]);
+            $this->assertNull($category[$field]);
+            $this->assertNull($details[$field]);
+        }
         $this->assertSame('100.0000000000', $category['bet_amount']);
+        $this->assertSame('100.0000000000', $details['bet_amount']);
     }
 
     public function test_valid_members_use_both_sharing_thresholds_inclusively(): void
@@ -415,8 +437,17 @@ class ReportingTest extends TestCase
         $row = $this->reader->summary($this->today())['summary'];
         $this->assertSame(2, $row['activity_minutes']); $this->assertSame(1, $row['active_member_count']); $this->assertSame(4, $row['member_count']);
         $this->assertSame('0.250000', $row['active_rate']);
-        $old = $this->reader->summary(['start_date' => '2026-10-24', 'end_date' => '2026-10-26'])['summary'];
-        $this->assertNull($old['activity_minutes']); $this->assertSame(2, $old['known_activity_minutes']);
+        $this->assertSame('0.250000', $row['known_active_rate']);
+        foreach ([[], ['start_date' => '2026-10-24', 'end_date' => '2026-10-26']] as $filters) {
+            $partial = $this->reader->summary($filters)['summary'];
+            $this->assertNull($partial['activity_minutes']);
+            $this->assertNull($partial['active_rate']);
+            $this->assertSame(2, $partial['known_activity_minutes']);
+            $this->assertSame('0.250000', $partial['known_active_rate']);
+        }
+        $beforeCollection = $this->reader->summary(['start_date' => '2026-10-24', 'end_date' => '2026-10-24'])['summary'];
+        $this->assertSame(0, $beforeCollection['active_member_count']);
+        $this->assertNull($beforeCollection['known_active_rate']);
     }
 
     public function test_full_sync_and_repeated_sync_do_not_change_source_data_or_double_count(): void
@@ -570,6 +601,7 @@ class ReportingTest extends TestCase
         $after = $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent10']))['summary'];
         $this->assertSame(2, $before['activity_minutes']); $this->assertSame(2, $after['activity_minutes']);
         $this->assertSame(1, $after['active_member_count']); $this->assertFalse($after['activity_population_complete']); $this->assertNull($after['active_rate']);
+        $this->assertNull($after['known_active_rate']);
         $other = $this->reader->summary($this->today(['report_type' => 'promotion', 'agent_account' => 'agent20']))['summary'];
         $this->assertSame(0, $other['activity_minutes']); $this->assertSame(0, $other['active_member_count']);
         $details = $this->reader->details($this->today(['report_type' => 'promotion']));