doge 1 dzień temu
rodzic
commit
ac21952263

+ 0 - 4
app/Console/Commands/BackfillShareCommissions.php

@@ -97,10 +97,6 @@ class BackfillShareCommissions extends Command
         if ($counts['real_invited_members_all'] && !$counts['linked_real_invited_members_all']) $this->warn('真实被邀请会员的邀请码未匹配到有效推广人。');
         if ($counts['real_invited_members_all'] && !$counts['linked_real_invited_members_all']) $this->warn('真实被邀请会员的邀请码未匹配到有效推广人。');
         if ($counts['real_invited_members_all'] && !$counts['real_invitee_deposit_logs_all']) $this->warn('真实被邀请会员没有正数“充值/三方充值”流水;人工充值不触发现有邀请返现。');
         if ($counts['real_invited_members_all'] && !$counts['real_invitee_deposit_logs_all']) $this->warn('真实被邀请会员没有正数“充值/三方充值”流水;人工充值不触发现有邀请返现。');
         if ($counts['linked_real_invitee_deposit_logs_all'] && !$counts['with_promoter_wallet_deposit_logs_all']) $this->warn('有推荐充值,但推广人钱包缺失。');
         if ($counts['linked_real_invitee_deposit_logs_all'] && !$counts['with_promoter_wallet_deposit_logs_all']) $this->warn('有推荐充值,但推广人钱包缺失。');
-        if (!$counts['positive_type1_rebate_logs_all'] && $counts['with_promoter_wallet_deposit_logs_all']
-            && is_numeric($data['recharge_rate']) && (float)$data['recharge_rate'] > 0) {
-            $this->warn('当前关系下有充值和推广人钱包,但没有正数返现;需核对当时配置、充值入口是否执行邀请返现,以及单笔返现是否不足0.01。');
-        }
         $this->line('实际旧返现使用 config.recharge_rate;分享设定中的一级/二级比例是另一套配置。');
         $this->line('实际旧返现使用 config.recharge_rate;分享设定中的一级/二级比例是另一套配置。');
         $this->line('上游数量按当前关系统计,仅供排查,不代表历史应付佣金;本诊断未写入记录或发钱。');
         $this->line('上游数量按当前关系统计,仅供排查,不代表历史应付佣金;本诊断未写入记录或发钱。');
         return self::SUCCESS;
         return self::SUCCESS;

+ 26 - 11
app/Services/ShareEarningReportService.php

@@ -106,27 +106,42 @@ class ShareEarningReportService
         $promoterCodes = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
         $promoterCodes = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
             ->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
             ->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
             ->select('agent_user_code')->distinct();
             ->select('agent_user_code')->distinct();
-        $query = User::query()->whereIn('user_code', $promoterCodes);
-        if (!empty($params['member_id'])) $query->where('member_id', $params['member_id']);
-        if (!empty($params['account'])) $query->where('account', 'like', '%' . $params['account'] . '%');
+        $promoterAlias = DB::getTablePrefix() . 'promoter';
+        $entries = DB::query()->fromSub($promoterCodes, 'referral')
+            ->leftJoin('users as promoter', 'promoter.user_code', '=', 'referral.agent_user_code')
+            ->select('referral.agent_user_code')
+            ->selectRaw("COUNT({$promoterAlias}.id) AS promoter_matches")
+            ->groupBy('referral.agent_user_code');
+        // Keep historical codes even when the inviter was removed or changed their code.
+        // An ambiguous code cannot safely identify one member or their paid commission.
+        foreach (['id' => 'promoter_id', 'member_id' => 'member_id', 'account' => 'account', 'first_name' => 'nickname'] as $column => $alias) {
+            $entries->selectRaw("CASE WHEN COUNT({$promoterAlias}.id) = 1 THEN MAX({$promoterAlias}.{$column}) END AS {$alias}");
+        }
+        $query = DB::query()->fromSub($entries, 'promoter_entry');
+        if (!empty($params['member_id'])) $query->where('promoter_entry.member_id', $params['member_id']);
+        if (!empty($params['account'])) $query->where('promoter_entry.account', 'like', '%' . $params['account'] . '%');
         $total = $query->count();
         $total = $query->count();
-        $promoters = $query->orderByDesc('id')->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
+        $promoters = $query->orderByDesc('promoter_entry.promoter_id')->orderByDesc('promoter_entry.agent_user_code')
+            ->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
         $invitees = User::query()->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
         $invitees = User::query()->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
