BackfillShareCommissions.php 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Models\BalanceLog;
  4. use App\Services\ShareCommissionDiagnostics;
  5. use App\Services\ShareCommissionRecorder;
  6. use Carbon\Carbon;
  7. use Illuminate\Console\Command;
  8. use Illuminate\Support\Facades\DB;
  9. class BackfillShareCommissions extends Command
  10. {
  11. protected $signature = 'share:backfill-commissions
  12. {--start-date= : 返现入账起始日 Y-m-d,默认结束日前30天}
  13. {--end-date= : 返现入账结束日 Y-m-d,默认今天}
  14. {--chunk=500 : 每批读取条数,1至2000}
  15. {--all : 查询全部历史,不能与日期选项同时使用}
  16. {--diagnose : 只读诊断数据来源与各级筛选,不登记或发奖}
  17. {--write : 实际登记佣金明细;不传时只预览}';
  18. protected $description = '从已有充值返现流水补记分享佣金明细,不修改钱包或再次发奖';
  19. public function handle(ShareCommissionRecorder $recorder): int
  20. {
  21. try {
  22. $all = (bool)$this->option('all');
  23. $diagnose = (bool)$this->option('diagnose');
  24. $write = (bool)$this->option('write');
  25. if ($diagnose && $write) throw new \InvalidArgumentException('--diagnose 为只读诊断,不能与 --write 同时使用');
  26. if ($all && ($this->option('start-date') !== null || $this->option('end-date') !== null)) {
  27. throw new \InvalidArgumentException('--all 不能与日期选项同时使用');
  28. }
  29. $range = null;
  30. if (!$all) {
  31. $end = $this->parseDate((string)($this->option('end-date') ?: date('Y-m-d')));
  32. $start = $this->parseDate((string)($this->option('start-date') ?: $end->copy()->subDays(30)->toDateString()));
  33. if ($start->gt($end) || $start->diffInDays($end) > 366) {
  34. throw new \InvalidArgumentException('日期范围须先后有效,单次不超过366天');
  35. }
  36. $range = [$start->toDateString() . ' 00:00:00', $end->toDateString() . ' 23:59:59'];
  37. }
  38. $chunk = filter_var($this->option('chunk'), FILTER_VALIDATE_INT);
  39. if ($chunk === false || $chunk < 1 || $chunk > 2000) {
  40. throw new \InvalidArgumentException('chunk 必须是1至2000的整数');
  41. }
  42. $label = $all ? '全部历史(无日期过滤)' : $start->toDateString() . ' 至 ' . $end->toDateString();
  43. if ($diagnose) return $this->diagnose($range, $label);
  44. $counts = ['scanned' => 0, 'created' => 0, 'pending' => 0, 'existing' => 0, 'skipped' => 0, 'incomplete_metadata' => 0];
  45. $reasons = [];
  46. $this->info(($write ? '写入登记' : '只读预览') . ':' . $label);
  47. $query = BalanceLog::where('change_type', '充值返现')->where('type', 1)->where('amount', '>', 0);
  48. if ($range !== null) $query->whereBetween('created_at', $range);
  49. $query->chunkById($chunk, function ($rows) use ($recorder, $write, &$counts, &$reasons) {
  50. foreach ($rows as $row) {
  51. $result = $write ? $recorder->record($row) : $recorder->preview($row);
  52. $counts['scanned']++;
  53. $counts[$result['status']]++;
  54. if (!empty($result['incomplete_metadata'])) $counts['incomplete_metadata']++;
  55. if ($result['status'] === 'skipped') {
  56. $reason = $result['reason'];
  57. $reasons[$reason] = ($reasons[$reason] ?? 0) + 1;
  58. }
  59. }
  60. });
  61. $this->table(['项目', '条数'], array_map(fn ($key, $count) => [$key, $count], array_keys($counts), array_values($counts)));
  62. foreach ($reasons as $reason => $count) $this->line("跳过 {$reason}: {$count}");
  63. if (!$counts['scanned']) {
  64. $this->warn('没有扫描到符合条件的返现流水;省略日期仍默认最近31天。可运行 --diagnose --all 定位上游数据。');
  65. }
  66. if (!$write) $this->info('预览未写入数据;确认后使用相同日期并加 --write 登记,钱包不会再次入账。');
  67. return self::SUCCESS;
  68. } catch (\Throwable $e) {
  69. $this->error($e->getMessage());
  70. return self::FAILURE;
  71. }
  72. }
  73. private function diagnose(?array $range, string $label): int
  74. {
  75. $this->info('只读诊断:' . $label);
  76. $this->line('database: ' . DB::connection()->getDatabaseName());
  77. $this->line('table_prefix: ' . DB::getTablePrefix());
  78. $data = (new ShareCommissionDiagnostics())->inspect($range);
  79. foreach (['recharge_rate', 'share_enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet', 'first_rebate_at', 'last_rebate_at'] as $key) {
  80. $this->line($key . ': ' . ($data[$key] ?? '(无)'));
  81. }
  82. $this->table(['检查项', '条数'], array_map(fn ($key, $count) => [$key, $count], array_keys($data['counts']), array_values($data['counts'])));
  83. $counts = $data['counts'];
  84. if (!$counts['rebate_logs_all']) $this->warn('全部历史没有“充值返现”流水,回填没有来源;需检查实际返现是否发生。');
  85. elseif (!$counts['positive_type1_rebate_logs_all']) $this->warn('存在返现流水,但没有 type=1 且金额大于0的可登记流水。');
  86. elseif (!$counts['selected_rebate_logs']) $this->warn('全历史有可登记返现,但当前日期范围没有;按原入账日期查询或使用 --all。');
  87. if ($data['recharge_rate'] === null || !is_numeric($data['recharge_rate']) || (float)$data['recharge_rate'] <= 0) {
  88. $this->warn('当前 recharge_rate 缺失、无效或为0,现有邀请充值返现路径不会产生正数返现。');
  89. }
  90. if ($counts['invited_members_all'] && !$counts['real_invited_members_all']) $this->warn('有推荐注册,但没有 from=1 的真实被邀请会员。');
  91. if ($counts['real_invited_members_all'] && !$counts['linked_real_invited_members_all']) $this->warn('真实被邀请会员的邀请码未匹配到有效推广人。');
  92. if ($counts['real_invited_members_all'] && !$counts['real_invitee_deposit_logs_all']) $this->warn('真实被邀请会员没有正数“充值/三方充值”流水;人工充值不触发现有邀请返现。');
  93. if ($counts['linked_real_invitee_deposit_logs_all'] && !$counts['with_promoter_wallet_deposit_logs_all']) $this->warn('有推荐充值,但推广人钱包缺失。');
  94. $this->line('实际旧充值返现使用 config.recharge_rate;新分享投注佣金与其独立。');
  95. $this->line('上游数量按当前关系统计,仅供排查,不代表历史应付佣金;本诊断未写入记录或发钱。');
  96. return self::SUCCESS;
  97. }
  98. private function parseDate(string $value): Carbon
  99. {
  100. $date = Carbon::createFromFormat('!Y-m-d', $value, config('app.timezone', 'Asia/Shanghai'));
  101. if (!$date || $date->format('Y-m-d') !== $value) {
  102. throw new \InvalidArgumentException('日期必须为有效的 Y-m-d');
  103. }
  104. return $date;
  105. }
  106. }