ReportReader.php 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  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', 'game_net_profit', 'complete', 'quality', 'categories'], 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. if (Schema::hasTable('third_game_orders')) {
  83. $query->selectSub(DB::table('third_game_orders')->select('platform')
  84. ->whereColumn('third_game_orders.id', 'reporting_orders.parent_id')
  85. ->where('reporting_orders.parent_source', 'third_game_orders')->limit(1), 'provider_platform');
  86. }
  87. foreach ($query->orderByDesc('bet_at')->orderByDesc('id')->cursor() as $record) {
  88. $row = (array) $record;
  89. if (!isset($users[$row['user_id']]) || !$this->scope($row, $p, $users, $agents)) continue;
  90. if ($p['category'] !== 'all' && $row['category'] !== $p['category']) continue;
  91. if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
  92. ++$total;
  93. if ($total > ((int) $p['page'] - 1) * (int) $p['limit'] && count($rows) < (int) $p['limit']) $rows[] = $row + ['member_account' => $this->account($users[$row['user_id']])];
  94. }
  95. return ['total' => $total, 'data' => $rows, 'timezone' => 'Asia/Shanghai', 'filters' => array_diff_key($p, ['start' => 1, 'end' => 1])];
  96. }
  97. public function events(array $input): array
  98. {
  99. $p = $this->params($input); [$users, $agents] = $this->people($p); $rows = []; $total = 0;
  100. $query = $this->eventQuery($p); if (isset($p['kind'])) $query->where('e.kind', $p['kind']);
  101. foreach ($query->orderByDesc('e.id')->cursor() as $record) {
  102. $row = (array) $record;
  103. if (!$this->scope($row, $p, $users, $agents)) continue;
  104. if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
  105. if ($p['category'] !== 'all' && $row['category'] !== $p['category']) continue;
  106. ++$total;
  107. if ($total > ((int) $p['page'] - 1) * (int) $p['limit'] && count($rows) < (int) $p['limit']) $rows[] = $row;
  108. }
  109. return ['total' => $total, 'data' => $rows, 'timezone' => 'Asia/Shanghai', 'filters' => array_diff_key($p, ['start' => 1, 'end' => 1])];
  110. }
  111. private function compute(array $p, string $mode): array
  112. {
  113. [$users, $agents] = $this->people($p); $settings = (new ReportAdmin())->settings();
  114. $qualification = (new ShareMemberQualification())->read($p['end_date'], $mode !== 'daily'); $policy = $qualification['policy_meta'];
  115. $groups = []; $unallocated = null; $now = now('Asia/Shanghai')->toDateTimeString();
  116. $dailyCategories = [];
  117. if ($mode === 'summary') foreach (['all' => '所有订单'] + MaintenancePolicy::CATEGORIES as $key => $label) $groups[$key] = $this->empty($key, $label);
  118. if ($mode === 'daily') {
  119. $groups['all'] = $this->empty('all', '总计');
  120. $offset = ((int) $p['page'] - 1) * (int) $p['limit'];
  121. if ($offset < Carbon::parse($p['start'])->diffInDays(Carbon::parse($p['end']))) {
  122. $day = Carbon::parse($p['end'])->subDay()->subDays($offset);
  123. for ($i = 0; $i < (int) $p['limit'] && $day->toDateTimeString() >= $p['start']; ++$i, $day->subDay()) {
  124. $groups[$day->toDateString()] = $this->empty($day->toDateString(), $day->toDateString());
  125. }
  126. }
  127. }
  128. $globalMissingTime = 0;
  129. foreach ($this->orderQuery($p)->orderBy('id')->cursor() as $record) {
  130. $row = (array) $record;
  131. if (!isset($users[$row['user_id']]) || !$this->scope($row, $p, $users, $agents)) continue;
  132. if ($p['category'] !== 'all' && $row['category'] !== $p['category']) continue;
  133. if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
  134. $keys = $mode === 'summary' ? ['all', $row['category']] : ($mode === 'details' ? [$this->group($row, $p)] : ['all']);
  135. if ($mode === 'daily') {
  136. if ($this->within($row['bet_at'], $p) && isset($groups[substr($row['bet_at'], 0, 10)])) $keys[] = substr($row['bet_at'], 0, 10);
  137. if ($this->within($row['settled_at'], $p) && isset($groups[substr($row['settled_at'], 0, 10)])) $keys[] = substr($row['settled_at'], 0, 10);
  138. }
  139. foreach (array_unique($keys) as $key) {
  140. if ($key === null) continue;
  141. if (!isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
  142. $window = $mode === 'daily' && $key !== 'all' ? ['start' => $key . ' 00:00:00', 'end' => Carbon::parse($key)->addDay()->toDateTimeString()] + $p : $p;
  143. $this->addOrder($groups[$key], $row, $window);
  144. if ($mode === 'daily' && isset(MaintenancePolicy::CATEGORIES[$row['category']])) {
  145. $category = $row['category'];
  146. $dailyCategories[$key][$category] ??= $this->emptyGameCategory($category);
  147. $this->addGameResult($dailyCategories[$key][$category], $row, $window);
  148. }
  149. if ($p['report_type'] === 'promotion' && $row['attribution_quality'] === 'historical_current_agent') ++$groups[$key]['quality']['unverified_attribution'];
  150. }
  151. }
  152. $events = $this->eventQuery($p);
  153. foreach ($events->orderBy('e.id')->cursor() as $record) {
  154. $row = (array) $record;
  155. if (!$this->scope($row, $p, $users, $agents)) continue;
  156. if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
  157. if ($row['reconciliation_id'] !== null) { $row['amount'] = $row['verified_amount']; $row['occurred_at'] = $row['verified_at']; }
  158. $keys = $mode === 'summary' ? ['all'] : ($mode === 'details' ? [$this->group($row, $p)] : ['all']);
  159. if ($mode === 'summary' && $row['category'] !== null) $keys[] = $row['category'];
  160. if ($mode === 'daily' && $row['occurred_at'] !== null && isset($groups[substr($row['occurred_at'], 0, 10)])) $keys[] = substr($row['occurred_at'], 0, 10);
  161. if ($mode === 'daily' && $row['occurred_at'] === null) ++$globalMissingTime;
  162. foreach (array_unique($keys) as $key) {
  163. if ($key === null) $key = 'unallocated';
  164. if (!isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
  165. $this->addEvent($groups[$key], $row);
  166. if ($p['report_type'] === 'promotion' && $row['user_id'] !== null && $row['attribution_quality'] === 'historical_current_agent') ++$groups[$key]['quality']['unverified_attribution'];
  167. }
  168. }
  169. $activity = $mode === 'daily' ? ['members' => [], 'groups' => []] : $this->activity($p, $users, $agents);
  170. foreach ($activity['groups'] as $key => $members) {
  171. if ($mode === 'details' && !isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
  172. }
  173. $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();
  174. $run = $stateRows->firstWhere('source', '__run__'); $ready = $run && (json_decode($run->state, true)['complete'] ?? false);
  175. $syncAt = $run->updated_at ?? null;
  176. $scopePopulation = []; $groupPopulation = [];
  177. if ($mode !== 'daily') foreach ($users as $uid => $u) {
  178. $person = ['user_id' => $u['id'], 'agent_id' => $u['agent_id'] ?? null, 'inviter_id' => $u['inviter_id'] ?? null, 'kind' => 'activity', 'client_platform' => $p['platform']];
  179. if (!$this->scope($person, $p, $users, $agents)) continue;
  180. if (isset($p['group_id']) && $this->group($person, $p) !== $p['group_id']) continue;
  181. $scopePopulation[$uid] = $u;
  182. $group = $this->group($person, $p); if ($group !== null) $groupPopulation[$group][$uid] = $u;
  183. }
  184. foreach ($groups as $key => &$value) {
  185. $population = $mode === 'details' ? ($groupPopulation[$key] ?? []) : $scopePopulation;
  186. if ($mode === 'details') {
  187. $person = $users[$key] ?? [];
  188. $value['member_account'] = $p['report_type'] === 'promotion' ? null : ($person ? $this->account($person) : null);
  189. $value['nickname'] = $p['report_type'] === 'promotion' ? null : ($person['first_name'] ?? '');
  190. $value['agent_account'] = $p['report_type'] === 'promotion' ? ($agents[$key]['username'] ?? null) : ($agents[$person['agent_id'] ?? 0]['username'] ?? null);
  191. $parent = $person['inviter_id'] ?? null; $value['inviter_account'] = isset($users[$parent]) ? $this->account($users[$parent]) : null;
  192. }
  193. $value['member_count'] = count($population); $value['valid_member_count'] = $policy['available'] ? 0 : null; $value['active_member_count'] = 0; $seconds = 0;
  194. if ($policy['available']) foreach ($value['_bettors'] as $uid => $_) if (isset($qualification['qualified'][$uid])) ++$value['valid_member_count'];
  195. $active = $mode === 'details' ? ($activity['groups'][$key] ?? []) : $activity['members'];
  196. $value['active_member_count'] = count($active);
  197. foreach ($active as $item) $seconds += $item['seconds'];
  198. // Current membership cannot prove a historical population after a reassignment.
  199. $value['activity_population_complete'] = !array_diff_key($active, $population);
  200. $covered = $p['start'] >= $settings['activity_started_at'];
  201. $value['known_activity_minutes'] = intdiv($seconds, 60); $value['activity_minutes'] = $covered ? $value['known_activity_minutes'] : null;
  202. $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;
  203. $value['order_count'] = count($value['_orders']); unset($value['_orders'], $value['_bettors']);
  204. $value['quality']['sync_errors'] = (int) $hasErrors; $value['quality']['not_synchronized'] = !$ready;
  205. if ($mode === 'daily') $value['quality']['missing_time'] += $key === 'all' ? 0 : $globalMissingTime;
  206. $complete = !array_sum(array_map('intval', $value['quality'])); $value['complete'] = $complete;
  207. $value['known_net_cash_flow'] = bcsub(bcsub($value['deposit_amount'], $value['withdraw_amount'], 10), bcadd($value['deposit_fee'], $value['withdraw_fee'], 10), 10);
  208. $cost = '0'; foreach (['bonus_amount', 'agent_commission', 'share_commission', 'deposit_fee', 'withdraw_fee', 'third_game_commission'] as $f) $cost = bcadd($cost, $value[$f], 10);
  209. $value['known_company_profit'] = bcsub($value['game_gross_profit'], $cost, 10);
  210. $this->finishGameResult($value);
  211. $cashComplete = !array_sum(array_map('intval', array_diff_key($value['quality'], ['missing_platform_ratio' => true])));
  212. $value['net_cash_flow'] = $cashComplete ? $value['known_net_cash_flow'] : null;
  213. $value['company_profit'] = $complete && $p['settlement_status'] !== 'pending' ? $value['known_company_profit'] : null;
  214. if (($mode === 'summary' && $key !== 'all') || $p['category'] !== 'all') {
  215. foreach (self::CASH as $f) $value[$f] = null;
  216. $value['net_cash_flow'] = $value['company_profit'] = $value['known_net_cash_flow'] = $value['known_company_profit'] = null;
  217. }
  218. if ($mode === 'daily' && $key !== 'all') { $value['activity_minutes'] = $value['active_rate'] = null; }
  219. if ($mode === 'daily') {
  220. $value['categories'] = [];
  221. foreach (MaintenancePolicy::CATEGORIES as $category => $label) {
  222. $game = $dailyCategories[$key][$category] ?? $this->emptyGameCategory($category);
  223. // Sync state is global: a failed or unfinished sync makes every category of the day uncertain.
  224. $game['quality']['sync_errors'] = $value['quality']['sync_errors'];
  225. $game['quality']['not_synchronized'] = $value['quality']['not_synchronized'];
  226. $this->finishGameResult($game);
  227. $value['categories'][] = $game;
  228. }
  229. }
  230. } unset($value);
  231. if (isset($groups['unallocated'])) { $unallocated = $groups['unallocated']; unset($groups['unallocated']); }
  232. return ['groups' => $groups, 'unallocated' => $unallocated, 'meta' => ['filters' => array_diff_key($p, ['start' => 1, 'end' => 1]),
  233. 'timezone' => 'Asia/Shanghai', 'currency' => (new ReportSync())->currency(), 'data_updated_at' => $syncAt,
  234. 'activity_started_at' => $settings['activity_started_at'], 'valid_deposit_min' => $policy['min_deposit'],
  235. 'valid_effective_bet_min' => $policy['min_effective_bet'], 'valid_member_policy' => $policy,
  236. 'commission_balances' => $this->commissionBalances($p, $users, $agents),
  237. 'third_game_commission_meta' => ThirdGameCommissionService::meta(), 'server_time' => $now]];
  238. }
  239. private function commissionBalances(array $p, array $users, array $agents): array
  240. {
  241. $result = ['date_basis' => 'current_account_balance', 'as_of' => now('Asia/Shanghai')->toDateTimeString(),
  242. 'agent_unpaid' => null, 'share_unclaimed' => null, 'share_reserved' => null, 'share_debt' => null];
  243. if ($p['platform'] !== 'all' || $p['category'] !== 'all') return $result;
  244. if ($p['report_type'] !== 'sharing' && Schema::hasTable('agent_rebate_records') && empty($p['member_account']) && empty($p['inviter_account'])) {
  245. $result['agent_unpaid'] = Money::zero();
  246. foreach (DB::table('agent_rebate_records')->cursor() as $row) {
  247. if (!empty($p['agent_account']) && !str_contains($agents[$row->agent_id]['username'] ?? '', $p['agent_account'])) continue;
  248. foreach (['flow', 'profit'] as $part) if ($row->{$part . '_status'} === 'pending') $result['agent_unpaid'] = bcadd($result['agent_unpaid'], (string) $row->{$part . '_commission'}, 10);
  249. }
  250. }
  251. if (Schema::hasTable('share_accounts')) {
  252. foreach (['share_unclaimed', 'share_reserved', 'share_debt'] as $f) $result[$f] = Money::zero();
  253. foreach (DB::table('share_accounts')->cursor() as $row) {
  254. if (!isset($users[$row->user_id])) continue; $user = $users[$row->user_id];
  255. $scope = ['user_id' => $row->user_id, 'agent_id' => $user['agent_id'] ?? null, 'inviter_id' => $user['inviter_id'] ?? null,
  256. 'kind' => 'share_commission', 'client_platform' => 'unknown'];
  257. if (!$this->scope($scope, $p, $users, $agents)) continue;
  258. $balance = bcsub((string) $row->earned_amount, (string) $row->paid_amount, 10);
  259. if (bccomp($balance, '0', 10) >= 0) $result['share_unclaimed'] = bcadd($result['share_unclaimed'], $balance, 10);
  260. else $result['share_debt'] = bcsub($result['share_debt'], $balance, 10);
  261. $result['share_reserved'] = bcadd($result['share_reserved'], (string) $row->reserved_amount, 10);
  262. }
  263. }
  264. return $result;
  265. }
  266. private function orderQuery(array $p)
  267. {
  268. $q = DB::table('reporting_orders')->where(function ($q) use ($p) {
  269. $q->where(function ($q) use ($p) { $q->where('bet_at', '>=', $p['start'])->where('bet_at', '<', $p['end']); })
  270. ->orWhere(function ($q) use ($p) { $q->where('settled_at', '>=', $p['start'])->where('settled_at', '<', $p['end']); })
  271. ->orWhere(function ($q) use ($p) { $q->whereNull('settled_at')->where('bet_at', '<', $p['end'])->where(function ($q) { $q->where('settled', 1)->orWhere('voided', 1); }); });
  272. });
  273. if ($p['settlement_status'] === 'settled') $q->where('settled', 1)->where('voided', 0);
  274. if ($p['settlement_status'] === 'pending') $q->where('settled', 0)->where('voided', 0);
  275. return $q->select('reporting_orders.*');
  276. }
  277. private function eventQuery(array $p)
  278. {
  279. return DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')
  280. ->where(function ($q) use ($p) {
  281. $q->where(function ($q) use ($p) { $q->where('e.occurred_at', '>=', $p['start'])->where('e.occurred_at', '<', $p['end'])->whereNull('r.id'); })
  282. ->orWhere(function ($q) use ($p) { $q->where('r.occurred_at', '>=', $p['start'])->where('r.occurred_at', '<', $p['end']); })
  283. ->orWhere(function ($q) use ($p) { $q->whereNull('e.occurred_at')->whereNull('r.id')->where('e.candidate_at', '<', $p['end']); });
  284. })->select('e.*', 'r.id as reconciliation_id', 'r.actual_amount as verified_amount', 'r.external_fee', 'r.occurred_at as verified_at');
  285. }
  286. private function people(array $p): array
  287. {
  288. $agents = []; if (Schema::hasTable('agents')) foreach (DB::table('agents')->get(['id', 'username']) as $r) $agents[$r->id] = (array) $r;
  289. $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;
  290. $users = []; foreach (DB::table('users')->where(function ($q) { $q->whereNull('from')->orWhere('from', '<>', 2); })->where('created_at', '<', $p['end'])->get() as $r) {
  291. $u = (array) $r; $u['inviter_id'] = $invites[$r->id] ?? null; $users[$r->id] = $u;
  292. }
  293. return [$users, $agents];
  294. }
  295. private function scope(array $row, array $p, array $users, array $agents): bool
  296. {
  297. $uid = $row['user_id'] ?? null; $agent = $row['agent_id'] ?? null; $inviter = $row['inviter_id'] ?? null;
  298. if ($uid !== null && !isset($users[$uid])) return false;
  299. if ($p['platform'] !== 'all' && ($row['client_platform'] ?? 'unknown') !== $p['platform']) return false;
  300. if ($p['report_type'] === 'promotion' && !$agent) return false;
  301. if ($p['report_type'] === 'sharing' && !$inviter && !in_array($row['kind'] ?? '', ['share_commission', 'share_accrual'], true)) return false;
  302. if (!empty($p['member_account']) && (!$uid || !str_contains($this->account($users[$uid]), $p['member_account']))) return false;
  303. if (!empty($p['agent_account']) && (!$agent || !str_contains($agents[$agent]['username'] ?? '', $p['agent_account']))) return false;
  304. if (!empty($p['inviter_account']) && (!$inviter || !str_contains(isset($users[$inviter]) ? $this->account($users[$inviter]) : '', $p['inviter_account']))) return false;
  305. return true;
  306. }
  307. private function empty(string $key, string $label): array
  308. {
  309. return ['group_id' => $key, 'label' => $label] + array_fill_keys(self::AMOUNTS, Money::zero()) + ['_orders' => [], '_bettors' => [],
  310. 'quality' => ['missing_time' => 0, 'missing_amount' => 0, 'unverified_fees' => 0, 'foreign_currency' => 0, 'unverified_attribution' => 0, 'missing_platform_ratio' => 0]];
  311. }
  312. private function addOrder(array &$value, array $row, array $p): void
  313. {
  314. if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
  315. if ($this->within($row['bet_at'], $p)) {
  316. $this->add($value, 'bet_amount', $row['bet_amount']); $value['_orders'][$row['parent_source'] . ':' . $row['parent_id']] = true;
  317. if (!$row['voided'] && bccomp($row['bet_amount'], '0', 10) > 0) $value['_bettors'][$row['user_id']] = true;
  318. }
  319. if (($row['settled'] || $row['voided']) && $row['settled_at'] === null) { ++$value['quality']['missing_time']; return; }
  320. if ($this->within($row['settled_at'], $p)) {
  321. if ($row['voided']) $this->add($value, 'refund_amount', $row['refund_amount']);
  322. elseif ($row['settled']) {
  323. $this->add($value, 'settled_bet_amount', bcsub($row['bet_amount'], $row['refund_amount'], 10)); $this->add($value, 'payout_amount', $row['payout_amount']);
  324. $this->add($value, 'refund_amount', $row['refund_amount']);
  325. $this->addGameResult($value, $row, $p);
  326. }
  327. }
  328. }
  329. private function emptyGameCategory(string $category): array
  330. {
  331. return [
  332. 'group_id' => $category, 'label' => MaintenancePolicy::CATEGORIES[$category],
  333. 'game_gross_profit' => Money::zero(), 'third_game_commission' => Money::zero(),
  334. 'quality' => ['missing_time' => 0, 'foreign_currency' => 0, 'missing_platform_ratio' => 0],
  335. ];
  336. }
  337. private function addGameResult(array &$value, array $row, array $p): void
  338. {
  339. // addOrder() counts these gaps itself before calling here; per-category totals rely on this check.
  340. if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
  341. if ($row['voided'] || !$row['settled']) return;
  342. if ($row['settled_at'] === null) { ++$value['quality']['missing_time']; return; }
  343. if (!$this->within($row['settled_at'], $p)) return;
  344. $this->add($value, 'game_gross_profit', bcsub('0', $row['net_profit'], 10));
  345. if ($row['parent_source'] === 'third_game_orders' && bccomp($row['net_profit'], '0', 10) < 0) {
  346. $amount = $row['third_game_commission'];
  347. if ($amount === null) {
  348. ++$value['quality']['missing_platform_ratio'];
  349. } else {
  350. $this->add($value, 'third_game_commission', $amount);
  351. }
  352. }
  353. }
  354. /** Game profit is only definite when synced data is current and no settled order was left out for a missing rate, time, amount or currency. */
  355. private function finishGameResult(array &$value): void
  356. {
  357. $value['known_third_game_commission'] = $value['third_game_commission'];
  358. if ($value['quality']['missing_platform_ratio']) $value['third_game_commission'] = null;
  359. $gaps = array_intersect_key($value['quality'], array_flip(['missing_platform_ratio', 'missing_time', 'missing_amount', 'foreign_currency', 'sync_errors', 'not_synchronized']));
  360. $value['game_net_profit'] = array_sum(array_map('intval', $gaps)) || $value['third_game_commission'] === null
  361. ? null : bcsub($value['game_gross_profit'], $value['third_game_commission'], 10);
  362. }
  363. private function addEvent(array &$value, array $row): void
  364. {
  365. if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
  366. if ($row['occurred_at'] === null) { ++$value['quality']['missing_time']; return; }
  367. if ($row['amount'] === null) { ++$value['quality']['missing_amount']; return; }
  368. $map = ['deposit' => 'deposit_amount', 'withdraw' => 'withdraw_amount', 'agent_withdraw' => 'withdraw_amount', 'bonus' => 'bonus_amount',
  369. 'agent_commission' => 'agent_commission', 'share_commission' => 'share_commission', 'agent_accrual' => 'agent_accrual', 'share_accrual' => 'share_accrual', 'manual_credit' => 'manual_credit', 'manual_debit' => 'manual_debit'];
  370. $this->add($value, $map[$row['kind']], $row['amount']);
  371. if (in_array($row['kind'], ['deposit', 'withdraw', 'agent_withdraw'], true)) {
  372. $this->add($value, 'customer_fee_income', $row['customer_fee']);
  373. if ($row['kind'] !== 'deposit') $this->add($value, $row['kind'] === 'withdraw' ? 'member_withdraw_amount' : 'agent_withdraw_amount', $row['amount']);
  374. if ($row['configured_fee'] !== null) $this->add($value, 'configured_fee', $row['configured_fee']);
  375. if ($row['external_fee'] === null) ++$value['quality']['unverified_fees'];
  376. else $this->add($value, $row['kind'] === 'deposit' ? 'deposit_fee' : 'withdraw_fee', $row['external_fee']);
  377. }
  378. }
  379. private function activity(array $p, array $users, array $agents): array
  380. {
  381. $q = DB::table('reporting_activity')->where('observed_at', '>=', Carbon::parse($p['start'])->subMinutes(5)->toDateTimeString())->where('observed_at', '<', $p['end']);
  382. if ($p['platform'] !== 'all') $q->where('client_platform', $p['platform']);
  383. $result = ['members' => [], 'groups' => []]; $observations = []; $userId = null;
  384. foreach ($q->orderBy('user_id')->orderBy('observed_at')->orderBy('id')->cursor() as $row) {
  385. if (!isset($users[$row->user_id])) continue;
  386. if ($userId !== null && $userId !== $row->user_id) {
  387. $this->memberActivity($observations, $p, $users, $agents, $result); $observations = [];
  388. }
  389. $userId = $row->user_id; $observations[] = (array)$row;
  390. }
  391. if ($observations) $this->memberActivity($observations, $p, $users, $agents, $result);
  392. return $result;
  393. }
  394. private function memberActivity(array $observations, array $p, array $users, array $agents, array &$result): void
  395. {
  396. $last = []; $intervals = []; $timeline = []; $windowStart = Carbon::parse($p['start'], 'Asia/Shanghai')->timestamp;
  397. foreach ($observations as $row) {
  398. $time = Carbon::parse($row['observed_at'], 'Asia/Shanghai')->timestamp; $session = $row['session_hash'];
  399. if ($time >= $windowStart) $this->addActivity($result, $row, 0, $p, $users, $agents);
  400. if (isset($last[$session]) && $time > $last[$session] && $time - $last[$session] <= 300) $intervals[] = [max($last[$session], $windowStart), $time];
  401. $last[$session] = $time;
  402. // Across devices, the latest observation owns time until the next observation.
  403. // If observations share a minute, the last persisted snapshot wins consistently.
  404. $timeline[$time] = $row;
  405. }
  406. usort($intervals, fn ($a, $b) => $a[0] <=> $b[0]); $merged = [];
  407. foreach ($intervals as [$start, $end]) {
  408. $i = count($merged) - 1;
  409. if ($i >= 0 && $start <= $merged[$i][1]) $merged[$i][1] = max($merged[$i][1], $end); else $merged[] = [$start, $end];
  410. }
  411. $times = array_keys($timeline); $index = 0;
  412. foreach ($merged as [$start, $end]) {
  413. while (($times[$index + 1] ?? PHP_INT_MAX) <= $start) ++$index;
  414. while ($start < $end) {
  415. $next = min($end, $times[$index + 1] ?? $end);
  416. $this->addActivity($result, $timeline[$times[$index]], $next - $start, $p, $users, $agents);
  417. $start = $next;
  418. if (isset($times[$index + 1]) && $start === $times[$index + 1]) ++$index;
  419. }
  420. }
  421. }
  422. private function addActivity(array &$result, array $row, int $seconds, array $p, array $users, array $agents): void
  423. {
  424. $row['kind'] = 'activity';
  425. if (!$this->scope($row, $p, $users, $agents)) return;
  426. $group = $this->group($row, $p);
  427. if ($group === null || (isset($p['group_id']) && $group !== $p['group_id'])) return;
  428. $uid = $row['user_id'];
  429. $result['members'][$uid]['seconds'] = ($result['members'][$uid]['seconds'] ?? 0) + $seconds;
  430. $result['groups'][$group][$uid]['seconds'] = ($result['groups'][$group][$uid]['seconds'] ?? 0) + $seconds;
  431. }
  432. private function within(?string $at, array $p): bool { return $at !== null && $at >= $p['start'] && $at < $p['end']; }
  433. private function add(array &$value, string $field, string $amount): void { $value[$field] = bcadd($value[$field], $amount, 10); }
  434. private function account(array $user): string { return (string) (($user['username'] ?? '') ?: (($user['account'] ?? '') ?: ($user['member_id'] ?? $user['user_id'] ?? $user['id']))); }
  435. private function group(array $row, array $p): ?string
  436. {
  437. if ($p['report_type'] === 'business') return isset($row['user_id']) ? (string) $row['user_id'] : null;
  438. if ($p['report_type'] === 'promotion') return isset($row['agent_id']) ? (string) $row['agent_id'] : null;
  439. return in_array($row['kind'] ?? '', ['share_commission', 'share_accrual'], true) ? (string) $row['user_id'] : (isset($row['inviter_id']) ? (string) $row['inviter_id'] : null);
  440. }
  441. private function label(string $id, array $p, array $users, array $agents): string
  442. {
  443. if ($id === 'unallocated') return '未分配公共资金与成本';
  444. if ($p['report_type'] === 'promotion') return (string) ($agents[$id]['username'] ?? $id);
  445. return isset($users[$id]) ? $this->account($users[$id]) : $id;
  446. }
  447. }