doge 8 ore în urmă
părinte
comite
e6eb63db75

+ 7 - 1
app/Console/Commands/SettleSharing.php

@@ -9,7 +9,7 @@ use Illuminate\Console\Command;
 
 class SettleSharing extends Command
 {
-    protected $signature='share:settle {date? : 原下注日期Y-m-d,默认北京时间昨日;allow-today时默认当天} {--preview : 只读预览,不登记应付佣金} {--reconcile : 处理迟到结算和改判} {--allow-today : 允许调试结算北京时间当天,不允许未来日期}';
+    protected $signature='share:settle {date? : 原下注日期Y-m-d,默认北京时间昨日;allow-today时默认当天} {--preview : 只读预览,不登记应付佣金} {--reconcile : 处理迟到结算和改判} {--allow-today : 允许调试结算北京时间当天,不允许未来日期} {--diagnose : 只读说明生效配置、投注明细及各项排除原因,不结算}';
     protected $description='每日结算新分享应付佣金,不直接入会员钱包';
     public function handle(ShareSettlementService $service): int
     {
@@ -17,8 +17,14 @@ class SettleSharing extends Command
             if ($this->option('reconcile') && $this->option('preview')) throw new \InvalidArgumentException('reconcile不支持preview,请使用日期预览');
             $allowToday=(bool)$this->option('allow-today');
             if ($this->option('reconcile') && $allowToday) throw new \InvalidArgumentException('allow-today不能与reconcile同时使用');
+            if ($this->option('reconcile') && $this->option('diagnose')) throw new \InvalidArgumentException('diagnose不能与reconcile同时使用');
             $defaultDate=Carbon::now(ShareDefaults::TIMEZONE);
             if (!$allowToday) $defaultDate->subDay();
+            if ($this->option('diagnose')) {
+                $result=$service->diagnose($this->argument('date') ?: $defaultDate->toDateString());
+                $this->line(json_encode($result,JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+                return self::SUCCESS;
+            }
             $result=$this->option('reconcile') ? $service->reconcile() : $service->settle(
                 $this->argument('date') ?: $defaultDate->toDateString(), !(bool)$this->option('preview'), $allowToday
             );

+ 91 - 6
app/Services/Sharing/ShareSettlementService.php

@@ -8,14 +8,17 @@ 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
+    public function projection(string $date,bool $withDiagnostics=false): 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;
+        $initialized=DB::table('share_system_state')->where('key','initialized_at')->exists();
         $depthRates = self::usesDepthRates($date);
+        $diagnostics=$withDiagnostics ? $this->diagnosticContext($date,$policy,$activation,$initialized,$depthRates) : null;
+        if ($withDiagnostics) $empty['diagnostics']=$diagnostics;
+        if (!$activation || $date<$activation || !$initialized) return $empty;
         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';
@@ -41,10 +44,33 @@ class ShareSettlementService
         }
         $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) {
+            $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) || $edge->bound_at > $fact->bet_at) continue;
+                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);
@@ -74,7 +100,66 @@ class ShareSettlementService
             unset($row['performance_by_depth']);
         }
         unset($categories,$row);
