ShareSettlementService.php 20 KB

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