ShareSettlementService.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  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,bool $withDiagnostics=false): 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. $initialized=DB::table('share_system_state')->where('key','initialized_at')->exists();
  15. $depthRates = self::usesDepthRates($date);
  16. $diagnostics=$withDiagnostics ? $this->diagnosticContext($date,$policy,$activation,$initialized,$depthRates) : null;
  17. if ($withDiagnostics) $empty['diagnostics']=$diagnostics;
  18. if (!$activation || $date<$activation || !$initialized) return $empty;
  19. if (!$policy || !$policy->benefit_enabled || $policy->valid_member_min_effective_bet === null
  20. || (!$depthRates && $policy->indirect_bonus_rate === null)) return $empty;
  21. $end = $date . ' 23:59:59';
  22. $users = DB::table('users')->where('from','<>',2)->where(function ($q) use ($end) { $q->where('created_at','<=',$end)->orWhereNull('created_at'); })
  23. ->get(['id','member_id'])->keyBy('id');
  24. $deposits = DB::table('balance_logs')->where('type',1)->where('amount','>',0)
  25. ->whereIn('change_type',['充值','三方充值'])->where('created_at','<=',$end)
  26. ->select('member_id')->selectRaw('SUM(amount) AS total')->groupBy('member_id')->pluck('total','member_id');
  27. $currency=(string)config('third_game.currency','CNY');
  28. $effective = DB::table('share_bet_facts')->where('settled',1)->where('voided',0)->where('currency',$currency)->where('bet_date','<=',$date)
  29. ->select('user_id')->selectRaw('SUM(effective_amount) AS total')->groupBy('user_id')->pluck('total','user_id');
  30. $qualified = [];
  31. foreach ($users as $id=>$user) if (bccomp((string)($deposits[$user->member_id] ?? 0),(string)$policy->valid_member_min_deposit,10)>=0
  32. && bccomp((string)($effective[$id] ?? 0),(string)$policy->valid_member_min_effective_bet,10)>=0) $qualified[(int)$id]=true;
  33. $counts = [];
  34. foreach (DB::table('share_tree')->where('depth',1)->where('bound_at','<=',$end)->cursor() as $edge) {
  35. 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;
  36. }
  37. $facts = DB::table('share_bet_facts')->where('bet_date',$date)->where('settled',1)->where('voided',0)->where('currency',$currency)->where('effective_amount','>',0)->get();
  38. $edges = [];
  39. foreach (array_chunk($facts->pluck('user_id')->unique()->all(),500) as $ids) {
  40. foreach (DB::table('share_tree')->whereIn('descendant_user_id',$ids)->where('depth','>',0)->get() as $edge) $edges[(int)$edge->descendant_user_id][]=$edge;
  41. }
  42. $rows = [];
  43. foreach ($facts as $fact) {
  44. $bettor=(int)$fact->user_id;
  45. if (!isset($qualified[$bettor])) {
  46. if ($withDiagnostics) {
  47. if (!$users->has($bettor)) ++$diagnostics['excluded']['bettor_unavailable'];
  48. else {
  49. if (bccomp((string)($deposits[$users[$bettor]->member_id] ?? 0),(string)$policy->valid_member_min_deposit,10)<0) ++$diagnostics['excluded']['deposit_below_threshold'];
  50. if (bccomp((string)($effective[$bettor] ?? 0),(string)$policy->valid_member_min_effective_bet,10)<0) ++$diagnostics['excluded']['effective_bet_below_threshold'];
  51. }
  52. }
  53. continue;
  54. }
  55. if (!array_key_exists($fact->category,ShareDefaults::categories())) {
  56. if ($withDiagnostics) ++$diagnostics['excluded']['unsupported_category'];
  57. continue;
  58. }
  59. $ancestors=$edges[$bettor] ?? [];
  60. if ($withDiagnostics && !$ancestors) ++$diagnostics['excluded']['no_ancestor_relationship'];
  61. foreach ($ancestors as $edge) {
  62. $receiver=(int)$edge->ancestor_user_id;
  63. if (!$users->has($receiver)) {
  64. if ($withDiagnostics) ++$diagnostics['excluded']['receiver_unavailable'];
  65. continue;
  66. }
  67. if ($edge->bound_at > $fact->bet_at) {
  68. if ($withDiagnostics) ++$diagnostics['excluded']['relationship_bound_after_bet'];
  69. continue;
  70. }
  71. if (!isset($rows[$receiver][$fact->category])) $rows[$receiver][$fact->category]=['direct_performance'=>'0','indirect_performance'=>'0','performance_by_depth'=>[]];
  72. $field=(int)$edge->depth===1 ? 'direct_performance' : 'indirect_performance';
  73. $rows[$receiver][$fact->category][$field]=bcadd($rows[$receiver][$fact->category][$field],(string)$fact->effective_amount,10);
  74. $depth=(int)$edge->depth;
  75. $byDepth=&$rows[$receiver][$fact->category]['performance_by_depth'];
  76. $byDepth[$depth]=bcadd($byDepth[$depth] ?? '0',(string)$fact->effective_amount,10);
  77. unset($byDepth);
  78. }
  79. }
  80. $rules=is_string($policy->rules) ? json_decode($policy->rules,true,512,JSON_THROW_ON_ERROR) : (array)$policy->rules;
  81. foreach ($rows as $id=>&$categories) foreach ($categories as $category=>&$row) {
  82. $row['valid_direct_members']=$counts[$id] ?? 0;
  83. $row['rate']=self::rate($rules[$category] ?? [],$row['valid_direct_members'],$row['direct_performance']);
  84. // Keep the archived coefficient only for replay of days before the formula cutover.
  85. $row['indirect_bonus_rate']=$depthRates ? '0' : (string)$policy->indirect_bonus_rate;
  86. $row['direct_commission']=self::percentage($row['direct_performance'],$row['rate']);
  87. if ($depthRates) {
  88. $indirect='0';
  89. foreach ($row['performance_by_depth'] as $depth=>$performance) {
  90. if ($depth<2) continue;
  91. $indirect=bcadd($indirect,self::depthCommission($performance,$row['rate'],$depth),30);
  92. }
  93. $row['indirect_commission']=bcadd($indirect,'0',10);
  94. } else {
  95. $row['indirect_commission']=self::percentage(self::percentage($row['indirect_performance'],$row['rate']),$row['indirect_bonus_rate']);
  96. }
  97. unset($row['performance_by_depth']);
  98. }
  99. unset($categories,$row);
  100. $result=['date'=>$date,'policy'=>$policy,'rows'=>$rows];
  101. if ($withDiagnostics) $result['diagnostics']=$diagnostics;
  102. return $result;
  103. }
  104. /** Read-only explanation from the same projection and filters used by real settlement. */
  105. public function diagnose(string $date): array
  106. {
  107. $projection=$this->projection($date,true);
  108. $report=['date'=>$date,'timezone'=>ShareDefaults::TIMEZONE,'written'=>false]+$projection['diagnostics'];
  109. $report['receivers']=count($projection['rows']);
  110. $report['positive_receivers']=0;
  111. $report['projected_commission']='0.0000000000';
  112. foreach ($projection['rows'] as $categories) {
  113. $total='0.0000000000';
  114. foreach ($categories as $row) $total=bcadd($total,bcadd($row['direct_commission'],$row['indirect_commission'],10),10);
  115. if (bccomp($total,'0',10)>0) ++$report['positive_receivers'];
  116. $report['projected_commission']=bcadd($report['projected_commission'],$total,10);
  117. }
  118. return $report;
  119. }
  120. private function diagnosticContext(string $date,?object $policy,?string $activation,bool $initialized,bool $depthRates): array
  121. {
  122. $blockers=[];
  123. if (!$activation) $blockers[]=['code'=>'activation_not_configured','message'=>'尚未配置分享返佣首次生效日期'];
  124. elseif ($date<$activation) $blockers[]=['code'=>'before_activation_date','message'=>'目标日期早于首次生效日期;allow-today不会绕过配置生效日'];
  125. if (!$initialized) $blockers[]=['code'=>'relations_not_initialized','message'=>'分享关系尚未初始化'];
  126. if (!$policy) $blockers[]=['code'=>'no_effective_policy','message'=>'目标日期没有已生效的返佣配置版本'];
  127. else {
  128. if (!$policy->benefit_enabled) $blockers[]=['code'=>'benefit_disabled','message'=>'目标日期的计佣配置未启用'];
  129. if ($policy->valid_member_min_effective_bet===null) $blockers[]=['code'=>'missing_effective_bet_threshold','message'=>'目标日期未配置有效投注门槛'];
  130. if (!$depthRates && $policy->indirect_bonus_rate===null) $blockers[]=['code'=>'legacy_policy_incomplete','message'=>'历史计佣版本缺少当时的间接佣金参数'];
  131. }
  132. $currency=(string)config('third_game.currency','CNY');
  133. $stats=DB::table('share_bet_facts')->where('bet_date',$date)->select('source')->selectRaw(
  134. 'COUNT(*) AS total, SUM(CASE WHEN settled = 0 THEN 1 ELSE 0 END) AS unsettled,
  135. SUM(CASE WHEN voided = 1 THEN 1 ELSE 0 END) AS voided,
  136. SUM(CASE WHEN currency IS NULL OR currency <> ? THEN 1 ELSE 0 END) AS currency_mismatch,
  137. SUM(CASE WHEN effective_amount <= 0 THEN 1 ELSE 0 END) AS zero_effective,
  138. SUM(CASE WHEN settled = 1 AND voided = 0 AND currency = ? AND effective_amount > 0 THEN 1 ELSE 0 END) AS candidates',
  139. [$currency,$currency]
  140. )->groupBy('source')->get();
  141. $counts=array_fill_keys(['total','unsettled','voided','currency_mismatch','zero_effective','candidates'],0);
  142. $sources=[];
  143. foreach ($stats as $row) {
  144. $values=[];
  145. foreach ($counts as $field=>$_) { $values[$field]=(int)$row->$field; $counts[$field]+=$values[$field]; }
  146. $sources[$row->source]=$values;
  147. }
  148. return [
  149. 'activation_date'=>$activation,'initialized'=>$initialized,'currency'=>$currency,
  150. 'policy'=>$policy ? ['id'=>(int)$policy->id,'effective_date'=>$policy->effective_date,
  151. 'benefit_enabled'=>(int)$policy->benefit_enabled,'valid_member_min_deposit'=>$policy->valid_member_min_deposit,
  152. 'valid_member_min_effective_bet'=>$policy->valid_member_min_effective_bet] : null,
  153. 'next_policy_effective_date'=>DB::table('share_policies')->where('effective_date','>',$date)->orderBy('effective_date')->value('effective_date'),
  154. 'blockers'=>$blockers,'facts'=>$counts,'facts_by_source'=>$sources,
  155. 'excluded'=>array_fill_keys(['bettor_unavailable','deposit_below_threshold','effective_bet_below_threshold',
  156. 'unsupported_category','no_ancestor_relationship','receiver_unavailable','relationship_bound_after_bet'],0),
  157. ];
  158. }
  159. public static function rate(array $rules,int $members,string $performance): string
  160. {
  161. $rate='0'; $bestPerformance='-1'; $bestPeople=-1;
  162. foreach ($rules as $rule) {
  163. if ($members < (int)$rule['min_valid_members'] || bccomp($performance,(string)$rule['min_performance'],10)<0) continue;
  164. $comparison=bccomp((string)$rule['min_performance'],$bestPerformance,10);
  165. if ($comparison>0 || ($comparison===0 && (int)$rule['min_valid_members']>$bestPeople)) {
  166. $bestPerformance=(string)$rule['min_performance']; $bestPeople=(int)$rule['min_valid_members']; $rate=(string)$rule['rate'];
  167. }
  168. }
  169. return $rate;
  170. }
  171. public static function percentage(string $amount,string $rate): string
  172. {
  173. return bcdiv(bcmul($amount,$rate,18),'100',10);
  174. }
  175. public static function usesDepthRates(string $date): bool
  176. {
  177. $start=DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value') ?? '1970-01-01';
  178. return $date >= $start;
  179. }
  180. /** Keep sub-ledger precision until all depth buckets have been added together. */
  181. public static function depthCommission(string $amount,string $rate,int $depth): string
  182. {
  183. if ($depth<1) throw new \InvalidArgumentException('佣金层级必须为正整数');
  184. if (bccomp($rate,'0',4)===0) return bcadd('0','0',30);
  185. if (bccomp($rate,'100',4)===0) return bcadd($amount,'0',30);
  186. $result=$amount;
  187. for ($level=0;$level<$depth;$level++) {
  188. $result=bcdiv(bcmul($result,$rate,40),'100',30);
  189. if (bccomp($result,'0',30)===0) break;
  190. }
  191. return $result;
  192. }
  193. public function settle(string $date,bool $write=true,bool $allowToday=false): array
  194. {
  195. $this->validateDate($date);
  196. $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
  197. if ($date>$today) throw new \InvalidArgumentException('未来日期佣金不能结算到账本');
  198. if ($date===$today && !$allowToday) throw new \InvalidArgumentException('当天佣金只能预估,调试结算请显式使用--allow-today');
  199. $activation=DB::table('share_settings')->value('activation_date');
  200. $result=['date'=>$date,'receivers'=>0,'changed'=>0,'delta'=>'0.0000000000','written'=>$write];
  201. if (!$activation || $date<$activation) return $result;
  202. if (!DB::table('share_system_state')->where('key','initialized_at')->exists()) throw new \RuntimeException('分享关系尚未初始化');
  203. // Facts, membership closure and policy must not change in the middle of this projection.
  204. return DB::transaction(function () use ($date,$write,$result,$today) {
  205. DB::table('share_system_state')->where('key','relations_lock')->lockForUpdate()->first();
  206. $projection=$this->projection($date); $rows=$projection['rows']; $policy=$projection['policy'];
  207. $depthRates=self::usesDepthRates($date);
  208. $previous=DB::table('share_settlements')->where('bet_date',$date)->get();
  209. foreach ($previous as $old) if (!isset($rows[$old->user_id][$old->category])) {
  210. $rows[$old->user_id][$old->category]=['direct_performance'=>'0','indirect_performance'=>'0','valid_direct_members'=>0,
  211. 'rate'=>'0','indirect_bonus_rate'=>$depthRates ? '0' : (string)($policy->indirect_bonus_rate ?? 0),'direct_commission'=>'0','indirect_commission'=>'0'];
  212. }
  213. $result['receivers']=count($rows);
  214. $now=Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s');
  215. ksort($rows);
  216. foreach ($rows as $userId=>$categories) {
  217. if ($write) {
  218. DB::table('share_accounts')->insertOrIgnore(['user_id'=>$userId,'created_at'=>$now,'updated_at'=>$now]);
  219. $account=DB::table('share_accounts')->where('user_id',$userId)->lockForUpdate()->first();
  220. } else $account=DB::table('share_accounts')->where('user_id',$userId)->first();
  221. foreach ($categories as $category=>$target) {
  222. $key=['user_id'=>$userId,'bet_date'=>$date,'category'=>$category];
  223. $target['policy_id']=$policy->id ?? null;
  224. $hash=hash('sha256',json_encode($target,JSON_PRESERVE_ZERO_FRACTION|JSON_THROW_ON_ERROR));
  225. $query=DB::table('share_settlements')->where($key);
  226. $old=$write ? $query->lockForUpdate()->first() : $query->first();
  227. if ($old && $old->input_hash===$hash) continue;
  228. $directDelta=bcsub($target['direct_commission'],(string)($old->direct_commission ?? 0),10);
  229. $indirectDelta=bcsub($target['indirect_commission'],(string)($old->indirect_commission ?? 0),10);
  230. $delta=bcadd($directDelta,$indirectDelta,10);
  231. $result['changed']++; $result['delta']=bcadd($result['delta'],$delta,10);
  232. if (!$write) continue;
  233. $revision=(int)($old->revision ?? 0)+1;
  234. $values=$target+['revision'=>$revision,'input_hash'=>$hash,'updated_at'=>$now];
  235. if ($old) { DB::table('share_settlements')->where('id',$old->id)->update($values); $settlementId=$old->id; }
  236. else $settlementId=DB::table('share_settlements')->insertGetId($key+$values+['created_at'=>$now]);
  237. foreach (['direct'=>$directDelta,'indirect'=>$indirectDelta] as $type=>$amount) {
  238. if (bccomp($amount,'0',10)===0) continue;
  239. DB::table('share_ledger')->insert(['user_id'=>$userId,'settlement_id'=>$settlementId,'revision'=>$revision,
  240. 'type'=>$type,'amount'=>$amount,'bet_date'=>$date,'category'=>$category,'created_at'=>$now,'updated_at'=>$now]);
  241. }
  242. $account->earned_amount=bcadd((string)$account->earned_amount,$delta,10);
  243. DB::table('share_accounts')->where('user_id',$userId)->update(['earned_amount'=>$account->earned_amount,'updated_at'=>$now]);
  244. }
  245. if ($write && $account) $this->upgrade((int)$userId,(string)$account->earned_amount,$now);
  246. }
  247. if ($write && $date<$today) {
  248. $last=DB::table('share_system_state')->where('key','last_settlement_date')->value('value');
  249. if (!$last || $date>$last) DB::table('share_system_state')->updateOrInsert(['key'=>'last_settlement_date'],['value'=>$date,'created_at'=>$now,'updated_at'=>$now]);
  250. }
  251. // A debug credit is still an open day: keep it pending for normal reconciliation after midnight.
  252. if ($write && $date===$today) $this->requeue([$date]);
  253. return $result;
  254. });
  255. }
  256. private function upgrade(int $userId,string $earned,string $now): void
  257. {
  258. $threshold='0'; $eligible=1;
  259. foreach (DB::table('share_levels')->orderBy('level')->get() as $level) {
  260. $threshold=bcadd($threshold,(string)$level->incremental_commission,10);
  261. if (bccomp($earned,$threshold,10)>=0) $eligible=(int)$level->level;
  262. }
  263. DB::table('share_members')->where('user_id',$userId)->where('level','<',$eligible)->update(['level'=>$eligible,'updated_at'=>$now]);
  264. }
  265. public function reconcile(): array
  266. {
  267. $dirty=DB::transaction(function () {
  268. $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first();
  269. if (!$row) return [];
  270. $dates=json_decode($row->value,true,512,JSON_THROW_ON_ERROR);
  271. DB::table('share_system_state')->where('key','facts_dirty_dates')->update(['value'=>'[]']);
  272. return $dates;
  273. });
  274. if (!$dirty) return ['dates'=>0,'changed'=>0];
  275. try {
  276. $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
  277. $open=array_values(array_filter($dirty,fn($date)=>$date >= $today));
  278. // Keep open betting days until a later run can close them, even if the midnight cron is missed.
  279. $this->requeue($open);
  280. $dirty=array_values(array_filter($dirty,fn($date)=>$date < $today));
  281. if (!$dirty) return ['dates'=>0,'changed'=>0];
  282. $activation=DB::table('share_settings')->value('activation_date');
  283. if (!$activation) return ['dates'=>0,'changed'=>0];
  284. $start=max(min($dirty),(string)$activation);
  285. $dates=DB::table('share_bet_facts')->where('bet_date','>=',$start)->where('bet_date','<',$today)->distinct()->pluck('bet_date')->all();
  286. $old=DB::table('share_settlements')->where('bet_date','>=',$start)->where('bet_date','<',$today)->distinct()->pluck('bet_date')->all();
  287. $dates=array_unique(array_merge($dates,$old)); sort($dates); $changed=0;
  288. foreach ($dates as $date) $changed+=$this->settle((string)$date)['changed'];
  289. return ['dates'=>count($dates),'changed'=>$changed];
  290. } catch (\Throwable $e) {
  291. $this->requeue($dirty);
  292. throw $e;
  293. }
  294. }
  295. private function requeue(array $dates): void
  296. {
  297. if (!$dates) return;
  298. DB::transaction(function () use ($dates) {
  299. $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first();
  300. $pending=$row ? json_decode($row->value,true,512,JSON_THROW_ON_ERROR) : [];
  301. DB::table('share_system_state')->updateOrInsert(['key'=>'facts_dirty_dates'],['value'=>json_encode(array_values(array_unique(array_merge($pending,$dates))))]);
  302. });
  303. }
  304. private function validateDate(string $date): void
  305. {
  306. $value=Carbon::createFromFormat('!Y-m-d',$date,ShareDefaults::TIMEZONE);
  307. if (!$value || $value->toDateString()!==$date) throw new \InvalidArgumentException('日期格式必须为Y-m-d');
  308. }
  309. }