AgentProfitLedgerService.php 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. <?php
  2. namespace App\Services\Agent;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentProfitCommissionProfile;
  5. use App\Models\Agent\AgentProfitEntry;
  6. use App\Services\Sharing\EffectiveBet;
  7. use Carbon\Carbon;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Log;
  10. use Throwable;
  11. /**
  12. * 合营实时分成:逐笔读取已结算注单(share_bet_facts),按代理当前方案比例计算分成并实时计入 realtime_balance。
  13. *
  14. * - 会员输 100、比例 10%:代理 +10;会员赢 100:代理 -10;
  15. * - 代理分成关闭(实时保证金不足)时注单记为 skipped_inactive,不再分成;
  16. * - 亏损最多扣到实时余额为 0;
  17. * - 注单改派彩或作废:未入账的原地修正,已入账的追加 revision 冲正。
  18. * share_bet_facts 由 share:sync-facts 维护(含 melbet 结算补偿),这里按 updated_at 游标增量读取。
  19. */
  20. class AgentProfitLedgerService
  21. {
  22. private const CURSOR = 'agent_profit_cursor';
  23. public function __construct(
  24. private AgentWalletService $wallets,
  25. private AgentProfitCommissionAssignmentService $assignments
  26. ) {
  27. }
  28. public function sync(): array
  29. {
  30. $startedAt = now()->format('Y-m-d H:i:s');
  31. $cursor = DB::table('share_system_state')->where('key', self::CURSOR)->value('value');
  32. $since = $cursor
  33. ? Carbon::parse(json_decode((string) $cursor, true, 512, JSON_THROW_ON_ERROR)['updated_at'])->subMinutes(5)
  34. : now()->subDays(2);
  35. $result = ['facts' => 0, 'counted' => 0, 'skipped' => 0, 'corrected' => 0, 'unchanged' => 0, 'failed' => 0];
  36. DB::table('share_bet_facts')->where('updated_at', '>=', $since->format('Y-m-d H:i:s'))
  37. ->orderBy('id')->chunkById(500, function ($facts) use (&$result) {
  38. $agentByUser = DB::table('users')->join('agents', 'agents.id', '=', 'users.agent_id')
  39. ->whereIn('users.id', $facts->pluck('user_id')->unique()->all())
  40. ->where('agents.type', Agent::TYPE_JOINT)
  41. ->pluck('agents.id', 'users.id');
  42. foreach ($facts as $fact) {
  43. $agentId = $agentByUser[$fact->user_id] ?? null;
  44. if (!$agentId) continue;
  45. ++$result['facts'];
  46. try {
  47. ++$result[$this->process($fact, (int) $agentId)];
  48. } catch (Throwable $e) {
  49. ++$result['failed'];
  50. Log::error('agent_profit_entry_failed', ['fact_id' => $fact->id, 'agent_id' => $agentId, 'exception' => $e]);
  51. }
  52. }
  53. });
  54. if ($result['failed'] === 0) {
  55. DB::table('share_system_state')->updateOrInsert(['key' => self::CURSOR],
  56. ['value' => json_encode(['updated_at' => $startedAt]), 'updated_at' => $startedAt, 'created_at' => $startedAt]);
  57. }
  58. return $result;
  59. }
  60. /** @return string counted|skipped|corrected|unchanged */
  61. public function process(object $fact, int $agentId): string
  62. {
  63. return DB::transaction(function () use ($fact, $agentId) {
  64. $agent = Agent::query()->lockForUpdate()->find($agentId);
  65. if (!$agent || !$agent->isJoint()) return 'unchanged';
  66. $entries = AgentProfitEntry::query()->where('fact_id', $fact->id)->orderBy('revision')->lockForUpdate()->get();
  67. $live = (int) $fact->settled === 1 && (int) $fact->voided === 0 && !empty($fact->settled_at);
  68. $net = $live ? $this->money($fact->net_profit) : '0.0000';
  69. if ($entries->isEmpty()) {
  70. return $live ? $this->create($agent, $fact, $net) : 'unchanged';
  71. }
  72. return $this->correct($agent, $fact, $entries, $net);
  73. }, 3);
  74. }
  75. private function create(Agent $agent, object $fact, string $net): string
  76. {
  77. $category = (string) $fact->category;
  78. $assignment = $this->assignments->at($agent, (string) $fact->settled_at);
  79. $status = AgentProfitEntry::STATUS_COUNTED;
  80. $rate = '0.0000';
  81. if (!isset(AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS[$category])) {
  82. $status = AgentProfitEntry::STATUS_SKIPPED_CATEGORY;
  83. } elseif (!$assignment || !$agent->profit_commission_profile_id) {
  84. $status = AgentProfitEntry::STATUS_SKIPPED_NO_PLAN;
  85. } elseif (!(int) $agent->profit_active) {
  86. $status = AgentProfitEntry::STATUS_SKIPPED_INACTIVE;
  87. $rate = $assignment->rateForCategory($category);
  88. } else {
  89. $rate = $assignment->rateForCategory($category);
  90. }
  91. $share = $status === AgentProfitEntry::STATUS_COUNTED ? $this->capped($agent, $this->share($net, $rate)) : '0.0000';
  92. if (bccomp($share, '0', 4) !== 0) {
  93. $agent->realtime_balance = bcadd((string) $agent->realtime_balance, $share, 4);
  94. $agent->save();
  95. }
  96. AgentProfitEntry::query()->create([
  97. 'agent_id' => $agent->id, 'fact_id' => $fact->id, 'revision' => 0, 'user_id' => $fact->user_id,
  98. 'category' => $category, 'settled_at' => $fact->settled_at,
  99. 'settle_date' => Carbon::parse($fact->settled_at)->toDateString(),
  100. 'bet_amount' => $this->money($fact->bet_amount), 'effective_amount' => $this->money($fact->effective_amount),
  101. 'net_profit' => $net, 'rate' => $rate, 'share_amount' => $share, 'status' => $status,
  102. 'realtime_balance_after' => $agent->realtime_balance,
  103. ]);
  104. if ($status === AgentProfitEntry::STATUS_COUNTED) {
  105. $this->wallets->evaluateActivation($agent);
  106. return 'counted';
  107. }
  108. return 'skipped';
  109. }
  110. private function correct(Agent $agent, object $fact, $entries, string $net): string
  111. {
  112. $latest = $entries->last();
  113. $first = $entries->first();
  114. if (bccomp((string) $latest->net_profit, $net, 4) === 0) return 'unchanged';
  115. if ($first->status !== AgentProfitEntry::STATUS_COUNTED) {
  116. // 当时未参与分成的注单,改派彩后仍不分成,只更新记录。
  117. $latest->net_profit = $net;
  118. $latest->save();
  119. return 'unchanged';
  120. }
  121. $target = $this->share($net, (string) $first->rate);
  122. $applied = '0.0000';
  123. foreach ($entries as $entry) $applied = bcadd($applied, (string) $entry->share_amount, 4);
  124. $delta = bccomp($target, $applied, 4) < 0 ? $this->capped($agent, bcsub($target, $applied, 4)) : bcsub($target, $applied, 4);
  125. $agent->realtime_balance = bcadd((string) $agent->realtime_balance, $delta, 4);
  126. $agent->save();
  127. if ($latest->rebate_record_id === null) {
  128. $latest->net_profit = $net;
  129. $latest->share_amount = bcadd((string) $latest->share_amount, $delta, 4);
  130. $latest->realtime_balance_after = $agent->realtime_balance;
  131. $latest->save();
  132. } else {
  133. $now = now();
  134. AgentProfitEntry::query()->create([
  135. 'agent_id' => $agent->id, 'fact_id' => $fact->id, 'revision' => (int) $latest->revision + 1,
  136. 'user_id' => $fact->user_id, 'category' => $first->category,
  137. 'settled_at' => $now->format('Y-m-d H:i:s'), 'settle_date' => $now->toDateString(),
  138. 'bet_amount' => 0, 'effective_amount' => 0, 'net_profit' => $net, 'rate' => $first->rate,
  139. 'share_amount' => $delta, 'status' => AgentProfitEntry::STATUS_COUNTED,
  140. 'realtime_balance_after' => $agent->realtime_balance,
  141. ]);
  142. }
  143. $this->wallets->evaluateActivation($agent);
  144. return 'corrected';
  145. }
  146. /** 代理视角的分成:会员盈亏取反 × 比例。 */
  147. public function share(string $net, string $rate): string
  148. {
  149. return bcdiv(bcmul(bcsub('0', $net, 4), $rate, 8), '100', 4);
  150. }
  151. /** 亏损最多扣到实时余额为 0。 */
  152. private function capped(Agent $agent, string $share): string
  153. {
  154. if (bccomp($share, '0', 4) >= 0) return $share;
  155. $available = bcadd((string) $agent->realtime_balance, '0', 4);
  156. if (bccomp($available, '0', 4) <= 0) return '0.0000';
  157. return bccomp(bcadd($available, $share, 4), '0', 4) < 0 ? bcsub('0', $available, 4) : $share;
  158. }
  159. private function money($value): string
  160. {
  161. return bcadd(EffectiveBet::decimal($value ?? '0'), '0', 4);
  162. }
  163. }