ReportReader.php 32 KB

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