ReportReader.php 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. <?php
  2. namespace App\Services\Reporting;
  3. use App\Services\Sharing\EffectiveBet as Money;
  4. use App\Services\Sharing\ShareMemberQualification;
  5. use App\Support\MaintenancePolicy;
  6. use Carbon\Carbon;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\Schema;
  9. use Illuminate\Support\Facades\Validator;
  10. use Illuminate\Validation\Rule;
  11. use Illuminate\Validation\ValidationException;
  12. class ReportReader
  13. {
  14. private const AMOUNTS = ['deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee',
  15. 'customer_fee_income', 'configured_fee', 'bonus_amount', 'agent_commission', 'share_commission', 'agent_accrual', 'share_accrual', 'manual_credit', 'manual_debit',
  16. 'bet_amount', 'settled_bet_amount', 'payout_amount', 'refund_amount', 'game_gross_profit'];
  17. 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'];
  18. public function params(array $input): array
  19. {
  20. $p = Validator::make($input, ['start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d'],
  21. 'report_type' => ['nullable', Rule::in(['business', 'promotion', 'sharing'])], 'platform' => ['nullable', Rule::in(['all', 'H5', 'PC', 'APP', 'unknown'])],
  22. 'category' => ['nullable', Rule::in(array_merge(['all'], array_keys(MaintenancePolicy::CATEGORIES)))],
  23. 'settlement_status' => ['nullable', Rule::in(['all', 'pending', 'settled'])], 'member_account' => ['nullable', 'string', 'max:100'],
  24. 'agent_account' => ['nullable', 'string', 'max:100'], 'inviter_account' => ['nullable', 'string', 'max:100'],
  25. 'group_id' => ['nullable', 'string', 'max:40'], 'page' => ['nullable', 'integer', 'min:1', 'max:1000000'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
  26. 'metric' => ['nullable', Rule::in(['deposit_amount', 'bet_amount', 'payout_amount', 'withdraw_amount', 'activity_minutes'])],
  27. 'kind' => ['nullable', Rule::in(['deposit', 'withdraw', 'agent_withdraw', 'bonus', 'agent_commission', 'share_commission', 'agent_accrual', 'share_accrual', 'manual_credit', 'manual_debit'])]])->validate();
  28. $p = array_filter($p, fn ($v) => $v !== null && $v !== '');
  29. $allTime = !isset($p['start_date']) && !isset($p['end_date']);
  30. $p += ['report_type' => 'business', 'platform' => 'all', 'category' => 'all', 'settlement_status' => 'all',
  31. 'start_date' => now('Asia/Shanghai')->toDateString(), 'end_date' => now('Asia/Shanghai')->toDateString(), 'page' => 1, 'limit' => 20, 'metric' => 'deposit_amount'];
  32. $p['all_time'] = (int) $allTime;
  33. if ($allTime) $p['start_date'] = $this->firstReportDate($p['end_date']);
  34. $start = Carbon::parse($p['start_date'], 'Asia/Shanghai'); $end = Carbon::parse($p['end_date'], 'Asia/Shanghai')->addDay();
  35. if ($end->lte($start) || (!$allTime && $start->diffInDays($end) > 366) || $p['end_date'] > now('Asia/Shanghai')->toDateString()) throw ValidationException::withMessages(['end_date' => '日期范围必须有效、不超过366天且不能查询未来日期']);
  36. $p['start'] = $start->toDateTimeString(); $p['end'] = $end->toDateTimeString(); return $p;
  37. }
  38. private function firstReportDate(string $today): string
  39. {
  40. $dates = [$today]; $grammar = DB::connection()->getQueryGrammar();
  41. $effectiveAt = 'COALESCE(' . $grammar->wrap('r.occurred_at') . ', ' . $grammar->wrap('e.occurred_at') . ')';
  42. $earliest = [DB::table('reporting_orders')->min('bet_at'), DB::table('reporting_orders')->min('settled_at'),
  43. DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')
  44. ->min(DB::raw($effectiveAt)),
  45. DB::table('reporting_activity')->min('observed_at'),
  46. DB::table('users')->where(function ($q) { $q->whereNull('from')->orWhere('from', '<>', 2); })->min('created_at')];
  47. foreach ($earliest as $at) if ($at !== null) $dates[] = substr((string) $at, 0, 10);
  48. return min($dates);
  49. }
  50. public function summary(array $input): array
  51. {
  52. $p = $this->params($input); $result = $this->compute($p, 'summary');
  53. return $result['meta'] + ['summary' => $result['groups']['all'], 'categories' => array_values(array_filter($result['groups'], fn ($k) => $k !== 'all', ARRAY_FILTER_USE_KEY))];
  54. }
  55. public function details(array $input, bool $ranking = false): array
  56. {
  57. $p = $this->params($input); if ($ranking) $p['report_type'] = 'business';
  58. $result = $this->compute($p, 'details'); $rows = array_values($result['groups']);
  59. if ($ranking) {
  60. $metric = $p['metric']; if ($metric === 'activity_minutes') $rows = array_values(array_filter($rows, fn ($row) => $row[$metric] !== null));
  61. usort($rows, fn ($a, $b) => bccomp((string) ($b[$metric] ?? 0), (string) ($a[$metric] ?? 0), 10) ?: strcmp($a['group_id'], $b['group_id']));
  62. foreach ($rows as $i => &$row) $row['rank'] = $i + 1; unset($row);
  63. } else usort($rows, fn ($a, $b) => bccomp((string) $b['bet_amount'], (string) $a['bet_amount'], 10) ?: strcmp($a['group_id'], $b['group_id']));
  64. $total = count($rows); $rows = array_slice($rows, ((int) $p['page'] - 1) * (int) $p['limit'], (int) $p['limit']);
  65. return $result['meta'] + ['total' => $total, 'data' => $rows];
  66. }
  67. public function funds(array $input): array
  68. {
  69. $p = $this->params($input + ['report_type' => 'business']); $p['report_type'] = 'business'; $p['category'] = 'all'; $p['settlement_status'] = 'all';
  70. $result = $this->compute($p, 'daily'); $all = $result['groups']['all']; unset($result['groups']['all']);
  71. $days = array_values($result['groups']); usort($days, fn ($a, $b) => strcmp($b['group_id'], $a['group_id']));
  72. $keep = array_flip(array_merge(['group_id', 'label', 'net_cash_flow', 'company_profit', 'known_net_cash_flow', 'known_company_profit', 'complete', 'quality'], self::AMOUNTS));
  73. $all = array_intersect_key($all, $keep);
  74. $days = array_map(fn ($row) => ['date' => $row['group_id']] + array_intersect_key($row, $keep), $days);
  75. return $result['meta'] + ['summary' => $all, 'total' => (int) Carbon::parse($p['start'])->diffInDays(Carbon::parse($p['end'])), 'data' => $days];
  76. }
  77. public function orders(array $input): array
  78. {
  79. $p = $this->params($input); [$users, $agents] = $this->people($p); $query = $this->orderQuery($p);
  80. $rows = []; $total = 0;
  81. foreach ($query->orderByDesc('bet_at')->orderByDesc('id')->cursor() as $record) {
  82. $row = (array) $record;
  83. if (!isset($users[$row['user_id']]) || !$this->scope($row, $p, $users, $agents)) continue;
  84. if ($p['category'] !== 'all' && $row['category'] !== $p['category']) continue;
  85. if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
  86. ++$total;
  87. if ($total > ((int) $p['page'] - 1) * (int) $p['limit'] && count($rows) < (int) $p['limit']) $rows[] = $row + ['member_account' => $this->account($users[$row['user_id']])];
  88. }
  89. return ['total' => $total, 'data' => $rows, 'timezone' => 'Asia/Shanghai', 'filters' => array_diff_key($p, ['start' => 1, 'end' => 1])];
  90. }
  91. public function events(array $input): array
  92. {
  93. $p = $this->params($input); [$users, $agents] = $this->people($p); $rows = []; $total = 0;
  94. $query = $this->eventQuery($p); if (isset($p['kind'])) $query->where('e.kind', $p['kind']);
  95. foreach ($query->orderByDesc('e.id')->cursor() as $record) {
  96. $row = (array) $record;
  97. if (!$this->scope($row, $p, $users, $agents)) continue;
  98. if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
  99. if ($p['category'] !== 'all' && $row['category'] !== $p['category']) continue;
  100. ++$total;
  101. if ($total > ((int) $p['page'] - 1) * (int) $p['limit'] && count($rows) < (int) $p['limit']) $rows[] = $row;
  102. }
  103. return ['total' => $total, 'data' => $rows, 'timezone' => 'Asia/Shanghai', 'filters' => array_diff_key($p, ['start' => 1, 'end' => 1])];
  104. }
  105. private function compute(array $p, string $mode): array
  106. {
  107. [$users, $agents] = $this->people($p); $settings = (new ReportAdmin())->settings();
  108. $qualification = (new ShareMemberQualification())->read($p['end_date'], $mode !== 'daily'); $policy = $qualification['policy_meta'];
  109. $groups = []; $unallocated = null; $now = now('Asia/Shanghai')->toDateTimeString();
  110. if ($mode === 'summary') foreach (['all' => '所有订单'] + MaintenancePolicy::CATEGORIES as $key => $label) $groups[$key] = $this->empty($key, $label);
  111. if ($mode === 'daily') {
  112. $groups['all'] = $this->empty('all', '总计');
  113. $offset = ((int) $p['page'] - 1) * (int) $p['limit'];
  114. if ($offset < Carbon::parse($p['start'])->diffInDays(Carbon::parse($p['end']))) {
  115. $day = Carbon::parse($p['end'])->subDay()->subDays($offset);
  116. for ($i = 0; $i < (int) $p['limit'] && $day->toDateTimeString() >= $p['start']; ++$i, $day->subDay()) {
  117. $groups[$day->toDateString()] = $this->empty($day->toDateString(), $day->toDateString());
  118. }
  119. }
  120. }
  121. $globalMissingTime = 0;
  122. foreach ($this->orderQuery($p)->orderBy('id')->cursor() as $record) {
  123. $row = (array) $record;
  124. if (!isset($users[$row['user_id']]) || !$this->scope($row, $p, $users, $agents)) continue;
  125. if ($p['category'] !== 'all' && $row['category'] !== $p['category']) continue;
  126. if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
  127. $keys = $mode === 'summary' ? ['all', $row['category']] : ($mode === 'details' ? [$this->group($row, $p)] : ['all']);
  128. if ($mode === 'daily') {
  129. if ($this->within($row['bet_at'], $p) && isset($groups[substr($row['bet_at'], 0, 10)])) $keys[] = substr($row['bet_at'], 0, 10);
  130. if ($this->within($row['settled_at'], $p) && isset($groups[substr($row['settled_at'], 0, 10)])) $keys[] = substr($row['settled_at'], 0, 10);
  131. }
  132. foreach (array_unique($keys) as $key) {
  133. if ($key === null) continue;
  134. if (!isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
  135. $window = $mode === 'daily' && $key !== 'all' ? ['start' => $key . ' 00:00:00', 'end' => Carbon::parse($key)->addDay()->toDateTimeString()] + $p : $p;
  136. $this->addOrder($groups[$key], $row, $window);
  137. if ($p['report_type'] === 'promotion' && $row['attribution_quality'] === 'historical_current_agent') ++$groups[$key]['quality']['unverified_attribution'];
  138. }
  139. }
  140. $events = $this->eventQuery($p);
  141. foreach ($events->orderBy('e.id')->cursor() as $record) {
  142. $row = (array) $record;
  143. if (!$this->scope($row, $p, $users, $agents)) continue;
  144. if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
  145. if ($row['reconciliation_id'] !== null) { $row['amount'] = $row['verified_amount']; $row['occurred_at'] = $row['verified_at']; }
  146. $keys = $mode === 'summary' ? ['all'] : ($mode === 'details' ? [$this->group($row, $p)] : ['all']);
  147. if ($mode === 'summary' && $row['category'] !== null) $keys[] = $row['category'];
  148. if ($mode === 'daily' && $row['occurred_at'] !== null && isset($groups[substr($row['occurred_at'], 0, 10)])) $keys[] = substr($row['occurred_at'], 0, 10);
  149. if ($mode === 'daily' && $row['occurred_at'] === null) ++$globalMissingTime;
  150. foreach (array_unique($keys) as $key) {
  151. if ($key === null) $key = 'unallocated';
  152. if (!isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
  153. $this->addEvent($groups[$key], $row);
  154. if ($p['report_type'] === 'promotion' && $row['user_id'] !== null && $row['attribution_quality'] === 'historical_current_agent') ++$groups[$key]['quality']['unverified_attribution'];
  155. }
  156. }
  157. $activity = $mode === 'daily' ? ['members' => [], 'groups' => []] : $this->activity($p, $users, $agents);
  158. foreach ($activity['groups'] as $key => $members) {
  159. if ($mode === 'details' && !isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
  160. }
  161. $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();
  162. $run = $stateRows->firstWhere('source', '__run__'); $ready = $run && (json_decode($run->state, true)['complete'] ?? false);
  163. $syncAt = $run->updated_at ?? null;
  164. $scopePopulation = []; $groupPopulation = [];
  165. if ($mode !== 'daily') foreach ($users as $uid => $u) {
  166. $person = ['user_id' => $u['id'], 'agent_id' => $u['agent_id'] ?? null, 'inviter_id' => $u['inviter_id'] ?? null, 'kind' => 'activity', 'client_platform' => $p['platform']];
  167. if (!$this->scope($person, $p, $users, $agents)) continue;
  168. if (isset($p['group_id']) && $this->group($person, $p) !== $p['group_id']) continue;
  169. $scopePopulation[$uid] = $u;
  170. $group = $this->group($person, $p); if ($group !== null) $groupPopulation[$group][$uid] = $u;
  171. }
  172. foreach ($groups as $key => &$value) {
  173. $population = $mode === 'details' ? ($groupPopulation[$key] ?? []) : $scopePopulation;
  174. if ($mode === 'details') {
  175. $person = $users[$key] ?? [];
  176. $value['member_account'] = $p['report_type'] === 'promotion' ? null : ($person ? $this->account($person) : null);
  177. $value['nickname'] = $p['report_type'] === 'promotion' ? null : ($person['first_name'] ?? '');
  178. $value['agent_account'] = $p['report_type'] === 'promotion' ? ($agents[$key]['username'] ?? null) : ($agents[$person['agent_id'] ?? 0]['username'] ?? null);
  179. $parent = $person['inviter_id'] ?? null; $value['inviter_account'] = isset($users[$parent]) ? $this->account($users[$parent]) : null;
  180. }
  181. $value['member_count'] = count($population); $value['valid_member_count'] = $policy['available'] ? 0 : null; $value['active_member_count'] = 0; $seconds = 0;
  182. if ($policy['available']) foreach ($value['_bettors'] as $uid => $_) if (isset($qualification['qualified'][$uid])) ++$value['valid_member_count'];
  183. $active = $mode === 'details' ? ($activity['groups'][$key] ?? []) : $activity['members'];
  184. $value['active_member_count'] = count($active);
  185. foreach ($active as $item) $seconds += $item['seconds'];
  186. // Current membership cannot prove a historical population after a reassignment.
  187. $value['activity_population_complete'] = !array_diff_key($active, $population);
  188. $covered = $p['start'] >= $settings['activity_started_at'];
  189. $value['known_activity_minutes'] = intdiv($seconds, 60); $value['activity_minutes'] = $covered ? $value['known_activity_minutes'] : null;
  190. $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;
  191. $value['order_count'] = count($value['_orders']); unset($value['_orders'], $value['_bettors']);
  192. $value['quality']['sync_errors'] = (int) $hasErrors; $value['quality']['not_synchronized'] = !$ready;
  193. if ($mode === 'daily') $value['quality']['missing_time'] += $key === 'all' ? 0 : $globalMissingTime;
  194. $complete = !array_sum(array_map('intval', $value['quality'])); $value['complete'] = $complete;
  195. $value['known_net_cash_flow'] = bcsub(bcsub($value['deposit_amount'], $value['withdraw_amount'], 10), bcadd($value['deposit_fee'], $value['withdraw_fee'], 10), 10);
  196. $cost = '0'; foreach (['bonus_amount', 'agent_commission', 'share_commission', 'deposit_fee', 'withdraw_fee'] as $f) $cost = bcadd($cost, $value[$f], 10);
  197. $value['known_company_profit'] = bcsub($value['game_gross_profit'], $cost, 10);
  198. $value['net_cash_flow'] = $complete ? $value['known_net_cash_flow'] : null;
  199. $value['company_profit'] = $complete && $p['settlement_status'] !== 'pending' ? $value['known_company_profit'] : null;
  200. if (($mode === 'summary' && $key !== 'all') || $p['category'] !== 'all') {
  201. foreach (self::CASH as $f) $value[$f] = null;
  202. $value['net_cash_flow'] = $value['company_profit'] = $value['known_net_cash_flow'] = $value['known_company_profit'] = null;
  203. }
  204. if ($mode === 'daily' && $key !== 'all') { $value['activity_minutes'] = $value['active_rate'] = null; }
  205. } unset($value);
  206. if (isset($groups['unallocated'])) { $unallocated = $groups['unallocated']; unset($groups['unallocated']); }
  207. return ['groups' => $groups, 'unallocated' => $unallocated, 'meta' => ['filters' => array_diff_key($p, ['start' => 1, 'end' => 1]),
  208. 'timezone' => 'Asia/Shanghai', 'currency' => (new ReportSync())->currency(), 'data_updated_at' => $syncAt,
  209. 'activity_started_at' => $settings['activity_started_at'], 'valid_deposit_min' => $policy['min_deposit'],
  210. 'valid_effective_bet_min' => $policy['min_effective_bet'], 'valid_member_policy' => $policy,
  211. 'commission_balances' => $this->commissionBalances($p, $users, $agents), 'server_time' => $now]];
  212. }
  213. private function commissionBalances(array $p, array $users, array $agents): array
  214. {
  215. $result = ['date_basis' => 'current_account_balance', 'as_of' => now('Asia/Shanghai')->toDateTimeString(),
  216. 'agent_unpaid' => null, 'share_unclaimed' => null, 'share_reserved' => null, 'share_debt' => null];
  217. if ($p['platform'] !== 'all' || $p['category'] !== 'all') return $result;
  218. if ($p['report_type'] !== 'sharing' && Schema::hasTable('agent_rebate_records') && empty($p['member_account']) && empty($p['inviter_account'])) {
  219. $result['agent_unpaid'] = Money::zero();
  220. foreach (DB::table('agent_rebate_records')->cursor() as $row) {
  221. if (!empty($p['agent_account']) && !str_contains($agents[$row->agent_id]['username'] ?? '', $p['agent_account'])) continue;
  222. foreach (['flow', 'profit'] as $part) if ($row->{$part . '_status'} === 'pending') $result['agent_unpaid'] = bcadd($result['agent_unpaid'], (string) $row->{$part . '_commission'}, 10);
  223. }
  224. }
  225. if (Schema::hasTable('share_accounts')) {
  226. foreach (['share_unclaimed', 'share_reserved', 'share_debt'] as $f) $result[$f] = Money::zero();
  227. foreach (DB::table('share_accounts')->cursor() as $row) {
  228. if (!isset($users[$row->user_id])) continue; $user = $users[$row->user_id];
  229. $scope = ['user_id' => $row->user_id, 'agent_id' => $user['agent_id'] ?? null, 'inviter_id' => $user['inviter_id'] ?? null,
  230. 'kind' => 'share_commission', 'client_platform' => 'unknown'];
  231. if (!$this->scope($scope, $p, $users, $agents)) continue;
  232. $balance = bcsub((string) $row->earned_amount, (string) $row->paid_amount, 10);
  233. if (bccomp($balance, '0', 10) >= 0) $result['share_unclaimed'] = bcadd($result['share_unclaimed'], $balance, 10);
  234. else $result['share_debt'] = bcsub($result['share_debt'], $balance, 10);
  235. $result['share_reserved'] = bcadd($result['share_reserved'], (string) $row->reserved_amount, 10);
  236. }
  237. }
  238. return $result;
  239. }
  240. private function orderQuery(array $p)
  241. {
  242. $q = DB::table('reporting_orders')->where(function ($q) use ($p) {
  243. $q->where(function ($q) use ($p) { $q->where('bet_at', '>=', $p['start'])->where('bet_at', '<', $p['end']); })
  244. ->orWhere(function ($q) use ($p) { $q->where('settled_at', '>=', $p['start'])->where('settled_at', '<', $p['end']); })
  245. ->orWhere(function ($q) use ($p) { $q->whereNull('settled_at')->where('bet_at', '<', $p['end'])->where(function ($q) { $q->where('settled', 1)->orWhere('voided', 1); }); });
  246. });
  247. if ($p['settlement_status'] === 'settled') $q->where('settled', 1)->where('voided', 0);
  248. if ($p['settlement_status'] === 'pending') $q->where('settled', 0)->where('voided', 0);
  249. return $q;
  250. }
  251. private function eventQuery(array $p)
  252. {
  253. return DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')
  254. ->where(function ($q) use ($p) {
  255. $q->where(function ($q) use ($p) { $q->where('e.occurred_at', '>=', $p['start'])->where('e.occurred_at', '<', $p['end'])->whereNull('r.id'); })
  256. ->orWhere(function ($q) use ($p) { $q->where('r.occurred_at', '>=', $p['start'])->where('r.occurred_at', '<', $p['end']); })
  257. ->orWhere(function ($q) use ($p) { $q->whereNull('e.occurred_at')->whereNull('r.id')->where('e.candidate_at', '<', $p['end']); });
  258. })->select('e.*', 'r.id as reconciliation_id', 'r.actual_amount as verified_amount', 'r.external_fee', 'r.occurred_at as verified_at');
  259. }
  260. private function people(array $p): array
  261. {
  262. $agents = []; if (Schema::hasTable('agents')) foreach (DB::table('agents')->get(['id', 'username']) as $r) $agents[$r->id] = (array) $r;
  263. $invites = []; if (Schema::hasTable('share_members')) foreach (DB::table('share_members')->where('bound_at', '<', $p['end'])->get(['user_id', 'parent_user_id']) as $r) $invites[$r->user_id] = $r->parent_user_id;
  264. $users = []; foreach (DB::table('users')->where(function ($q) { $q->whereNull('from')->orWhere('from', '<>', 2); })->where('created_at', '<', $p['end'])->get() as $r) {
  265. $u = (array) $r; $u['inviter_id'] = $invites[$r->id] ?? null; $users[$r->id] = $u;
  266. }
  267. return [$users, $agents];
  268. }
  269. private function scope(array $row, array $p, array $users, array $agents): bool
  270. {
  271. $uid = $row['user_id'] ?? null; $agent = $row['agent_id'] ?? null; $inviter = $row['inviter_id'] ?? null;
  272. if ($uid !== null && !isset($users[$uid])) return false;
  273. if ($p['platform'] !== 'all' && ($row['client_platform'] ?? 'unknown') !== $p['platform']) return false;
  274. if ($p['report_type'] === 'promotion' && !$agent) return false;
  275. if ($p['report_type'] === 'sharing' && !$inviter && !in_array($row['kind'] ?? '', ['share_commission', 'share_accrual'], true)) return false;
  276. if (!empty($p['member_account']) && (!$uid || !str_contains($this->account($users[$uid]), $p['member_account']))) return false;
  277. if (!empty($p['agent_account']) && (!$agent || !str_contains($agents[$agent]['username'] ?? '', $p['agent_account']))) return false;
  278. if (!empty($p['inviter_account']) && (!$inviter || !str_contains(isset($users[$inviter]) ? $this->account($users[$inviter]) : '', $p['inviter_account']))) return false;
  279. return true;
  280. }
  281. private function empty(string $key, string $label): array
  282. {
  283. return ['group_id' => $key, 'label' => $label] + array_fill_keys(self::AMOUNTS, Money::zero()) + ['_orders' => [], '_bettors' => [],
  284. 'quality' => ['missing_time' => 0, 'missing_amount' => 0, 'unverified_fees' => 0, 'foreign_currency' => 0, 'unverified_attribution' => 0]];
  285. }
  286. private function addOrder(array &$value, array $row, array $p): void
  287. {
  288. if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
  289. if ($this->within($row['bet_at'], $p)) {
  290. $this->add($value, 'bet_amount', $row['bet_amount']); $value['_orders'][$row['parent_source'] . ':' . $row['parent_id']] = true;
  291. if (!$row['voided'] && bccomp($row['bet_amount'], '0', 10) > 0) $value['_bettors'][$row['user_id']] = true;
  292. }
  293. if (($row['settled'] || $row['voided']) && $row['settled_at'] === null) { ++$value['quality']['missing_time']; return; }
  294. if ($this->within($row['settled_at'], $p)) {
  295. if ($row['voided']) $this->add($value, 'refund_amount', $row['refund_amount']);
  296. elseif ($row['settled']) {
  297. $this->add($value, 'settled_bet_amount', bcsub($row['bet_amount'], $row['refund_amount'], 10)); $this->add($value, 'payout_amount', $row['payout_amount']);
  298. $this->add($value, 'refund_amount', $row['refund_amount']);
  299. $this->add($value, 'game_gross_profit', bcsub('0', $row['net_profit'], 10));
  300. }
  301. }
  302. }
  303. private function addEvent(array &$value, array $row): void
  304. {
  305. if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
  306. if ($row['occurred_at'] === null) { ++$value['quality']['missing_time']; return; }
  307. if ($row['amount'] === null) { ++$value['quality']['missing_amount']; return; }
  308. $map = ['deposit' => 'deposit_amount', 'withdraw' => 'withdraw_amount', 'agent_withdraw' => 'withdraw_amount', 'bonus' => 'bonus_amount',
  309. 'agent_commission' => 'agent_commission', 'share_commission' => 'share_commission', 'agent_accrual' => 'agent_accrual', 'share_accrual' => 'share_accrual', 'manual_credit' => 'manual_credit', 'manual_debit' => 'manual_debit'];
  310. $this->add($value, $map[$row['kind']], $row['amount']);
  311. if (in_array($row['kind'], ['deposit', 'withdraw', 'agent_withdraw'], true)) {
  312. $this->add($value, 'customer_fee_income', $row['customer_fee']);
  313. if ($row['kind'] !== 'deposit') $this->add($value, $row['kind'] === 'withdraw' ? 'member_withdraw_amount' : 'agent_withdraw_amount', $row['amount']);
  314. if ($row['configured_fee'] !== null) $this->add($value, 'configured_fee', $row['configured_fee']);
  315. if ($row['external_fee'] === null) ++$value['quality']['unverified_fees'];
  316. else $this->add($value, $row['kind'] === 'deposit' ? 'deposit_fee' : 'withdraw_fee', $row['external_fee']);
  317. }
  318. }
  319. private function activity(array $p, array $users, array $agents): array
  320. {
  321. $q = DB::table('reporting_activity')->where('observed_at', '>=', Carbon::parse($p['start'])->subMinutes(5)->toDateTimeString())->where('observed_at', '<', $p['end']);
  322. if ($p['platform'] !== 'all') $q->where('client_platform', $p['platform']);
  323. $result = ['members' => [], 'groups' => []]; $observations = []; $userId = null;
  324. foreach ($q->orderBy('user_id')->orderBy('observed_at')->orderBy('id')->cursor() as $row) {
  325. if (!isset($users[$row->user_id])) continue;
  326. if ($userId !== null && $userId !== $row->user_id) {
  327. $this->memberActivity($observations, $p, $users, $agents, $result); $observations = [];
  328. }
  329. $userId = $row->user_id; $observations[] = (array)$row;
  330. }
  331. if ($observations) $this->memberActivity($observations, $p, $users, $agents, $result);
  332. return $result;
  333. }
  334. private function memberActivity(array $observations, array $p, array $users, array $agents, array &$result): void
  335. {
  336. $last = []; $intervals = []; $timeline = []; $windowStart = Carbon::parse($p['start'], 'Asia/Shanghai')->timestamp;
  337. foreach ($observations as $row) {
  338. $time = Carbon::parse($row['observed_at'], 'Asia/Shanghai')->timestamp; $session = $row['session_hash'];
  339. if ($time >= $windowStart) $this->addActivity($result, $row, 0, $p, $users, $agents);
  340. if (isset($last[$session]) && $time > $last[$session] && $time - $last[$session] <= 300) $intervals[] = [max($last[$session], $windowStart), $time];
  341. $last[$session] = $time;
  342. // Across devices, the latest observation owns time until the next observation.
  343. // If observations share a minute, the last persisted snapshot wins consistently.
  344. $timeline[$time] = $row;
  345. }
  346. usort($intervals, fn ($a, $b) => $a[0] <=> $b[0]); $merged = [];
  347. foreach ($intervals as [$start, $end]) {
  348. $i = count($merged) - 1;
  349. if ($i >= 0 && $start <= $merged[$i][1]) $merged[$i][1] = max($merged[$i][1], $end); else $merged[] = [$start, $end];
  350. }
  351. $times = array_keys($timeline); $index = 0;
  352. foreach ($merged as [$start, $end]) {
  353. while (($times[$index + 1] ?? PHP_INT_MAX) <= $start) ++$index;
  354. while ($start < $end) {
  355. $next = min($end, $times[$index + 1] ?? $end);
  356. $this->addActivity($result, $timeline[$times[$index]], $next - $start, $p, $users, $agents);
  357. $start = $next;
  358. if (isset($times[$index + 1]) && $start === $times[$index + 1]) ++$index;
  359. }
  360. }
  361. }
  362. private function addActivity(array &$result, array $row, int $seconds, array $p, array $users, array $agents): void
  363. {
  364. $row['kind'] = 'activity';
  365. if (!$this->scope($row, $p, $users, $agents)) return;
  366. $group = $this->group($row, $p);
  367. if ($group === null || (isset($p['group_id']) && $group !== $p['group_id'])) return;
  368. $uid = $row['user_id'];
  369. $result['members'][$uid]['seconds'] = ($result['members'][$uid]['seconds'] ?? 0) + $seconds;
  370. $result['groups'][$group][$uid]['seconds'] = ($result['groups'][$group][$uid]['seconds'] ?? 0) + $seconds;
  371. }
  372. private function within(?string $at, array $p): bool { return $at !== null && $at >= $p['start'] && $at < $p['end']; }
  373. private function add(array &$value, string $field, string $amount): void { $value[$field] = bcadd($value[$field], $amount, 10); }
  374. private function account(array $user): string { return (string) (($user['username'] ?? '') ?: (($user['account'] ?? '') ?: ($user['member_id'] ?? $user['user_id'] ?? $user['id']))); }
  375. private function group(array $row, array $p): ?string
  376. {
  377. if ($p['report_type'] === 'business') return isset($row['user_id']) ? (string) $row['user_id'] : null;
  378. if ($p['report_type'] === 'promotion') return isset($row['agent_id']) ? (string) $row['agent_id'] : null;
  379. return in_array($row['kind'] ?? '', ['share_commission', 'share_accrual'], true) ? (string) $row['user_id'] : (isset($row['inviter_id']) ? (string) $row['inviter_id'] : null);
  380. }
  381. private function label(string $id, array $p, array $users, array $agents): string
  382. {
  383. if ($id === 'unallocated') return '未分配公共资金与成本';
  384. if ($p['report_type'] === 'promotion') return (string) ($agents[$id]['username'] ?? $id);
  385. return isset($users[$id]) ? $this->account($users[$id]) : $id;
  386. }
  387. }