-            ->whereIn('agent_user_code', $promoters->pluck('user_code'))
+            ->whereIn('agent_user_code', $promoters->pluck('agent_user_code'))
             ->get(['id', 'member_id', 'agent_user_code']);
             ->get(['id', 'member_id', 'agent_user_code']);
         $records = ShareCommissionRecord::query()->where('stat_date', $day)->where('status', 1)
         $records = ShareCommissionRecord::query()->where('stat_date', $day)->where('status', 1)
-            ->whereIn('promoter_user_id', $promoters->pluck('id'))->get()->groupBy('promoter_user_id');
+            ->whereIn('promoter_user_id', $promoters->pluck('promoter_id')->filter())->get()->groupBy('promoter_user_id');
         $setting = ShareSetting::query()->first();
         $setting = ShareSetting::query()->first();
         $metricsByMember = $this->moneyMetricsByMember($invitees->pluck('member_id')->all());
         $metricsByMember = $this->moneyMetricsByMember($invitees->pluck('member_id')->all());
         $rows = $promoters->map(function ($promoter) use ($invitees, $records, $setting, $day, $metricsByMember) {
         $rows = $promoters->map(function ($promoter) use ($invitees, $records, $setting, $day, $metricsByMember) {
-            $children = $invitees->where('agent_user_code', $promoter->user_code);
+            $children = $invitees->whereStrict('agent_user_code', (string)$promoter->agent_user_code);
             $metrics = $this->sumMetrics($metricsByMember, $children->pluck('member_id')->all());
             $metrics = $this->sumMetrics($metricsByMember, $children->pluck('member_id')->all());
-            $commission = $records->get($promoter->id, collect())->where('type', 'commission')->sum('amount');
+            $commission = $records->get($promoter->promoter_id, collect())->where('type', 'commission')->sum('amount');
             return [
             return [
                 'date' => $day,
                 'date' => $day,
-                'member_id' => (string)$promoter->member_id,
-                'account' => (string)$promoter->account,
-                'nickname' => (string)$promoter->first_name,
+                'promoter_code' => (string)$promoter->agent_user_code,
+                'promoter_status' => (int)$promoter->promoter_matches === 1 ? 'matched'
+                    : ((int)$promoter->promoter_matches === 0 ? 'missing' : 'ambiguous'),
+                'member_id' => (string)($promoter->member_id ?? ''),
+                'account' => (string)($promoter->account ?? ''),
+                'nickname' => (string)($promoter->nickname ?? ''),
                 'deposit_total' => $metrics['deposit'],
                 'deposit_total' => $metrics['deposit'],
                 'registered_count' => $children->count(),
                 'registered_count' => $children->count(),
                 'commission' => number_format((float)$commission, 4, '.', ''),
                 'commission' => number_format((float)$commission, 4, '.', ''),

+ 38 - 2
docs/前端接口/分享赚钱/推广会员列表/接口.md

@@ -25,7 +25,7 @@
 | 返回字段 | 中文含义 |
 | 返回字段 | 中文含义 |
 |---|---|
 |---|---|
 | `date` | 被邀请会员注册日期 |
 | `date` | 被邀请会员注册日期 |
-| `promoter_count` | 当日产生注册的推广会员数 |
+| `promoter_count` | 当日产生注册的不同推荐邀请码数,包含未匹配或关联异常的邀请码 |
 | `registered_members` | 当日邀请码注册会员数 |
 | `registered_members` | 当日邀请码注册会员数 |
 | `valid_members` | 累计存款达到“有效会员最低存款”的注册会员数 |
 | `valid_members` | 累计存款达到“有效会员最低存款”的注册会员数 |
 | `h5_registered/ios_registered/android_registered` | 会员首次成功登录端统计 |
 | `h5_registered/ios_registered/android_registered` | 会员首次成功登录端统计 |
@@ -39,7 +39,43 @@
 
 
 可追加 `member_id`、`account` 查询。返回:推广会员 ID、账号、昵称、其当日新注册会员的累计存款、注册人数、已落库佣金、当前一级比例。
 可追加 `member_id`、`account` 查询。返回:推广会员 ID、账号、昵称、其当日新注册会员的累计存款、注册人数、已落库佣金、当前一级比例。
 
 
-主要字段:`member_id`、`account`、`nickname`、`deposit_total`、`registered_count`、`commission`、`current_fee_rate`。
+主要字段:`member_id`、`account`、`nickname`、`deposit_total`、`registered_count`、`commission`、`current_fee_rate`,以及以下关联状态字段:
+
+| 字段 | 说明 |
+|---|---|
+| `promoter_code` | 当日注册会员实际保存的推荐邀请码 |
+| `promoter_status` | `matched`:唯一匹配到现有推广会员;`missing`:未匹配到;`ambiguous`:同一邀请码匹配到多个会员,不能确定身份 |
+
+详情按与日汇总相同的邀请码分组,不会因推广会员被删除或邀请码变更而丢弃对应注册记录。未传筛选条件时,`data.total` 与该日汇总的 `promoter_count` 一致;仍按 `page/limit` 分页。`member_id`、`account` 为空字符串不产生筛选条件;填写时只匹配已确定的推广会员身份,不会将被邀请会员的 ID 或邀请码当作推广会员 ID。
+
+未匹配或存在歧义时,`member_id/account/nickname` 返回空字符串,注册人数和这些注册会员的累计存款仍正常统计。前端可用 `promoter_code` 和状态展示“推广会员关联缺失”或“邀请码关联异常”,避免显示为空白行。无法确定推广会员时,`commission` 返回 `0.0000` 占位,前端可按状态显示 `--`;这不证明历史没有发过佣金,接口不会猜测归属或重新发奖。
+
+例如旧邀请码已经找不到推广会员时,仍返回:
+
+```json
+{
+  "code": 0,
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "date": "2026-05-08",
+        "promoter_code": "OLD_CODE",
+        "promoter_status": "missing",
+        "member_id": "",
+        "account": "",
+        "nickname": "",
+        "deposit_total": "100.0000",
+        "registered_count": 1,
+        "commission": "0.0000",
+        "current_fee_rate": "4.0000"
+      }
+    ]
+  }
+}
+```
+
+`date` 必须使用点击行返回的注册日期,无需再次传 `start_date/end_date`;佣金仍取该日已生成且有效的佣金明细,与注册会员累计存款的时间口径不同。
 
 
 ## 注册会员详情弹窗
 ## 注册会员详情弹窗
 
 

+ 25 - 0
docs/后端/分享赚钱-推广汇总明细登记与历史回填.md

@@ -4,6 +4,31 @@
 
 
 这两个 API 在起止日期均未传或为空时都查询全部历史日期,仍按页返回;显式日期只查询所选范围。`members` 每条是一个注册日期的汇总,`total` 是日期数,人数看 `registered_members`。它直接读取 `users.agent_user_code` 推荐关系和 `users.created_at` 注册日期,无需历史回填;没有推荐关系的会员不纳入此报表。
 这两个 API 在起止日期均未传或为空时都查询全部历史日期,仍按页返回;显式日期只查询所选范围。`members` 每条是一个注册日期的汇总,`total` 是日期数,人数看 `registered_members`。它直接读取 `users.agent_user_code` 推荐关系和 `users.created_at` 注册日期,无需历史回填;没有推荐关系的会员不纳入此报表。
 
 
+### 日汇总有数据,推广详情为空
+
+旧查询的日汇总统计非空推荐邀请码,详情却要求该邀请码仍能匹配到现有推广会员。原推广会员被删除、邀请码变更或历史关联缺失时,会出现日汇总有数量、`members/promoters` 返回 `code=0,total=0` 的差异。
+
+详情现在从当日实际保存的推荐邀请码分组,保留未匹配的记录,并返回 `promoter_code`、`promoter_status`(`matched/missing/ambiguous`)。无账号和会员 ID 筛选时,其 `total` 与该日 `promoter_count` 一致。未匹配或匹配多个会员时,不猜测身份与佣金归属;注册人数和存款来自当日被邀请会员的实际记录。没有写库或修复推荐关系的脚本需要执行。
+
+核对线上 `2026-05-08` 的关联,可执行以下只读查询;将示例 `users` 替换为实际带前缀的会员表名:
+
+```sql
+SELECT referral.agent_user_code AS promoter_code,
+       referral.registered_count,
+       COUNT(promoter.id) AS matched_promoters
+FROM (
+    SELECT agent_user_code, COUNT(*) AS registered_count
+    FROM users
+    WHERE agent_user_code IS NOT NULL AND agent_user_code <> ''
+      AND created_at BETWEEN '2026-05-08 00:00:00' AND '2026-05-08 23:59:59'
+    GROUP BY agent_user_code
+) AS referral
+LEFT JOIN users AS promoter ON promoter.user_code = referral.agent_user_code
+GROUP BY referral.agent_user_code, referral.registered_count;
+```
+
+`matched_promoters=0` 表示未匹配到现有推广会员,大于 `1` 表示关联不唯一。此查询用于确认线上原因;接口成功返回空列表本身不足以证明会员曾被删除。
+
 本次接通已有邀请返现的明细登记,保留现有 `config.recharge_rate`、真实用户充值及原推荐关系的发放条件,不新增一级/二级、投注返佣、手续费减免或其他资金规则。
 本次接通已有邀请返现的明细登记,保留现有 `config.recharge_rate`、真实用户充值及原推荐关系的发放条件,不新增一级/二级、投注返佣、手续费减免或其他资金规则。
 
 
 ## 新返现
 ## 新返现

+ 109 - 0
tests/Integration/ShareCommissionRecordingTest.php

@@ -550,6 +550,7 @@ class ShareCommissionRecordingTest extends TestCase
     {
     {
         DB::connection()->getSchemaBuilder()->create('share_settings', function (Blueprint $t) {
         DB::connection()->getSchemaBuilder()->create('share_settings', function (Blueprint $t) {
             $t->id(); $t->decimal('valid_member_min_deposit', 20, 2);
             $t->id(); $t->decimal('valid_member_min_deposit', 20, 2);
+            $t->decimal('tier_one_rate', 8, 4)->default(4);
         });
         });
         DB::table('share_settings')->insert(['valid_member_min_deposit' => 20]);
         DB::table('share_settings')->insert(['valid_member_min_deposit' => 20]);
         DB::connection()->getSchemaBuilder()->create('user_login', function (Blueprint $t) {
         DB::connection()->getSchemaBuilder()->create('user_login', function (Blueprint $t) {
@@ -659,4 +660,112 @@ class ShareCommissionRecordingTest extends TestCase
         $this->assertSame(-3, $invalid['code']);
         $this->assertSame(-3, $invalid['code']);
         $this->assertStringContainsString('366', $invalid['msg']);
         $this->assertStringContainsString('366', $invalid['msg']);
     }
     }
+
+    private function promotersResponse(array $params): array
+    {
+        $request = Request::create('/admin/shareEarning/members/promoters', 'GET', $params);
+        return (new \App\Http\Middleware\TrimStrings())->handle($request, function ($request) {
+            return (new \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull())->handle($request, function ($request) {
+                $this->app->instance('request', $request);
+                return (new ShareEarning())->promoterDetails(new ShareEarningReportService());
+            });
+        })->getData(true);
+    }
+
+    public function test_promoter_details_preserves_historical_codes_when_no_current_promoter_matches(): void
+    {
+        $this->memberHistory();
+        DB::table('users')->whereIn('id', [42, 43])->update(['created_at' => '2026-05-08 12:00:00']);
+        DB::table('users')->where('id', 7)->update(['user_code' => 'CHANGED']);
+        $before = DB::table('users')->orderBy('id')->get()->toJson();
+        $days = $this->membersResponse(['start_date' => '2026-05-08', 'end_date' => '2026-05-08']);
+        $result = $this->promotersResponse(['date' => '2026-05-08', 'page' => 1, 'limit' => 10, 'member_id' => '', 'account' => '']);
+        $this->assertSame(0, $result['code']);
+        $this->assertSame($days['data']['data'][0]['promoter_count'], $result['data']['total']);
+        $this->assertCount(2, $result['data']['data']);
+        $rows = array_column($result['data']['data'], null, 'promoter_code');
+        $this->assertSame('100.0000', $rows['PROMO']['deposit_total']);
+        $this->assertSame('30.0000', $rows['OTHER']['deposit_total']);
+        foreach ($rows as $row) {
+            $this->assertSame('2026-05-08', $row['date']);
+            $this->assertSame('missing', $row['promoter_status']);
+            $this->assertSame('', $row['member_id']);
+            $this->assertSame('', $row['account']);
+            $this->assertSame('', $row['nickname']);
+            $this->assertSame(1, $row['registered_count']);
+            $this->assertSame('0.0000', $row['commission']);
+        }
+        $this->assertSame($before, DB::table('users')->orderBy('id')->get()->toJson());
+        $this->assertSame(0, ShareCommissionRecord::count());
+    }
+
+    public function test_promoter_details_keeps_matched_identity_commission_and_missing_code_in_same_report(): void
+    {
+        $this->memberHistory();
+        foreach ([['2020-01-01', 1, '2.5000'], ['2020-01-01', 0, '100.0000'], ['2021-06-15', 1, '50.0000']] as $i => [$day, $status, $amount]) {
+            ShareCommissionRecord::create([
+                'stat_date' => $day, 'promoter_user_id' => 7, 'type' => 'commission', 'category' => 'recharge',
+                'base_amount' => '100.0000', 'rate' => '2.5000', 'amount' => $amount,
+                'source_type' => 'promoter_test', 'source_id' => (string)$i, 'status' => $status,
+            ]);
+        }
+        $result = $this->promotersResponse(['date' => '2020-01-01']);
+        $this->assertSame(0, $result['code']);
+        $this->assertSame(2, $result['data']['total']);
+        $rows = array_column($result['data']['data'], null, 'promoter_code');
+        $this->assertSame('matched', $rows['PROMO']['promoter_status']);
+        $this->assertSame('1001', $rows['PROMO']['member_id']);
+        $this->assertSame('promoter', $rows['PROMO']['account']);
+        $this->assertSame('推广人', $rows['PROMO']['nickname']);
+        $this->assertSame('100.0000', $rows['PROMO']['deposit_total']);
+        $this->assertSame(1, $rows['PROMO']['registered_count']);
+        $this->assertSame('2.5000', $rows['PROMO']['commission']);
+        $this->assertSame('4.0000', $rows['PROMO']['current_fee_rate']);
+        $this->assertSame('missing', $rows['OTHER']['promoter_status']);
+        $this->assertSame('30.0000', $rows['OTHER']['deposit_total']);
+    }
+
+    public function test_promoter_details_pagination_filters_and_empty_dates_still_apply(): void
+    {
+        $this->memberHistory();
+        $first = $this->promotersResponse(['date' => '2020-01-01', 'page' => 1, 'limit' => 1]);
+        $this->assertSame(0, $first['code']);
+        $this->assertSame(2, $first['data']['total']);
+        $this->assertCount(1, $first['data']['data']);
+        $this->assertSame('PROMO', $first['data']['data'][0]['promoter_code']);
+        $last = $this->promotersResponse(['date' => '2020-01-01', 'page' => 2, 'limit' => 1]);
+        $this->assertSame(2, $last['data']['total']);
+        $this->assertSame('OTHER', $last['data']['data'][0]['promoter_code']);
+        foreach ([['member_id' => '1001'], ['account' => 'prom']] as $filter) {
+            $result = $this->promotersResponse($filter + ['date' => '2020-01-01']);
+            $this->assertSame(0, $result['code']);
+            $this->assertSame(1, $result['data']['total']);
+            $this->assertSame('PROMO', $result['data']['data'][0]['promoter_code']);
+        }
+        foreach ([['date' => '2019-01-01'], ['date' => '2020-01-01', 'page' => 3, 'limit' => 1],
+            ['date' => '2020-01-01', 'member_id' => '1002'], ['date' => '2020-01-01', 'account' => 'OTHER']] as $params) {
+            $result = $this->promotersResponse($params);
+            $this->assertSame(0, $result['code']);
+            $this->assertSame([], $result['data']['data']);
+        }
+    }
+
+    public function test_promoter_details_does_not_assign_an_ambiguous_code_to_one_of_several_users(): void
+    {
+        $this->memberHistory();
+        DB::table('users')->insert([
+            'id' => 99, 'member_id' => '1009', 'account' => 'duplicate',
+            'first_name' => '重复邀请码', 'user_code' => 'PROMO',
+        ]);
+        $result = $this->promotersResponse(['date' => '2020-01-01']);
+        $this->assertSame(0, $result['code']);
+        $this->assertSame(2, $result['data']['total']);
+        $rows = array_column($result['data']['data'], null, 'promoter_code');
+        $this->assertSame('ambiguous', $rows['PROMO']['promoter_status']);
+        $this->assertSame('', $rows['PROMO']['member_id']);
+        $this->assertSame('', $rows['PROMO']['account']);
+        $this->assertSame('100.0000', $rows['PROMO']['deposit_total']);
+        $this->assertSame(1, $rows['PROMO']['registered_count']);
+        $this->assertSame('0.0000', $rows['PROMO']['commission']);
+    }
 }
 }