Browse Source

feat: 新增代理盈亏佣金与返佣记录

doge 1 ngày trước cách đây
mục cha
commit
5a3e0b9e36

+ 66 - 20
app/Http/Controllers/admin/Agent.php

@@ -4,10 +4,10 @@ namespace App\Http\Controllers\admin;
 
 
 use App\Http\Controllers\AgentApiController;
 use App\Http\Controllers\AgentApiController;
 use App\Models\Agent\Agent as AgentModel;
 use App\Models\Agent\Agent as AgentModel;
-use App\Models\Agent\AgentCommission;
 use App\Models\Agent\AgentCommissionRule;
 use App\Models\Agent\AgentCommissionRule;
 use App\Models\Agent\AgentLoginLog;
 use App\Models\Agent\AgentLoginLog;
 use App\Models\Agent\AgentWithdrawal;
 use App\Models\Agent\AgentWithdrawal;
+use App\Models\Agent\AgentRebateRecord;
 use App\Models\EgameItem;
 use App\Models\EgameItem;
 use App\Models\User;
 use App\Models\User;
 use App\Services\Agent\AgentCommissionService;
 use App\Services\Agent\AgentCommissionService;
@@ -16,6 +16,7 @@ use App\Services\Agent\AgentReportService;
 use App\Services\Agent\AgentService;
 use App\Services\Agent\AgentService;
 use App\Services\Agent\AgentWalletService;
 use App\Services\Agent\AgentWalletService;
 use App\Services\Agent\AgentWithdrawalService;
 use App\Services\Agent\AgentWithdrawalService;
+use App\Services\Agent\AgentProfitCommissionProfileService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\Rule;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
@@ -36,7 +37,7 @@ class Agent extends AgentApiController
             ]);
             ]);
             $page = max(1, (int) ($data['page'] ?? 1));
             $page = max(1, (int) ($data['page'] ?? 1));
             $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
             $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
-            $query = AgentModel::query()->with(['parent:id,username', 'token:id,agent_id,expires_at,last_used_at'])->withCount(['children', 'members']);
+            $query = AgentModel::query()->with(['parent:id,username', 'token:id,agent_id,expires_at,last_used_at', 'profitCommissionProfile'])->withCount(['children', 'members']);
             if (!empty($data['username'])) $query->where('username', 'like', '%' . $data['username'] . '%');
             if (!empty($data['username'])) $query->where('username', 'like', '%' . $data['username'] . '%');
             if (!empty($data['real_name'])) $query->where('real_name', 'like', '%' . $data['real_name'] . '%');
             if (!empty($data['real_name'])) $query->where('real_name', 'like', '%' . $data['real_name'] . '%');
             if (!empty($data['parent_id'])) $query->where('parent_id', $data['parent_id']);
             if (!empty($data['parent_id'])) $query->where('parent_id', $data['parent_id']);
@@ -53,7 +54,7 @@ class Agent extends AgentApiController
     {
     {
         return $this->execute(function () use ($request, $domains) {
         return $this->execute(function () use ($request, $domains) {
             $data = $request->validate(['id' => ['required', 'integer', 'exists:agents,id']]);
             $data = $request->validate(['id' => ['required', 'integer', 'exists:agents,id']]);
-            $agent = AgentModel::query()->with(['parent:id,username', 'token:id,agent_id,expires_at,last_used_at'])->withCount(['children', 'members'])->findOrFail($data['id']);
+            $agent = AgentModel::query()->with(['parent:id,username', 'token:id,agent_id,expires_at,last_used_at', 'profitCommissionProfile'])->withCount(['children', 'members'])->findOrFail($data['id']);
             return $this->formatAgent($agent, $domains);
             return $this->formatAgent($agent, $domains);
         });
         });
     }
     }
@@ -85,7 +86,12 @@ class Agent extends AgentApiController
     {
     {
         return $this->execute(function () use ($request, $service) {
         return $this->execute(function () use ($request, $service) {
             $data = $request->validate(array_merge(['id' => ['required', 'integer', 'exists:agents,id']], $this->rules(true)));
             $data = $request->validate(array_merge(['id' => ['required', 'integer', 'exists:agents,id']], $this->rules(true)));
-            return $service->update(AgentModel::query()->findOrFail($data['id']), $data);
+            return $service->update(
+                AgentModel::query()->findOrFail($data['id']),
+                $data,
+                null,
+                (int) request()->user->id
+            );
         });
         });
     }
     }
 
 
@@ -238,31 +244,70 @@ class Agent extends AgentApiController
         });
         });
     }
     }
 
 
-    public function saveProfitCommissionRule(Request $request, AgentCommissionService $commissions)
+    public function commissions(Request $request)
     {
     {
-        return $this->execute(function () use ($request, $commissions) {
+        return $this->execute(function () use ($request) {
             $data = $request->validate([
             $data = $request->validate([
-                'agent_id' => ['required', 'integer', 'exists:agents,id'],
-                'profit_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'effective_from' => ['required', 'date_format:Y-m-d', 'date_equals:' . now()->toDateString()],
+                'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'agent_id' => ['nullable', 'integer'], 'order_no' => ['nullable', 'string', 'max:48'],
+                'username' => ['nullable', 'string', 'max:64'],
+                'platform' => ['nullable', 'string', 'max:32'],
+                'flow_status' => ['nullable', 'in:pending,credited'],
+                'profit_status' => ['nullable', 'in:pending,credited'],
+                'settlement_date' => ['nullable', 'date_format:Y-m-d'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
             ]);
             ]);
-            return $commissions->saveProfitRate(AgentModel::query()->findOrFail($data['agent_id']), (string) $data['profit_rate'], $data['effective_from'], (int) request()->user->id);
+            $query = AgentRebateRecord::query()->with('agent:id,username');
+            foreach (['agent_id', 'order_no', 'platform', 'flow_status', 'profit_status'] as $field) {
+                if (!empty($data[$field])) $query->where($field, $data[$field]);
+            }
+            if (!empty($data['username'])) {
+                $query->whereHas('agent', fn($query) => $query->where('username', 'like', '%' . $data['username'] . '%'));
+            }
+            if (!empty($data['settlement_date'])) $query->where('settlement_date', $data['settlement_date']);
+            if (!empty($data['start_date'])) $query->where('created_at', '>=', $data['start_date'] . ' 00:00:00');
+            if (!empty($data['end_date'])) $query->where('created_at', '<=', $data['end_date'] . ' 23:59:59');
+            return $this->paginate($query, $data, 'settlement_date', false);
         });
         });
     }
     }
 
 
-    public function commissions(Request $request)
+    public function profitCommissionProfiles(Request $request, AgentProfitCommissionProfileService $service)
     {
     {
-        return $this->execute(function () use ($request) {
+        return $this->execute(function () use ($request, $service) {
             $data = $request->validate([
             $data = $request->validate([
                 'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
                 'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
-                'agent_id' => ['nullable', 'integer'], 'type' => ['nullable', 'in:flow,profit'], 'status' => ['nullable', 'in:pending,credited'],
-                'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+                'name' => ['nullable', 'string', 'max:128'],
             ]);
             ]);
-            $query = AgentCommission::query()->with('agent:id,username');
-            foreach (['agent_id', 'type', 'status'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
-            if (!empty($data['start_date'])) $query->where('settlement_date', '>=', $data['start_date']);
-            if (!empty($data['end_date'])) $query->where('settlement_date', '<=', $data['end_date']);
-            return $this->paginate($query, $data, 'settlement_date', false);
+            return $service->paginate($data);
+        });
+    }
+
+    public function saveProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $id = (int) $request->input('id', 0);
+            $data = $request->validate([
+                'id' => ['nullable', 'integer', 'exists:agent_profit_commission_profiles,id'],
+                'name' => ['required', 'string', 'max:128', Rule::unique('agent_profit_commission_profiles', 'name')->ignore($id)],
+                'live_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'slot_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'lottery_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'sport_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'esports_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'fishing_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'chess_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+                'is_default' => ['required', 'boolean'],
+            ]);
+            return $service->save($data, (int) request()->user->id);
+        });
+    }
+
+    public function deleteProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $data = $request->validate(['id' => ['required', 'integer', 'exists:agent_profit_commission_profiles,id']]);
+            $service->delete((int) $data['id'], (int) request()->user->id);
+            return [];
         });
         });
     }
     }
 
 
@@ -278,7 +323,8 @@ class Agent extends AgentApiController
             'deposit_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
             'deposit_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
             'withdraw_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
             'withdraw_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
             'flow_commission_rate' => $update ? ['prohibited'] : ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
             'flow_commission_rate' => $update ? ['prohibited'] : ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-            'profit_commission_rate' => $update ? ['prohibited'] : ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
+            'profit_commission_rate' => ['prohibited'],
+            'profit_commission_profile_id' => ['nullable', 'integer', 'exists:agent_profit_commission_profiles,id'],
             'status' => ['nullable', 'integer', 'in:0,1'],
             'status' => ['nullable', 'integer', 'in:0,1'],
             'initial_balance' => $update ? ['prohibited'] : ['nullable', 'numeric', 'min:0', new DecimalNumber()],
             'initial_balance' => $update ? ['prohibited'] : ['nullable', 'numeric', 'min:0', new DecimalNumber()],
         ];
         ];

+ 17 - 9
app/Http/Controllers/agent/CommissionController.php

@@ -4,7 +4,7 @@ namespace App\Http\Controllers\agent;
 
 
 use App\Http\Controllers\AgentApiController;
 use App\Http\Controllers\AgentApiController;
 use App\Models\Agent\Agent;
 use App\Models\Agent\Agent;
-use App\Models\Agent\AgentCommission;
+use App\Models\Agent\AgentRebateRecord;
 use App\Models\Agent\AgentCommissionRule;
 use App\Models\Agent\AgentCommissionRule;
 use App\Services\Agent\AgentCommissionService;
 use App\Services\Agent\AgentCommissionService;
 use App\Services\Agent\AgentReportService;
 use App\Services\Agent\AgentReportService;
@@ -19,7 +19,11 @@ class CommissionController extends AgentApiController
         return $this->execute(function () use ($request, $reports) {
         return $this->execute(function () use ($request, $reports) {
             $data = $request->validate([
             $data = $request->validate([
                 'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
                 'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
-                'type' => ['nullable', 'in:flow,profit'], 'status' => ['nullable', 'in:pending,credited'],
+                'order_no' => ['nullable', 'string', 'max:48'], 'username' => ['nullable', 'string', 'max:64'],
+                'platform' => ['nullable', 'string', 'max:32'],
+                'flow_status' => ['nullable', 'in:pending,credited'],
+                'profit_status' => ['nullable', 'in:pending,credited'],
+                'settlement_date' => ['nullable', 'date_format:Y-m-d'],
                 'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
                 'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
                 'include_children' => ['nullable', 'boolean'],
                 'include_children' => ['nullable', 'boolean'],
             ]);
             ]);
@@ -27,11 +31,16 @@ class CommissionController extends AgentApiController
             $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
             $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
             $agent = $this->currentAgent();
             $agent = $this->currentAgent();
             $ids = !empty($data['include_children']) ? $reports->visibleAgentIds($agent) : [(int) $agent->id];
             $ids = !empty($data['include_children']) ? $reports->visibleAgentIds($agent) : [(int) $agent->id];
-            $query = AgentCommission::query()->with('agent:id,username')->whereIn('agent_id', $ids);
-            if (!empty($data['type'])) $query->where('type', $data['type']);
-            if (!empty($data['status'])) $query->where('status', $data['status']);
-            if (!empty($data['start_date'])) $query->where('settlement_date', '>=', $data['start_date']);
-            if (!empty($data['end_date'])) $query->where('settlement_date', '<=', $data['end_date']);
+            $query = AgentRebateRecord::query()->with('agent:id,username')->whereIn('agent_id', $ids);
+            foreach (['order_no', 'platform', 'flow_status', 'profit_status'] as $field) {
+                if (!empty($data[$field])) $query->where($field, $data[$field]);
+            }
+            if (!empty($data['username'])) {
+                $query->whereHas('agent', fn($query) => $query->where('username', 'like', '%' . $data['username'] . '%'));
+            }
+            if (!empty($data['settlement_date'])) $query->where('settlement_date', $data['settlement_date']);
+            if (!empty($data['start_date'])) $query->where('created_at', '>=', $data['start_date'] . ' 00:00:00');
+            if (!empty($data['end_date'])) $query->where('created_at', '<=', $data['end_date'] . ' 23:59:59');
             $total = (clone $query)->count();
             $total = (clone $query)->count();
             $list = $query->orderByDesc('settlement_date')->orderByDesc('id')->forPage($page, $limit)->get();
             $list = $query->orderByDesc('settlement_date')->orderByDesc('id')->forPage($page, $limit)->get();
             return compact('total', 'page', 'limit', 'list');
             return compact('total', 'page', 'limit', 'list');
@@ -55,12 +64,11 @@ class CommissionController extends AgentApiController
             $data = $request->validate([
             $data = $request->validate([
                 'agent_id' => ['required', 'integer', 'exists:agents,id'],
                 'agent_id' => ['required', 'integer', 'exists:agents,id'],
                 'flow_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'flow_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
-                'profit_rate' => ['required', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'effective_from' => ['required', 'date_format:Y-m-d', 'date_equals:' . now()->toDateString()],
                 'effective_from' => ['required', 'date_format:Y-m-d', 'date_equals:' . now()->toDateString()],
             ]);
             ]);
             if ((int) $data['agent_id'] === (int) $root->id) throw new \RuntimeException('代理不能修改自己的佣金比例');
             if ((int) $data['agent_id'] === (int) $root->id) throw new \RuntimeException('代理不能修改自己的佣金比例');
             $agents->assertVisible($root, (int) $data['agent_id']);
             $agents->assertVisible($root, (int) $data['agent_id']);
-            return $commissions->saveRule(Agent::query()->findOrFail($data['agent_id']), (string) $data['flow_rate'], (string) $data['profit_rate'], $data['effective_from'], null);
+            return $commissions->saveFlowRate(Agent::query()->findOrFail($data['agent_id']), (string) $data['flow_rate'], $data['effective_from'], null);
         });
         });
     }
     }
 }
 }

+ 3 - 0
app/Http/Controllers/agent/ProfileController.php

