read($date); $policy = $qualification['policy']; $empty = ['date'=>$date,'policy'=>$policy,'rows'=>[]]; $initialized=DB::table('share_system_state')->where('key','initialized_at')->exists(); $depthRates = self::usesDepthRates($date); $diagnostics=$withDiagnostics ? $this->diagnosticContext($date,$policy,$initialized,$depthRates) : null; if ($withDiagnostics) $empty['diagnostics']=$diagnostics; if (!$qualification['policy_meta']['available']) return $empty; if (!$policy || $policy->valid_member_min_effective_bet === null || (!$depthRates && $policy->indirect_bonus_rate === null)) return $empty; $end = $date . ' 23:59:59'; $users = $qualification['users']; $deposits = $qualification['deposits']; $currency = $qualification['currency']; $effective = $qualification['effective']; $qualified = $qualification['qualified']; $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) { $bettor=(int)$fact->user_id; if (!isset($qualified[$bettor])) { if ($withDiagnostics) { if (!$users->has($bettor)) ++$diagnostics['excluded']['bettor_unavailable']; else { if (bccomp((string)($deposits[$users[$bettor]->member_id] ?? 0),(string)$policy->valid_member_min_deposit,10)<0) ++$diagnostics['excluded']['deposit_below_threshold']; if (bccomp((string)($effective[$bettor] ?? 0),(string)$policy->valid_member_min_effective_bet,10)<0) ++$diagnostics['excluded']['effective_bet_below_threshold']; } } continue; } if (!array_key_exists($fact->category,ShareDefaults::categories())) { if ($withDiagnostics) ++$diagnostics['excluded']['unsupported_category']; continue; } $ancestors=$edges[$bettor] ?? []; if ($withDiagnostics && !$ancestors) ++$diagnostics['excluded']['no_ancestor_relationship']; foreach ($ancestors as $edge) { $receiver=(int)$edge->ancestor_user_id; if (!$users->has($receiver)) { if ($withDiagnostics) ++$diagnostics['excluded']['receiver_unavailable']; continue; } if ($edge->bound_at > $fact->bet_at) { if ($withDiagnostics) ++$diagnostics['excluded']['relationship_bound_after_bet']; 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); $result=['date'=>$date,'policy'=>$policy,'rows'=>$rows]; if ($withDiagnostics) $result['diagnostics']=$diagnostics; return $result; } /** Read-only explanation from the same projection and filters used by real settlement. */ public function diagnose(string $date): array { $projection=$this->projection($date,true); $report=['date'=>$date,'timezone'=>ShareDefaults::TIMEZONE,'written'=>false]+$projection['diagnostics']; $report['receivers']=count($projection['rows']); $report['positive_receivers']=0; $report['projected_commission']='0.0000000000'; foreach ($projection['rows'] as $categories) { $total='0.0000000000'; foreach ($categories as $row) $total=bcadd($total,bcadd($row['direct_commission'],$row['indirect_commission'],10),10); if (bccomp($total,'0',10)>0) ++$report['positive_receivers']; $report['projected_commission']=bcadd($report['projected_commission'],$total,10); } return $report; } private function diagnosticContext(string $date,?object $policy,bool $initialized,bool $depthRates): array { $blockers=[]; if (!$initialized) $blockers[]=['code'=>'relations_not_initialized','message'=>'分享关系尚未初始化']; if (!$policy) $blockers[]=['code'=>'no_effective_policy','message'=>'目标日期没有已生效的返佣配置版本']; else { if ($policy->valid_member_min_effective_bet===null) $blockers[]=['code'=>'missing_effective_bet_threshold','message'=>'目标日期未配置有效投注门槛']; if (!$depthRates && $policy->indirect_bonus_rate===null) $blockers[]=['code'=>'legacy_policy_incomplete','message'=>'历史计佣版本缺少当时的间接佣金参数']; } $currency=(string)config('third_game.currency','CNY'); $stats=DB::table('share_bet_facts')->where('bet_date',$date)->select('source')->selectRaw( 'COUNT(*) AS total, SUM(CASE WHEN settled = 0 THEN 1 ELSE 0 END) AS unsettled, SUM(CASE WHEN voided = 1 THEN 1 ELSE 0 END) AS voided, SUM(CASE WHEN currency IS NULL OR currency <> ? THEN 1 ELSE 0 END) AS currency_mismatch, SUM(CASE WHEN effective_amount <= 0 THEN 1 ELSE 0 END) AS zero_effective, SUM(CASE WHEN settled = 1 AND voided = 0 AND currency = ? AND effective_amount > 0 THEN 1 ELSE 0 END) AS candidates', [$currency,$currency] )->groupBy('source')->get(); $counts=array_fill_keys(['total','unsettled','voided','currency_mismatch','zero_effective','candidates'],0); $sources=[]; foreach ($stats as $row) { $values=[]; foreach ($counts as $field=>$_) { $values[$field]=(int)$row->$field; $counts[$field]+=$values[$field]; } $sources[$row->source]=$values; } return [ 'initialized'=>$initialized,'currency'=>$currency, 'policy'=>$policy ? ['id'=>(int)$policy->id,'effective_date'=>$policy->effective_date, 'valid_member_min_deposit'=>$policy->valid_member_min_deposit, 'valid_member_min_effective_bet'=>$policy->valid_member_min_effective_bet] : null, 'next_policy_effective_date'=>DB::table('share_policies')->where('effective_date','>',$date)->orderBy('effective_date')->value('effective_date'), 'blockers'=>$blockers,'facts'=>$counts,'facts_by_source'=>$sources, 'excluded'=>array_fill_keys(['bettor_unavailable','deposit_below_threshold','effective_bet_below_threshold', 'unsupported_category','no_ancestor_relationship','receiver_unavailable','relationship_bound_after_bet'],0), ]; } 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'); $result=['date'=>$date,'receivers'=>0,'changed'=>0,'delta'=>'0.0000000000','written'=>$write]; 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 { $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString(); $dates=DB::transaction(function () use ($today) { // Match fact publication's lock order, before any repeatable-read snapshot. $this->lockReconciliationRelations(); $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first(); if (!$row) return []; $pending=json_decode($row->value,true,512,JSON_THROW_ON_ERROR); $closed=array_values(array_filter($pending,fn($date)=>$date < $today)); if (!$closed) return []; $start=min($closed); $facts=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_values(array_unique(array_merge($closed,$facts,$old))); sort($dates); // Persist dependent days too: after the original day is acknowledged, a restart // must still know that later cumulative qualifications require correction. $pending=array_values(array_unique(array_merge($pending,$dates))); sort($pending); DB::table('share_system_state')->where('key','facts_dirty_dates')->update([ 'value'=>json_encode($pending,JSON_THROW_ON_ERROR),'updated_at'=>Carbon::now(ShareDefaults::TIMEZONE)->toDateTimeString(), ]); return $dates; },3); $result=['dates'=>0,'changed'=>0]; foreach ($dates as $date) { $settled=DB::transaction(function () use ($date) { $this->lockReconciliationRelations(); $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) : []; // Another reconciler may already have completed this day while we waited. if (!in_array($date,$pending,true)) return null; $settled=$this->settle((string)$date); $pending=array_values(array_filter($pending,fn($day)=>$day!==$date)); // The ledger and acknowledgement commit together. An interrupted transaction // leaves the day pending; a later publisher can re-add it only after this lock. DB::table('share_system_state')->where('key','facts_dirty_dates')->update([ 'value'=>json_encode($pending,JSON_THROW_ON_ERROR),'updated_at'=>Carbon::now(ShareDefaults::TIMEZONE)->toDateTimeString(), ]); return $settled; },3); if ($settled!==null) { ++$result['dates']; $result['changed']+=$settled['changed']; } } return $result; } private function lockReconciliationRelations(): void { if (!DB::table('share_system_state')->where('key','relations_lock')->lockForUpdate()->first()) { throw new \RuntimeException('Sharing migration/relations lock is missing'); } } 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'); } }