AgentCommissionService.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. <?php
  2. namespace App\Services\Agent;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentCommission;
  5. use App\Models\Agent\AgentDailyStat;
  6. use App\Models\Agent\AgentDailyGameStat;
  7. use App\Models\Agent\AgentRebateRecord;
  8. use Carbon\Carbon;
  9. use Illuminate\Support\Facades\DB;
  10. class AgentCommissionService
  11. {
  12. public function __construct(
  13. private AgentReportService $reports,
  14. private AgentWalletService $wallets,
  15. private AgentFlowCommissionAssignmentService $flowAssignments,
  16. private AgentProfitCommissionAssignmentService $profitAssignments
  17. ) {
  18. }
  19. public function settle(string $date, bool $credit = true): array
  20. {
  21. $day = Carbon::parse($date)->toDateString();
  22. $created = 0;
  23. $credited = 0;
  24. $legacyRowsByAgent = AgentCommission::query()->where('settlement_date', $day)
  25. ->get(['agent_id', 'type', 'status'])->groupBy('agent_id');
  26. Agent::query()->where('status', Agent::STATUS_ENABLED)
  27. ->where('created_at', '<', Carbon::parse($day)->addDay()->startOfDay())
  28. ->orderBy('id')->chunkById(100, function ($agents) use ($day, $credit, $legacyRowsByAgent, &$created, &$credited) {
  29. foreach ($agents as $agent) {
  30. $legacyRows = $legacyRowsByAgent->get($agent->id, collect());
  31. if ($legacyRows->isNotEmpty()) {
  32. $creditedTypes = $legacyRows->where('status', AgentRebateRecord::STATUS_CREDITED)
  33. ->pluck('type')->unique()->values();
  34. if ($creditedTypes->contains('flow') && $creditedTypes->contains('profit')) {
  35. continue;
  36. }
  37. throw new \RuntimeException("代理 {$agent->id} 的 {$day} 存在未完成旧佣金记录,请先人工核对");
  38. }
  39. $rows = $this->spreadRows($agent, $day);
  40. foreach ($rows as $row) {
  41. $result = DB::transaction(function () use ($agent, $row, $day, $credit) {
  42. $record = AgentRebateRecord::query()->lockForUpdate()->firstOrNew([
  43. 'settlement_date' => $day, 'agent_id' => $agent->id,
  44. 'platform' => (string) $row['platform'], 'game_type' => (int) $row['game_type'],
  45. ]);
  46. $isNew = !$record->exists;
  47. if ($isNew) {
  48. $record->fill([
  49. 'order_no' => $record->order_no ?: $this->rebateOrderNo($day, (int) $agent->id, (string) $row['platform'], (int) $row['game_type']),
  50. 'bet_count' => (int) $row['bet_count'], 'bet_amount' => $row['bet_amount'],
  51. 'valid_bet_amount' => $row['valid_bet_amount'], 'win_loss' => $row['win_loss'],
  52. 'flow_rate' => $row['flow_rate'], 'flow_commission' => $row['flow_commission'],
  53. 'flow_status' => AgentRebateRecord::STATUS_PENDING,
  54. 'profit_rate' => $row['profit_rate'], 'profit_commission' => $row['profit_commission'],
  55. 'profit_status' => AgentRebateRecord::STATUS_PENDING,
  56. 'snapshot' => $row['snapshot'],
  57. ]);
  58. } elseif ($record->flow_status === AgentRebateRecord::STATUS_PENDING
  59. && $record->profit_status === AgentRebateRecord::STATUS_PENDING) {
  60. $record->bet_count = (int) $row['bet_count'];
  61. $record->bet_amount = $row['bet_amount'];
  62. $record->valid_bet_amount = $row['valid_bet_amount'];
  63. $record->win_loss = $row['win_loss'];
  64. $record->flow_rate = $row['flow_rate'];
  65. $record->flow_commission = $row['flow_commission'];
  66. $record->profit_rate = $row['profit_rate'];
  67. $record->profit_commission = $row['profit_commission'];
  68. $record->snapshot = $row['snapshot'];
  69. }
  70. $record->save();
  71. $partsCredited = 0;
  72. if ($credit) {
  73. $partsCredited += $this->creditRebatePart($record, 'flow', $day, (int) $agent->id);
  74. $partsCredited += $this->creditRebatePart($record, 'profit', $day, (int) $agent->id);
  75. $record->refresh();
  76. if ($record->flow_status === AgentRebateRecord::STATUS_CREDITED
  77. && $record->profit_status === AgentRebateRecord::STATUS_CREDITED
  78. && $record->credited_at === null) {
  79. $record->credited_at = now();
  80. }
  81. $record->save();
  82. }
  83. return ['created' => $isNew ? 1 : 0, 'credited' => $partsCredited];
  84. }, 3);
  85. $created += $result['created'];
  86. $credited += $result['credited'];
  87. }
  88. AgentDailyStat::query()->where('stat_date', $day)->where('agent_id', $agent->id)->update([
  89. 'commission_amount' => AgentRebateRecord::query()->where('settlement_date', $day)
  90. ->where('agent_id', $agent->id)
  91. ->selectRaw("COALESCE(SUM(CASE WHEN flow_status='credited' THEN flow_commission ELSE 0 END + CASE WHEN profit_status='credited' THEN profit_commission ELSE 0 END),0) total")
  92. ->value('total'),
  93. ]);
  94. }
  95. });
  96. return compact('created', 'credited');
  97. }
  98. public static function rateSpread(string $parentRate, string $childRate): string
  99. {
  100. $diff = bcsub($parentRate, $childRate, 4);
  101. return bccomp($diff, '0', 4) > 0 ? $diff : '0.0000';
  102. }
  103. public static function amountByRate(string $base, string $rate): string
  104. {
  105. return bcdiv(bcmul($base, $rate, 8), '100', 4);
  106. }
  107. public static function profitBase(string $winLoss): string
  108. {
  109. return bccomp($winLoss, '0', 4) < 0 ? bcsub('0', $winLoss, 4) : '0.0000';
  110. }
  111. private function spreadRows(Agent $agent, string $day): array
  112. {
  113. $flowAssignment = $this->flowAssignments->at($agent, $day);
  114. $profitAssignment = $this->profitAssignments->at($agent, $day);
  115. $descendantIds = [(int) $agent->id];
  116. $rows = [];
  117. $this->addSegment($rows, $this->gameStats($day, [(int) $agent->id]), $flowAssignment, $profitAssignment, false);
  118. $children = Agent::query()->where('parent_id', $agent->id)
  119. ->where('created_at', '<', Carbon::parse($day)->addDay()->startOfDay())
  120. ->orderBy('id')->get();
  121. $childSnapshots = [];
  122. foreach ($children as $child) {
  123. $childFlow = $this->flowAssignments->at($child, $day);
  124. $childProfit = $this->profitAssignments->at($child, $day);
  125. $childIds = $this->reports->visibleAgentIds($child);
  126. $descendantIds = array_merge($descendantIds, $childIds);
  127. $this->addSegment($rows, $this->gameStats($day, $childIds), $flowAssignment, $profitAssignment, true, $childFlow, $childProfit);
  128. $childSnapshots[] = [
  129. 'agent_id' => (int) $child->id,
  130. 'agent_ids' => $childIds,
  131. 'flow_profile_id' => $childFlow->flow_commission_profile_id,
  132. 'flow_assignment_id' => $childFlow->id,
  133. 'profit_profile_id' => $childProfit->profit_commission_profile_id,
  134. 'profit_assignment_id' => $childProfit->id,
  135. ];
  136. }
  137. $snapshot = [
  138. 'mode' => 'spread',
  139. 'agent_ids' => $descendantIds,
  140. 'flow_profile_id' => $flowAssignment->flow_commission_profile_id,
  141. 'flow_assignment_id' => $flowAssignment->id,
  142. 'profit_profile_id' => $profitAssignment->profit_commission_profile_id,
  143. 'profit_assignment_id' => $profitAssignment->id,
  144. 'children' => $childSnapshots,
  145. ];
  146. foreach ($rows as &$row) {
  147. $row['flow_rate'] = $flowAssignment->rateForGameType((int) $row['game_type']);
  148. $row['profit_rate'] = $profitAssignment->rateForGameType((int) $row['game_type']);
  149. $row['snapshot'] = $snapshot;
  150. }
  151. unset($row);
  152. return array_values($rows);
  153. }
  154. private function addSegment(
  155. array &$rows,
  156. $stats,
  157. $flowAssignment,
  158. $profitAssignment,
  159. bool $spread,
  160. $childFlow = null,
  161. $childProfit = null
  162. ): void {
  163. foreach ($stats as $stat) {
  164. $gameType = (int) $stat->game_type;
  165. $flowRate = $spread
  166. ? self::rateSpread($flowAssignment->rateForGameType($gameType), $childFlow->rateForGameType($gameType))
  167. : $flowAssignment->rateForGameType($gameType);
  168. $profitRate = $spread
  169. ? self::rateSpread($profitAssignment->rateForGameType($gameType), $childProfit->rateForGameType($gameType))
  170. : $profitAssignment->rateForGameType($gameType);
  171. $key = (string) $stat->platform . "\0" . $gameType;
  172. if (!isset($rows[$key])) {
  173. $rows[$key] = [
  174. 'platform' => (string) $stat->platform,
  175. 'game_type' => $gameType,
  176. 'bet_count' => 0,
  177. 'bet_amount' => '0.0000',
  178. 'valid_bet_amount' => '0.0000',
  179. 'win_loss' => '0.0000',
  180. 'flow_commission' => '0.0000',
  181. 'profit_commission' => '0.0000',
  182. ];
  183. }
  184. $rows[$key]['bet_count'] += (int) $stat->bet_count;
  185. $rows[$key]['bet_amount'] = bcadd($rows[$key]['bet_amount'], (string) $stat->bet_amount, 4);
  186. $rows[$key]['valid_bet_amount'] = bcadd($rows[$key]['valid_bet_amount'], (string) $stat->valid_bet_amount, 4);
  187. $rows[$key]['win_loss'] = bcadd($rows[$key]['win_loss'], (string) $stat->win_loss, 4);
  188. $rows[$key]['flow_commission'] = bcadd(
  189. $rows[$key]['flow_commission'],
  190. self::amountByRate((string) $stat->valid_bet_amount, $flowRate),
  191. 4
  192. );
  193. $rows[$key]['profit_commission'] = bcadd(
  194. $rows[$key]['profit_commission'],
  195. self::amountByRate(self::profitBase((string) $stat->win_loss), $profitRate),
  196. 4
  197. );
  198. }
  199. }
  200. private function gameStats(string $day, array $agentIds)
  201. {
  202. $agentIds = array_values(array_unique(array_filter(array_map('intval', $agentIds))));
  203. if ($agentIds === []) return collect();
  204. return AgentDailyGameStat::query()->where('stat_date', $day)->whereIn('agent_id', $agentIds)
  205. ->selectRaw('platform, game_type, SUM(bet_count) bet_count, SUM(bet_amount) bet_amount, SUM(valid_bet_amount) valid_bet_amount, SUM(win_loss) win_loss')
  206. ->groupBy('platform', 'game_type')->get();
  207. }
  208. private function creditRebatePart(AgentRebateRecord $record, string $type, string $day, int $agentId): int
  209. {
  210. return DB::transaction(function () use ($record, $type, $day, $agentId) {
  211. $locked = AgentRebateRecord::query()->lockForUpdate()->findOrFail($record->id);
  212. $statusField = $type . '_status';
  213. $amountField = $type . '_commission';
  214. if ($locked->{$statusField} !== AgentRebateRecord::STATUS_PENDING) return 0;
  215. $amount = (string) $locked->{$amountField};
  216. if (bccomp($amount, '0', 4) > 0) {
  217. $this->wallets->adjust(
  218. $agentId, $amount, 'commission',
  219. $day . ($type === 'flow' ? '流水佣金' : '盈亏佣金') . '-' . $locked->platform,
  220. 'rebate:' . $locked->id . ':' . $type, 'system', null,
  221. 'agent_rebate_record', (int) $locked->id
  222. );
  223. }
  224. $locked->{$statusField} = AgentRebateRecord::STATUS_CREDITED;
  225. $locked->save();
  226. return 1;
  227. });
  228. }
  229. private function rebateOrderNo(string $day, int $agentId, string $platform, int $gameType): string
  230. {
  231. return 'AR' . str_replace('-', '', $day) . $agentId
  232. . strtoupper(substr(sha1($platform . '|' . $gameType), 0, 10));
  233. }
  234. }