ShareSettlementService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. <?php
  2. namespace App\Services\Sharing;
  3. use Carbon\Carbon;
  4. use Illuminate\Support\Facades\DB;
  5. class ShareSettlementService
  6. {
  7. /** A deterministic original-bet-day projection. Reading it never creates a payable balance. */
  8. public function projection(string $date): array
  9. {
  10. $this->validateDate($date);
  11. $policy = DB::table('share_policies')->where('effective_date','<=',$date)->orderByDesc('effective_date')->first();
  12. $empty = ['date'=>$date,'policy'=>$policy,'rows'=>[]];
  13. $activation=DB::table('share_settings')->value('activation_date');
  14. if (!$activation || $date<$activation || !DB::table('share_system_state')->where('key','initialized_at')->exists()) return $empty;
  15. $depthRates = self::usesDepthRates($date);
  16. if (!$policy || !$policy->benefit_enabled || $policy->valid_member_min_effective_bet === null
  17. || (!$depthRates && $policy->indirect_bonus_rate === null)) return $empty;
  18. $end = $date . ' 23:59:59';
  19. $users = DB::table('users')->where('from','<>',2)->where(function ($q) use ($end) { $q->where('created_at','<=',$end)->orWhereNull('created_at'); })
  20. ->get(['id','member_id'])->keyBy('id');
  21. $deposits = DB::table('balance_logs')->where('type',1)->where('amount','>',0)
  22. ->whereIn('change_type',['充值','三方充值'])->where('created_at','<=',$end)
  23. ->select('member_id')->selectRaw('SUM(amount) AS total')->groupBy('member_id')->pluck('total','member_id');
  24. $currency=(string)config('third_game.currency','CNY');
  25. $effective = DB::table('share_bet_facts')->where('settled',1)->where('voided',0)->where('currency',$currency)->where('bet_date','<=',$date)
  26. ->select('user_id')->selectRaw('SUM(effective_amount) AS total')->groupBy('user_id')->pluck('total','user_id');
  27. $qualified = [];
  28. foreach ($users as $id=>$user) if (bccomp((string)($deposits[$user->member_id] ?? 0),(string)$policy->valid_member_min_deposit,10)>=0
  29. && bccomp((string)($effective[$id] ?? 0),(string)$policy->valid_member_min_effective_bet,10)>=0) $qualified[(int)$id]=true;
  30. $counts = [];
  31. foreach (DB::table('share_tree')->where('depth',1)->where('bound_at','<=',$end)->cursor() as $edge) {
  32. if (isset($qualified[(int)$edge->descendant_user_id]) && $users->has($edge->ancestor_user_id)) $counts[(int)$edge->ancestor_user_id]=($counts[(int)$edge->ancestor_user_id] ?? 0)+1;
  33. }
  34. $facts = DB::table('share_bet_facts')->where('bet_date',$date)->where('settled',1)->where('voided',0)->where('currency',$currency)->where('effective_amount','>',0)->get();
  35. $edges = [];
  36. foreach (array_chunk($facts->pluck('user_id')->unique()->all(),500) as $ids) {
  37. foreach (DB::table('share_tree')->whereIn('descendant_user_id',$ids)->where('depth','>',0)->get() as $edge) $edges[(int)$edge->descendant_user_id][]=$edge;
  38. }
  39. $rows = [];
  40. foreach ($facts as $fact) {
  41. if (!isset($qualified[(int)$fact->user_id]) || !array_key_exists($fact->category,ShareDefaults::categories())) continue;
  42. foreach ($edges[(int)$fact->user_id] ?? [] as $edge) {
  43. $receiver=(int)$edge->ancestor_user_id;
  44. if (!$users->has($receiver) || $edge->bound_at > $fact->bet_at) continue;
  45. if (!isset($rows[$receiver][$fact->category])) $rows[$receiver][$fact->category]=['direct_performance'=>'0','indirect_performance'=>'0','performance_by_depth'=>[]];
  46. $field=(int)$edge->depth===1 ? 'direct_performance' : 'indirect_performance';
  47. $rows[$receiver][$fact->category][$field]=bcadd($rows[$receiver][$fact->category][$field],(string)$fact->effective_amount,10);
  48. $depth=(int)$edge->depth;
  49. $byDepth=&$rows[$receiver][$fact->category]['performance_by_depth'];
  50. $byDepth[$depth]=bcadd($byDepth[$depth] ?? '0',(string)$fact->effective_amount,10);
  51. unset($byDepth);
  52. }
  53. }
  54. $rules=is_string($policy->rules) ? json_decode($policy->rules,true,512,JSON_THROW_ON_ERROR) : (array)$policy->rules;
  55. foreach ($rows as $id=>&$categories) foreach ($categories as $category=>&$row) {
  56. $row['valid_direct_members']=$counts[$id] ?? 0;
  57. $row['rate']=self::rate($rules[$category] ?? [],$row['valid_direct_members'],$row['direct_performance']);
  58. // Keep the archived coefficient only for replay of days before the formula cutover.
  59. $row['indirect_bonus_rate']=$depthRates ? '0' : (string)$policy->indirect_bonus_rate;
  60. $row['direct_commission']=self::percentage($row['direct_performance'],$row['rate']);
  61. if ($depthRates) {
  62. $indirect='0';
  63. foreach ($row['performance_by_depth'] as $depth=>$performance) {
  64. if ($depth<2) continue;
  65. $indirect=bcadd($indirect,self::depthCommission($performance,$row['rate'],$depth),30);
  66. }
  67. $row['indirect_commission']=bcadd($indirect,'0',10);
  68. } else {
  69. $row['indirect_commission']=self::percentage(self::percentage($row['indirect_performance'],$row['rate']),$row['indirect_bonus_rate']);
  70. }
  71. unset($row['performance_by_depth']);
  72. }
  73. unset($categories,$row);
  74. return ['date'=>$date,'policy'=>$policy,'rows'=>$rows];
  75. }
  76. public static function rate(array $rules,int $members,string $performance): string
  77. {
  78. $rate='0'; $bestPerformance='-1'; $bestPeople=-1;
  79. foreach ($rules as $rule) {
  80. if ($members < (int)$rule['min_valid_members'] || bccomp($performance,(string)$rule['min_performance'],10)<0) continue;
  81. $comparison=bccomp((string)$rule['min_performance'],$bestPerformance,10);
  82. if ($comparison>0 || ($comparison===0 && (int)$rule['min_valid_members']>$bestPeople)) {
  83. $bestPerformance=(string)$rule['min_performance']; $bestPeople=(int)$rule['min_valid_members']; $rate=(string)$rule['rate'];
  84. }
  85. }
  86. return $rate;
  87. }
  88. public static function percentage(string $amount,string $rate): string
  89. {
  90. return bcdiv(bcmul($amount,$rate,18),'100',10);
  91. }
  92. public static function usesDepthRates(string $date): bool
  93. {
  94. $start=DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value') ?? '1970-01-01';
  95. return $date >= $start;
  96. }
  97. /** Keep sub-ledger precision until all depth buckets have been added together. */
  98. public static function depthCommission(string $amount,string $rate,int $depth): string
  99. {
  100. if ($depth<1) throw new \InvalidArgumentException('佣金层级必须为正整数');
  101. if (bccomp($rate,'0',4)===0) return bcadd('0','0',30);
  102. if (bccomp($rate,'100',4)===0) return bcadd($amount,'0',30);
  103. $result=$amount;
  104. for ($level=0;$level<$depth;$level++) {
  105. $result=bcdiv(bcmul($result,$rate,40),'100',30);
  106. if (bccomp($result,'0',30)===0) break;
  107. }
  108. return $result;
  109. }
  110. public function settle(string $date,bool $write=true,bool $allowToday=false): array
  111. {
  112. $this->validateDate($date);
  113. $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
  114. if ($date>$today) throw new \InvalidArgumentException('未来日期佣金不能结算到账本');
  115. if ($date===$today && !$allowToday) throw new \InvalidArgumentException('当天佣金只能预估,调试结算请显式使用--allow-today');
  116. $activation=DB::table('share_settings')->value('activation_date');
  117. $result=['date'=>$date,'receivers'=>0,'changed'=>0,'delta'=>'0.0000000000','written'=>$write];
  118. if (!$activation || $date<$activation) return $result;
  119. if (!DB::table('share_system_state')->where('key','initialized_at')->exists()) throw new \RuntimeException('分享关系尚未初始化');
  120. // Facts, membership closure and policy must not change in the middle of this projection.
  121. return DB::transaction(function () use ($date,$write,$result,$today) {
  122. DB::table('share_system_state')->where('key','relations_lock')->lockForUpdate()->first();
  123. $projection=$this->projection($date); $rows=$projection['rows']; $policy=$projection['policy'];
  124. $depthRates=self::usesDepthRates($date);
  125. $previous=DB::table('share_settlements')->where('bet_date',$date)->get();
  126. foreach ($previous as $old) if (!isset($rows[$old->user_id][$old->category])) {
  127. $rows[$old->user_id][$old->category]=['direct_performance'=>'0','indirect_performance'=>'0','valid_direct_members'=>0,
  128. 'rate'=>'0','indirect_bonus_rate'=>$depthRates ? '0' : (string)($policy->indirect_bonus_rate ?? 0),'direct_commission'=>'0','indirect_commission'=>'0'];
  129. }
  130. $result['receivers']=count($rows);
  131. $now=Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s');
  132. ksort($rows);
  133. foreach ($rows as $userId=>$categories) {
  134. if ($write) {
  135. DB::table('share_accounts')->insertOrIgnore(['user_id'=>$userId,'created_at'=>$now,'updated_at'=>$now]);
  136. $account=DB::table('share_accounts')->where('user_id',$userId)->lockForUpdate()->first();
  137. } else $account=DB::table('share_accounts')->where('user_id',$userId)->first();
  138. foreach ($categories as $category=>$target) {
  139. $key=['user_id'=>$userId,'bet_date'=>$date,'category'=>$category];
  140. $target['policy_id']=$policy->id ?? null;
  141. $hash=hash('sha256',json_encode($target,JSON_PRESERVE_ZERO_FRACTION|JSON_THROW_ON_ERROR));
  142. $query=DB::table('share_settlements')->where($key);
  143. $old=$write ? $query->lockForUpdate()->first() : $query->first();
  144. if ($old && $old->input_hash===$hash) continue;
  145. $directDelta=bcsub($target['direct_commission'],(string)($old->direct_commission ?? 0),10);
  146. $indirectDelta=bcsub($target['indirect_commission'],(string)($old->indirect_commission ?? 0),10);
  147. $delta=bcadd($directDelta,$indirectDelta,10);
  148. $result['changed']++; $result['delta']=bcadd($result['delta'],$delta,10);
  149. if (!$write) continue;
  150. $revision=(int)($old->revision ?? 0)+1;
  151. $values=$target+['revision'=>$revision,'input_hash'=>$hash,'updated_at'=>$now];
  152. if ($old) { DB::table('share_settlements')->where('id',$old->id)->update($values); $settlementId=$old->id; }
  153. else $settlementId=DB::table('share_settlements')->insertGetId($key+$values+['created_at'=>$now]);
  154. foreach (['direct'=>$directDelta,'indirect'=>$indirectDelta] as $type=>$amount) {
  155. if (bccomp($amount,'0',10)===0) continue;
  156. DB::table('share_ledger')->insert(['user_id'=>$userId,'settlement_id'=>$settlementId,'revision'=>$revision,
  157. 'type'=>$type,'amount'=>$amount,'bet_date'=>$date,'category'=>$category,'created_at'=>$now,'updated_at'=>$now]);
  158. }
  159. $account->earned_amount=bcadd((string)$account->earned_amount,$delta,10);
  160. DB::table('share_accounts')->where('user_id',$userId)->update(['earned_amount'=>$account->earned_amount,'updated_at'=>$now]);
  161. }
  162. if ($write && $account) $this->upgrade((int)$userId,(string)$account->earned_amount,$now);
  163. }
  164. if ($write && $date<$today) {
  165. $last=DB::table('share_system_state')->where('key','last_settlement_date')->value('value');
  166. if (!$last || $date>$last) DB::table('share_system_state')->updateOrInsert(['key'=>'last_settlement_date'],['value'=>$date,'created_at'=>$now,'updated_at'=>$now]);
  167. }
  168. // A debug credit is still an open day: keep it pending for normal reconciliation after midnight.
  169. if ($write && $date===$today) $this->requeue([$date]);
  170. return $result;
  171. });
  172. }
  173. private function upgrade(int $userId,string $earned,string $now): void
  174. {
  175. $threshold='0'; $eligible=1;
  176. foreach (DB::table('share_levels')->orderBy('level')->get() as $level) {
  177. $threshold=bcadd($threshold,(string)$level->incremental_commission,10);
  178. if (bccomp($earned,$threshold,10)>=0) $eligible=(int)$level->level;
  179. }
  180. DB::table('share_members')->where('user_id',$userId)->where('level','<',$eligible)->update(['level'=>$eligible,'updated_at'=>$now]);
  181. }
  182. public function reconcile(): array
  183. {
  184. $dirty=DB::transaction(function () {
  185. $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first();
  186. if (!$row) return [];
  187. $dates=json_decode($row->value,true,512,JSON_THROW_ON_ERROR);
  188. DB::table('share_system_state')->where('key','facts_dirty_dates')->update(['value'=>'[]']);
  189. return $dates;
  190. });
  191. if (!$dirty) return ['dates'=>0,'changed'=>0];
  192. try {
  193. $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
  194. $open=array_values(array_filter($dirty,fn($date)=>$date >= $today));
  195. // Keep open betting days until a later run can close them, even if the midnight cron is missed.
  196. $this->requeue($open);
  197. $dirty=array_values(array_filter($dirty,fn($date)=>$date < $today));
  198. if (!$dirty) return ['dates'=>0,'changed'=>0];
  199. $activation=DB::table('share_settings')->value('activation_date');
  200. if (!$activation) return ['dates'=>0,'changed'=>0];
  201. $start=max(min($dirty),(string)$activation);
  202. $dates=DB::table('share_bet_facts')->where('bet_date','>=',$start)->where('bet_date','<',$today)->distinct()->pluck('bet_date')->all();
  203. $old=DB::table('share_settlements')->where('bet_date','>=',$start)->where('bet_date','<',$today)->distinct()->pluck('bet_date')->all();
  204. $dates=array_unique(array_merge($dates,$old)); sort($dates); $changed=0;
  205. foreach ($dates as $date) $changed+=$this->settle((string)$date)['changed'];
  206. return ['dates'=>count($dates),'changed'=>$changed];
  207. } catch (\Throwable $e) {
  208. $this->requeue($dirty);
  209. throw $e;
  210. }
  211. }
  212. private function requeue(array $dates): void
  213. {
  214. if (!$dates) return;
  215. DB::transaction(function () use ($dates) {
  216. $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first();
  217. $pending=$row ? json_decode($row->value,true,512,JSON_THROW_ON_ERROR) : [];
  218. DB::table('share_system_state')->updateOrInsert(['key'=>'facts_dirty_dates'],['value'=>json_encode(array_values(array_unique(array_merge($pending,$dates))))]);
  219. });
  220. }
  221. private function validateDate(string $date): void
  222. {
  223. $value=Carbon::createFromFormat('!Y-m-d',$date,ShareDefaults::TIMEZONE);
  224. if (!$value || $value->toDateString()!==$date) throw new \InvalidArgumentException('日期格式必须为Y-m-d');
  225. }
  226. }