AgentPeriodReportService.php 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. <?php
  2. namespace App\Services\Agent;
  3. use App\Models\Agent\Agent;
  4. use App\Services\Sharing\ShareDefaults;
  5. use Carbon\Carbon;
  6. use Illuminate\Support\Facades\DB;
  7. /**
  8. * 代理周期报表:按日 / 周 / 月汇总,数据来自会员每日报表 member_daily_reports(每 10 分钟刷新当天,04:30 重算前 3 天)。
  9. * - 合营代理:自己名下会员(scope=own),带 agent_share 合营分成;
  10. * - 入股代理(股东):全平台会员,不含试玩账号 users.from=2(scope=platform)。
  11. * 只返回汇总数,不返回会员明细。周为周一至周日;首尾周期按查询日期截断。
  12. */
  13. class AgentPeriodReportService
  14. {
  15. public const PERIODS = ['day', 'week', 'month'];
  16. private const MONEY = ['deposit_amount', 'withdraw_amount', 'manual_credit', 'manual_debit', 'bonus_amount',
  17. 'rebate_amount', 'bet_amount', 'valid_bet_amount', 'win_loss', 'agent_share'];
  18. private const CATEGORY_FIELDS = ['bet_amount', 'valid_bet_amount', 'win_loss'];
  19. public function report(Agent $agent, array $params): array
  20. {
  21. $period = (string) ($params['period'] ?? 'day');
  22. if (!in_array($period, self::PERIODS, true)) throw new \InvalidArgumentException('统计周期错误');
  23. $today = now()->toDateString();
  24. $start = Carbon::parse((string) ($params['start_date'] ?? now()->subDays(6)->toDateString()))->startOfDay();
  25. $end = Carbon::parse((string) ($params['end_date'] ?? $today))->startOfDay();
  26. if ($start->gt($end)) throw new \InvalidArgumentException('开始日期不能大于结束日期');
  27. if ($end->toDateString() > $today) throw new \InvalidArgumentException('不能查询未来日期');
  28. if ($start->diffInDays($end) > 366) throw new \InvalidArgumentException('单次查询不能超过366天');
  29. $platform = $agent->isEquity();
  30. $from = $start->toDateString();
  31. $to = $end->toDateString();
  32. $buckets = $this->buckets($period, $start, $end);
  33. $bucketOf = fn (string $date) => $this->key($period, Carbon::parse($date));
  34. // 资金 / 注单:先按天汇总,再归到周期
  35. $g = DB::connection()->getQueryGrammar();
  36. $col = fn (string $c) => $g->wrap('member_daily_reports.' . $c);
  37. $daily = $this->reports($agent)->whereBetween('member_daily_reports.report_date', [$from, $to])
  38. ->groupBy('member_daily_reports.report_date')
  39. ->selectRaw($col('report_date') . ' AS d, COALESCE(SUM(' . $col('bet_count') . '),0) AS bet_count, '
  40. . 'COUNT(DISTINCT CASE WHEN ' . $col('bet_count') . ' > 0 THEN ' . $col('user_id') . ' END) AS active_members, '
  41. . implode(', ', array_map(fn ($f) => 'COALESCE(SUM(' . $col($f) . "),0) AS {$f}", self::MONEY)))
  42. ->get();
  43. foreach ($daily as $row) {
  44. $key = $bucketOf(substr((string) $row->d, 0, 10));
  45. if (!isset($buckets[$key])) continue;
  46. $buckets[$key]['bet_count'] += (int) $row->bet_count;
  47. if ($period === 'day') $buckets[$key]['active_members'] = (int) $row->active_members;
  48. foreach (self::MONEY as $f) $buckets[$key][$f] = bcadd($buckets[$key][$f], (string) $row->{$f}, 4);
  49. }
  50. // 周 / 月的投注人数要跨天去重,逐个周期查询(最多 53 周 / 13 个月)
  51. if ($period !== 'day') {
  52. foreach ($buckets as $key => $bucket) {
  53. $buckets[$key]['active_members'] = $this->activeMembers($agent, $bucket['start_date'], $bucket['end_date']);
  54. }
  55. }
  56. // 新增会员(按注册时间)
  57. foreach ($this->members($agent)->whereBetween('created_at', [$from . ' 00:00:00', $to . ' 23:59:59'])
  58. ->groupByRaw('DATE(' . $g->wrap('created_at') . ')')
  59. ->selectRaw('DATE(' . $g->wrap('created_at') . ') AS d, COUNT(*) AS c')->get() as $row) {
  60. $key = $bucketOf(substr((string) $row->d, 0, 10));
  61. if (isset($buckets[$key])) $buckets[$key]['new_members'] += (int) $row->c;
  62. }
  63. // 游戏分类(来源同会员日报:已结算、未作废的注单,按结算时间)
  64. $categoryNames = ShareDefaults::categories();
  65. $totalCategories = [];
  66. $settled = $g->wrap('settled_at');
  67. foreach (DB::table('share_bet_facts')->where('settled', 1)->where('voided', 0)
  68. ->whereBetween('settled_at', [$from . ' 00:00:00', $to . ' 23:59:59'])
  69. ->whereIn('user_id', $this->members($agent)->select('id'))
  70. ->groupByRaw('DATE(' . $settled . '), ' . $g->wrap('category'))
  71. ->selectRaw('DATE(' . $settled . ') AS d, ' . $g->wrap('category') . ' AS category, COUNT(*) AS bet_count, '
  72. . 'COALESCE(SUM(' . $g->wrap('bet_amount') . '),0) AS bet_amount, COALESCE(SUM(' . $g->wrap('effective_amount') . '),0) AS valid_bet_amount, '
  73. . 'COALESCE(SUM(' . $g->wrap('net_profit') . '),0) AS win_loss')->get() as $row) {
  74. $key = $bucketOf(substr((string) $row->d, 0, 10));
  75. if (!isset($buckets[$key])) continue;
  76. $category = (string) $row->category;
  77. foreach ([&$buckets[$key]['categories'], &$totalCategories] as &$target) {
  78. $target[$category] ??= ['category' => $category, 'name' => $categoryNames[$category] ?? $category,
  79. 'bet_count' => 0, 'bet_amount' => '0.0000', 'valid_bet_amount' => '0.0000', 'win_loss' => '0.0000'];
  80. $target[$category]['bet_count'] += (int) $row->bet_count;
  81. foreach (self::CATEGORY_FIELDS as $f) $target[$category][$f] = bcadd($target[$category][$f], (string) $row->{$f}, 4);
  82. }
  83. unset($target);
  84. }
  85. $totals = $this->emptyRow('total', '合计', $from, $to);
  86. foreach ($buckets as $bucket) {
  87. $totals['bet_count'] += $bucket['bet_count'];
  88. $totals['new_members'] += $bucket['new_members'];
  89. foreach (self::MONEY as $f) $totals[$f] = bcadd($totals[$f], $bucket[$f], 4);
  90. }
  91. $totals['active_members'] = $this->activeMembers($agent, $from, $to);
  92. $totals['categories'] = $totalCategories;
  93. $list = array_map(fn ($row) => $this->finish($row, $platform), array_reverse(array_values($buckets)));
  94. $refreshed = $this->reports($agent)->max('member_daily_reports.updated_at');
  95. return [
  96. 'scope' => $platform ? 'platform' : 'own',
  97. 'period' => $period,
  98. 'start_date' => $from,
  99. 'end_date' => $to,
  100. 'refreshed_at' => $refreshed ? Carbon::parse($refreshed)->format('Y-m-d H:i:s') : null,
  101. 'totals' => $this->finish($totals, $platform),
  102. 'list' => $list,
  103. ];
  104. }
  105. /** 统计范围内的会员(合营:名下;入股:全平台非试玩)。 */
  106. private function members(Agent $agent)
  107. {
  108. $query = DB::table('users');
  109. // 入股股东看全平台:排除试玩账号和测试会员;代理看自己名下的会员(测试代理也照常显示自己的)
  110. return $agent->isEquity()
  111. ? \App\Support\StatsScope::realUsers($query, 'users')
  112. : $query->where('agent_id', $agent->id);
  113. }
  114. private function reports(Agent $agent)
  115. {
  116. $query = DB::table('member_daily_reports');
  117. return $agent->isEquity()
  118. ? $query->whereIn('member_daily_reports.user_id', $this->members($agent)->select('id'))
  119. : $query->where('member_daily_reports.agent_id', $agent->id);
  120. }
  121. private function activeMembers(Agent $agent, string $from, string $to): int
  122. {
  123. return (int) $this->reports($agent)->whereBetween('member_daily_reports.report_date', [$from, $to])
  124. ->where('member_daily_reports.bet_count', '>', 0)->distinct()->count('member_daily_reports.user_id');
  125. }
  126. /** 查询范围内的全部周期(包括没有数据的周期),首尾按查询日期截断。 */
  127. private function buckets(string $period, Carbon $start, Carbon $end): array
  128. {
  129. $buckets = [];
  130. for ($cursor = $start->copy(); $cursor->lte($end); ) {
  131. $bucketEnd = match ($period) {
  132. 'day' => $cursor->copy(),
  133. 'week' => $cursor->copy()->endOfWeek(Carbon::SUNDAY)->startOfDay(),
  134. 'month' => $cursor->copy()->endOfMonth()->startOfDay(),
  135. };
  136. if ($bucketEnd->gt($end)) $bucketEnd = $end->copy();
  137. $key = $this->key($period, $cursor);
  138. $label = match ($period) {
  139. 'day' => $cursor->toDateString(),
  140. 'week' => $cursor->format('m-d') . ' ~ ' . $bucketEnd->format('m-d'),
  141. 'month' => $cursor->format('Y年m月'),
  142. };
  143. $buckets[$key] = $this->emptyRow($key, $label, $cursor->toDateString(), $bucketEnd->toDateString());
  144. $cursor = $bucketEnd->copy()->addDay();
  145. }
  146. return $buckets;
  147. }
  148. private function key(string $period, Carbon $date): string
  149. {
  150. return match ($period) {
  151. 'day' => $date->toDateString(),
  152. 'week' => $date->format('o-\WW'),
  153. 'month' => $date->format('Y-m'),
  154. };
  155. }
  156. private function emptyRow(string $key, string $label, string $from, string $to): array
  157. {
  158. $row = ['period_key' => $key, 'label' => $label, 'start_date' => $from, 'end_date' => $to,
  159. 'new_members' => 0, 'active_members' => 0, 'bet_count' => 0, 'categories' => []];
  160. foreach (self::MONEY as $f) $row[$f] = '0.0000';
  161. return $row;
  162. }
  163. /** 公司盈亏口径同首页:充值 − 提现 − 优惠 − 返水;入股代理不返回合营分成。 */
  164. private function finish(array $row, bool $platform): array
  165. {
  166. $row['company_profit'] = bcsub(bcsub($row['deposit_amount'], $row['withdraw_amount'], 4),
  167. bcadd($row['bonus_amount'], $row['rebate_amount'], 4), 4);
  168. if ($platform) unset($row['agent_share']);
  169. $row['categories'] = array_values($row['categories']);
  170. return $row;
  171. }
  172. }