@@ -17,6 +17,7 @@ class ProfileController extends AgentApiController
     {
     {
         return $this->execute(function () use ($domains) {
         return $this->execute(function () use ($domains) {
             $agent = $this->currentAgent()->load('parent:id,username');
             $agent = $this->currentAgent()->load('parent:id,username');
+            $agent->load('profitCommissionProfile');
             return array_merge([
             return array_merge([
                 'id' => (int) $agent->id,
                 'id' => (int) $agent->id,
                 'username' => $agent->username,
                 'username' => $agent->username,
@@ -26,6 +27,8 @@ class ProfileController extends AgentApiController
                 'level' => (int) $agent->level,
                 'level' => (int) $agent->level,
                 'level_text' => $agent->level_text,
                 'level_text' => $agent->level_text,
                 'can_create_sub_agent' => $agent->can_create_sub_agent,
                 'can_create_sub_agent' => $agent->can_create_sub_agent,
+                'profit_commission_profile_id' => $agent->profit_commission_profile_id,
+                'profit_commission_profile' => $agent->profitCommissionProfile,
                 'invitation_code' => $agent->invitation_code,
                 'invitation_code' => $agent->invitation_code,
                 'balance' => (string) $agent->balance,
                 'balance' => (string) $agent->balance,
                 'frozen_balance' => (string) $agent->frozen_balance,
                 'frozen_balance' => (string) $agent->frozen_balance,

+ 1 - 2
app/Http/Controllers/agent/SubAgentController.php

@@ -24,7 +24,7 @@ class SubAgentController extends AgentApiController
             $root = $this->currentAgent();
             $root = $this->currentAgent();
             $page = max(1, (int) ($data['page'] ?? 1));
             $page = max(1, (int) ($data['page'] ?? 1));
             $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
             $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
-            $query = Agent::query()->with('parent:id,username');
+            $query = Agent::query()->with(['parent:id,username', 'profitCommissionProfile']);
             if (!empty($data['direct_only'])) {
             if (!empty($data['direct_only'])) {
                 $query->where('parent_id', $root->id);
                 $query->where('parent_id', $root->id);
             } else {
             } else {
@@ -53,7 +53,6 @@ class SubAgentController extends AgentApiController
                 'deposit_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'deposit_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'withdraw_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'withdraw_fee_rate' => ['nullable', 'numeric', 'between:0,100', new DecimalNumber(3, 4)],
                 'flow_commission_rate' => ['nullable', 'numeric', 'between:0,' . $root->flow_commission_rate, new DecimalNumber(3, 4)],
                 'flow_commission_rate' => ['nullable', 'numeric', 'between:0,' . $root->flow_commission_rate, new DecimalNumber(3, 4)],
-                'profit_commission_rate' => ['nullable', 'numeric', 'between:0,' . $root->profit_commission_rate, new DecimalNumber(3, 4)],
                 'status' => ['nullable', 'integer', 'in:0,1'],
                 'status' => ['nullable', 'integer', 'in:0,1'],
             ]);
             ]);
             return $service->create($data, null, $root);
             return $service->create($data, null, $root);

+ 12 - 0
app/Models/Agent/Agent.php

@@ -19,6 +19,7 @@ class Agent extends BaseModel
         'real_name', 'invitation_code', 'balance', 'frozen_balance',
         'real_name', 'invitation_code', 'balance', 'frozen_balance',
         'deposit_fee_rate', 'withdraw_fee_rate', 'flow_commission_rate',
         'deposit_fee_rate', 'withdraw_fee_rate', 'flow_commission_rate',
         'profit_commission_rate', 'status', 'last_login_at', 'last_login_ip', 'created_by',
         'profit_commission_rate', 'status', 'last_login_at', 'last_login_ip', 'created_by',
+        'profit_commission_profile_id',
     ];
     ];
     protected $hidden = ['password', 'withdraw_password'];
     protected $hidden = ['password', 'withdraw_password'];
     protected $appends = ['level_text', 'can_create_sub_agent'];
     protected $appends = ['level_text', 'can_create_sub_agent'];
@@ -28,6 +29,7 @@ class Agent extends BaseModel
         'deposit_fee_rate' => 'decimal:4', 'withdraw_fee_rate' => 'decimal:4',
         'deposit_fee_rate' => 'decimal:4', 'withdraw_fee_rate' => 'decimal:4',
         'flow_commission_rate' => 'decimal:4', 'profit_commission_rate' => 'decimal:4',
         'flow_commission_rate' => 'decimal:4', 'profit_commission_rate' => 'decimal:4',
         'last_login_at' => 'datetime',
         'last_login_at' => 'datetime',
+        'profit_commission_profile_id' => 'integer',
     ];
     ];
 
 
     public function parent()
     public function parent()
@@ -55,6 +57,16 @@ class Agent extends BaseModel
         return $this->hasMany(User::class, 'agent_id');
         return $this->hasMany(User::class, 'agent_id');
     }
     }
 
 
+    public function profitCommissionProfile()
+    {
+        return $this->belongsTo(AgentProfitCommissionProfile::class, 'profit_commission_profile_id');
+    }
+
+    public function profitCommissionAssignments()
+    {
+        return $this->hasMany(AgentProfitCommissionAssignment::class);
+    }
+
     public function getLevelTextAttribute(): string
     public function getLevelTextAttribute(): string
     {
     {
         return (int) $this->level === self::LEVEL_ONE ? '一级代理' : '二级代理';
         return (int) $this->level === self::LEVEL_ONE ? '一级代理' : '二级代理';

+ 16 - 0
app/Models/Agent/AgentDailyGameStat.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+class AgentDailyGameStat extends BaseModel
+{
+    protected $table = 'agent_daily_game_stats';
+    protected $guarded = [];
+    protected $hidden = [];
+    protected $casts = [
+        'stat_date' => 'date', 'agent_id' => 'integer', 'game_type' => 'integer', 'bet_count' => 'integer',
+        'bet_amount' => 'decimal:4', 'valid_bet_amount' => 'decimal:4', 'win_loss' => 'decimal:4',
+    ];
+}

+ 41 - 0
app/Models/Agent/AgentProfitCommissionAssignment.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+class AgentProfitCommissionAssignment extends BaseModel
+{
+    protected $table = 'agent_profit_commission_assignments';
+    protected $hidden = [];
+    protected $guarded = [];
+    protected $casts = [
+        'agent_id' => 'integer',
+        'profit_commission_profile_id' => 'integer',
+        'live_rate' => 'decimal:4',
+        'slot_rate' => 'decimal:4',
+        'lottery_rate' => 'decimal:4',
+        'sport_rate' => 'decimal:4',
+        'esports_rate' => 'decimal:4',
+        'fishing_rate' => 'decimal:4',
+        'chess_rate' => 'decimal:4',
+        'effective_from' => 'date',
+        'created_by' => 'integer',
+    ];
+
+    public function agent()
+    {
+        return $this->belongsTo(Agent::class);
+    }
+
+    public function profile()
+    {
+        return $this->belongsTo(AgentProfitCommissionProfile::class, 'profit_commission_profile_id');
+    }
+
+    public function rateForGameType(int $gameType): string
+    {
+        $field = AgentProfitCommissionProfile::GAME_TYPE_RATE_FIELDS[$gameType] ?? null;
+        return $field ? (string) $this->{$field} : '0.0000';
+    }
+}

+ 50 - 0
app/Models/Agent/AgentProfitCommissionProfile.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+class AgentProfitCommissionProfile extends BaseModel
+{
+    public const RATE_FIELDS = [
+        'live_rate', 'slot_rate', 'lottery_rate', 'sport_rate',
+        'esports_rate', 'fishing_rate', 'chess_rate',
+    ];
+
+    public const GAME_TYPE_RATE_FIELDS = [
+        1 => 'live_rate',
+        2 => 'slot_rate',
+        3 => 'lottery_rate',
+        4 => 'sport_rate',
+        5 => 'esports_rate',
+        6 => 'fishing_rate',
+        7 => 'chess_rate',
+    ];
+
+    protected $table = 'agent_profit_commission_profiles';
+    protected $hidden = [];
+    protected $fillable = [
+        'name', 'live_rate', 'slot_rate', 'lottery_rate', 'sport_rate',
+        'esports_rate', 'fishing_rate', 'chess_rate', 'is_default',
+    ];
+    protected $casts = [
+        'live_rate' => 'decimal:4', 'slot_rate' => 'decimal:4', 'lottery_rate' => 'decimal:4',
+        'sport_rate' => 'decimal:4', 'esports_rate' => 'decimal:4', 'fishing_rate' => 'decimal:4',
+        'chess_rate' => 'decimal:4', 'is_default' => 'integer',
+    ];
+
+    public function rateForGameType(int $gameType): string
+    {
+        $field = self::GAME_TYPE_RATE_FIELDS[$gameType] ?? null;
+        return $field ? (string) $this->{$field} : '0.0000';
+    }
+
+    public function rateSnapshot(): array
+    {
+        $snapshot = [];
+        foreach (self::RATE_FIELDS as $field) {
+            $snapshot[$field] = (string) $this->{$field};
+        }
+        return $snapshot;
+    }
+}

+ 64 - 0
app/Models/Agent/AgentRebateRecord.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+class AgentRebateRecord extends BaseModel
+{
+    public const STATUS_PENDING = 'pending';
+    public const STATUS_CREDITED = 'credited';
+
+    public const GAME_TYPE_NAMES = [
+        0 => '历史汇总',
+        1 => '真人',
+        2 => '电子',
+        3 => '彩票',
+        4 => '体育',
+        5 => '电竞',
+        6 => '捕鱼',
+        7 => '棋牌',
+    ];
+
+    protected $table = 'agent_rebate_records';
+    protected $hidden = ['snapshot'];
+    protected $guarded = [];
+    protected $appends = ['agent_username', 'game_type_text', 'flow_status_text', 'profit_status_text'];
+    protected $casts = [
+        'settlement_date' => 'date', 'agent_id' => 'integer', 'game_type' => 'integer', 'bet_count' => 'integer',
+        'bet_amount' => 'decimal:4', 'valid_bet_amount' => 'decimal:4', 'win_loss' => 'decimal:4',
+        'flow_rate' => 'decimal:4', 'flow_commission' => 'decimal:4',
+        'profit_rate' => 'decimal:4', 'profit_commission' => 'decimal:4',
+        'snapshot' => 'array', 'credited_at' => 'datetime',
+    ];
+
+    public function agent()
+    {
+        return $this->belongsTo(Agent::class);
+    }
+
+    public function getAgentUsernameAttribute(): string
+    {
+        return (string) ($this->agent?->username ?? '');
+    }
+
+    public function getGameTypeTextAttribute(): string
+    {
+        return self::GAME_TYPE_NAMES[(int) $this->game_type] ?? '未知';
+    }
+
+    public function getFlowStatusTextAttribute(): string
+    {
+        return $this->statusText((string) $this->flow_status);
+    }
+
+    public function getProfitStatusTextAttribute(): string
+    {
+        return $this->statusText((string) $this->profit_status);
+    }
+
+    private function statusText(string $status): string
+    {
+        return $status === self::STATUS_CREDITED ? '已返佣' : '待返佣';
+    }
+}

+ 147 - 108
app/Services/Agent/AgentCommissionService.php

@@ -6,28 +6,58 @@ use App\Models\Agent\Agent;
 use App\Models\Agent\AgentCommission;
 use App\Models\Agent\AgentCommission;
 use App\Models\Agent\AgentCommissionRule;
 use App\Models\Agent\AgentCommissionRule;
 use App\Models\Agent\AgentDailyStat;
 use App\Models\Agent\AgentDailyStat;
+use App\Models\Agent\AgentDailyGameStat;
+use App\Models\Agent\AgentRebateRecord;
 use Carbon\Carbon;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 
 
 class AgentCommissionService
 class AgentCommissionService
 {
 {
-    public function __construct(private AgentReportService $reports, private AgentWalletService $wallets)
-    {
-    }
-
-    public function saveRule(Agent $agent, string $flowRate, string $profitRate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
-    {
-        return $this->saveRates($agent, $flowRate, $profitRate, $effectiveFrom, $adminId);
+    public function __construct(
+        private AgentReportService $reports,
+        private AgentWalletService $wallets,
+        private AgentProfitCommissionAssignmentService $profitAssignments
+    ) {
     }
     }
 
 
     public function saveFlowRate(Agent $agent, string $rate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
     public function saveFlowRate(Agent $agent, string $rate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
     {
     {
-        return $this->saveRates($agent, $rate, null, $effectiveFrom, $adminId);
-    }
+        if (bccomp($rate, '0', 4) < 0 || bccomp($rate, '100', 4) > 0) {
+            throw new \InvalidArgumentException('流水佣金比例必须在0到100之间');
+        }
+        $day = Carbon::parse($effectiveFrom)->toDateString();
+        return DB::transaction(function () use ($agent, $rate, $day, $adminId) {
+            $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            $legacyCredited = AgentCommission::query()->where('agent_id', $agent->id)
+                ->where('type', 'flow')->where('settlement_date', '>=', $day)
+                ->where('status', AgentRebateRecord::STATUS_CREDITED)->exists();
+            $rebateCredited = AgentRebateRecord::query()->where('agent_id', $agent->id)
+                ->where('settlement_date', '>=', $day)
+                ->where('flow_status', AgentRebateRecord::STATUS_CREDITED)->exists();
+            if ($legacyCredited || $rebateCredited) {
+                throw new \RuntimeException('该日期流水佣金已入账,不能修改比例');
+            }
 
 
-    public function saveProfitRate(Agent $agent, string $rate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
-    {
-        return $this->saveRates($agent, null, $rate, $effectiveFrom, $adminId);
+            $parent = $agent->parent;
+            if ($parent && bccomp($rate, $this->flowRateAt($parent, $day), 4) > 0) {
+                throw new \RuntimeException('下级代理流水佣金比例不能高于上级');
+            }
+            $maxChildRate = Agent::query()->where('parent_id', $agent->id)->get()
+                ->map(fn(Agent $child) => $this->flowRateAt($child, $day))->max() ?? 0;
+            if (bccomp($rate, (string) $maxChildRate, 4) < 0) {
+                throw new \RuntimeException('代理流水佣金比例不能低于现有下级比例');
+            }
+
+            $existing = AgentCommissionRule::query()->where('agent_id', $agent->id)
+                ->where('effective_from', $day)->lockForUpdate()->first();
+            $profitRate = (string) ($existing?->profit_rate ?? $agent->profit_commission_rate ?? 0);
+            $agent->flow_commission_rate = $rate;
+            $agent->save();
+            return AgentCommissionRule::query()->updateOrCreate(
+                ['agent_id' => $agent->id, 'effective_from' => $day],
+                ['flow_rate' => $rate, 'profit_rate' => $profitRate, 'status' => 1, 'created_by' => $adminId]
+            );
+        });
     }
     }
 
 
     public function settle(string $date, bool $credit = true): array
     public function settle(string $date, bool $credit = true): array
@@ -35,125 +65,134 @@ class AgentCommissionService
         $day = Carbon::parse($date)->toDateString();
         $day = Carbon::parse($date)->toDateString();
         $created = 0;
         $created = 0;
         $credited = 0;
         $credited = 0;
-        Agent::query()->where('status', Agent::STATUS_ENABLED)->orderBy('id')->chunkById(100, function ($agents) use ($day, $credit, &$created, &$credited) {
+        $legacyRowsByAgent = AgentCommission::query()->where('settlement_date', $day)
+            ->get(['agent_id', 'type', 'status'])->groupBy('agent_id');
+        Agent::query()->where('status', Agent::STATUS_ENABLED)->orderBy('id')->chunkById(100, function ($agents) use ($day, $credit, $legacyRowsByAgent, &$created, &$credited) {
             foreach ($agents as $agent) {
             foreach ($agents as $agent) {
+                $legacyRows = $legacyRowsByAgent->get($agent->id, collect());
+                if ($legacyRows->isNotEmpty()) {
+                    $creditedTypes = $legacyRows->where('status', AgentRebateRecord::STATUS_CREDITED)
+                        ->pluck('type')->unique()->values();
+                    if ($creditedTypes->contains('flow') && $creditedTypes->contains('profit')) {
+                        continue;
+                    }
+                    throw new \RuntimeException("代理 {$agent->id} 的 {$day} 存在未完成旧佣金记录,请先人工核对");
+                }
                 $descendantIds = $this->reports->visibleAgentIds($agent);
                 $descendantIds = $this->reports->visibleAgentIds($agent);
-                $base = AgentDailyStat::query()->where('stat_date', $day)->whereIn('agent_id', $descendantIds)
-                    ->selectRaw('COALESCE(SUM(valid_bet_amount),0) valid_bet_amount, COALESCE(SUM(company_profit),0) company_profit, COALESCE(SUM(bet_count),0) bet_count')
-                    ->first();
                 $rule = AgentCommissionRule::query()->where('agent_id', $agent->id)->where('status', 1)
                 $rule = AgentCommissionRule::query()->where('agent_id', $agent->id)->where('status', 1)
                     ->where('effective_from', '<=', $day)->orderByDesc('effective_from')->first();
                     ->where('effective_from', '<=', $day)->orderByDesc('effective_from')->first();
                 if (!$rule) {
                 if (!$rule) {
                     throw new \RuntimeException("代理 {$agent->id} 缺少 {$day} 可用的佣金比例记录");
                     throw new \RuntimeException("代理 {$agent->id} 缺少 {$day} 可用的佣金比例记录");
                 }
                 }
-                $rates = [
-                    'flow' => (string) $rule->flow_rate,
-                    'profit' => (string) $rule->profit_rate,
-                ];
-                $bases = [
-                    'flow' => (string) ($base->valid_bet_amount ?? 0),
-                    'profit' => max('0', (string) ($base->company_profit ?? 0)),
-                ];
-                foreach (['flow', 'profit'] as $type) {
-                    $amount = bcdiv(bcmul($bases[$type], $rates[$type], 8), '100', 4);
-                    $commission = AgentCommission::query()->firstOrNew([
-                        'settlement_date' => $day,
-                        'agent_id' => $agent->id,
-                        'type' => $type,
+                $profitAssignment = $this->profitAssignments->at($agent, $day);
+                $gameRows = AgentDailyGameStat::query()->where('stat_date', $day)->whereIn('agent_id', $descendantIds)
+                    ->selectRaw('platform, game_type, SUM(bet_count) bet_count, SUM(bet_amount) bet_amount, SUM(valid_bet_amount) valid_bet_amount, SUM(win_loss) win_loss')
+                    ->groupBy('platform', 'game_type')->get();
+                foreach ($gameRows as $gameRow) {
+                    $flowRate = (string) $rule->flow_rate;
+                    $profitRate = $profitAssignment->rateForGameType((int) $gameRow->game_type);
+                    $flowAmount = bcdiv(bcmul((string) $gameRow->valid_bet_amount, $flowRate, 8), '100', 4);
+                    $profitBase = bccomp((string) $gameRow->win_loss, '0', 4) < 0
+                        ? bcsub('0', (string) $gameRow->win_loss, 4) : '0.0000';
+                    $profitAmount = bcdiv(bcmul($profitBase, $profitRate, 8), '100', 4);
+                    $record = AgentRebateRecord::query()->firstOrNew([
+                        'settlement_date' => $day, 'agent_id' => $agent->id,
+                        'platform' => (string) $gameRow->platform, 'game_type' => (int) $gameRow->game_type,
                     ]);
                     ]);
-                    $isNew = !$commission->exists;
-                    if ($isNew || $commission->status === 'pending') {
-                        $commission->fill([
-                            'base_amount' => $bases[$type], 'rate' => $rates[$type], 'amount' => $amount,
-                            'bet_count' => (int) ($base->bet_count ?? 0), 'status' => 'pending',
-                            'snapshot' => ['agent_ids' => $descendantIds],
-                        ]);
-                        $commission->save();
-                    }
+                    $isNew = !$record->exists;
                     if ($isNew) {
                     if ($isNew) {
-                        $created++;
+                        $record->fill([
+                            'order_no' => $record->order_no ?: $this->rebateOrderNo($day, (int) $agent->id, (string) $gameRow->platform, (int) $gameRow->game_type),
+                            'bet_count' => (int) $gameRow->bet_count, 'bet_amount' => (string) $gameRow->bet_amount,
+                            'valid_bet_amount' => (string) $gameRow->valid_bet_amount, 'win_loss' => (string) $gameRow->win_loss,
+                            'flow_rate' => $flowRate, 'flow_commission' => $flowAmount,
+                            'flow_status' => AgentRebateRecord::STATUS_PENDING,
+                            'profit_rate' => $profitRate, 'profit_commission' => $profitAmount,
+                            'profit_status' => AgentRebateRecord::STATUS_PENDING,
+                            'snapshot' => [
+                                'agent_ids' => $descendantIds,
+                                'profit_profile_id' => $profitAssignment->profit_commission_profile_id,
+                                'profit_assignment_id' => $profitAssignment->id,
+                            ],
+                        ]);
+                    } else {
+                        if ($record->flow_status === AgentRebateRecord::STATUS_PENDING
+                            && $record->profit_status === AgentRebateRecord::STATUS_PENDING) {
+                            $record->flow_rate = $flowRate;
+                            $record->flow_commission = $flowAmount;
+                            $record->profit_rate = $profitRate;
+                            $record->profit_commission = $profitAmount;
+                            $record->bet_count = (int) $gameRow->bet_count;
+                            $record->bet_amount = (string) $gameRow->bet_amount;
+                            $record->valid_bet_amount = (string) $gameRow->valid_bet_amount;
+                            $record->win_loss = (string) $gameRow->win_loss;
+                            $record->snapshot = [
+                                'agent_ids' => $descendantIds,
+                                'profit_profile_id' => $profitAssignment->profit_commission_profile_id,
+                                'profit_assignment_id' => $profitAssignment->id,
+                            ];
+                        }
                     }
                     }
-                    if ($credit && $commission->status === 'pending' && bccomp((string) $commission->amount, '0', 4) > 0) {
-                        $this->wallets->adjust(
-                            (int) $agent->id,
-                            (string) $commission->amount,
-                            'commission',
-                            $day . ($type === 'flow' ? '流水佣金' : '盈亏佣金'),
-                            'commission:' . $commission->id,
-                            'system',
-                            null,
-                            'agent_commission',
-                            (int) $commission->id
-                        );
-                        $commission->status = 'credited';
-                        $commission->credited_at = now();
-                        $commission->save();
-                        $credited++;
-                    } elseif ($credit && $commission->status === 'pending') {
-                        $commission->status = 'credited';
-                        $commission->credited_at = now();
-                        $commission->save();
+                    $record->save();
+                    if ($isNew) $created++;
+                    if ($credit) {
+                        $credited += $this->creditRebatePart($record, 'flow', $day, (int) $agent->id);
+                        $credited += $this->creditRebatePart($record, 'profit', $day, (int) $agent->id);
+                        $record->refresh();
+                        if ($record->flow_status === AgentRebateRecord::STATUS_CREDITED
+                            && $record->profit_status === AgentRebateRecord::STATUS_CREDITED
+                            && $record->credited_at === null) {
+                            $record->credited_at = now();
+                        }
+                        $record->save();
                     }
                     }
                 }
                 }
                 AgentDailyStat::query()->where('stat_date', $day)->where('agent_id', $agent->id)->update([
                 AgentDailyStat::query()->where('stat_date', $day)->where('agent_id', $agent->id)->update([
-                    'commission_amount' => AgentCommission::query()->where('settlement_date', $day)
-                        ->where('agent_id', $agent->id)->where('status', 'credited')->sum('amount'),
+                    'commission_amount' => AgentRebateRecord::query()->where('settlement_date', $day)
+                        ->where('agent_id', $agent->id)
+                        ->selectRaw("COALESCE(SUM(CASE WHEN flow_status='credited' THEN flow_commission ELSE 0 END + CASE WHEN profit_status='credited' THEN profit_commission ELSE 0 END),0) total")
+                        ->value('total'),
                 ]);
                 ]);
             }
             }
         });
         });
         return compact('created', 'credited');
         return compact('created', 'credited');
     }
     }
 
 
-    private function rateAt(Agent $agent, string $field, string $day): string
+    private function creditRebatePart(AgentRebateRecord $record, string $type, string $day, int $agentId): int
     {
     {
-        $rule = AgentCommissionRule::query()->where('agent_id', $agent->id)->where('status', 1)
-            ->where('effective_from', '<=', $day)->orderByDesc('effective_from')->first();
-        if ($rule) {
-            return (string) $rule->{$field};
-        }
-        $agentField = $field === 'flow_rate' ? 'flow_commission_rate' : 'profit_commission_rate';
-        return (string) $agent->{$agentField};
+        return DB::transaction(function () use ($record, $type, $day, $agentId) {
+            $locked = AgentRebateRecord::query()->lockForUpdate()->findOrFail($record->id);
+            $statusField = $type . '_status';
+            $amountField = $type . '_commission';
+            if ($locked->{$statusField} !== AgentRebateRecord::STATUS_PENDING) return 0;
+            $amount = (string) $locked->{$amountField};
+            if (bccomp($amount, '0', 4) > 0) {
+                $this->wallets->adjust(
+                    $agentId, $amount, 'commission',
+                    $day . ($type === 'flow' ? '流水佣金' : '盈亏佣金') . '-' . $locked->platform,
+                    'rebate:' . $locked->id . ':' . $type, 'system', null,
+                    'agent_rebate_record', (int) $locked->id
+                );
+            }
+            $locked->{$statusField} = AgentRebateRecord::STATUS_CREDITED;
+            $locked->save();
+            return 1;
+        });
     }
     }
 
 
-    private function saveRates(Agent $agent, ?string $flowRate, ?string $profitRate, string $effectiveFrom, ?int $adminId): AgentCommissionRule
+    private function rebateOrderNo(string $day, int $agentId, string $platform, int $gameType): string
     {
     {
-        foreach (array_filter([$flowRate, $profitRate], fn($rate) => $rate !== null) as $rate) {
-            if (bccomp((string) $rate, '0', 4) < 0 || bccomp((string) $rate, '100', 4) > 0) {
-                throw new \InvalidArgumentException('佣金比例必须在0到100之间');
-            }
-        }
-        $day = Carbon::parse($effectiveFrom)->toDateString();
-        return DB::transaction(function () use ($agent, $flowRate, $profitRate, $day, $adminId) {
-            $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
-            if (AgentCommission::query()->where('agent_id', $agent->id)->where('settlement_date', '>=', $day)->where('status', 'credited')->exists()) {
-                throw new \RuntimeException('该日期佣金已入账,不能修改比例');
-            }
-            $existing = AgentCommissionRule::query()->where('agent_id', $agent->id)
-                ->where('effective_from', $day)->lockForUpdate()->first();
-            $flowRate = $flowRate ?? (string) ($existing?->flow_rate ?? $this->rateAt($agent, 'flow_rate', $day));
-            $profitRate = $profitRate ?? (string) ($existing?->profit_rate ?? $this->rateAt($agent, 'profit_rate', $day));
-
-            $parent = $agent->parent;
-            if ($parent && (
-                bccomp($flowRate, $this->rateAt($parent, 'flow_rate', $day), 4) > 0
-                || bccomp($profitRate, $this->rateAt($parent, 'profit_rate', $day), 4) > 0
-            )) {
-                throw new \RuntimeException('下级代理佣金比例不能高于上级');
-            }
-            $children = Agent::query()->where('parent_id', $agent->id)->get();
-            $maxFlow = $children->map(fn(Agent $child) => $this->rateAt($child, 'flow_rate', $day))->max() ?? 0;
-            $maxProfit = $children->map(fn(Agent $child) => $this->rateAt($child, 'profit_rate', $day))->max() ?? 0;
-            if (bccomp($flowRate, (string) $maxFlow, 4) < 0 || bccomp($profitRate, (string) $maxProfit, 4) < 0) {
-                throw new \RuntimeException('代理佣金比例不能低于现有下级比例');
-            }
+        return 'AR' . str_replace('-', '', $day) . $agentId
+            . strtoupper(substr(sha1($platform . '|' . $gameType), 0, 10));
+    }
 
 
-            $agent->flow_commission_rate = $flowRate;
-            $agent->profit_commission_rate = $profitRate;
-            $agent->save();
-            return AgentCommissionRule::query()->updateOrCreate(
-                ['agent_id' => $agent->id, 'effective_from' => $day],
-                ['flow_rate' => $flowRate, 'profit_rate' => $profitRate, 'status' => 1, 'created_by' => $adminId]
-            );
-        });
+    private function flowRateAt(Agent $agent, string $day): string
+    {
+        $rule = AgentCommissionRule::query()->where('agent_id', $agent->id)->where('status', 1)
+            ->where('effective_from', '<=', $day)->orderByDesc('effective_from')->first();
+        if ($rule) {
+            return (string) $rule->flow_rate;
+        }
+        return (string) $agent->flow_commission_rate;
     }
     }
 }
 }

+ 85 - 0
app/Services/Agent/AgentProfitCommissionAssignmentService.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentCommission;
+use App\Models\Agent\AgentProfitCommissionAssignment;
+use App\Models\Agent\AgentProfitCommissionProfile;
+use App\Models\Agent\AgentRebateRecord;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+
+class AgentProfitCommissionAssignmentService
+{
+    public function at(Agent $agent, string $date): AgentProfitCommissionAssignment
+    {
+        $day = Carbon::parse($date)->toDateString();
+        $assignment = AgentProfitCommissionAssignment::query()
+            ->where('agent_id', $agent->id)
+            ->where('effective_from', '<=', $day)
+            ->orderByDesc('effective_from')
+            ->orderByDesc('id')
+            ->first();
+        if (!$assignment) {
+            throw new \RuntimeException("代理 {$agent->id} 缺少 {$day} 可用的盈亏佣金比例历史");
+        }
+        return $assignment;
+    }
+
+    public function assign(
+        Agent $agent,
+        AgentProfitCommissionProfile $profile,
+        string $effectiveFrom,
+        ?int $createdBy = null
+    ): AgentProfitCommissionAssignment {
+        $this->assignMany([(int) $agent->id], $profile, $effectiveFrom, $createdBy);
+        return AgentProfitCommissionAssignment::query()
+            ->where('agent_id', $agent->id)
+            ->where('effective_from', Carbon::parse($effectiveFrom)->toDateString())
+            ->firstOrFail();
+    }
+
+    public function assignMany(
+        array $agentIds,
+        AgentProfitCommissionProfile $profile,
+        string $effectiveFrom,
+        ?int $createdBy = null
+    ): void {
+        $agentIds = array_values(array_unique(array_map('intval', $agentIds)));
+        if ($agentIds === []) return;
+        $day = Carbon::parse($effectiveFrom)->toDateString();
+        $this->assertMutable($agentIds, $day);
+        $values = array_merge($profile->rateSnapshot(), [
+            'profit_commission_profile_id' => (int) $profile->id,
+            'profile_name' => (string) $profile->name,
+            'created_by' => $createdBy,
+        ]);
+        DB::transaction(function () use ($agentIds, $day, $values) {
+            foreach (array_chunk($agentIds, 500) as $chunk) {
+                foreach ($chunk as $agentId) {
+                    AgentProfitCommissionAssignment::query()->updateOrCreate(
+                        ['agent_id' => $agentId, 'effective_from' => $day],
+                        $values
+                    );
+                }
+            }
+        });
+    }
+
+    private function assertMutable(array $agentIds, string $day): void
+    {
+        $newCredited = AgentRebateRecord::query()->whereIn('agent_id', $agentIds)
+            ->where('settlement_date', '>=', $day)
+            ->where('profit_status', AgentRebateRecord::STATUS_CREDITED)
+            ->exists();
+        $legacyCredited = AgentCommission::query()->whereIn('agent_id', $agentIds)
+            ->where('type', 'profit')
+            ->where('settlement_date', '>=', $day)
+            ->where('status', AgentRebateRecord::STATUS_CREDITED)
+            ->exists();
+        if ($newCredited || $legacyCredited) {
+            throw new \RuntimeException('生效日期已有盈亏佣金入账,不能修改比例方案');
+        }
+    }
+}

+ 92 - 0
app/Services/Agent/AgentProfitCommissionProfileService.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace App\Services\Agent;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentProfitCommissionProfile;
+use Illuminate\Support\Facades\DB;
+
+class AgentProfitCommissionProfileService
+{
+    public function __construct(private AgentProfitCommissionAssignmentService $assignments)
+    {
+    }
+
+    public function paginate(array $params): array
+    {
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $query = AgentProfitCommissionProfile::query();
+        if (!empty($params['name'])) $query->where('name', 'like', '%' . $params['name'] . '%');
+        $total = (clone $query)->count();
+        $list = $query->orderByDesc('is_default')->orderByDesc('id')->forPage($page, $limit)->get();
+        return compact('total', 'page', 'limit', 'list');
+    }
+
+    public function save(array $data, ?int $adminId = null): AgentProfitCommissionProfile
+    {
+        return DB::transaction(function () use ($data, $adminId) {
+            $profiles = AgentProfitCommissionProfile::query()->orderBy('id')->lockForUpdate()->get(['id', 'is_default']);
+            $profile = empty($data['id'])
+                ? new AgentProfitCommissionProfile()
+                : AgentProfitCommissionProfile::query()->lockForUpdate()->findOrFail($data['id']);
+            $oldDefaultId = $profiles->firstWhere('is_default', 1)?->id;
+            $isDefault = !empty($data['is_default']);
+            if ($profile->exists && (int) $oldDefaultId === (int) $profile->id && !$isDefault) {
+                throw new \RuntimeException('默认比例必须始终保留一条,请先将其他比例设为默认');
+            }
+            $name = trim((string) $data['name']);
+            if ($name === '') throw new \InvalidArgumentException('盈亏比例名称不能为空');
+            $duplicate = AgentProfitCommissionProfile::query()->where('name', $name)
+                ->when($profile->exists, fn($query) => $query->where('id', '<>', $profile->id))->exists();
+            if ($duplicate) throw new \InvalidArgumentException('盈亏比例名称已存在');
+            $profile->name = $name;
+            foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) $profile->{$field} = (string) $data[$field];
+            $willBeDefault = $isDefault || (!$profile->exists && !$oldDefaultId);
+            $profile->is_default = $willBeDefault ? 1 : 0;
+            $snapshotChanged = !$profile->exists
+                || $profile->isDirty(AgentProfitCommissionProfile::RATE_FIELDS);
+            $affectedAgentIds = [];
+            if ($snapshotChanged && $profile->exists) {
+                $affectedAgentIds = Agent::query()->where('profit_commission_profile_id', $profile->id)
+                    ->pluck('id')->map(fn($id) => (int) $id)->all();
+            }
+            if ($willBeDefault) {
+                $defaultAgentIds = Agent::query()->where(function ($query) use ($oldDefaultId, $profile) {
+                    $query->whereNull('profit_commission_profile_id');
+                    if ($oldDefaultId && (int) $oldDefaultId !== (int) $profile->id) {
+                        $query->orWhere('profit_commission_profile_id', $oldDefaultId);
+                    }
+                })->pluck('id')->map(fn($id) => (int) $id)->all();
+                $affectedAgentIds = array_values(array_unique(array_merge($affectedAgentIds, $defaultAgentIds)));
+            }
+            $profile->save();
+
+            if ($willBeDefault) {
+                AgentProfitCommissionProfile::query()->where('id', '<>', $profile->id)->update(['is_default' => 0]);
+                Agent::query()->where(function ($query) use ($oldDefaultId) {
+                    $query->whereNull('profit_commission_profile_id');
+                    if ($oldDefaultId) $query->orWhere('profit_commission_profile_id', $oldDefaultId);
+                })->update(['profit_commission_profile_id' => $profile->id]);
+            }
+            $this->assignments->assignMany($affectedAgentIds, $profile, now()->toDateString(), $adminId);
+            return $profile->fresh();
+        });
+    }
+
+    public function delete(int $id, ?int $adminId = null): void
+    {
+        DB::transaction(function () use ($id, $adminId) {
+            $profile = AgentProfitCommissionProfile::query()->lockForUpdate()->findOrFail($id);
+            if ((int) $profile->is_default === 1) throw new \RuntimeException('默认比例不能删除');
+            $default = AgentProfitCommissionProfile::query()->where('is_default', 1)->lockForUpdate()->first();
+            if (!$default) throw new \RuntimeException('缺少默认比例,不能删除');
+            $agentIds = Agent::query()->where('profit_commission_profile_id', $profile->id)
+                ->pluck('id')->map(fn($agentId) => (int) $agentId)->all();
+            $this->assignments->assignMany($agentIds, $default, now()->toDateString(), $adminId);
+            Agent::query()->where('profit_commission_profile_id', $profile->id)
+                ->update(['profit_commission_profile_id' => $default->id]);
+            $profile->delete();
+        });
+    }
+}

+ 65 - 3
app/Services/Agent/AgentReportService.php

@@ -4,6 +4,8 @@ namespace App\Services\Agent;
 
 
 use App\Models\Agent\Agent;
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentDailyStat;
 use App\Models\Agent\AgentDailyStat;
+use App\Models\Agent\AgentDailyGameStat;
+use App\Models\Agent\AgentRebateRecord;
 use App\Models\User;
 use App\Models\User;
 use Carbon\Carbon;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
@@ -102,9 +104,11 @@ class AgentReportService
             (string) ($funds->bonus_amount ?? 0),
             (string) ($funds->bonus_amount ?? 0),
             (string) ($funds->rebate_amount ?? 0)
             (string) ($funds->rebate_amount ?? 0)
         );
         );
-        $commissionAmount = $commissionAgentIds === [] ? 0 : DB::table('agent_commissions')
-            ->whereIn('agent_id', $commissionAgentIds)->where('status', 'credited')
-            ->whereBetween('settlement_date', [$start->toDateString(), $end->toDateString()])->sum('amount');
+        $commissionAmount = $commissionAgentIds === [] ? 0 : AgentRebateRecord::query()
+            ->whereIn('agent_id', $commissionAgentIds)
+            ->whereBetween('settlement_date', [$start->toDateString(), $end->toDateString()])
+            ->selectRaw("COALESCE(SUM(CASE WHEN flow_status='credited' THEN flow_commission ELSE 0 END + CASE WHEN profit_status='credited' THEN profit_commission ELSE 0 END),0) total")
+            ->value('total');
 
 
         return [
         return [
             'agent_count' => $agentCount,
             'agent_count' => $agentCount,
@@ -128,6 +132,7 @@ class AgentReportService
     public function refreshDaily(string $date): int
     public function refreshDaily(string $date): int
     {
     {
         $day = Carbon::parse($date)->toDateString();
         $day = Carbon::parse($date)->toDateString();
+        $this->refreshDailyGameStats($day);
         $count = 0;
         $count = 0;
         Agent::query()->orderBy('id')->chunkById(100, function ($agents) use ($day, &$count) {
         Agent::query()->orderBy('id')->chunkById(100, function ($agents) use ($day, &$count) {
             foreach ($agents as $agent) {
             foreach ($agents as $agent) {
@@ -158,6 +163,63 @@ class AgentReportService
         return $count;
         return $count;
     }
     }
 
 
+    public function refreshDailyGameStats(string $date): int
+    {
+        $day = Carbon::parse($date)->toDateString();
+        $start = Carbon::parse($day)->startOfDay();
+        $end = Carbon::parse($day)->endOfDay();
+        $queries = [];
+        if (Schema::hasTable('third_game_orders')) {
+            $queries[] = DB::table('third_game_orders as o')->join('users as u', 'u.id', '=', 'o.user_id')
+                ->whereNotNull('u.agent_id')->where('o.status', 1)->whereBetween('o.last_update_time', [$start, $end])
+                ->selectRaw('u.agent_id, LOWER(o.platform) platform, o.game_type, COUNT(*) bet_count, COALESCE(SUM(o.bet_amount),0) bet_amount, COALESCE(SUM(o.valid_amount),0) valid_bet_amount, COALESCE(SUM(o.settled_amount),0) win_loss')
+                ->groupBy('u.agent_id', 'o.platform', 'o.game_type');
+        }
+        if (Schema::hasTable('bets')) {
+            $queries[] = DB::table('bets as o')->join('users as u', 'u.member_id', '=', 'o.member_id')
+                ->whereNotNull('u.agent_id')->where('o.status', 2)->whereBetween('o.created_at', [$start, $end])
+                ->selectRaw("u.agent_id, 'pc28' platform, 3 game_type, COUNT(*) bet_count, COALESCE(SUM(o.amount),0) bet_amount, COALESCE(SUM(o.amount),0) valid_bet_amount, COALESCE(SUM(o.profit-o.amount),0) win_loss")
+                ->groupBy('u.agent_id');
+        }
+        foreach ([['sport_game_order', 'sport', 4], ['jisu_game_order', 'jisu', 3]] as [$table, $platform, $gameType]) {
+            if (!Schema::hasTable($table)) continue;
+            $queries[] = DB::table("{$table} as o")->join('users as u', 'u.member_id', '=', 'o.member_id')
+                ->whereNotNull('u.agent_id')->whereIn('o.status', [1, 2])->whereBetween('o.created_at', [$start, $end])
+                ->selectRaw("u.agent_id, '{$platform}' platform, {$gameType} game_type, COUNT(*) bet_count, COALESCE(SUM(o.amount),0) bet_amount, COALESCE(SUM(o.amount),0) valid_bet_amount, COALESCE(SUM(CASE WHEN o.status=1 THEN -o.amount ELSE o.profit_and_loss END),0) win_loss")
+                ->groupBy('u.agent_id');
+        }
+        if (Schema::hasTable('lhc_order')) {
+            $amount = Schema::hasColumn('lhc_order', 'total_amount') ? 'COALESCE(o.total_amount,o.amount)' : 'o.amount';
+            $queries[] = DB::table('lhc_order as o')->join('users as u', 'u.member_id', '=', 'o.member_id')
+                ->whereNotNull('u.agent_id')->whereIn('o.lottery_status', [1, 2])
+                ->whereBetween('o.created_at', [$start->timestamp, $end->timestamp])
+                ->selectRaw("u.agent_id, 'lhc' platform, 3 game_type, COUNT(*) bet_count, COALESCE(SUM({$amount}),0) bet_amount, COALESCE(SUM({$amount}),0) valid_bet_amount, COALESCE(SUM(COALESCE(o.win_amount,0)-{$amount}),0) win_loss")
+                ->groupBy('u.agent_id');
+        }
+        $rows = collect();
+        if ($queries !== []) {
+            $union = array_shift($queries);
+            foreach ($queries as $query) $union->unionAll($query);
+            $rows = DB::query()->fromSub($union, 'game_stats')
+                ->selectRaw('agent_id, platform, game_type, SUM(bet_count) bet_count, SUM(bet_amount) bet_amount, SUM(valid_bet_amount) valid_bet_amount, SUM(win_loss) win_loss')
+                ->groupBy('agent_id', 'platform', 'game_type')->get();
+        }
+        DB::transaction(function () use ($day, $rows) {
+            AgentDailyGameStat::query()->where('stat_date', $day)->delete();
+            $now = now();
+            foreach ($rows->chunk(500) as $chunk) {
+                AgentDailyGameStat::query()->insert($chunk->map(fn($row) => [
+                    'stat_date' => $day, 'agent_id' => (int) $row->agent_id,
+                    'platform' => (string) $row->platform, 'game_type' => (int) $row->game_type,
+                    'bet_count' => (int) $row->bet_count, 'bet_amount' => (string) $row->bet_amount,
+                    'valid_bet_amount' => (string) $row->valid_bet_amount, 'win_loss' => (string) $row->win_loss,
+                    'created_at' => $now, 'updated_at' => $now,
+                ])->all());
+            }
+        });
+        return $rows->count();
+    }
+
     public function reportRows(?Agent $scope, array $params): array
     public function reportRows(?Agent $scope, array $params): array
     {
     {
         $page = max(1, (int) ($params['page'] ?? 1));
         $page = max(1, (int) ($params['page'] ?? 1));

+ 27 - 6
app/Services/Agent/AgentService.php

@@ -4,12 +4,17 @@ namespace App\Services\Agent;
 
 
 use App\Models\Agent\Agent;
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentCommissionRule;
 use App\Models\Agent\AgentCommissionRule;
+use App\Models\Agent\AgentProfitCommissionProfile;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
 use Illuminate\Support\Str;
 
 
 class AgentService
 class AgentService
 {
 {
+    public function __construct(private AgentProfitCommissionAssignmentService $profitAssignments)
+    {
+    }
+
     public function create(array $data, ?int $createdBy = null, ?Agent $scopeAgent = null): Agent
     public function create(array $data, ?int $createdBy = null, ?Agent $scopeAgent = null): Agent
     {
     {
         return DB::transaction(function () use ($data, $createdBy, $scopeAgent): Agent {
         return DB::transaction(function () use ($data, $createdBy, $scopeAgent): Agent {
@@ -36,6 +41,11 @@ class AgentService
             if ($level > Agent::MAX_LEVEL) {
             if ($level > Agent::MAX_LEVEL) {
                 throw new \RuntimeException('代理层级最多为二级');
                 throw new \RuntimeException('代理层级最多为二级');
             }
             }
+            $profitProfileId = (int) ($data['profit_commission_profile_id']
+                ?? $parent?->profit_commission_profile_id
+                ?? AgentProfitCommissionProfile::query()->where('is_default', 1)->value('id'));
+            if ($profitProfileId <= 0) throw new \RuntimeException('请先配置默认代理盈亏佣金比例');
+            $profitProfile = AgentProfitCommissionProfile::query()->findOrFail($profitProfileId);
 
 
             $agent = Agent::query()->create([
             $agent = Agent::query()->create([
                 'parent_id' => $parent?->id,
                 'parent_id' => $parent?->id,
@@ -50,11 +60,13 @@ class AgentService
                 'withdraw_fee_rate' => (string) ($data['withdraw_fee_rate'] ?? 0),
                 'withdraw_fee_rate' => (string) ($data['withdraw_fee_rate'] ?? 0),
                 'flow_commission_rate' => (string) ($data['flow_commission_rate'] ?? 0),
                 'flow_commission_rate' => (string) ($data['flow_commission_rate'] ?? 0),
                 'profit_commission_rate' => (string) ($data['profit_commission_rate'] ?? 0),
                 'profit_commission_rate' => (string) ($data['profit_commission_rate'] ?? 0),
+                'profit_commission_profile_id' => $profitProfileId,
                 'status' => (int) ($data['status'] ?? Agent::STATUS_ENABLED),
                 'status' => (int) ($data['status'] ?? Agent::STATUS_ENABLED),
                 'created_by' => $createdBy,
                 'created_by' => $createdBy,
             ]);
             ]);
             $agent->path = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
             $agent->path = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
             $agent->save();
             $agent->save();
+            $this->profitAssignments->assign($agent, $profitProfile, now()->toDateString(), $createdBy);
             AgentCommissionRule::query()->create([
             AgentCommissionRule::query()->create([
                 'agent_id' => $agent->id,
                 'agent_id' => $agent->id,
                 'flow_rate' => $agent->flow_commission_rate,
                 'flow_rate' => $agent->flow_commission_rate,
@@ -67,10 +79,17 @@ class AgentService
         });
         });
     }
     }
 
 
-    public function update(Agent $agent, array $data, ?Agent $scopeAgent = null): Agent
+    public function update(Agent $agent, array $data, ?Agent $scopeAgent = null, ?int $createdBy = null): Agent
     {
     {
-        return DB::transaction(function () use ($agent, $data, $scopeAgent): Agent {
+        return DB::transaction(function () use ($agent, $data, $scopeAgent, $createdBy): Agent {
             $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
             $agent = Agent::query()->lockForUpdate()->findOrFail($agent->id);
+            $newProfitProfile = null;
+            if (array_key_exists('profit_commission_profile_id', $data)
+                && $data['profit_commission_profile_id'] !== null
+                && (int) $data['profit_commission_profile_id'] !== (int) $agent->profit_commission_profile_id) {
+                $newProfitProfile = AgentProfitCommissionProfile::query()
+                    ->findOrFail((int) $data['profit_commission_profile_id']);
+            }
             if ($scopeAgent) {
             if ($scopeAgent) {
                 $this->assertVisible($scopeAgent, (int) $agent->id);
                 $this->assertVisible($scopeAgent, (int) $agent->id);
                 if ($agent->parent) {
                 if ($agent->parent) {
@@ -83,7 +102,7 @@ class AgentService
             if (array_key_exists('real_name', $data)) {
             if (array_key_exists('real_name', $data)) {
                 $agent->real_name = trim((string) ($data['real_name'] ?? ''));
                 $agent->real_name = trim((string) ($data['real_name'] ?? ''));
             }
             }
-            foreach (['username', 'status', 'deposit_fee_rate', 'withdraw_fee_rate', 'flow_commission_rate', 'profit_commission_rate'] as $field) {
+            foreach (['username', 'status', 'deposit_fee_rate', 'withdraw_fee_rate', 'flow_commission_rate', 'profit_commission_profile_id'] as $field) {
                 if (array_key_exists($field, $data) && $data[$field] !== null) {
                 if (array_key_exists($field, $data) && $data[$field] !== null) {
                     $agent->{$field} = $data[$field];
                     $agent->{$field} = $data[$field];
                 }
                 }
@@ -95,6 +114,9 @@ class AgentService
                 $agent->withdraw_password = Hash::make((string) $data['withdraw_password']);
                 $agent->withdraw_password = Hash::make((string) $data['withdraw_password']);
             }
             }
             $agent->save();
             $agent->save();
+            if ($newProfitProfile) {
+                $this->profitAssignments->assign($agent, $newProfitProfile, now()->toDateString(), $createdBy);
+            }
             return $agent->fresh();
             return $agent->fresh();
         });
         });
     }
     }
@@ -120,7 +142,7 @@ class AgentService
 
 
     private function assertRatesWithinParent(Agent $parent, array $data): void
     private function assertRatesWithinParent(Agent $parent, array $data): void
     {
     {
-        foreach (['flow_commission_rate', 'profit_commission_rate'] as $field) {
+        foreach (['flow_commission_rate'] as $field) {
             if (array_key_exists($field, $data) && bccomp((string) $data[$field], (string) $parent->{$field}, 4) > 0) {
             if (array_key_exists($field, $data) && bccomp((string) $data[$field], (string) $parent->{$field}, 4) > 0) {
                 throw new \RuntimeException('下级代理佣金比例不能高于上级');
                 throw new \RuntimeException('下级代理佣金比例不能高于上级');
             }
             }
@@ -129,7 +151,7 @@ class AgentService
 
 
     private function assertRatesCoverChildren(Agent $agent, array $data): void
     private function assertRatesCoverChildren(Agent $agent, array $data): void
     {
     {
-        foreach (['flow_commission_rate', 'profit_commission_rate'] as $field) {
+        foreach (['flow_commission_rate'] as $field) {
             if (!array_key_exists($field, $data)) {
             if (!array_key_exists($field, $data)) {
                 continue;
                 continue;
             }
             }
@@ -159,7 +181,6 @@ class AgentService
         if ($parent) {
         if ($parent) {
             $this->assertRatesWithinParent($parent, [
             $this->assertRatesWithinParent($parent, [
                 'flow_commission_rate' => $agent->flow_commission_rate,
                 'flow_commission_rate' => $agent->flow_commission_rate,
-                'profit_commission_rate' => $agent->profit_commission_rate,
             ]);
             ]);
         }
         }
         $oldPath = (string) $agent->path;
         $oldPath = (string) $agent->path;

+ 317 - 0
database/migrations/2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php

@@ -0,0 +1,317 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    private const RATE_FIELDS = [
+        'live_rate', 'slot_rate', 'lottery_rate', 'sport_rate',
+        'esports_rate', 'fishing_rate', 'chess_rate',
+    ];
+
+    public function up(): void
+    {
+        $this->createProfitProfiles();
+        $defaultId = $this->ensureDefaultProfile();
+        $this->addAgentProfitProfileColumn();
+        $this->createProfitAssignments();
+        $this->migrateLegacyAgentRates($defaultId);
+        $this->createDailyGameStats();
+        $this->createRebateRecords();
+        $this->migrateLegacyCommissionRecords();
+    }
+
+    private function createProfitProfiles(): void
+    {
+        if (Schema::hasTable('agent_profit_commission_profiles')) return;
+        Schema::create('agent_profit_commission_profiles', function (Blueprint $table) {
+            $table->id();
+            $table->string('name', 128)->unique();
+            foreach (self::RATE_FIELDS as $field) $table->decimal($field, 8, 4)->default(0);
+            $table->unsignedTinyInteger('is_default')->default(0)->index();
+            $table->timestamps();
+        });
+    }
+
+    private function ensureDefaultProfile(): int
+    {
+        $defaultId = DB::table('agent_profit_commission_profiles')->where('is_default', 1)->orderBy('id')->value('id');
+        if (!$defaultId) {
+            $defaultId = DB::table('agent_profit_commission_profiles')->where('name', '官方默认比例')->value('id');
+            if ($defaultId) {
+                DB::table('agent_profit_commission_profiles')->where('id', $defaultId)->update([
+                    'is_default' => 1,
+                    'updated_at' => now(),
+                ]);
+            } else {
+                $defaultId = DB::table('agent_profit_commission_profiles')->insertGetId([
+                    'name' => '官方默认比例',
+                    'is_default' => 1,
+                    'created_at' => now(),
+                    'updated_at' => now(),
+                ]);
+            }
+        }
+        DB::table('agent_profit_commission_profiles')->where('id', '<>', $defaultId)->update(['is_default' => 0]);
+        return (int) $defaultId;
+    }
+
+    private function addAgentProfitProfileColumn(): void
+    {
+        if (Schema::hasTable('agents') && !Schema::hasColumn('agents', 'profit_commission_profile_id')) {
+            Schema::table('agents', function (Blueprint $table) {
+                $table->unsignedBigInteger('profit_commission_profile_id')->nullable()->index();
+            });
+        }
+    }
+
+    private function createProfitAssignments(): void
+    {
+        if (Schema::hasTable('agent_profit_commission_assignments')) return;
+        Schema::create('agent_profit_commission_assignments', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('agent_id')->index();
+            $table->unsignedBigInteger('profit_commission_profile_id')->nullable()->index();
+            $table->string('profile_name', 128)->default('');
+            foreach (self::RATE_FIELDS as $field) $table->decimal($field, 8, 4)->default(0);
+            $table->date('effective_from')->index();
+            $table->unsignedBigInteger('created_by')->nullable();
+            $table->timestamps();
+            $table->unique(['agent_id', 'effective_from'], 'uniq_agent_profit_assignment_date');
+        });
+    }
+
+    private function migrateLegacyAgentRates(int $defaultId): void
+    {
+        if (!Schema::hasTable('agents')) return;
+        DB::table('agents')->orderBy('id')->chunkById(500, function ($agents) use ($defaultId) {
+            foreach ($agents as $agent) {
+                $legacyRate = $this->decimal($agent->profit_commission_rate ?? 0);
+                $hasAssignment = DB::table('agent_profit_commission_assignments')
+                    ->where('agent_id', $agent->id)->exists();
+                $profileId = $hasAssignment && $agent->profit_commission_profile_id
+                    ? (int) $agent->profit_commission_profile_id
+                    : $this->profileIdForRate($legacyRate, $defaultId);
+                if ((int) ($agent->profit_commission_profile_id ?? 0) !== $profileId) {
+                    DB::table('agents')->where('id', $agent->id)->update([
+                        'profit_commission_profile_id' => $profileId,
+                    ]);
+                }
+
+                $rules = Schema::hasTable('agent_commission_rules')
+                    ? DB::table('agent_commission_rules')->where('agent_id', $agent->id)
+                        ->where('status', 1)
+                        ->orderBy('effective_from')->orderBy('id')->get()
+                    : collect();
+                if ($rules->isEmpty()) {
+                    $this->upsertAssignment((int) $agent->id, '1970-01-01', $legacyRate, $profileId, null, null);
+                    continue;
+                }
+
+                $first = $rules->first();
+                $firstRate = $this->decimal($first->profit_rate ?? $legacyRate);
+                $this->upsertAssignment(
+                    (int) $agent->id,
+                    '1970-01-01',
+                    $firstRate,
+                    $this->profileIdForRate($firstRate, $defaultId),
+                    $first->created_by ?? null,
+                    $first->created_at ?? null
+                );
+                foreach ($rules as $rule) {
+                    $rate = $this->decimal($rule->profit_rate ?? 0);
+                    $this->upsertAssignment(
+                        (int) $agent->id,
+                        (string) $rule->effective_from,
+                        $rate,
+                        $this->profileIdForRate($rate, $defaultId),
+                        $rule->created_by ?? null,
+                        $rule->created_at ?? null
+                    );
+                }
+            }
+        }, 'id');
+    }
+
+    private function profileIdForRate(string $rate, int $defaultId): int
+    {
+        if (bccomp($rate, '0', 4) === 0) return $defaultId;
+        $query = DB::table('agent_profit_commission_profiles');
+        foreach (self::RATE_FIELDS as $field) $query->where($field, $rate);
+        $profileId = $query->value('id');
+        if ($profileId) return (int) $profileId;
+
+        $values = array_fill_keys(self::RATE_FIELDS, $rate);
+        $name = '历史盈亏比例 ' . $rate . '%';
+        if (DB::table('agent_profit_commission_profiles')->where('name', $name)->exists()) {
+            $name .= ' ' . substr(sha1($rate), 0, 8);
+        }
+        return (int) DB::table('agent_profit_commission_profiles')->insertGetId(array_merge($values, [
+            'name' => $name,
+            'is_default' => 0,
+            'created_at' => now(),
+            'updated_at' => now(),
+        ]));
+    }
+
+    private function upsertAssignment(
+        int $agentId,
+        string $effectiveFrom,
+        string $rate,
+        int $profileId,
+        ?int $createdBy,
+        $createdAt
+    ): void {
+        $profileName = (string) DB::table('agent_profit_commission_profiles')->where('id', $profileId)->value('name');
+        $values = array_fill_keys(self::RATE_FIELDS, $rate);
+        DB::table('agent_profit_commission_assignments')->updateOrInsert(
+            ['agent_id' => $agentId, 'effective_from' => substr($effectiveFrom, 0, 10)],
+            array_merge($values, [
+                'profit_commission_profile_id' => $profileId,
+                'profile_name' => $profileName,
+                'created_by' => $createdBy,
+                'created_at' => $createdAt ?: now(),
+                'updated_at' => now(),
+            ])
+        );
+    }
+
+    private function createDailyGameStats(): void
+    {
+        if (Schema::hasTable('agent_daily_game_stats')) return;
+        Schema::create('agent_daily_game_stats', function (Blueprint $table) {
+            $table->id();
+            $table->date('stat_date');
+            $table->unsignedBigInteger('agent_id')->index();
+            $table->string('platform', 32);
+            $table->unsignedTinyInteger('game_type');
+            $table->unsignedBigInteger('bet_count')->default(0);
+            $table->decimal('bet_amount', 18, 4)->default(0);
+            $table->decimal('valid_bet_amount', 18, 4)->default(0);
+            $table->decimal('win_loss', 18, 4)->default(0);
+            $table->timestamps();
+            $table->unique(['stat_date', 'agent_id', 'platform', 'game_type'], 'uniq_agent_daily_game_stat');
+            $table->index(['agent_id', 'stat_date'], 'idx_agent_game_stat_agent_date');
+        });
+    }
+
+    private function createRebateRecords(): void
+    {
+        if (Schema::hasTable('agent_rebate_records')) return;
+        Schema::create('agent_rebate_records', function (Blueprint $table) {
+            $table->id();
+            $table->string('order_no', 48)->unique();
+            $table->date('settlement_date');
+            $table->unsignedBigInteger('agent_id')->index();
+            $table->string('platform', 32)->index();
+            $table->unsignedTinyInteger('game_type')->index();
+            $table->unsignedBigInteger('bet_count')->default(0);
+            $table->decimal('bet_amount', 18, 4)->default(0);
+            $table->decimal('valid_bet_amount', 18, 4)->default(0);
+            $table->decimal('win_loss', 18, 4)->default(0);
+            $table->decimal('flow_rate', 8, 4)->default(0);
+            $table->decimal('flow_commission', 18, 4)->default(0);
+            $table->string('flow_status', 16)->default('pending')->index();
+            $table->decimal('profit_rate', 8, 4)->default(0);
+            $table->decimal('profit_commission', 18, 4)->default(0);
+            $table->string('profit_status', 16)->default('pending')->index();
+            $table->json('snapshot')->nullable();
+            $table->timestamp('credited_at')->nullable();
+            $table->timestamps();
+            $table->unique(['settlement_date', 'agent_id', 'platform', 'game_type'], 'uniq_agent_rebate_daily');
+            $table->index(['agent_id', 'settlement_date'], 'idx_agent_rebate_agent_date');
+            $table->index('created_at', 'idx_agent_rebate_created_at');
+        });
+    }
+
+    private function migrateLegacyCommissionRecords(): void
+    {
+        if (!Schema::hasTable('agent_commissions')) return;
+        DB::table('agent_commissions')->orderBy('id')->chunkById(500, function ($rows) {
+            foreach ($rows as $row) $this->upsertLegacyCommissionRecord($row);
+        }, 'id');
+    }
+
+    private function upsertLegacyCommissionRecord(object $row): void
+    {
+        $day = substr((string) $row->settlement_date, 0, 10);
+        $key = [
+            'settlement_date' => $day,
+            'agent_id' => (int) $row->agent_id,
+            'platform' => 'legacy',
+            'game_type' => 0,
+        ];
+        $record = DB::table('agent_rebate_records')->where($key)->first();
+        if (!$record) {
+            DB::table('agent_rebate_records')->insert(array_merge($key, [
+                'order_no' => 'ARL' . str_replace('-', '', $day) . (int) $row->agent_id,
+                'bet_count' => 0,
+                'bet_amount' => 0,
+                'valid_bet_amount' => 0,
+                'win_loss' => 0,
+                'flow_rate' => 0,
+                'flow_commission' => 0,
+                'flow_status' => 'pending',
+                'profit_rate' => 0,
+                'profit_commission' => 0,
+                'profit_status' => 'pending',
+                'snapshot' => json_encode(['legacy' => true], JSON_UNESCAPED_UNICODE),
+                'credited_at' => null,
+                'created_at' => $row->created_at ?? now(),
+                'updated_at' => $row->updated_at ?? now(),
+            ]));
+            $record = DB::table('agent_rebate_records')->where($key)->first();
+        }
+
+        $status = in_array((string) $row->status, ['pending', 'credited'], true)
+            ? (string) $row->status : 'pending';
+        $baseAmount = $this->decimal($row->base_amount ?? 0);
+        $updates = [
+            'bet_count' => max((int) ($record->bet_count ?? 0), (int) ($row->bet_count ?? 0)),
+            'updated_at' => $row->updated_at ?? now(),
+        ];
+        if ((string) $row->type === 'flow') {
+            $updates['bet_amount'] = $baseAmount;
+            $updates['valid_bet_amount'] = $baseAmount;
+            $updates['flow_rate'] = $this->decimal($row->rate ?? 0);
+            $updates['flow_commission'] = $this->decimal($row->amount ?? 0);
+            $updates['flow_status'] = $status;
+        } elseif ((string) $row->type === 'profit') {
+            $updates['win_loss'] = bcsub('0', $baseAmount, 4);
+            $updates['profit_rate'] = $this->decimal($row->rate ?? 0);
+            $updates['profit_commission'] = $this->decimal($row->amount ?? 0);
+            $updates['profit_status'] = $status;
+        }
+        DB::table('agent_rebate_records')->where('id', $record->id)->update($updates);
+        $record = DB::table('agent_rebate_records')->where('id', $record->id)->first();
+        $creditedAt = null;
+        if ($record->flow_status === 'credited' && $record->profit_status === 'credited') {
+            $creditedAt = DB::table('agent_commissions')
+                ->where('agent_id', $row->agent_id)
+                ->where('settlement_date', $day)
+                ->where('status', 'credited')
+                ->max('credited_at');
+        }
+        DB::table('agent_rebate_records')->where('id', $record->id)->update(['credited_at' => $creditedAt]);
+    }
+
+    private function decimal($value): string
+    {
+        return bcadd((string) ($value ?? 0), '0', 4);
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('agent_rebate_records');
+        Schema::dropIfExists('agent_daily_game_stats');
+        Schema::dropIfExists('agent_profit_commission_assignments');
+        if (Schema::hasTable('agents') && Schema::hasColumn('agents', 'profit_commission_profile_id')) {
+            Schema::table('agents', function (Blueprint $table) {
+                $table->dropColumn('profit_commission_profile_id');
+            });
+        }
+        Schema::dropIfExists('agent_profit_commission_profiles');
+    }
+};

+ 16 - 1
docs/代理/个人信息.md

@@ -28,7 +28,20 @@
     "deposit_fee_rate": "0.0000",
     "deposit_fee_rate": "0.0000",
     "withdraw_fee_rate": "1.5000",
     "withdraw_fee_rate": "1.5000",
     "flow_commission_rate": "0.3000",
     "flow_commission_rate": "0.3000",
-    "profit_commission_rate": "10.0000",
+    "profit_commission_rate": "0.0000",
+    "profit_commission_profile_id": 1,
+    "profit_commission_profile": {
+      "id": 1,
+      "name": "官方默认比例",
+      "live_rate": "30.0000",
+      "slot_rate": "30.0000",
+      "lottery_rate": "30.0000",
+      "sport_rate": "30.0000",
+      "esports_rate": "30.0000",
+      "fishing_rate": "30.0000",
+      "chess_rate": "30.0000",
+      "is_default": 1
+    },
     "last_login_at": "2026-08-24 18:00:00",
     "last_login_at": "2026-08-24 18:00:00",
     "last_login_ip": "1.2.3.4",
     "last_login_ip": "1.2.3.4",
     "pc_domain": "https://pc.example.com",
     "pc_domain": "https://pc.example.com",
@@ -58,3 +71,5 @@
 ```
 ```
 
 
 账号、上级、余额和佣金比例不能通过此接口修改。
 账号、上级、余额和佣金比例不能通过此接口修改。
+
+`profit_commission_rate` 是兼容旧数据的保留字段,前端不要使用;盈亏佣金显示 `profit_commission_profile`。

+ 6 - 6
docs/代理/代理佣金比例.md

@@ -26,7 +26,7 @@ GET /agent/commission-rules?include_children=1
       "id": 20,
       "id": 20,
       "agent_id": 15,
       "agent_id": 15,
       "flow_rate": "0.2000",
       "flow_rate": "0.2000",
-      "profit_rate": "8.0000",
+      "profit_rate": "0.0000",
       "effective_from": "2026-08-24T00:00:00.000000Z",
       "effective_from": "2026-08-24T00:00:00.000000Z",
       "status": 1,
       "status": 1,
       "created_by": null,
       "created_by": null,
@@ -46,14 +46,12 @@ GET /agent/commission-rules?include_children=1
 |---|---|---:|---|
 |---|---|---:|---|
 | `agent_id` | integer | 是 | 当前代理下级树中的代理 ID,不能是自己 |
 | `agent_id` | integer | 是 | 当前代理下级树中的代理 ID,不能是自己 |
 | `flow_rate` | decimal string | 是 | 流水佣金百分比,0~100,最多 4 位小数 |
 | `flow_rate` | decimal string | 是 | 流水佣金百分比,0~100,最多 4 位小数 |
-| `profit_rate` | decimal string | 是 | 盈亏佣金百分比,0~100,最多 4 位小数 |
 | `effective_from` | string | 是 | 必须是操作当天,`Y-m-d` |
 | `effective_from` | string | 是 | 必须是操作当天,`Y-m-d` |
 
 
 ```json
 ```json
 {
 {
   "agent_id": 15,
   "agent_id": 15,
   "flow_rate": "0.2000",
   "flow_rate": "0.2000",
-  "profit_rate": "8.0000",
   "effective_from": "2026-08-24"
   "effective_from": "2026-08-24"
 }
 }
 ```
 ```
@@ -62,8 +60,10 @@ GET /agent/commission-rules?include_children=1
 
 
 规则:
 规则:
 
 
-- 下级比例不能高于其直属上级。
-- 当前代理的比例不能低于已有直属下级比例。
+- 下级流水比例不能高于其直属上级。
+- 当前代理的流水比例不能低于已有直属下级比例。
 - 当天佣金已入账后不能修改当天比例。
 - 当天佣金已入账后不能修改当天比例。
 - 流水佣金=`有效投注额 × flow_rate ÷ 100`。
 - 流水佣金=`有效投注额 × flow_rate ÷ 100`。
-- 盈亏佣金=`max(公司盈亏,0) × profit_rate ÷ 100`。
+- 盈亏比例方案由总后台配置,代理端不能修改。
+
+`profit_rate` 是旧表保留字段,前端不要再使用;当前盈亏佣金以代理绑定的 `profit_commission_profile` 为准。

+ 4 - 4
docs/代理/所有代理.md

@@ -43,7 +43,9 @@ GET /agent/sub-agents?page=1&limit=20&direct_only=1
         "deposit_fee_rate": "0.0000",
         "deposit_fee_rate": "0.0000",
         "withdraw_fee_rate": "1.0000",
         "withdraw_fee_rate": "1.0000",
         "flow_commission_rate": "0.2000",
         "flow_commission_rate": "0.2000",
-        "profit_commission_rate": "8.0000",
+        "profit_commission_rate": "0.0000",
+        "profit_commission_profile_id": 1,
+        "profit_commission_profile": {"id":1,"name":"官方默认比例","live_rate":"30.0000","slot_rate":"30.0000","lottery_rate":"30.0000","sport_rate":"30.0000","esports_rate":"30.0000","fishing_rate":"30.0000","chess_rate":"30.0000","is_default":1},
         "status": 1,
         "status": 1,
         "last_login_at": null,
         "last_login_at": null,
         "last_login_ip": "",
         "last_login_ip": "",
@@ -73,7 +75,6 @@ GET /agent/sub-agents?page=1&limit=20&direct_only=1
 | `deposit_fee_rate` | decimal string | 否 | 0~100 |
 | `deposit_fee_rate` | decimal string | 否 | 0~100 |
 | `withdraw_fee_rate` | decimal string | 否 | 0~100 |
 | `withdraw_fee_rate` | decimal string | 否 | 0~100 |
 | `flow_commission_rate` | decimal string | 否 | 不能高于上级流水比例 |
 | `flow_commission_rate` | decimal string | 否 | 不能高于上级流水比例 |
-| `profit_commission_rate` | decimal string | 否 | 不能高于上级盈亏比例 |
 | `status` | integer | 否 | `1` 启用,`0` 禁用;默认 `1` |
 | `status` | integer | 否 | `1` 启用,`0` 禁用;默认 `1` |
 
 
 ```json
 ```json
@@ -83,12 +84,11 @@ GET /agent/sub-agents?page=1&limit=20&direct_only=1
   "withdraw_password": "654321",
   "withdraw_password": "654321",
   "real_name": "李四",
   "real_name": "李四",
   "flow_commission_rate": "0.2",
   "flow_commission_rate": "0.2",
-  "profit_commission_rate": "8",
   "status": 1
   "status": 1
 }
 }
 ```
 ```
 
 
-前端不要传 `parent_id`。成功时 `data` 返回新代理对象,包括后端生成的 `id`、`path`、`level=2`、`invitation_code`,初始 `balance` 和 `frozen_balance` 都为 `0.0000`。
+前端不要传 `parent_id`。二级代理自动继承一级代理的盈亏比例方案。成功时 `data` 返回新代理对象,包括后端生成的 `id`、`path`、`level=2`、`invitation_code`,初始 `balance` 和 `frozen_balance` 都为 `0.0000`。
 
 
 ## 3. 修改下级代理
 ## 3. 修改下级代理
 
 

+ 5 - 4
docs/代理/资金账户.md

@@ -19,7 +19,8 @@
     "balance": "5500.0000",
     "balance": "5500.0000",
     "frozen_balance": "200.0000",
     "frozen_balance": "200.0000",
     "flow_commission_rate": "0.3000",
     "flow_commission_rate": "0.3000",
-    "profit_commission_rate": "10.0000"
+    "profit_commission_profile_id": 1,
+    "profit_commission_profile": {"id":1,"name":"官方默认比例","live_rate":"30.0000","slot_rate":"30.0000","lottery_rate":"30.0000","sport_rate":"30.0000","esports_rate":"30.0000","fishing_rate":"30.0000","chess_rate":"30.0000","is_default":1}
   }
   }
 }
 }
 ```
 ```
@@ -54,13 +55,13 @@
 
 
 ### `GET /agent/commissions`
 ### `GET /agent/commissions`
 
 
-参数:`type=flow|profit`、`status=credited`、`start_date`、`end_date`、`page`、`limit`。分别请求 `flow` 和 `profit` 后汇总 `data.list[].amount`。
+参数:`flow_status=credited`、`profit_status=credited`、`start_date`、`end_date`、`page`、`limit`。日期参数按返佣记录创建时间筛选;汇总 `data.list[].flow_commission` 和 `data.list[].profit_commission`。
 
 
 ```http
 ```http
-GET /agent/commissions?type=flow&status=credited&start_date=2026-08-01&end_date=2026-08-24&limit=100
+GET /agent/commissions?flow_status=credited&profit_status=credited&start_date=2026-08-01&end_date=2026-08-24&limit=100
 ```
 ```
 
 
-返回 `data.total/page/limit/list`,每项含 `type`、`base_amount`、`rate`、`amount`、`status`、`settlement_date`、`credited_at`。
+返回 `data.total/page/limit/list`,每项含 `platform`、`flow_rate`、`flow_commission`、`flow_status`、`profit_rate`、`profit_commission`、`profit_status`、`bet_count`、`bet_amount`、`settlement_date`、`credited_at`。
 
 
 ## 4. 已提现金额
 ## 4. 已提现金额
 
 

+ 28 - 12
docs/代理/返佣记录.md

@@ -10,14 +10,18 @@
 |---|---|---:|---|---|
 |---|---|---:|---|---|
 | `page` | integer | 否 | `1` | 页码 |
 | `page` | integer | 否 | `1` | 页码 |
 | `limit` | integer | 否 | `20` | 每页 1~100 |
 | `limit` | integer | 否 | `20` | 每页 1~100 |
-| `type` | string | 否 | - | `flow` 流水佣金;`profit` 盈亏佣金 |
-| `status` | string | 否 | - | `pending` 待入账;`credited` 已入账 |
-| `start_date` | string | 否 | - | 结算开始日期,`Y-m-d` |
-| `end_date` | string | 否 | - | 结算结束日期,不能早于开始日期 |
+| `order_no` | string | 否 | - | 返佣单号 |
+| `username` | string | 否 | - | 代理账号模糊查询;包含下级时使用 |
+| `platform` | string | 否 | - | 游戏平台 |
+| `flow_status` | string | 否 | - | 流水佣金状态:`pending`、`credited` |
+| `profit_status` | string | 否 | - | 盈亏佣金状态:`pending`、`credited` |
+| `settlement_date` | string | 否 | - | 精确账期,`Y-m-d` |
+| `start_date` | string | 否 | - | 创建开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 创建结束日期,不能早于开始日期 |
 | `include_children` | boolean | 否 | `false` | 是否包含整棵下级树的返佣记录 |
 | `include_children` | boolean | 否 | `false` | 是否包含整棵下级树的返佣记录 |
 
 
 ```http
 ```http
-GET /agent/commissions?page=1&limit=20&type=flow&status=credited&include_children=1
+GET /agent/commissions?page=1&limit=20&flow_status=credited&profit_status=credited&include_children=1
 ```
 ```
 
 
 ```json
 ```json
@@ -32,15 +36,25 @@ GET /agent/commissions?page=1&limit=20&type=flow&status=credited&include_childre
     "list": [
     "list": [
       {
       {
         "id": 30,
         "id": 30,
+        "order_no": "AR2026082812ABCDEF1234",
         "settlement_date": "2026-08-23T00:00:00.000000Z",
         "settlement_date": "2026-08-23T00:00:00.000000Z",
         "agent_id": 12,
         "agent_id": 12,
-        "type": "flow",
-        "base_amount": "28000.0000",
-        "rate": "0.3000",
-        "amount": "84.0000",
+        "agent_username": "agent001",
+        "platform": "ag",
+        "game_type": 1,
+        "game_type_text": "真人",
         "bet_count": 188,
         "bet_count": 188,
-        "status": "credited",
-        "snapshot": {"agent_ids":[12,15,16,17]},
+        "bet_amount": "30000.0000",
+        "valid_bet_amount": "28000.0000",
+        "win_loss": "-3000.0000",
+        "flow_rate": "0.3000",
+        "flow_commission": "84.0000",
+        "flow_status": "credited",
+        "flow_status_text": "已返佣",
+        "profit_rate": "30.0000",
+        "profit_commission": "900.0000",
+        "profit_status": "credited",
+        "profit_status_text": "已返佣",
         "credited_at": "2026-08-24T04:10:00.000000Z",
         "credited_at": "2026-08-24T04:10:00.000000Z",
         "created_at": "2026-08-24 04:10:00",
         "created_at": "2026-08-24 04:10:00",
         "updated_at": "2026-08-24 04:10:00",
         "updated_at": "2026-08-24 04:10:00",
@@ -51,4 +65,6 @@ GET /agent/commissions?page=1&limit=20&type=flow&status=credited&include_childre
 }
 }
 ```
 ```
 
 
-`base_amount` 在 `flow` 类型下是有效投注额,在 `profit` 类型下是公司正盈利。每日 04:10 结算前一天数据并写入代理资金流水。
+每日 04:10 按游戏平台结算前一天数据,流水佣金和盈亏佣金分别写入代理资金流水。
+
+旧系统返佣记录迁移后显示为 `platform=legacy`、`game_type=0`、`game_type_text=历史汇总`,金额和入账状态保持不变。

+ 9 - 4
docs/总后台/代理列表.md

@@ -59,7 +59,8 @@ GET /admin/agent?page=1&limit=20&username=agent&status=1
         "deposit_fee_rate": "0.0000",
         "deposit_fee_rate": "0.0000",
         "withdraw_fee_rate": "1.5000",
         "withdraw_fee_rate": "1.5000",
         "flow_commission_rate": "0.3000",
         "flow_commission_rate": "0.3000",
-        "profit_commission_rate": "10.0000",
+        "profit_commission_rate": "0.0000",
+        "profit_commission_profile_id": 1,
         "status": 1,
         "status": 1,
         "last_login_at": "2026-08-24T10:00:00.000000Z",
         "last_login_at": "2026-08-24T10:00:00.000000Z",
         "last_login_ip": "1.2.3.4",
         "last_login_ip": "1.2.3.4",
@@ -71,6 +72,7 @@ GET /admin/agent?page=1&limit=20&username=agent&status=1
         "updated_at": "2026-08-24 10:00:00",
         "updated_at": "2026-08-24 10:00:00",
         "children_count": 3,
         "children_count": 3,
         "members_count": 25,
         "members_count": 25,
+        "profit_commission_profile": {"id":1,"name":"官方默认比例","live_rate":"30.0000","slot_rate":"30.0000","lottery_rate":"30.0000","sport_rate":"30.0000","esports_rate":"30.0000","fishing_rate":"30.0000","chess_rate":"30.0000","is_default":1},
         "parent": null,
         "parent": null,
         "pc_domain": "https://pc.example.com",
         "pc_domain": "https://pc.example.com",
         "h5_domain": "https://h5.example.com",
         "h5_domain": "https://h5.example.com",
@@ -82,7 +84,7 @@ GET /admin/agent?page=1&limit=20&username=agent&status=1
 }
 }
 ```
 ```
 
 
-`members_count` 是直属会员数,`children_count` 是直属下级代理数。`login_status=1` 表示最近 5 分钟有有效代理 Token 活动,否则为离线。`path`、`level` 由后端维护,前端只展示,不提交。
+`members_count` 是直属会员数,`children_count` 是直属下级代理数。`login_status=1` 表示最近 5 分钟有有效代理 Token 活动,否则为离线。`path`、`level` 由后端维护,前端只展示,不提交。`profit_commission_rate` 是兼容旧数据的保留字段,前端改用 `profit_commission_profile`。
 
 
 ## 2. 代理详情
 ## 2. 代理详情
 
 
@@ -131,7 +133,7 @@ GET /admin/agent/show?id=12
 | `deposit_fee_rate` | decimal string | 否 | 存款手续费百分比,0~100,最多 4 位小数 |
 | `deposit_fee_rate` | decimal string | 否 | 存款手续费百分比,0~100,最多 4 位小数 |
 | `withdraw_fee_rate` | decimal string | 否 | 提现手续费百分比,0~100,最多 4 位小数 |
 | `withdraw_fee_rate` | decimal string | 否 | 提现手续费百分比,0~100,最多 4 位小数 |
 | `flow_commission_rate` | decimal string | 否 | 初始流水佣金百分比,0~100 |
 | `flow_commission_rate` | decimal string | 否 | 初始流水佣金百分比,0~100 |
-| `profit_commission_rate` | decimal string | 否 | 初始盈亏佣金百分比,0~100 |
+| `profit_commission_profile_id` | integer | 否 | 盈亏比例方案 ID;不传使用默认方案 |
 | `status` | integer | 否 | `1` 启用,`0` 禁用;默认 `1` |
 | `status` | integer | 否 | `1` 启用,`0` 禁用;默认 `1` |
 
 
 ```json
 ```json
@@ -144,7 +146,7 @@ GET /admin/agent/show?id=12
   "deposit_fee_rate": "0",
   "deposit_fee_rate": "0",
   "withdraw_fee_rate": "1.5",
   "withdraw_fee_rate": "1.5",
   "flow_commission_rate": "0.3",
   "flow_commission_rate": "0.3",
-  "profit_commission_rate": "10",
+  "profit_commission_profile_id": 1,
   "status": 1
   "status": 1
 }
 }
 ```
 ```
@@ -186,9 +188,12 @@ GET /admin/agent/show?id=12
 | `deposit_fee_rate` | decimal string | 否 | 0~100 |
 | `deposit_fee_rate` | decimal string | 否 | 0~100 |
 | `withdraw_fee_rate` | decimal string | 否 | 0~100 |
 | `withdraw_fee_rate` | decimal string | 否 | 0~100 |
 | `status` | integer | 否 | `1` 启用,`0` 禁用 |
 | `status` | integer | 否 | `1` 启用,`0` 禁用 |
+| `profit_commission_profile_id` | integer | 否 | 切换代理使用的盈亏比例方案 |
 
 
 以下字段禁止在此接口提交:`initial_balance`、`flow_commission_rate`、`profit_commission_rate`。余额走加扣款接口;佣金比例走佣金比例菜单。
 以下字段禁止在此接口提交:`initial_balance`、`flow_commission_rate`、`profit_commission_rate`。余额走加扣款接口;佣金比例走佣金比例菜单。
 
 
+切换 `profit_commission_profile_id` 从操作当天生效,并保存日期快照;补结更早账期仍使用当时绑定的方案比例。
+
 ```json
 ```json
 {
 {
   "id": 12,
   "id": 12,

+ 91 - 39
docs/总后台/代理盈亏佣金比例.md

@@ -2,73 +2,125 @@
 
 
 需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
 需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
 
 
-盈亏佣金基数是公司正盈利:`max(company_profit, 0)`;计算公式为 `基数 × profit_rate ÷ 100`。`company_profit` 按总存款减总提款、优惠/加送和返水计算,手动加扣款不参与。
+## 1. 比例方案列表
 
 
-## 1. 查询比例历史
+### `GET /admin/agent/profit-commission-profiles`
 
 
-### `GET /admin/agent/commission-rules`
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `name` | string | 否 | - | 比例名称模糊查询 |
 
 
-| 参数 | 类型 | 必填 | 说明 |
-|---|---|---:|---|
-| `agent_id` | integer | 否 | 代理 ID;不传返回全部代理比例历史 |
-
-返回 `data[]` 字段:`id`、`agent_id`、`flow_rate`、`profit_rate`、`effective_from`、`status`、`created_by`、`created_at`、`updated_at`、`agent`。
+返回字段:`name`、`live_rate`、`slot_rate`、`lottery_rate`、`sport_rate`、`esports_rate`、`fishing_rate`、`chess_rate`、`is_default`、创建时间。
 
 
 ```json
 ```json
 {
 {
   "code": 0,
   "code": 0,
-  "timestamp": 1787568000,
+  "timestamp": 1787911800,
   "msg": "OK",
   "msg": "OK",
-  "data": [
-    {
-      "id": 20,
-      "agent_id": 12,
-      "flow_rate": "0.3000",
-      "profit_rate": "10.0000",
-      "effective_from": "2026-08-24T00:00:00.000000Z",
-      "status": 1,
-      "created_by": 1,
-      "agent": {"id":12,"username":"agent001"}
-    }
-  ]
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [{
+      "id": 1,
+      "name": "官方默认比例",
+      "live_rate": "30.0000",
+      "slot_rate": "30.0000",
+      "lottery_rate": "30.0000",
+      "sport_rate": "30.0000",
+      "esports_rate": "30.0000",
+      "fishing_rate": "30.0000",
+      "chess_rate": "30.0000",
+      "is_default": 1,
+      "created_at": "2026-08-28 19:10:00"
+    }]
+  }
 }
 }
 ```
 ```
 
 
-## 2. 设置盈亏佣金比例
+## 2. 新增或修改比例方案
+
+### `POST /admin/agent/profit-commission-profiles`
 
 
-### `POST /admin/agent/profit-commission-rule`
+修改时传 `id`,新增时不传。
 
 
 | 参数 | 类型 | 必填 | 说明 |
 | 参数 | 类型 | 必填 | 说明 |
 |---|---|---:|---|
 |---|---|---:|---|
-| `agent_id` | integer | 是 | 代理 ID |
-| `profit_rate` | decimal string | 是 | 盈亏佣金百分比,0~100,最多 4 位小数 |
-| `effective_from` | string | 是 | 必须是操作当天,`Y-m-d` |
+| `id` | integer | 修改时是 | 方案 ID |
+| `name` | string | 是 | 盈亏比例名称,全局唯一,最长 128 |
+| `live_rate` | decimal string | 是 | 真人盈亏比例,0~100 |
+| `slot_rate` | decimal string | 是 | 电子盈亏比例,0~100 |
+| `lottery_rate` | decimal string | 是 | 彩票盈亏比例,0~100 |
+| `sport_rate` | decimal string | 是 | 体育盈亏比例,0~100 |
+| `esports_rate` | decimal string | 是 | 电竞盈亏比例,0~100 |
+| `fishing_rate` | decimal string | 是 | 捕鱼盈亏比例,0~100 |
+| `chess_rate` | decimal string | 是 | 棋牌盈亏比例,0~100 |
+| `is_default` | boolean | 是 | 是否默认;默认方案全系统只能有一条 |
 
 
 ```json
 ```json
 {
 {
-  "agent_id": 12,
-  "profit_rate": "10.0000",
-  "effective_from": "2026-08-24"
+  "name": "一级代理",
+  "live_rate": "5",
+  "slot_rate": "0",
+  "lottery_rate": "0",
+  "sport_rate": "0",
+  "esports_rate": "0",
+  "fishing_rate": "0",
+  "chess_rate": "0",
+  "is_default": false
 }
 }
 ```
 ```
 
 
-成功时 `data` 返回保存后的比例记录:
+成功返回保存后的完整方案
 
 
 ```json
 ```json
 {
 {
   "code": 0,
   "code": 0,
-  "timestamp": 1787568000,
+  "timestamp": 1787911800,
   "msg": "OK",
   "msg": "OK",
   "data": {
   "data": {
-    "id": 20,
-    "agent_id": 12,
-    "flow_rate": "0.3000",
-    "profit_rate": "10.0000",
-    "effective_from": "2026-08-24T00:00:00.000000Z",
-    "status": 1,
-    "created_by": 1
+    "id": 2,
+    "name": "一级代理",
+    "live_rate": "5.0000",
+    "slot_rate": "0.0000",
+    "lottery_rate": "0.0000",
+    "sport_rate": "0.0000",
+    "esports_rate": "0.0000",
+    "fishing_rate": "0.0000",
+    "chess_rate": "0.0000",
+    "is_default": 0,
+    "created_at": "2026-08-29 10:00:00",
+    "updated_at": "2026-08-29 10:00:00"
   }
   }
 }
 }
 ```
 ```
 
 
-下级比例不能高于上级,当前代理比例不能低于已有下级比例,已经入账的账期不能修改。
+将新方案设为默认时,旧默认会自动取消;原先未指定方案或使用旧默认方案的代理会迁移到新默认。默认方案不能直接取消或删除,必须先把另一方案设为默认。
+
+比例数值修改从操作当天生效,后端会为所有使用该方案的代理保存一份不可变的日期快照;补结历史账期时仍使用当时的比例。若当天盈亏佣金已经入账,则不能再修改当天生效的比例。只修改方案名称不会改变历史比例。
+
+## 3. 删除比例方案
+
+### `POST /admin/agent/profit-commission-profiles/delete`
+
+```json
+{"id":2}
+```
+
+非默认方案可以删除;仍在使用该方案的代理会自动切换到当前默认方案。
+
+成功返回:
+
+```json
+{"code":0,"timestamp":1787911800,"msg":"OK","data":[]}
+```
+
+## 4. 给代理指定方案
+
+新增或修改代理时传 `profit_commission_profile_id`。不传时,一级代理使用默认方案;一级代理新增二级代理时,二级代理默认继承一级代理的方案。
+
+盈亏佣金按游戏平台所属游戏类型计算:`max(-会员输赢, 0) × 对应游戏类型比例 ÷ 100`。
+
+首次部署迁移会把旧 `profit_commission_rate` 转成七个分类相同比例的“历史盈亏比例”方案,并按旧 `agent_commission_rules.effective_from` 建立历史快照,不会把原有非零比例重置为 0。

+ 39 - 37
docs/总后台/代理返佣记录.md

@@ -2,6 +2,8 @@
 
 
 需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
 需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
 
 
+仅提供查询,不提供“批量流水返佣”接口。
+
 ## 返佣记录列表
 ## 返佣记录列表
 
 
 ### `GET /admin/agent/commissions`
 ### `GET /admin/agent/commissions`
@@ -11,54 +13,54 @@
 | `page` | integer | 否 | `1` | 页码 |
 | `page` | integer | 否 | `1` | 页码 |
 | `limit` | integer | 否 | `20` | 每页 1~100 |
 | `limit` | integer | 否 | `20` | 每页 1~100 |
 | `agent_id` | integer | 否 | - | 代理 ID |
 | `agent_id` | integer | 否 | - | 代理 ID |
-| `type` | string | 否 | - | `flow` 流水佣金;`profit` 盈亏佣金 |
-| `status` | string | 否 | - | `pending` 待入账;`credited` 已入账 |
-| `start_date` | string | 否 | - | 结算开始日期,`Y-m-d` |
-| `end_date` | string | 否 | - | 结算结束日期,不能早于开始日期 |
-
-```http
-GET /admin/agent/commissions?page=1&limit=20&agent_id=12&type=flow&status=credited&start_date=2026-08-01&end_date=2026-08-24
-```
+| `username` | string | 否 | - | 代理账号模糊查询 |
+| `order_no` | string | 否 | - | 代理返佣单号 |
+| `platform` | string | 否 | - | 游戏平台,例如 `ag`、`pg` |
+| `flow_status` | string | 否 | - | `pending`、`credited` |
+| `profit_status` | string | 否 | - | `pending`、`credited` |
+| `settlement_date` | string | 否 | - | 精确账期,`Y-m-d` |
+| `start_date` | string | 否 | - | 创建开始日期,`Y-m-d` |
+| `end_date` | string | 否 | - | 创建结束日期,`Y-m-d` |
 
 
 ```json
 ```json
 {
 {
   "code": 0,
   "code": 0,
-  "timestamp": 1787568000,
+  "timestamp": 1787911800,
   "msg": "OK",
   "msg": "OK",
   "data": {
   "data": {
     "total": 1,
     "total": 1,
     "page": 1,
     "page": 1,
     "limit": 20,
     "limit": 20,
-    "list": [
-      {
-        "id": 30,
-        "settlement_date": "2026-08-23T00:00:00.000000Z",
-        "agent_id": 12,
-        "type": "flow",
-        "base_amount": "28000.0000",
-        "rate": "0.3000",
-        "amount": "84.0000",
-        "bet_count": 188,
-        "status": "credited",
-        "snapshot": {"agent_ids":[12,15,16,17]},
-        "credited_at": "2026-08-24T04:10:00.000000Z",
-        "created_at": "2026-08-24 04:10:00",
-        "updated_at": "2026-08-24 04:10:00",
-        "agent": {"id":12,"username":"agent001"}
-      }
-    ]
+    "list": [{
+      "id": 30,
+      "order_no": "AR2026082812ABCDEF1234",
+      "settlement_date": "2026-08-28T00:00:00.000000Z",
+      "agent_id": 12,
+      "agent_username": "agent001",
+      "platform": "ag",
+      "game_type": 1,
+      "game_type_text": "真人",
+      "bet_count": 188,
+      "bet_amount": "30000.0000",
+      "valid_bet_amount": "28000.0000",
+      "win_loss": "-3000.0000",
+      "flow_rate": "0.3000",
+      "flow_commission": "84.0000",
+      "flow_status": "credited",
+      "flow_status_text": "已返佣",
+      "profit_rate": "30.0000",
+      "profit_commission": "900.0000",
+      "profit_status": "credited",
+      "profit_status_text": "已返佣",
+      "credited_at": "2026-08-29T04:10:00.000000Z",
+      "created_at": "2026-08-29 04:10:00",
+      "updated_at": "2026-08-29 04:10:00",
+      "agent": {"id":12,"username":"agent001"}
+    }]
   }
   }
 }
 }
 ```
 ```
 
 
-字段说明:
-
-- `settlement_date`:所属账期。
-- `base_amount`:流水佣金为有效投注额;盈亏佣金为公司正盈利。
-- `rate`:结算时锁定的比例快照。
-- `amount`:应返佣金额。
-- `bet_count`:参与计算的注单数。
-- `snapshot.agent_ids`:本次佣金覆盖的代理树 ID 快照。
-- `credited_at`:实际入账时间。
+每条记录按“账期+代理+游戏平台+游戏类型”唯一。流水和盈亏佣金分别使用独立幂等流水入账,重复执行结算不会重复加余额。`start_date/end_date` 对应截图里的“创建日期”;需要按业务账期查时使用 `settlement_date`。
 
 
-每天 04:10 自动结算前一天数据。每个代理、日期、佣金类型只有一条记录,重复执行不会重复入账
+旧 `agent_commissions` 会在迁移时合并为 `platform=legacy`、`game_type=0`、`game_type_text=历史汇总` 的记录继续展示。已经由旧系统入账的账期不会再次生成新平台返佣;若旧账期只完成了一部分,结算命令会停止并要求人工核对,避免重复加款。

+ 3 - 1
routes/admin.php

@@ -89,8 +89,10 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/reports', [AgentAdmin::class, 'reports']);
             Route::get('/reports', [AgentAdmin::class, 'reports']);
             Route::get('/commission-rules', [AgentAdmin::class, 'commissionRules']);
             Route::get('/commission-rules', [AgentAdmin::class, 'commissionRules']);
             Route::post('/flow-commission-rule', [AgentAdmin::class, 'saveFlowCommissionRule']);
             Route::post('/flow-commission-rule', [AgentAdmin::class, 'saveFlowCommissionRule']);
-            Route::post('/profit-commission-rule', [AgentAdmin::class, 'saveProfitCommissionRule']);
             Route::get('/commissions', [AgentAdmin::class, 'commissions']);
             Route::get('/commissions', [AgentAdmin::class, 'commissions']);
+            Route::get('/profit-commission-profiles', [AgentAdmin::class, 'profitCommissionProfiles']);
+            Route::post('/profit-commission-profiles', [AgentAdmin::class, 'saveProfitCommissionProfile']);
+            Route::post('/profit-commission-profiles/delete', [AgentAdmin::class, 'deleteProfitCommissionProfile']);
         });
         });
 
 
         Route::get('/menu/mineMenu', [Menu::class, 'mineMenu']); // 我的菜单
         Route::get('/menu/mineMenu', [Menu::class, 'mineMenu']); // 我的菜单

+ 40 - 0
tests/Unit/AgentProfitCommissionProfileTest.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Models\Agent\AgentProfitCommissionProfile;
+use App\Models\Agent\AgentProfitCommissionAssignment;
+use PHPUnit\Framework\TestCase;
+
+class AgentProfitCommissionProfileTest extends TestCase
+{
+    public function test_game_types_map_to_the_expected_profit_rates(): void
+    {
+        $profile = new AgentProfitCommissionProfile([
+            'live_rate' => '1', 'slot_rate' => '2', 'lottery_rate' => '3', 'sport_rate' => '4',
+            'esports_rate' => '5', 'fishing_rate' => '6', 'chess_rate' => '7',
+        ]);
+
+        $this->assertSame('1.0000', $profile->rateForGameType(1));
+        $this->assertSame('2.0000', $profile->rateForGameType(2));
+        $this->assertSame('3.0000', $profile->rateForGameType(3));
+        $this->assertSame('4.0000', $profile->rateForGameType(4));
+        $this->assertSame('5.0000', $profile->rateForGameType(5));
+        $this->assertSame('6.0000', $profile->rateForGameType(6));
+        $this->assertSame('7.0000', $profile->rateForGameType(7));
+        $this->assertSame('0.0000', $profile->rateForGameType(99));
+    }
+
+    public function test_historical_assignment_uses_its_rate_snapshot(): void
+    {
+        $assignment = new AgentProfitCommissionAssignment([
+            'live_rate' => '11', 'slot_rate' => '12', 'lottery_rate' => '13', 'sport_rate' => '14',
+            'esports_rate' => '15', 'fishing_rate' => '16', 'chess_rate' => '17',
+        ]);
+
+        $this->assertSame('11.0000', $assignment->rateForGameType(1));
+        $this->assertSame('14.0000', $assignment->rateForGameType(4));
+        $this->assertSame('17.0000', $assignment->rateForGameType(7));
+        $this->assertSame('0.0000', $assignment->rateForGameType(99));
+    }
+}