| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- <?php
- namespace App\Services\Sharing;
- use Carbon\Carbon;
- use Illuminate\Support\Facades\DB;
- class ShareSettlementService
- {
- /** A deterministic original-bet-day projection. Reading it never creates a payable balance. */
- public function projection(string $date): array
- {
- $this->validateDate($date);
- $policy = DB::table('share_policies')->where('effective_date','<=',$date)->orderByDesc('effective_date')->first();
- $empty = ['date'=>$date,'policy'=>$policy,'rows'=>[]];
- $activation=DB::table('share_settings')->value('activation_date');
- if (!$activation || $date<$activation || !DB::table('share_system_state')->where('key','initialized_at')->exists()) return $empty;
- $depthRates = self::usesDepthRates($date);
- if (!$policy || !$policy->benefit_enabled || $policy->valid_member_min_effective_bet === null
- || (!$depthRates && $policy->indirect_bonus_rate === null)) return $empty;
- $end = $date . ' 23:59:59';
- $users = DB::table('users')->where('from','<>',2)->where(function ($q) use ($end) { $q->where('created_at','<=',$end)->orWhereNull('created_at'); })
- ->get(['id','member_id'])->keyBy('id');
- $deposits = DB::table('balance_logs')->where('type',1)->where('amount','>',0)
- ->whereIn('change_type',['充值','三方充值'])->where('created_at','<=',$end)
- ->select('member_id')->selectRaw('SUM(amount) AS total')->groupBy('member_id')->pluck('total','member_id');
- $currency=(string)config('third_game.currency','CNY');
- $effective = DB::table('share_bet_facts')->where('settled',1)->where('voided',0)->where('currency',$currency)->where('bet_date','<=',$date)
- ->select('user_id')->selectRaw('SUM(effective_amount) AS total')->groupBy('user_id')->pluck('total','user_id');
- $qualified = [];
- foreach ($users as $id=>$user) if (bccomp((string)($deposits[$user->member_id] ?? 0),(string)$policy->valid_member_min_deposit,10)>=0
- && bccomp((string)($effective[$id] ?? 0),(string)$policy->valid_member_min_effective_bet,10)>=0) $qualified[(int)$id]=true;
- $counts = [];
- foreach (DB::table('share_tree')->where('depth',1)->where('bound_at','<=',$end)->cursor() as $edge) {
- 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;
- }
- $facts = DB::table('share_bet_facts')->where('bet_date',$date)->where('settled',1)->where('voided',0)->where('currency',$currency)->where('effective_amount','>',0)->get();
- $edges = [];
- foreach (array_chunk($facts->pluck('user_id')->unique()->all(),500) as $ids) {
- foreach (DB::table('share_tree')->whereIn('descendant_user_id',$ids)->where('depth','>',0)->get() as $edge) $edges[(int)$edge->descendant_user_id][]=$edge;
- }
- $rows = [];
- foreach ($facts as $fact) {
- if (!isset($qualified[(int)$fact->user_id]) || !array_key_exists($fact->category,ShareDefaults::categories())) continue;
- foreach ($edges[(int)$fact->user_id] ?? [] as $edge) {
- $receiver=(int)$edge->ancestor_user_id;
- if (!$users->has($receiver) || $edge->bound_at > $fact->bet_at) continue;
- if (!isset($rows[$receiver][$fact->category])) $rows[$receiver][$fact->category]=['direct_performance'=>'0','indirect_performance'=>'0','performance_by_depth'=>[]];
- $field=(int)$edge->depth===1 ? 'direct_performance' : 'indirect_performance';
- $rows[$receiver][$fact->category][$field]=bcadd($rows[$receiver][$fact->category][$field],(string)$fact->effective_amount,10);
- $depth=(int)$edge->depth;
- $byDepth=&$rows[$receiver][$fact->category]['performance_by_depth'];
- $byDepth[$depth]=bcadd($byDepth[$depth] ?? '0',(string)$fact->effective_amount,10);
- unset($byDepth);
- }
- }
- $rules=is_string($policy->rules) ? json_decode($policy->rules,true,512,JSON_THROW_ON_ERROR) : (array)$policy->rules;
- foreach ($rows as $id=>&$categories) foreach ($categories as $category=>&$row) {
- $row['valid_direct_members']=$counts[$id] ?? 0;
- $row['rate']=self::rate($rules[$category] ?? [],$row['valid_direct_members'],$row['direct_performance']);
- // Keep the archived coefficient only for replay of days before the formula cutover.
- $row['indirect_bonus_rate']=$depthRates ? '0' : (string)$policy->indirect_bonus_rate;
- $row['direct_commission']=self::percentage($row['direct_performance'],$row['rate']);
- if ($depthRates) {
- $indirect='0';
- foreach ($row['performance_by_depth'] as $depth=>$performance) {
- if ($depth<2) continue;
- $indirect=bcadd($indirect,self::depthCommission($performance,$row['rate'],$depth),30);
- }
- $row['indirect_commission']=bcadd($indirect,'0',10);
- } else {
- $row['indirect_commission']=self::percentage(self::percentage($row['indirect_performance'],$row['rate']),$row['indirect_bonus_rate']);
- }
- unset($row['performance_by_depth']);
- }
- unset($categories,$row);
- return ['date'=>$date,'policy'=>$policy,'rows'=>$rows];
- }
- public static function rate(array $rules,int $members,string $performance): string
- {
- $rate='0'; $bestPerformance='-1'; $bestPeople=-1;
- foreach ($rules as $rule) {
- if ($members < (int)$rule['min_valid_members'] || bccomp($performance,(string)$rule['min_performance'],10)<0) continue;
- $comparison=bccomp((string)$rule['min_performance'],$bestPerformance,10);
- if ($comparison>0 || ($comparison===0 && (int)$rule['min_valid_members']>$bestPeople)) {
- $bestPerformance=(string)$rule['min_performance']; $bestPeople=(int)$rule['min_valid_members']; $rate=(string)$rule['rate'];
- }
- }
- return $rate;
- }
- public static function percentage(string $amount,string $rate): string
- {
- return bcdiv(bcmul($amount,$rate,18),'100',10);
- }
- public static function usesDepthRates(string $date): bool
- {
- $start=DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value') ?? '1970-01-01';
- return $date >= $start;
- }
- /** Keep sub-ledger precision until all depth buckets have been added together. */
- public static function depthCommission(string $amount,string $rate,int $depth): string
- {
- if ($depth<1) throw new \InvalidArgumentException('佣金层级必须为正整数');
- if (bccomp($rate,'0',4)===0) return bcadd('0','0',30);
- if (bccomp($rate,'100',4)===0) return bcadd($amount,'0',30);
- $result=$amount;
- for ($level=0;$level<$depth;$level++) {
- $result=bcdiv(bcmul($result,$rate,40),'100',30);
- if (bccomp($result,'0',30)===0) break;
- }
- return $result;
- }
- public function settle(string $date,bool $write=true,bool $allowToday=false): array
- {
- $this->validateDate($date);
- $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
- if ($date>$today) throw new \InvalidArgumentException('未来日期佣金不能结算到账本');
- if ($date===$today && !$allowToday) throw new \InvalidArgumentException('当天佣金只能预估,调试结算请显式使用--allow-today');
- $activation=DB::table('share_settings')->value('activation_date');
- $result=['date'=>$date,'receivers'=>0,'changed'=>0,'delta'=>'0.0000000000','written'=>$write];
- if (!$activation || $date<$activation) return $result;
- if (!DB::table('share_system_state')->where('key','initialized_at')->exists()) throw new \RuntimeException('分享关系尚未初始化');
- // Facts, membership closure and policy must not change in the middle of this projection.
- return DB::transaction(function () use ($date,$write,$result,$today) {
- DB::table('share_system_state')->where('key','relations_lock')->lockForUpdate()->first();
- $projection=$this->projection($date); $rows=$projection['rows']; $policy=$projection['policy'];
- $depthRates=self::usesDepthRates($date);
- $previous=DB::table('share_settlements')->where('bet_date',$date)->get();
- foreach ($previous as $old) if (!isset($rows[$old->user_id][$old->category])) {
- $rows[$old->user_id][$old->category]=['direct_performance'=>'0','indirect_performance'=>'0','valid_direct_members'=>0,
- 'rate'=>'0','indirect_bonus_rate'=>$depthRates ? '0' : (string)($policy->indirect_bonus_rate ?? 0),'direct_commission'=>'0','indirect_commission'=>'0'];
- }
- $result['receivers']=count($rows);
- $now=Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s');
- ksort($rows);
- foreach ($rows as $userId=>$categories) {
- if ($write) {
- DB::table('share_accounts')->insertOrIgnore(['user_id'=>$userId,'created_at'=>$now,'updated_at'=>$now]);
- $account=DB::table('share_accounts')->where('user_id',$userId)->lockForUpdate()->first();
- } else $account=DB::table('share_accounts')->where('user_id',$userId)->first();
- foreach ($categories as $category=>$target) {
- $key=['user_id'=>$userId,'bet_date'=>$date,'category'=>$category];
- $target['policy_id']=$policy->id ?? null;
- $hash=hash('sha256',json_encode($target,JSON_PRESERVE_ZERO_FRACTION|JSON_THROW_ON_ERROR));
- $query=DB::table('share_settlements')->where($key);
- $old=$write ? $query->lockForUpdate()->first() : $query->first();
- if ($old && $old->input_hash===$hash) continue;
- $directDelta=bcsub($target['direct_commission'],(string)($old->direct_commission ?? 0),10);
- $indirectDelta=bcsub($target['indirect_commission'],(string)($old->indirect_commission ?? 0),10);
- $delta=bcadd($directDelta,$indirectDelta,10);
- $result['changed']++; $result['delta']=bcadd($result['delta'],$delta,10);
- if (!$write) continue;
- $revision=(int)($old->revision ?? 0)+1;
- $values=$target+['revision'=>$revision,'input_hash'=>$hash,'updated_at'=>$now];
- if ($old) { DB::table('share_settlements')->where('id',$old->id)->update($values); $settlementId=$old->id; }
- else $settlementId=DB::table('share_settlements')->insertGetId($key+$values+['created_at'=>$now]);
- foreach (['direct'=>$directDelta,'indirect'=>$indirectDelta] as $type=>$amount) {
- if (bccomp($amount,'0',10)===0) continue;
- DB::table('share_ledger')->insert(['user_id'=>$userId,'settlement_id'=>$settlementId,'revision'=>$revision,
- 'type'=>$type,'amount'=>$amount,'bet_date'=>$date,'category'=>$category,'created_at'=>$now,'updated_at'=>$now]);
- }
- $account->earned_amount=bcadd((string)$account->earned_amount,$delta,10);
- DB::table('share_accounts')->where('user_id',$userId)->update(['earned_amount'=>$account->earned_amount,'updated_at'=>$now]);
- }
- if ($write && $account) $this->upgrade((int)$userId,(string)$account->earned_amount,$now);
- }
- if ($write && $date<$today) {
- $last=DB::table('share_system_state')->where('key','last_settlement_date')->value('value');
- if (!$last || $date>$last) DB::table('share_system_state')->updateOrInsert(['key'=>'last_settlement_date'],['value'=>$date,'created_at'=>$now,'updated_at'=>$now]);
- }
- // A debug credit is still an open day: keep it pending for normal reconciliation after midnight.
- if ($write && $date===$today) $this->requeue([$date]);
- return $result;
- });
- }
- private function upgrade(int $userId,string $earned,string $now): void
- {
- $threshold='0'; $eligible=1;
- foreach (DB::table('share_levels')->orderBy('level')->get() as $level) {
- $threshold=bcadd($threshold,(string)$level->incremental_commission,10);
- if (bccomp($earned,$threshold,10)>=0) $eligible=(int)$level->level;
- }
- DB::table('share_members')->where('user_id',$userId)->where('level','<',$eligible)->update(['level'=>$eligible,'updated_at'=>$now]);
- }
- public function reconcile(): array
- {
- $dirty=DB::transaction(function () {
- $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first();
- if (!$row) return [];
- $dates=json_decode($row->value,true,512,JSON_THROW_ON_ERROR);
- DB::table('share_system_state')->where('key','facts_dirty_dates')->update(['value'=>'[]']);
- return $dates;
- });
- if (!$dirty) return ['dates'=>0,'changed'=>0];
- try {
- $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
- $open=array_values(array_filter($dirty,fn($date)=>$date >= $today));
- // Keep open betting days until a later run can close them, even if the midnight cron is missed.
- $this->requeue($open);
- $dirty=array_values(array_filter($dirty,fn($date)=>$date < $today));
- if (!$dirty) return ['dates'=>0,'changed'=>0];
- $activation=DB::table('share_settings')->value('activation_date');
- if (!$activation) return ['dates'=>0,'changed'=>0];
- $start=max(min($dirty),(string)$activation);
- $dates=DB::table('share_bet_facts')->where('bet_date','>=',$start)->where('bet_date','<',$today)->distinct()->pluck('bet_date')->all();
- $old=DB::table('share_settlements')->where('bet_date','>=',$start)->where('bet_date','<',$today)->distinct()->pluck('bet_date')->all();
- $dates=array_unique(array_merge($dates,$old)); sort($dates); $changed=0;
- foreach ($dates as $date) $changed+=$this->settle((string)$date)['changed'];
- return ['dates'=>count($dates),'changed'=>$changed];
- } catch (\Throwable $e) {
- $this->requeue($dirty);
- throw $e;
- }
- }
- private function requeue(array $dates): void
- {
- if (!$dates) return;
- DB::transaction(function () use ($dates) {
- $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first();
- $pending=$row ? json_decode($row->value,true,512,JSON_THROW_ON_ERROR) : [];
- DB::table('share_system_state')->updateOrInsert(['key'=>'facts_dirty_dates'],['value'=>json_encode(array_values(array_unique(array_merge($pending,$dates))))]);
- });
- }
- private function validateDate(string $date): void
- {
- $value=Carbon::createFromFormat('!Y-m-d',$date,ShareDefaults::TIMEZONE);
- if (!$value || $value->toDateString()!==$date) throw new \InvalidArgumentException('日期格式必须为Y-m-d');
- }
- }
|