AgentCommissionService.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. <?php
  2. namespace App\Services\Agent;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentCommission;
  5. use App\Models\Agent\AgentCommissionRule;
  6. use App\Models\Agent\AgentDailyStat;
  7. use App\Models\Agent\AgentDailyGameStat;
  8. use App\Models\Agent\AgentRebateRecord;
  9. use Carbon\Carbon;
  10. use Illuminate\Support\Facades\DB;
  11. class AgentCommissionService
  12. {
  13. public function __construct(
  14. private AgentReportService $reports,
  15. private AgentWalletService $wallets,
  16. private AgentProfitCommissionAssignmentService $profitAssignments
  17. ) {
  18. }
  19. public function saveFlowRate(Agent $agent, string $rate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
  20. {
  21. if (bccomp($rate, '0', 4) < 0 || bccomp($rate, '100', 4) > 0) {
  22. throw new \InvalidArgumentException('流水佣金比例必须在0到100之间');
  23. }
  24. $day = Carbon::parse($effectiveFrom)->toDateString();
  25. return DB::transaction(function () use ($agent, $rate, $day, $adminId) {
  26. $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
  27. $legacyCredited = AgentCommission::query()->where('agent_id', $agent->id)
  28. ->where('type', 'flow')->where('settlement_date', '>=', $day)
  29. ->where('status', AgentRebateRecord::STATUS_CREDITED)->exists();
  30. $rebateCredited = AgentRebateRecord::query()->where('agent_id', $agent->id)
  31. ->where('settlement_date', '>=', $day)
  32. ->where('flow_status', AgentRebateRecord::STATUS_CREDITED)->exists();
  33. if ($legacyCredited || $rebateCredited) {
  34. throw new \RuntimeException('该日期流水佣金已入账,不能修改比例');
  35. }
  36. $parent = $agent->parent;
  37. if ($parent && bccomp($rate, $this->flowRateAt($parent, $day), 4) > 0) {
  38. throw new \RuntimeException('下级代理流水佣金比例不能高于上级');
  39. }
  40. $maxChildRate = Agent::query()->where('parent_id', $agent->id)->get()
  41. ->map(fn(Agent $child) => $this->flowRateAt($child, $day))->max() ?? 0;
  42. if (bccomp($rate, (string) $maxChildRate, 4) < 0) {
  43. throw new \RuntimeException('代理流水佣金比例不能低于现有下级比例');
  44. }
  45. $existing = AgentCommissionRule::query()->where('agent_id', $agent->id)
  46. ->where('effective_from', $day)->lockForUpdate()->first();
  47. $profitRate = (string) ($existing?->profit_rate ?? $agent->profit_commission_rate ?? 0);
  48. $agent->flow_commission_rate = $rate;
  49. $agent->save();
  50. return AgentCommissionRule::query()->updateOrCreate(
  51. ['agent_id' => $agent->id, 'effective_from' => $day],
  52. ['flow_rate' => $rate, 'profit_rate' => $profitRate, 'status' => 1, 'created_by' => $adminId]
  53. );
  54. });
  55. }
  56. public function settle(string $date, bool $credit = true): array
  57. {
  58. $day = Carbon::parse($date)->toDateString();
  59. $created = 0;
  60. $credited = 0;
  61. $legacyRowsByAgent = AgentCommission::query()->where('settlement_date', $day)
  62. ->get(['agent_id', 'type', 'status'])->groupBy('agent_id');
  63. Agent::query()->where('status', Agent::STATUS_ENABLED)->orderBy('id')->chunkById(100, function ($agents) use ($day, $credit, $legacyRowsByAgent, &$created, &$credited) {
  64. foreach ($agents as $agent) {
  65. $legacyRows = $legacyRowsByAgent->get($agent->id, collect());
  66. if ($legacyRows->isNotEmpty()) {
  67. $creditedTypes = $legacyRows->where('status', AgentRebateRecord::STATUS_CREDITED)
  68. ->pluck('type')->unique()->values();
  69. if ($creditedTypes->contains('flow') && $creditedTypes->contains('profit')) {
  70. continue;
  71. }
  72. throw new \RuntimeException("代理 {$agent->id} 的 {$day} 存在未完成旧佣金记录,请先人工核对");
  73. }
  74. $descendantIds = $this->reports->visibleAgentIds($agent);
  75. $rule = AgentCommissionRule::query()->where('agent_id', $agent->id)->where('status', 1)
  76. ->where('effective_from', '<=', $day)->orderByDesc('effective_from')->first();
  77. if (!$rule) {
  78. throw new \RuntimeException("代理 {$agent->id} 缺少 {$day} 可用的佣金比例记录");
  79. }
  80. $profitAssignment = $this->profitAssignments->at($agent, $day);
  81. $gameRows = AgentDailyGameStat::query()->where('stat_date', $day)->whereIn('agent_id', $descendantIds)
  82. ->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')
  83. ->groupBy('platform', 'game_type')->get();
  84. foreach ($gameRows as $gameRow) {
  85. $flowRate = (string) $rule->flow_rate;
  86. $profitRate = $profitAssignment->rateForGameType((int) $gameRow->game_type);
  87. $flowAmount = bcdiv(bcmul((string) $gameRow->valid_bet_amount, $flowRate, 8), '100', 4);
  88. $profitBase = bccomp((string) $gameRow->win_loss, '0', 4) < 0
  89. ? bcsub('0', (string) $gameRow->win_loss, 4) : '0.0000';
  90. $profitAmount = bcdiv(bcmul($profitBase, $profitRate, 8), '100', 4);
  91. $record = AgentRebateRecord::query()->firstOrNew([
  92. 'settlement_date' => $day, 'agent_id' => $agent->id,
  93. 'platform' => (string) $gameRow->platform, 'game_type' => (int) $gameRow->game_type,
  94. ]);
  95. $isNew = !$record->exists;
  96. if ($isNew) {
  97. $record->fill([
  98. 'order_no' => $record->order_no ?: $this->rebateOrderNo($day, (int) $agent->id, (string) $gameRow->platform, (int) $gameRow->game_type),
  99. 'bet_count' => (int) $gameRow->bet_count, 'bet_amount' => (string) $gameRow->bet_amount,
  100. 'valid_bet_amount' => (string) $gameRow->valid_bet_amount, 'win_loss' => (string) $gameRow->win_loss,
  101. 'flow_rate' => $flowRate, 'flow_commission' => $flowAmount,
  102. 'flow_status' => AgentRebateRecord::STATUS_PENDING,
  103. 'profit_rate' => $profitRate, 'profit_commission' => $profitAmount,
  104. 'profit_status' => AgentRebateRecord::STATUS_PENDING,
  105. 'snapshot' => [
  106. 'agent_ids' => $descendantIds,
  107. 'profit_profile_id' => $profitAssignment->profit_commission_profile_id,
  108. 'profit_assignment_id' => $profitAssignment->id,
  109. ],
  110. ]);
  111. } else {
  112. if ($record->flow_status === AgentRebateRecord::STATUS_PENDING
  113. && $record->profit_status === AgentRebateRecord::STATUS_PENDING) {
  114. $record->flow_rate = $flowRate;
  115. $record->flow_commission = $flowAmount;
  116. $record->profit_rate = $profitRate;
  117. $record->profit_commission = $profitAmount;
  118. $record->bet_count = (int) $gameRow->bet_count;
  119. $record->bet_amount = (string) $gameRow->bet_amount;
  120. $record->valid_bet_amount = (string) $gameRow->valid_bet_amount;
  121. $record->win_loss = (string) $gameRow->win_loss;
  122. $record->snapshot = [
  123. 'agent_ids' => $descendantIds,
  124. 'profit_profile_id' => $profitAssignment->profit_commission_profile_id,
  125. 'profit_assignment_id' => $profitAssignment->id,
  126. ];
  127. }
  128. }
  129. $record->save();
  130. if ($isNew) $created++;
  131. if ($credit) {
  132. $credited += $this->creditRebatePart($record, 'flow', $day, (int) $agent->id);
  133. $credited += $this->creditRebatePart($record, 'profit', $day, (int) $agent->id);
  134. $record->refresh();
  135. if ($record->flow_status === AgentRebateRecord::STATUS_CREDITED
  136. && $record->profit_status === AgentRebateRecord::STATUS_CREDITED
  137. && $record->credited_at === null) {
  138. $record->credited_at = now();
  139. }
  140. $record->save();
  141. }
  142. }
  143. AgentDailyStat::query()->where('stat_date', $day)->where('agent_id', $agent->id)->update([
  144. 'commission_amount' => AgentRebateRecord::query()->where('settlement_date', $day)
  145. ->where('agent_id', $agent->id)
  146. ->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")
  147. ->value('total'),
  148. ]);
  149. }
  150. });
  151. return compact('created', 'credited');
  152. }
  153. private function creditRebatePart(AgentRebateRecord $record, string $type, string $day, int $agentId): int
  154. {
  155. return DB::transaction(function () use ($record, $type, $day, $agentId) {
  156. $locked = AgentRebateRecord::query()->lockForUpdate()->findOrFail($record->id);
  157. $statusField = $type . '_status';
  158. $amountField = $type . '_commission';
  159. if ($locked->{$statusField} !== AgentRebateRecord::STATUS_PENDING) return 0;
  160. $amount = (string) $locked->{$amountField};
  161. if (bccomp($amount, '0', 4) > 0) {
  162. $this->wallets->adjust(
  163. $agentId, $amount, 'commission',
  164. $day . ($type === 'flow' ? '流水佣金' : '盈亏佣金') . '-' . $locked->platform,
  165. 'rebate:' . $locked->id . ':' . $type, 'system', null,
  166. 'agent_rebate_record', (int) $locked->id
  167. );
  168. }
  169. $locked->{$statusField} = AgentRebateRecord::STATUS_CREDITED;
  170. $locked->save();
  171. return 1;
  172. });
  173. }
  174. private function rebateOrderNo(string $day, int $agentId, string $platform, int $gameType): string
  175. {
  176. return 'AR' . str_replace('-', '', $day) . $agentId
  177. . strtoupper(substr(sha1($platform . '|' . $gameType), 0, 10));
  178. }
  179. private function flowRateAt(Agent $agent, string $day): string
  180. {
  181. $rule = AgentCommissionRule::query()->where('agent_id', $agent->id)->where('status', 1)
  182. ->where('effective_from', '<=', $day)->orderByDesc('effective_from')->first();
  183. if ($rule) {
  184. return (string) $rule->flow_rate;
  185. }
  186. return (string) $agent->flow_commission_rate;
  187. }
  188. }