AgentCommissionService.php 12 KB

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