-        return ['date'=>$date,'policy'=>$policy,'rows'=>$rows];
+        $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,?string $activation,bool $initialized,bool $depthRates): array
+    {
+        $blockers=[];
+        if (!$activation) $blockers[]=['code'=>'activation_not_configured','message'=>'尚未配置分享返佣首次生效日期'];
+        elseif ($date<$activation) $blockers[]=['code'=>'before_activation_date','message'=>'目标日期早于首次生效日期;allow-today不会绕过配置生效日'];
+        if (!$initialized) $blockers[]=['code'=>'relations_not_initialized','message'=>'分享关系尚未初始化'];
+        if (!$policy) $blockers[]=['code'=>'no_effective_policy','message'=>'目标日期没有已生效的返佣配置版本'];
+        else {
+            if (!$policy->benefit_enabled) $blockers[]=['code'=>'benefit_disabled','message'=>'目标日期的计佣配置未启用'];
+            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 [
+            'activation_date'=>$activation,'initialized'=>$initialized,'currency'=>$currency,
+            'policy'=>$policy ? ['id'=>(int)$policy->id,'effective_date'=>$policy->effective_date,
+                'benefit_enabled'=>(int)$policy->benefit_enabled,'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

+ 37 - 0
docs/后端/分享返佣-当天调试结算.md

@@ -40,3 +40,40 @@ php artisan share:settle --allow-today --preview
 同一天反复执行按现有账本计算差额,不会把相同佣金重复累计。新增有效投注、迟到结算或改判后,再次执行会按最新明细修正差额。
 
 当天调试不会把全局“最近完成日结日期”提前标记为当天。该日期保留在待核对队列中,跨日后的正常日结或补结算会继续处理,已有调试入账金额只补差额。
+
+## 结算为零时的只读诊断
+
+```bash
+php artisan share:settle 2026-10-25 --allow-today --diagnose
+```
+
+`--diagnose` 只查询并解释该日期的配置、明细和排除原因,始终返回 `written=false`,不会发放、写入佣金账本、修改等级或日结标记。诊断当天也可以只传日期和 `--diagnose`;不传日期时,`--allow-today` 仍用于选择默认当天。不能与 `--reconcile` 同用。
+
+重点查看:
+
+| 输出字段 | 含义 |
+| --- | --- |
+| `activation_date` | 分享返佣首次生效日期;首次保存完整配置默认次日生效,`--allow-today` 不会改变这个日期 |
+| `policy` | 目标日期实际采用的配置,包括充值门槛、有效投注门槛;为 `null` 表示当天没有已生效版本 |
+| `next_policy_effective_date` | 下一个配置版本的生效日期,便于识别“今天保存、明天生效” |
+| `blockers` | 阻止计佣的配置原因,每项含英文 `code` 和中文 `message` |
+| `facts.total` | 已同步到分享明细、原投注日为目标日期的记录数,不是全库原始订单数 |
+| `facts.unsettled` / `voided` | 尚未结算 / 已撤销的明细数 |
+| `facts.currency_mismatch` | 币种与返佣币种不符的明细数 |
+| `facts.zero_effective` | 有效投注为零的明细数;有投注金额不一定有有效投注 |
+| `facts.candidates` | 已结算、未撤销、币种一致、有效投注大于零的候选明细数 |
+| `facts_by_source` | 按订单来源拆分上述明细统计 |
+| `excluded.deposit_below_threshold` | 候选明细的投注会员,累计有效充值未达到配置门槛 |
+| `excluded.effective_bet_below_threshold` | 候选明细的投注会员,累计有效投注未达到配置门槛 |
+| `excluded.no_ancestor_relationship` | 候选明细缺少固化的上级关系,仅有旧邀请码字段并不足以计佣 |
+| `excluded.relationship_bound_after_bet` | 推荐关系的绑定时间晚于投注时间,不给新上级追溯此前投注 |
+| `excluded.bettor_unavailable` / `receiver_unavailable` | 投注会员 / 收佣会员不在当日有效会员范围内,例如虚拟用户或注册日期晚于目标日 |
+| `excluded.unsupported_category` | 游戏分类不在支持的返佣分类中 |
+| `receivers` / `positive_receivers` | 计算出的收佣人数 / 其中佣金大于零的人数 |
+| `projected_commission` | 按当前明细投影的佣金总额,不是本次新增入账差额 |
+
+`excluded` 的会员、门槛、分类项按候选明细计数,收佣人和绑定时间项按候选明细对应的上级关系计数;部分原因可能重叠,不应简单相加。
+
+只有 `--allow-today` 仍不足以产生佣金:实际生效配置、已同步的有效投注、会员资格和投注之前建立的推荐关系都须满足。诊断不会自动启用明天的配置,也不会猜测订单归属或补造订单时间。
+
+`share:sync-facts --write` 输出中的 `unchanged` 只表示已有同步明细没有变化,不能据此断定当天有可计佣明细;`errors` 中的来源时间缺失、会员无法唯一匹配等问题需要核对原始订单。历史订单错误不一定属于目标日期,请结合本诊断的日期统计判断。

+ 67 - 0
tests/Integration/SharingSettlementTest.php

@@ -391,6 +391,7 @@ class SharingSettlementTest extends TestCase
             'invalid calendar date' => [['date' => '2026-02-30', '--allow-today' => true]],
             'reconcile' => [['--allow-today' => true, '--reconcile' => true]],
             'reconcile and preview' => [['--allow-today' => true, '--reconcile' => true, '--preview' => true]],
+            'diagnose and reconcile' => [['--diagnose' => true, '--reconcile' => true]],
         ];
     }
 
@@ -403,6 +404,72 @@ class SharingSettlementTest extends TestCase
         $this->assertFalse(DB::table('share_system_state')->where('key', 'last_settlement_date')->exists());
     }
 
+    public function test_diagnose_reports_tomorrow_activation_without_settling_or_changing_any_state(): void
+    {
+        $this->fact(2, '200', '2026-10-25');
+        DB::table('share_settings')->update(['activation_date' => '2026-10-26']);
+        DB::table('share_policies')->where('effective_date','<>','2026-10-24')->delete();
+        DB::table('share_policies')->update(['effective_date' => '2026-10-26']);
+        $tables=['share_accounts','share_ledger','share_settlements','share_system_state','share_members','wallets'];
+        $before=[];
+        foreach ($tables as $table) $before[$table]=DB::table($table)->get()->toJson();
+        $command=$this->command(['date'=>'2026-10-25','--allow-today'=>true,'--diagnose'=>true]);
+        $this->assertSame(0,$command->getStatusCode());
+        $data=json_decode($command->getDisplay(),true);
+        $this->assertFalse($data['written']);
+        $this->assertSame('2026-10-26',$data['activation_date']);
+        $this->assertSame('2026-10-26',$data['next_policy_effective_date']);
+        $this->assertNull($data['policy']);
+        $this->assertSame(['before_activation_date','no_effective_policy'],array_column($data['blockers'],'code'));
+        $this->assertSame(1,$data['facts']['candidates']);
+        $this->assertSame(0,$data['receivers']);
+        foreach ($tables as $table) $this->assertSame($before[$table],DB::table($table)->get()->toJson(),$table);
+    }
+
+    public function test_diagnose_counts_pending_voided_foreign_and_zero_effective_facts_and_uses_actual_projection_totals(): void
+    {
+        $this->fact(2,'200','2026-10-25');
+        $this->fact(2,'200','2026-10-25',['settled'=>0]);
+        $this->fact(2,'200','2026-10-25',['voided'=>1]);
+        $this->fact(2,'200','2026-10-25',['currency'=>'USD']);
+        $this->fact(2,'200','2026-10-25',['effective_amount'=>0]);
+        $data=$this->service->diagnose('2026-10-25');
+        $this->assertSame(['total'=>5,'unsettled'=>1,'voided'=>1,'currency_mismatch'=>1,'zero_effective'=>1,'candidates'=>1],$data['facts']);
+        $this->assertSame($data['facts'],$data['facts_by_source']['fixture']);
+        $this->assertSame([],$data['blockers']);
+        $this->assertSame(1,$data['receivers']);
+        $this->assertSame(1,$data['positive_receivers']);
+        $this->assertSame('0.2000000000',$data['projected_commission']);
+        $this->assertSame(0,DB::table('share_ledger')->count());
+    }
+
+    /** @dataProvider diagnosticExclusions */
+    public function test_diagnose_explains_the_same_member_and_relationship_filters_as_settlement(string $reason): void
+    {
+        $this->fact(2,'200','2026-10-25');
+        switch ($reason) {
+            case 'deposit_below_threshold': DB::table('balance_logs')->where('member_id','M2')->delete(); break;
+            case 'effective_bet_below_threshold': DB::table('share_bet_facts')->update(['effective_amount'=>50]); break;
+            case 'no_ancestor_relationship': DB::table('share_tree')->where('descendant_user_id',2)->where('depth','>',0)->delete(); break;
+            case 'relationship_bound_after_bet': DB::table('share_tree')->where('descendant_user_id',2)->where('depth','>',0)->update(['bound_at'=>'2026-10-25 12:00:01']); break;
+            case 'bettor_unavailable': DB::table('users')->where('id',2)->update(['from'=>2]); break;
+            case 'unsupported_category': DB::table('share_bet_facts')->update(['category'=>'unknown']); break;
+            case 'receiver_unavailable': DB::table('users')->where('id',1)->update(['from'=>2]); break;
+        }
+        $data=$this->service->diagnose('2026-10-25');
+        $this->assertSame(1,$data['facts']['candidates']);
+        $this->assertSame(1,$data['excluded'][$reason]);
+        $this->assertSame(0,$data['receivers']);
+        $this->assertSame(0,count($this->service->projection('2026-10-25')['rows']));
+        $this->assertSame('0.0000000000',$data['projected_commission']);
+    }
+
+    public static function diagnosticExclusions(): array
+    {
+        return array_map(fn($reason)=>[$reason],['deposit_below_threshold','effective_bet_below_threshold',
+            'no_ancestor_relationship','relationship_bound_after_bet','bettor_unavailable','unsupported_category','receiver_unavailable']);
+    }
+
     public function test_open_day_stays_queued_and_a_missed_midnight_run_is_automatically_recovered(): void
     {
         Carbon::setTestNow(Carbon::parse('2026-10-24 12:00:00','Asia/Shanghai'));