doge 1 час назад
Родитель
Сommit
42e8d3c7fb
36 измененных файлов с 1148 добавлено и 225 удалено
  1. 5 5
      app/Http/Controllers/admin/Agent.php
  2. 4 0
      app/Http/Controllers/admin/RechargeChannel.php
  3. 104 12
      app/Http/Controllers/agent/CommissionController.php
  4. 6 1
      app/Http/Controllers/agent/SubAgentController.php
  5. 4 0
      app/Http/Controllers/api/Settle.php
  6. 1 1
      app/Models/Agent/AgentCommission.php
  7. 1 1
      app/Models/Agent/AgentDailyGameStat.php
  8. 1 1
      app/Models/Agent/AgentDailyStat.php
  9. 1 1
      app/Models/Agent/AgentFlowCommissionAssignment.php
  10. 22 0
      app/Models/Agent/AgentFlowCommissionProfile.php
  11. 1 1
      app/Models/Agent/AgentProfitCommissionAssignment.php
  12. 27 0
      app/Models/Agent/AgentProfitCommissionProfile.php
  13. 1 1
      app/Models/Agent/AgentRebateRecord.php
  14. 1 1
      app/Providers/RouteServiceProvider.php
  15. 135 46
      app/Services/Agent/AgentCommissionService.php
  16. 12 3
      app/Services/Agent/AgentFlowCommissionAssignmentService.php
  17. 123 40
      app/Services/Agent/AgentFlowCommissionProfileService.php
  18. 8 6
      app/Services/Agent/AgentProfitCommissionAssignmentService.php
  19. 150 26
      app/Services/Agent/AgentProfitCommissionProfileService.php
  20. 1 1
      app/Services/Agent/AgentReportService.php
  21. 77 14
      app/Services/Agent/AgentService.php
  22. 91 0
      database/migrations/2026_09_03_120000_add_owner_agent_id_to_commission_profiles.php
  23. 5 42
      docs/代理/代理佣金比例.md
  24. 147 0
      docs/代理/代理流水佣金比例.md
  25. 119 0
      docs/代理/代理盈亏佣金比例.md
  26. 6 3
      docs/代理/所有代理.md
  27. 1 1
      docs/代理/返佣记录.md
  28. 1 1
      docs/前端接口/支付配置/与充值通道关联说明.md
  29. 4 3
      docs/总后台/代理流水佣金比例-前端变更说明.md
  30. 12 8
      docs/总后台/代理流水佣金比例.md
  31. 5 5
      docs/总后台/代理盈亏佣金比例.md
  32. 1 1
      docs/总后台/代理返佣记录.md
  33. 5 0
      routes/agent.php
  34. 38 0
      tests/Unit/AgentCommissionSpreadTest.php
  35. 14 0
      tests/Unit/AgentFlowCommissionProfileTest.php
  36. 14 0
      tests/Unit/AgentProfitCommissionProfileTest.php

+ 5 - 5
app/Http/Controllers/admin/Agent.php

@@ -277,7 +277,7 @@ class Agent extends AgentApiController
             $id = (int)$request->input('id', 0);
             $data = $request->validate([
                 'id' => ['nullable', 'integer', 'exists:agent_flow_commission_profiles,id'],
-                'name' => ['required', 'string', 'max:128', Rule::unique('agent_flow_commission_profiles', 'name')->ignore($id)],
+                'name' => ['required', 'string', 'max:128', Rule::unique('agent_flow_commission_profiles', 'name')->whereNull('owner_agent_id')->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)],
@@ -306,7 +306,7 @@ class Agent extends AgentApiController
             $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)],
+                'name' => ['required', 'string', 'max:128', Rule::unique('agent_profit_commission_profiles', 'name')->whereNull('owner_agent_id')->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)],
@@ -343,9 +343,9 @@ class Agent extends AgentApiController
             'flow_commission_rate' => ['prohibited'],
             'profit_commission_rate' => ['prohibited'],
             'flow_commission_profile_id' => $update
-                ? ['nullable', 'integer', 'exists:agent_flow_commission_profiles,id']
-                : ['required', 'integer', 'exists:agent_flow_commission_profiles,id'],
-            'profit_commission_profile_id' => ['nullable', 'integer', 'exists:agent_profit_commission_profiles,id'],
+                ? ['nullable', 'integer', Rule::exists('agent_flow_commission_profiles', 'id')->whereNull('owner_agent_id')]
+                : ['required', 'integer', Rule::exists('agent_flow_commission_profiles', 'id')->whereNull('owner_agent_id')],
+            'profit_commission_profile_id' => ['nullable', 'integer', Rule::exists('agent_profit_commission_profiles', 'id')->whereNull('owner_agent_id')],
             'status' => ['nullable', 'integer', 'in:0,1'],
             'initial_balance' => $update ? ['prohibited'] : ['nullable', 'numeric', 'min:0', new DecimalNumber()],
         ];

+ 4 - 0
app/Http/Controllers/admin/RechargeChannel.php

@@ -58,6 +58,10 @@ class RechargeChannel extends Controller
                 'activity_type' => ['nullable','array'],
             ]);
             foreach (['recharge_type' => 1, 'withdraw_type' => 2, 'activity_type' => 3] as $field => $dataType) {
+                if ($field === 'activity_type' && !request()->exists('activity_type')) {
+                    unset($params['activity_type']);
+                    continue;
+                }
                 $types = array_values(array_unique(array_filter(array_map('strval', $params[$field] ?? []))));
                 if ($types) {
                     $existingCount = RechargeChannelModel::query()->where('data_type', $dataType)

+ 104 - 12
app/Http/Controllers/agent/CommissionController.php

@@ -3,10 +3,14 @@
 namespace App\Http\Controllers\agent;
 
 use App\Http\Controllers\AgentApiController;
+use App\Models\Agent\Agent;
 use App\Models\Agent\AgentRebateRecord;
-use App\Models\Agent\AgentFlowCommissionProfile;
+use App\Rules\DecimalNumber;
+use App\Services\Agent\AgentFlowCommissionProfileService;
+use App\Services\Agent\AgentProfitCommissionProfileService;
 use App\Services\Agent\AgentReportService;
 use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
 
 class CommissionController extends AgentApiController
 {
@@ -26,7 +30,7 @@ class CommissionController extends AgentApiController
             $page = max(1, (int) ($data['page'] ?? 1));
             $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
             $agent = $this->currentAgent();
-            $ids = !empty($data['include_children']) ? $reports->visibleAgentIds($agent) : [(int) $agent->id];
+            $ids = $request->boolean('include_children') ? $reports->visibleAgentIds($agent) : [(int) $agent->id];
             $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]);
@@ -43,18 +47,106 @@ class CommissionController extends AgentApiController
         });
     }
 
-    public function flowProfiles()
+    public function flowProfiles(Request $request, AgentFlowCommissionProfileService $service)
     {
-        return $this->execute(function () {
-            $root = $this->currentAgent()->loadMissing('flowCommissionProfile');
-            $query = AgentFlowCommissionProfile::query();
-            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
-                if (!$root->flowCommissionProfile) throw new \RuntimeException('当前代理未关联流水佣金方案');
-                $maxRate = (string)$root->flowCommissionProfile->{$field};
-                $query->where($field, '<=', $maxRate);
-            }
-            return $query->orderByDesc('is_default')->orderByDesc('id')->get();
+        return $this->execute(function () use ($request, $service) {
+            return $this->listProfiles($request, $service);
+        });
+    }
+
+    public function saveFlowCommissionProfile(Request $request, AgentFlowCommissionProfileService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $owner = $this->requireLevelOne();
+            $data = $request->validate($this->profileRules('agent_flow_commission_profiles', (int) $owner->id));
+            return $service->save($data, null, $owner);
+        });
+    }
+
+    public function deleteFlowCommissionProfile(Request $request, AgentFlowCommissionProfileService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $owner = $this->requireLevelOne();
+            $data = $request->validate(['id' => ['required', 'integer', 'exists:agent_flow_commission_profiles,id']]);
+            $service->delete((int) $data['id'], null, $owner);
+            return [];
+        });
+    }
+
+    public function profitProfiles(Request $request, AgentProfitCommissionProfileService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            return $this->listProfiles($request, $service);
+        });
+    }
+
+    public function saveProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $owner = $this->requireLevelOne();
+            $data = $request->validate($this->profileRules('agent_profit_commission_profiles', (int) $owner->id));
+            return $service->save($data, null, $owner);
+        });
+    }
+
+    public function deleteProfitCommissionProfile(Request $request, AgentProfitCommissionProfileService $service)
+    {
+        return $this->execute(function () use ($request, $service) {
+            $owner = $this->requireLevelOne();
+            $data = $request->validate(['id' => ['required', 'integer', 'exists:agent_profit_commission_profiles,id']]);
+            $service->delete((int) $data['id'], null, $owner);
+            return [];
         });
     }
 
+    private function listProfiles(
+        Request $request,
+        AgentFlowCommissionProfileService|AgentProfitCommissionProfileService $service
+    ): array {
+        $data = $request->validate([
+            'page' => ['nullable', 'integer', 'min:1'],
+            'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+            'name' => ['nullable', 'string', 'max:128'],
+            'selectable' => ['nullable', 'boolean'],
+        ]);
+        $agent = $this->currentAgent();
+        if ((int) $agent->level !== Agent::LEVEL_ONE) {
+            $page = max(1, (int) ($data['page'] ?? 1));
+            $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
+            return ['total' => 0, 'page' => $page, 'limit' => $limit, 'list' => []];
+        }
+        if ($request->boolean('selectable')) {
+            return $service->paginateSelectable($agent, $data);
+        }
+        return $service->paginate($data, $agent);
+    }
+
+    private function requireLevelOne(): Agent
+    {
+        $agent = $this->currentAgent();
+        if ((int) $agent->level !== Agent::LEVEL_ONE) {
+            throw new \RuntimeException('只有一级代理可以配置下级佣金方案');
+        }
+        return $agent;
+    }
+
+    private function profileRules(string $table, int $ownerId): array
+    {
+        $id = (int) request()->input('id', 0);
+        return [
+            'id' => ['nullable', 'integer', 'exists:' . $table . ',id'],
+            'name' => [
+                'required', 'string', 'max:128',
+                Rule::unique($table, 'name')->where('owner_agent_id', $ownerId)->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'],
+        ];
+    }
 }

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

@@ -25,7 +25,7 @@ class SubAgentController extends AgentApiController
             $page = max(1, (int) ($data['page'] ?? 1));
             $limit = min(100, max(1, (int) ($data['limit'] ?? 20)));
             $query = Agent::query()->with(['parent:id,username', 'flowCommissionProfile', 'profitCommissionProfile']);
-            if (!empty($data['direct_only'])) {
+            if ($request->boolean('direct_only')) {
                 $query->where('parent_id', $root->id);
             } else {
                 $query->whereIn('id', $reports->visibleAgentIds($root, false));
@@ -53,7 +53,9 @@ class SubAgentController extends AgentApiController
                 'deposit_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' => ['prohibited'],
+                'profit_commission_rate' => ['prohibited'],
                 'flow_commission_profile_id' => ['required', 'integer', 'exists:agent_flow_commission_profiles,id'],
+                'profit_commission_profile_id' => ['nullable', 'integer', 'exists:agent_profit_commission_profiles,id'],
                 'status' => ['nullable', 'integer', 'in:0,1'],
             ]);
             return $service->create($data, null, $root);
@@ -71,7 +73,10 @@ class SubAgentController extends AgentApiController
                 'real_name' => ['nullable', 'string', 'max:128'],
                 'deposit_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' => ['prohibited'],
+                'profit_commission_rate' => ['prohibited'],
                 'flow_commission_profile_id' => ['nullable', 'integer', 'exists:agent_flow_commission_profiles,id'],
+                'profit_commission_profile_id' => ['nullable', 'integer', 'exists:agent_profit_commission_profiles,id'],
                 'status' => ['nullable', 'integer', 'in:0,1'],
             ]);
             if ((int) $data['id'] === (int) $root->id) throw new \RuntimeException('不能在下级代理接口修改自己');

+ 4 - 0
app/Http/Controllers/api/Settle.php

@@ -17,6 +17,10 @@ class Settle extends Controller
 {
     public function inject(Request $request)
     {
+        if (!app()->environment('local')) {
+            abort(404);
+        }
+
         $issue_no  = $request->input('issue_no');
         $member_id = $request->input('member_id');
         $keywords  = $request->input('keywords');

+ 1 - 1
app/Models/Agent/AgentCommission.php

@@ -6,7 +6,7 @@ class AgentCommission extends BaseModel
     protected $table = 'agent_commissions';
     protected $fillable = ['settlement_date', 'agent_id', 'type', 'base_amount', 'rate', 'amount', 'bet_count', 'status', 'snapshot', 'credited_at'];
     protected $hidden = [];
-    protected $casts = ['settlement_date' => 'date', 'agent_id' => 'integer', 'base_amount' => 'decimal:4', 'rate' => 'decimal:4', 'amount' => 'decimal:4', 'snapshot' => 'array', 'credited_at' => 'datetime'];
+    protected $casts = ['settlement_date' => 'date:Y-m-d', 'agent_id' => 'integer', 'base_amount' => 'decimal:4', 'rate' => 'decimal:4', 'amount' => 'decimal:4', 'snapshot' => 'array', 'credited_at' => 'datetime'];
 
     public function agent()
     {

+ 1 - 1
app/Models/Agent/AgentDailyGameStat.php

@@ -10,7 +10,7 @@ class AgentDailyGameStat extends BaseModel
     protected $guarded = [];
     protected $hidden = [];
     protected $casts = [
-        'stat_date' => 'date', 'agent_id' => 'integer', 'game_type' => 'integer', 'bet_count' => 'integer',
+        'stat_date' => 'date:Y-m-d', 'agent_id' => 'integer', 'game_type' => 'integer', 'bet_count' => 'integer',
         'bet_amount' => 'decimal:4', 'valid_bet_amount' => 'decimal:4', 'win_loss' => 'decimal:4',
     ];
 }

+ 1 - 1
app/Models/Agent/AgentDailyStat.php

@@ -6,5 +6,5 @@ class AgentDailyStat extends BaseModel
     protected $table = 'agent_daily_stats';
     protected $guarded = [];
     protected $hidden = [];
-    protected $casts = ['stat_date' => 'date', 'agent_id' => 'integer'];
+    protected $casts = ['stat_date' => 'date:Y-m-d', 'agent_id' => 'integer'];
 }

+ 1 - 1
app/Models/Agent/AgentFlowCommissionAssignment.php

@@ -19,7 +19,7 @@ class AgentFlowCommissionAssignment extends BaseModel
         'esports_rate' => 'decimal:4',
         'fishing_rate' => 'decimal:4',
         'chess_rate' => 'decimal:4',
-        'effective_from' => 'date',
+        'effective_from' => 'date:Y-m-d',
         'created_by' => 'integer',
     ];
 

+ 22 - 0
app/Models/Agent/AgentFlowCommissionProfile.php

@@ -28,16 +28,38 @@ class AgentFlowCommissionProfile extends BaseModel
         'esports_rate', 'fishing_rate', 'chess_rate', 'is_default',
     ];
     protected $casts = [
+        'owner_agent_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', 'is_default' => 'integer',
     ];
 
+    public function owner()
+    {
+        return $this->belongsTo(Agent::class, 'owner_agent_id');
+    }
+
     public function agents()
     {
         return $this->hasMany(Agent::class, 'flow_commission_profile_id');
     }
 
+    public function isOfficial(): bool
+    {
+        return empty($this->owner_agent_id);
+    }
+
+    public function isOwnedBy(?int $agentId): bool
+    {
+        return $agentId !== null && (int) $this->owner_agent_id === $agentId;
+    }
+
+    public static function officialDefaultId(): ?int
+    {
+        $id = static::query()->whereNull('owner_agent_id')->where('is_default', 1)->value('id');
+        return $id ? (int) $id : null;
+    }
+
     public function rateForGameType(int $gameType): string
     {
         $field = self::GAME_TYPE_RATE_FIELDS[$gameType] ?? null;

+ 1 - 1
app/Models/Agent/AgentProfitCommissionAssignment.php

@@ -19,7 +19,7 @@ class AgentProfitCommissionAssignment extends BaseModel
         'esports_rate' => 'decimal:4',
         'fishing_rate' => 'decimal:4',
         'chess_rate' => 'decimal:4',
-        'effective_from' => 'date',
+        'effective_from' => 'date:Y-m-d',
         'created_by' => 'integer',
     ];
 

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

@@ -28,11 +28,38 @@ class AgentProfitCommissionProfile extends BaseModel
         'esports_rate', 'fishing_rate', 'chess_rate', 'is_default',
     ];
     protected $casts = [
+        'owner_agent_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', 'is_default' => 'integer',
     ];
 
+    public function owner()
+    {
+        return $this->belongsTo(Agent::class, 'owner_agent_id');
+    }
+
+    public function agents()
+    {
+        return $this->hasMany(Agent::class, 'profit_commission_profile_id');
+    }
+
+    public function isOfficial(): bool
+    {
+        return empty($this->owner_agent_id);
+    }
+
+    public function isOwnedBy(?int $agentId): bool
+    {
+        return $agentId !== null && (int) $this->owner_agent_id === $agentId;
+    }
+
+    public static function officialDefaultId(): ?int
+    {
+        $id = static::query()->whereNull('owner_agent_id')->where('is_default', 1)->value('id');
+        return $id ? (int) $id : null;
+    }
+
     public function rateForGameType(int $gameType): string
     {
         $field = self::GAME_TYPE_RATE_FIELDS[$gameType] ?? null;

+ 1 - 1
app/Models/Agent/AgentRebateRecord.php

@@ -25,7 +25,7 @@ class AgentRebateRecord extends BaseModel
     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',
+        'settlement_date' => 'date:Y-m-d', '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',

+ 1 - 1
app/Providers/RouteServiceProvider.php

@@ -45,7 +45,7 @@ class RouteServiceProvider extends ServiceProvider
             Route::middleware('web')
                 ->group(base_path('routes/web.php'));
 
-            if (file_exists(base_path('routes/api_private.php'))) {
+            if (app()->environment('local') && file_exists(base_path('routes/api_private.php'))) {
                 Route::middleware('api')
                     ->prefix('api')
                     ->group(base_path('routes/api_private.php'));

+ 135 - 46
app/Services/Agent/AgentCommissionService.php

@@ -38,60 +38,35 @@ class AgentCommissionService
                     }
                     throw new \RuntimeException("代理 {$agent->id} 的 {$day} 存在未完成旧佣金记录,请先人工核对");
                 }
-                $descendantIds = $this->reports->visibleAgentIds($agent);
-                $flowAssignment = $this->flowAssignments->at($agent, $day);
-                $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 = $flowAssignment->rateForGameType((int)$gameRow->game_type);
-                    $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);
+                $rows = $this->spreadRows($agent, $day);
+                foreach ($rows as $row) {
                     $record = AgentRebateRecord::query()->firstOrNew([
                         'settlement_date' => $day, 'agent_id' => $agent->id,
-                        'platform' => (string) $gameRow->platform, 'game_type' => (int) $gameRow->game_type,
+                        'platform' => (string) $row['platform'], 'game_type' => (int) $row['game_type'],
                     ]);
                     $isNew = !$record->exists;
                     if ($isNew) {
                         $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,
+                            'order_no' => $record->order_no ?: $this->rebateOrderNo($day, (int) $agent->id, (string) $row['platform'], (int) $row['game_type']),
+                            'bet_count' => (int) $row['bet_count'], 'bet_amount' => $row['bet_amount'],
+                            'valid_bet_amount' => $row['valid_bet_amount'], 'win_loss' => $row['win_loss'],
+                            'flow_rate' => $row['flow_rate'], 'flow_commission' => $row['flow_commission'],
                             'flow_status' => AgentRebateRecord::STATUS_PENDING,
-                            'profit_rate' => $profitRate, 'profit_commission' => $profitAmount,
+                            'profit_rate' => $row['profit_rate'], 'profit_commission' => $row['profit_commission'],
                             'profit_status' => AgentRebateRecord::STATUS_PENDING,
-                            'snapshot' => [
-                                'agent_ids' => $descendantIds,
-                                'flow_profile_id' => $flowAssignment->flow_commission_profile_id,
-                                'flow_assignment_id' => $flowAssignment->id,
-                                'profit_profile_id' => $profitAssignment->profit_commission_profile_id,
-                                'profit_assignment_id' => $profitAssignment->id,
-                            ],
+                            'snapshot' => $row['snapshot'],
                         ]);
-                    } 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,
-                                'flow_profile_id' => $flowAssignment->flow_commission_profile_id,
-                                'flow_assignment_id' => $flowAssignment->id,
-                                'profit_profile_id' => $profitAssignment->profit_commission_profile_id,
-                                'profit_assignment_id' => $profitAssignment->id,
-                            ];
-                        }
+                    } elseif ($record->flow_status === AgentRebateRecord::STATUS_PENDING
+                        && $record->profit_status === AgentRebateRecord::STATUS_PENDING) {
+                        $record->bet_count = (int) $row['bet_count'];
+                        $record->bet_amount = $row['bet_amount'];
+                        $record->valid_bet_amount = $row['valid_bet_amount'];
+                        $record->win_loss = $row['win_loss'];
+                        $record->flow_rate = $row['flow_rate'];
+                        $record->flow_commission = $row['flow_commission'];
+                        $record->profit_rate = $row['profit_rate'];
+                        $record->profit_commission = $row['profit_commission'];
+                        $record->snapshot = $row['snapshot'];
                     }
                     $record->save();
                     if ($isNew) $created++;
@@ -118,6 +93,121 @@ class AgentCommissionService
         return compact('created', 'credited');
     }
 
+    public static function rateSpread(string $parentRate, string $childRate): string
+    {
+        $diff = bcsub($parentRate, $childRate, 4);
+        return bccomp($diff, '0', 4) > 0 ? $diff : '0.0000';
+    }
+
+    public static function amountByRate(string $base, string $rate): string
+    {
+        return bcdiv(bcmul($base, $rate, 8), '100', 4);
+    }
+
+    public static function profitBase(string $winLoss): string
+    {
+        return bccomp($winLoss, '0', 4) < 0 ? bcsub('0', $winLoss, 4) : '0.0000';
+    }
+
+    private function spreadRows(Agent $agent, string $day): array
+    {
+        $flowAssignment = $this->flowAssignments->at($agent, $day);
+        $profitAssignment = $this->profitAssignments->at($agent, $day);
+        $descendantIds = $this->reports->visibleAgentIds($agent);
+        $rows = [];
+        $this->addSegment($rows, $this->gameStats($day, [(int) $agent->id]), $flowAssignment, $profitAssignment, false);
+
+        $children = Agent::query()->where('parent_id', $agent->id)->orderBy('id')->get();
+        $childSnapshots = [];
+        foreach ($children as $child) {
+            $childFlow = $this->flowAssignments->at($child, $day);
+            $childProfit = $this->profitAssignments->at($child, $day);
+            $childIds = $this->reports->visibleAgentIds($child);
+            $this->addSegment($rows, $this->gameStats($day, $childIds), $flowAssignment, $profitAssignment, true, $childFlow, $childProfit);
+            $childSnapshots[] = [
+                'agent_id' => (int) $child->id,
+                'agent_ids' => $childIds,
+                'flow_profile_id' => $childFlow->flow_commission_profile_id,
+                'flow_assignment_id' => $childFlow->id,
+                'profit_profile_id' => $childProfit->profit_commission_profile_id,
+                'profit_assignment_id' => $childProfit->id,
+            ];
+        }
+
+        $snapshot = [
+            'mode' => 'spread',
+            'agent_ids' => $descendantIds,
+            'flow_profile_id' => $flowAssignment->flow_commission_profile_id,
+            'flow_assignment_id' => $flowAssignment->id,
+            'profit_profile_id' => $profitAssignment->profit_commission_profile_id,
+            'profit_assignment_id' => $profitAssignment->id,
+            'children' => $childSnapshots,
+        ];
+        foreach ($rows as &$row) {
+            $row['flow_rate'] = $flowAssignment->rateForGameType((int) $row['game_type']);
+            $row['profit_rate'] = $profitAssignment->rateForGameType((int) $row['game_type']);
+            $row['snapshot'] = $snapshot;
+        }
+        unset($row);
+        return array_values($rows);
+    }
+
+    private function addSegment(
+        array &$rows,
+        $stats,
+        $flowAssignment,
+        $profitAssignment,
+        bool $spread,
+        $childFlow = null,
+        $childProfit = null
+    ): void {
+        foreach ($stats as $stat) {
+            $gameType = (int) $stat->game_type;
+            $flowRate = $spread
+                ? self::rateSpread($flowAssignment->rateForGameType($gameType), $childFlow->rateForGameType($gameType))
+                : $flowAssignment->rateForGameType($gameType);
+            $profitRate = $spread
+                ? self::rateSpread($profitAssignment->rateForGameType($gameType), $childProfit->rateForGameType($gameType))
+                : $profitAssignment->rateForGameType($gameType);
+            $key = (string) $stat->platform . "\0" . $gameType;
+            if (!isset($rows[$key])) {
+                $rows[$key] = [
+                    'platform' => (string) $stat->platform,
+                    'game_type' => $gameType,
+                    'bet_count' => 0,
+                    'bet_amount' => '0.0000',
+                    'valid_bet_amount' => '0.0000',
+                    'win_loss' => '0.0000',
+                    'flow_commission' => '0.0000',
+                    'profit_commission' => '0.0000',
+                ];
+            }
+            $rows[$key]['bet_count'] += (int) $stat->bet_count;
+            $rows[$key]['bet_amount'] = bcadd($rows[$key]['bet_amount'], (string) $stat->bet_amount, 4);
+            $rows[$key]['valid_bet_amount'] = bcadd($rows[$key]['valid_bet_amount'], (string) $stat->valid_bet_amount, 4);
+            $rows[$key]['win_loss'] = bcadd($rows[$key]['win_loss'], (string) $stat->win_loss, 4);
+            $rows[$key]['flow_commission'] = bcadd(
+                $rows[$key]['flow_commission'],
+                self::amountByRate((string) $stat->valid_bet_amount, $flowRate),
+                4
+            );
+            $rows[$key]['profit_commission'] = bcadd(
+                $rows[$key]['profit_commission'],
+                self::amountByRate(self::profitBase((string) $stat->win_loss), $profitRate),
+                4
+            );
+        }
+    }
+
+    private function gameStats(string $day, array $agentIds)
+    {
+        $agentIds = array_values(array_unique(array_filter(array_map('intval', $agentIds))));
+        if ($agentIds === []) return collect();
+        return AgentDailyGameStat::query()->where('stat_date', $day)->whereIn('agent_id', $agentIds)
+            ->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();
+    }
+
     private function creditRebatePart(AgentRebateRecord $record, string $type, string $day, int $agentId): int
     {
         return DB::transaction(function () use ($record, $type, $day, $agentId) {
@@ -145,5 +235,4 @@ class AgentCommissionService
         return 'AR' . str_replace('-', '', $day) . $agentId
             . strtoupper(substr(sha1($platform . '|' . $gameType), 0, 10));
     }
-
 }

+ 12 - 3
app/Services/Agent/AgentFlowCommissionAssignmentService.php

@@ -3,6 +3,7 @@
 namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
+use App\Models\Agent\AgentCommission;
 use App\Models\Agent\AgentFlowCommissionAssignment;
 use App\Models\Agent\AgentFlowCommissionProfile;
 use App\Models\Agent\AgentRebateRecord;
@@ -46,13 +47,13 @@ class AgentFlowCommissionAssignmentService
         $agentIds = array_values(array_unique(array_filter(array_map('intval', $agentIds))));
         if ($agentIds === []) return;
         $day = Carbon::parse($effectiveFrom)->toDateString();
-        $this->assertMutable($agentIds, $day);
         $values = array_merge($profile->rateSnapshot(), [
             'flow_commission_profile_id' => (int)$profile->id,
             'profile_name' => (string)$profile->name,
             'created_by' => $createdBy,
         ]);
         DB::transaction(function () use ($agentIds, $day, $values) {
+            $this->assertMutable($agentIds, $day);
             foreach (array_chunk($agentIds, 500) as $chunk) {
                 foreach ($chunk as $agentId) {
                     AgentFlowCommissionAssignment::query()->updateOrCreate(
@@ -68,8 +69,16 @@ class AgentFlowCommissionAssignmentService
     {
         $newCredited = AgentRebateRecord::query()->whereIn('agent_id', $agentIds)
             ->where('settlement_date', '>=', $day)
-            ->where('flow_status', AgentRebateRecord::STATUS_CREDITED)->exists();
-        if ($newCredited) {
+            ->lockForUpdate()
+            ->pluck('flow_status')
+            ->contains(AgentRebateRecord::STATUS_CREDITED);
+        $legacyCredited = AgentCommission::query()->whereIn('agent_id', $agentIds)
+            ->where('type', 'flow')
+            ->where('settlement_date', '>=', $day)
+            ->lockForUpdate()
+            ->pluck('status')
+            ->contains(AgentRebateRecord::STATUS_CREDITED);
+        if ($newCredited || $legacyCredited) {
             throw new \RuntimeException('生效日期已有流水佣金入账,不能修改比例方案');
         }
     }

+ 123 - 40
app/Services/Agent/AgentFlowCommissionProfileService.php

@@ -4,6 +4,7 @@ namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentFlowCommissionProfile;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Facades\DB;
 
 class AgentFlowCommissionProfileService
@@ -12,86 +13,168 @@ class AgentFlowCommissionProfileService
     {
     }
 
-    public function paginate(array $params): array
+    public function paginate(array $params, ?Agent $owner = null): array
     {
-        $page = max(1, (int)($params['page'] ?? 1));
-        $limit = min(100, max(1, (int)($params['limit'] ?? 20)));
-        $query = AgentFlowCommissionProfile::query();
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $query = $this->ownedQuery($owner);
         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();
+        if ($owner) {
+            $list->each(fn (AgentFlowCommissionProfile $profile) => $profile->setAttribute('editable', true));
+        }
         return compact('total', 'page', 'limit', 'list');
     }
 
-    public function save(array $data, ?int $adminId = null): AgentFlowCommissionProfile
+    public function paginateSelectable(Agent $agent, array $params): array
     {
-        return DB::transaction(function () use ($data, $adminId) {
-            $profiles = AgentFlowCommissionProfile::query()->orderBy('id')->lockForUpdate()->get(['id', 'is_default']);
+        $agent->loadMissing('flowCommissionProfile');
+        if (!$agent->flowCommissionProfile) throw new \RuntimeException('当前代理未关联流水佣金方案');
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $query = AgentFlowCommissionProfile::query()->where(function (Builder $query) use ($agent) {
+            $query->whereNull('owner_agent_id')->orWhere('owner_agent_id', $agent->id);
+        });
+        foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
+            $query->where($field, '<=', (string) $agent->flowCommissionProfile->{$field});
+        }
+        if (!empty($params['name'])) $query->where('name', 'like', '%' . $params['name'] . '%');
+        $total = (clone $query)->count();
+        $list = $query->orderByRaw('owner_agent_id is null')->orderByDesc('is_default')->orderByDesc('id')
+            ->forPage($page, $limit)->get();
+        $list->each(function (AgentFlowCommissionProfile $profile) use ($agent) {
+            $profile->setAttribute('editable', $profile->isOwnedBy((int) $agent->id));
+        });
+        return compact('total', 'page', 'limit', 'list');
+    }
+
+    public function save(array $data, ?int $adminId = null, ?Agent $owner = null): AgentFlowCommissionProfile
+    {
+        return DB::transaction(function () use ($data, $adminId, $owner) {
+            $profiles = $this->ownedQuery($owner)->orderBy('id')->lockForUpdate()->get(['id', 'is_default']);
             $profile = empty($data['id'])
                 ? new AgentFlowCommissionProfile()
                 : AgentFlowCommissionProfile::query()->lockForUpdate()->findOrFail($data['id']);
+            if ($profile->exists) $this->assertOwned($profile, $owner);
+            else $profile->owner_agent_id = $owner?->id;
+
             $oldDefaultId = $profiles->firstWhere('is_default', 1)?->id;
             $isDefault = !empty($data['is_default']);
-            if ($profile->exists && (int)$oldDefaultId === (int)$profile->id && !$isDefault) {
+            if ($profile->exists && (int) $oldDefaultId === (int) $profile->id && !$isDefault) {
                 throw new \RuntimeException('默认流水方案必须始终保留一条,请先将其他方案设为默认');
             }
-            $name = trim((string)$data['name']);
+            $name = trim((string) $data['name']);
             if ($name === '') throw new \InvalidArgumentException('流水方案名称不能为空');
-            $duplicate = AgentFlowCommissionProfile::query()->where('name', $name)
-                ->when($profile->exists, fn($query) => $query->where('id', '<>', $profile->id))->exists();
+            $duplicate = $this->ownedQuery($owner)->where('name', $name)
+                ->when($profile->exists, fn ($query) => $query->where('id', '<>', $profile->id))->exists();
             if ($duplicate) throw new \InvalidArgumentException('流水方案名称已存在');
 
             $profile->name = $name;
-            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $profile->{$field} = (string)$data[$field];
+            foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $profile->{$field} = (string) $data[$field];
+            if ($owner) $this->assertRatesWithinOwner($owner, $profile);
             $willBeDefault = $isDefault || (!$profile->exists && !$oldDefaultId);
             $profile->is_default = $willBeDefault ? 1 : 0;
             $snapshotChanged = !$profile->exists || $profile->isDirty(AgentFlowCommissionProfile::RATE_FIELDS);
             $affectedAgentIds = [];
             if ($snapshotChanged && $profile->exists) {
                 $affectedAgentIds = Agent::query()->where('flow_commission_profile_id', $profile->id)
-                    ->pluck('id')->map(fn($id) => (int)$id)->all();
+                    ->pluck('id')->map(fn ($id) => (int) $id)->all();
             }
             if ($willBeDefault) {
-                $defaultAgentIds = Agent::query()->where(function ($query) use ($oldDefaultId, $profile) {
-                    $query->whereNull('flow_commission_profile_id');
-                    if ($oldDefaultId && (int)$oldDefaultId !== (int)$profile->id) {
-                        $query->orWhere('flow_commission_profile_id', $oldDefaultId);
-                    }
-                })->pluck('id')->map(fn($id) => (int)$id)->all();
+                $defaultAgentIds = $this->defaultMigrationQuery($oldDefaultId, $profile, $owner)
+                    ->pluck('id')->map(fn ($id) => (int) $id)->all();
                 $affectedAgentIds = array_values(array_unique(array_merge($affectedAgentIds, $defaultAgentIds)));
             }
             $this->assertHierarchy($affectedAgentIds, $profile);
             $profile->save();
 
             if ($willBeDefault) {
-                AgentFlowCommissionProfile::query()->where('id', '<>', $profile->id)->update(['is_default' => 0]);
-                Agent::query()->where(function ($query) use ($oldDefaultId) {
-                    $query->whereNull('flow_commission_profile_id');
-                    if ($oldDefaultId) $query->orWhere('flow_commission_profile_id', $oldDefaultId);
-                })->update(['flow_commission_profile_id' => $profile->id]);
+                $this->ownedQuery($owner)->where('id', '<>', $profile->id)->update(['is_default' => 0]);
+                $this->defaultMigrationQuery($oldDefaultId, $profile, $owner)
+                    ->update(['flow_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
+    public function delete(int $id, ?int $adminId = null, ?Agent $owner = null): void
     {
-        DB::transaction(function () use ($id, $adminId) {
+        DB::transaction(function () use ($id, $adminId, $owner) {
             $profile = AgentFlowCommissionProfile::query()->lockForUpdate()->findOrFail($id);
-            if ((int)$profile->is_default === 1) throw new \RuntimeException('默认流水方案不能删除');
-            $default = AgentFlowCommissionProfile::query()->where('is_default', 1)->lockForUpdate()->first();
+            $this->assertOwned($profile, $owner);
+            if ((int) $profile->is_default === 1) throw new \RuntimeException('默认流水方案不能删除');
+            $default = $this->ownedQuery($owner)->where('is_default', 1)->lockForUpdate()->first();
             if (!$default) throw new \RuntimeException('缺少默认流水方案,不能删除');
-            $agentIds = Agent::query()->where('flow_commission_profile_id', $profile->id)
-                ->pluck('id')->map(fn($agentId) => (int)$agentId)->all();
+            $agentIds = $this->agentsUsingProfile($profile->id, $owner);
             $this->assertHierarchy($agentIds, $default);
             $this->assignments->assignMany($agentIds, $default, now()->toDateString(), $adminId);
             Agent::query()->where('flow_commission_profile_id', $profile->id)
+                ->when($owner, fn ($query) => $query->where('parent_id', $owner->id))
                 ->update(['flow_commission_profile_id' => $default->id]);
             $profile->delete();
         });
     }
 
+    private function ownedQuery(?Agent $owner): Builder
+    {
+        $query = AgentFlowCommissionProfile::query();
+        return $owner
+            ? $query->where('owner_agent_id', $owner->id)
+            : $query->whereNull('owner_agent_id');
+    }
+
+    private function defaultMigrationQuery(?int $oldDefaultId, AgentFlowCommissionProfile $profile, ?Agent $owner): Builder
+    {
+        $query = Agent::query()->where(function (Builder $query) use ($oldDefaultId, $profile) {
+            $query->whereNull('flow_commission_profile_id');
+            if ($oldDefaultId && (int) $oldDefaultId !== (int) $profile->id) {
+                $query->orWhere('flow_commission_profile_id', $oldDefaultId);
+            }
+        });
+        return $owner ? $query->where('parent_id', $owner->id) : $query;
+    }
+
+    private function agentsUsingProfile(int $profileId, ?Agent $owner): array
+    {
+        $ids = Agent::query()->where('flow_commission_profile_id', $profileId)
+            ->pluck('id')->map(fn ($agentId) => (int) $agentId);
+        if ($owner) {
+            $childIds = Agent::query()->where('parent_id', $owner->id)
+                ->pluck('id')->map(fn ($agentId) => (int) $agentId);
+            if ($ids->diff($childIds)->isNotEmpty()) {
+                throw new \RuntimeException('该流水方案仍被非直属下级使用,不能删除');
+            }
+            return $ids->intersect($childIds)->values()->all();
+        }
+        return $ids->all();
+    }
+
+    private function assertOwned(AgentFlowCommissionProfile $profile, ?Agent $owner): void
+    {
+        if ($owner) {
+            if (!$profile->isOwnedBy((int) $owner->id)) {
+                throw new \RuntimeException('无权操作该流水方案');
+            }
+            return;
+        }
+        if (!$profile->isOfficial()) {
+            throw new \RuntimeException('总后台只能维护官方流水方案');
+        }
+    }
+
+    private function assertRatesWithinOwner(Agent $owner, AgentFlowCommissionProfile $profile): void
+    {
+        $owner->loadMissing('flowCommissionProfile');
+        if (!$owner->flowCommissionProfile) throw new \RuntimeException('当前代理未关联流水佣金方案');
+        foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
+            if (bccomp((string) $profile->{$field}, (string) $owner->flowCommissionProfile->{$field}, 4) > 0) {
+                throw new \RuntimeException('下级流水方案不能高于当前代理方案');
+            }
+        }
+    }
+
     private function assertHierarchy(array $agentIds, AgentFlowCommissionProfile $candidate): void
     {
         $agentIds = array_values(array_unique(array_map('intval', $agentIds)));
@@ -104,26 +187,26 @@ class AgentFlowCommissionProfileService
         foreach ($agents as $agent) {
             if ($agent->parent) {
                 foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
-                    if (!isset($affected[(int)$agent->parent->id]) && !$agent->parent->flowCommissionProfile) {
+                    if (!isset($affected[(int) $agent->parent->id]) && !$agent->parent->flowCommissionProfile) {
                         throw new \RuntimeException('上级代理未关联流水佣金方案');
                     }
-                    $parentRate = isset($affected[(int)$agent->parent->id])
-                        ? (string)$candidate->{$field}
-                        : (string)$agent->parent->flowCommissionProfile->{$field};
-                    if (bccomp((string)$candidate->{$field}, $parentRate, 4) > 0) {
+                    $parentRate = isset($affected[(int) $agent->parent->id])
+                        ? (string) $candidate->{$field}
+                        : (string) $agent->parent->flowCommissionProfile->{$field};
+                    if (bccomp((string) $candidate->{$field}, $parentRate, 4) > 0) {
                         throw new \RuntimeException('修改后的流水方案会高于上级代理方案');
                     }
                 }
             }
             foreach ($agent->children as $child) {
                 foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) {
-                    if (!isset($affected[(int)$child->id]) && !$child->flowCommissionProfile) {
+                    if (!isset($affected[(int) $child->id]) && !$child->flowCommissionProfile) {
                         throw new \RuntimeException('下级代理未关联流水佣金方案');
                     }
-                    $childRate = isset($affected[(int)$child->id])
-                        ? (string)$candidate->{$field}
-                        : (string)$child->flowCommissionProfile->{$field};
-                    if (bccomp((string)$candidate->{$field}, $childRate, 4) < 0) {
+                    $childRate = isset($affected[(int) $child->id])
+                        ? (string) $candidate->{$field}
+                        : (string) $child->flowCommissionProfile->{$field};
+                    if (bccomp((string) $candidate->{$field}, $childRate, 4) < 0) {
                         throw new \RuntimeException('修改后的流水方案会低于下级代理方案');
                     }
                 }

+ 8 - 6
app/Services/Agent/AgentProfitCommissionAssignmentService.php

@@ -46,16 +46,16 @@ class AgentProfitCommissionAssignmentService
         string $effectiveFrom,
         ?int $createdBy = null
     ): void {
-        $agentIds = array_values(array_unique(array_map('intval', $agentIds)));
+        $agentIds = array_values(array_unique(array_filter(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) {
+            $this->assertMutable($agentIds, $day);
             foreach (array_chunk($agentIds, 500) as $chunk) {
                 foreach ($chunk as $agentId) {
                     AgentProfitCommissionAssignment::query()->updateOrCreate(
@@ -71,13 +71,15 @@ class AgentProfitCommissionAssignmentService
     {
         $newCredited = AgentRebateRecord::query()->whereIn('agent_id', $agentIds)
             ->where('settlement_date', '>=', $day)
-            ->where('profit_status', AgentRebateRecord::STATUS_CREDITED)
-            ->exists();
+            ->lockForUpdate()
+            ->pluck('profit_status')
+            ->contains(AgentRebateRecord::STATUS_CREDITED);
         $legacyCredited = AgentCommission::query()->whereIn('agent_id', $agentIds)
             ->where('type', 'profit')
             ->where('settlement_date', '>=', $day)
-            ->where('status', AgentRebateRecord::STATUS_CREDITED)
-            ->exists();
+            ->lockForUpdate()
+            ->pluck('status')
+            ->contains(AgentRebateRecord::STATUS_CREDITED);
         if ($newCredited || $legacyCredited) {
             throw new \RuntimeException('生效日期已有盈亏佣金入账,不能修改比例方案');
         }

+ 150 - 26
app/Services/Agent/AgentProfitCommissionProfileService.php

@@ -4,6 +4,7 @@ namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentProfitCommissionProfile;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Facades\DB;
 
 class AgentProfitCommissionProfileService
@@ -12,24 +13,52 @@ class AgentProfitCommissionProfileService
     {
     }
 
-    public function paginate(array $params): array
+    public function paginate(array $params, ?Agent $owner = null): array
     {
         $page = max(1, (int) ($params['page'] ?? 1));
         $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
-        $query = AgentProfitCommissionProfile::query();
+        $query = $this->ownedQuery($owner);
         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();
+        if ($owner) {
+            $list->each(fn (AgentProfitCommissionProfile $profile) => $profile->setAttribute('editable', true));
+        }
+        return compact('total', 'page', 'limit', 'list');
+    }
+
+    public function paginateSelectable(Agent $agent, array $params): array
+    {
+        $agent->loadMissing('profitCommissionProfile');
+        if (!$agent->profitCommissionProfile) throw new \RuntimeException('当前代理未关联盈亏佣金方案');
+        $page = max(1, (int) ($params['page'] ?? 1));
+        $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
+        $query = AgentProfitCommissionProfile::query()->where(function (Builder $query) use ($agent) {
+            $query->whereNull('owner_agent_id')->orWhere('owner_agent_id', $agent->id);
+        });
+        foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
+            $query->where($field, '<=', (string) $agent->profitCommissionProfile->{$field});
+        }
+        if (!empty($params['name'])) $query->where('name', 'like', '%' . $params['name'] . '%');
+        $total = (clone $query)->count();
+        $list = $query->orderByRaw('owner_agent_id is null')->orderByDesc('is_default')->orderByDesc('id')
+            ->forPage($page, $limit)->get();
+        $list->each(function (AgentProfitCommissionProfile $profile) use ($agent) {
+            $profile->setAttribute('editable', $profile->isOwnedBy((int) $agent->id));
+        });
         return compact('total', 'page', 'limit', 'list');
     }
 
-    public function save(array $data, ?int $adminId = null): AgentProfitCommissionProfile
+    public function save(array $data, ?int $adminId = null, ?Agent $owner = null): AgentProfitCommissionProfile
     {
-        return DB::transaction(function () use ($data, $adminId) {
-            $profiles = AgentProfitCommissionProfile::query()->orderBy('id')->lockForUpdate()->get(['id', 'is_default']);
+        return DB::transaction(function () use ($data, $adminId, $owner) {
+            $profiles = $this->ownedQuery($owner)->orderBy('id')->lockForUpdate()->get(['id', 'is_default']);
             $profile = empty($data['id'])
                 ? new AgentProfitCommissionProfile()
                 : AgentProfitCommissionProfile::query()->lockForUpdate()->findOrFail($data['id']);
+            if ($profile->exists) $this->assertOwned($profile, $owner);
+            else $profile->owner_agent_id = $owner?->id;
+
             $oldDefaultId = $profiles->firstWhere('is_default', 1)?->id;
             $isDefault = !empty($data['is_default']);
             if ($profile->exists && (int) $oldDefaultId === (int) $profile->id && !$isDefault) {
@@ -37,56 +66,151 @@ class AgentProfitCommissionProfileService
             }
             $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();
+            $duplicate = $this->ownedQuery($owner)->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];
+            if ($owner) $this->assertRatesWithinOwner($owner, $profile);
             $willBeDefault = $isDefault || (!$profile->exists && !$oldDefaultId);
             $profile->is_default = $willBeDefault ? 1 : 0;
-            $snapshotChanged = !$profile->exists
-                || $profile->isDirty(AgentProfitCommissionProfile::RATE_FIELDS);
+            $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();
+                    ->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();
+                $defaultAgentIds = $this->defaultMigrationQuery($oldDefaultId, $profile, $owner)
+                    ->pluck('id')->map(fn ($id) => (int) $id)->all();
                 $affectedAgentIds = array_values(array_unique(array_merge($affectedAgentIds, $defaultAgentIds)));
             }
+            $this->assertHierarchy($affectedAgentIds, $profile);
             $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->ownedQuery($owner)->where('id', '<>', $profile->id)->update(['is_default' => 0]);
+                $this->defaultMigrationQuery($oldDefaultId, $profile, $owner)
+                    ->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
+    public function delete(int $id, ?int $adminId = null, ?Agent $owner = null): void
     {
-        DB::transaction(function () use ($id, $adminId) {
+        DB::transaction(function () use ($id, $adminId, $owner) {
             $profile = AgentProfitCommissionProfile::query()->lockForUpdate()->findOrFail($id);
+            $this->assertOwned($profile, $owner);
             if ((int) $profile->is_default === 1) throw new \RuntimeException('默认比例不能删除');
-            $default = AgentProfitCommissionProfile::query()->where('is_default', 1)->lockForUpdate()->first();
+            $default = $this->ownedQuery($owner)->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();
+            $agentIds = $this->agentsUsingProfile($profile->id, $owner);
+            $this->assertHierarchy($agentIds, $default);
             $this->assignments->assignMany($agentIds, $default, now()->toDateString(), $adminId);
             Agent::query()->where('profit_commission_profile_id', $profile->id)
+                ->when($owner, fn ($query) => $query->where('parent_id', $owner->id))
                 ->update(['profit_commission_profile_id' => $default->id]);
             $profile->delete();
         });
     }
+
+    private function ownedQuery(?Agent $owner): Builder
+    {
+        $query = AgentProfitCommissionProfile::query();
+        return $owner
+            ? $query->where('owner_agent_id', $owner->id)
+            : $query->whereNull('owner_agent_id');
+    }
+
+    private function defaultMigrationQuery(?int $oldDefaultId, AgentProfitCommissionProfile $profile, ?Agent $owner): Builder
+    {
+        $query = Agent::query()->where(function (Builder $query) use ($oldDefaultId, $profile) {
+            $query->whereNull('profit_commission_profile_id');
+            if ($oldDefaultId && (int) $oldDefaultId !== (int) $profile->id) {
+                $query->orWhere('profit_commission_profile_id', $oldDefaultId);
+            }
+        });
+        return $owner ? $query->where('parent_id', $owner->id) : $query;
+    }
+
+    private function agentsUsingProfile(int $profileId, ?Agent $owner): array
+    {
+        $ids = Agent::query()->where('profit_commission_profile_id', $profileId)
+            ->pluck('id')->map(fn ($agentId) => (int) $agentId);
+        if ($owner) {
+            $childIds = Agent::query()->where('parent_id', $owner->id)
+                ->pluck('id')->map(fn ($agentId) => (int) $agentId);
+            if ($ids->diff($childIds)->isNotEmpty()) {
+                throw new \RuntimeException('该盈亏方案仍被非直属下级使用,不能删除');
+            }
+            return $ids->intersect($childIds)->values()->all();
+        }
+        return $ids->all();
+    }
+
+    private function assertOwned(AgentProfitCommissionProfile $profile, ?Agent $owner): void
+    {
+        if ($owner) {
+            if (!$profile->isOwnedBy((int) $owner->id)) {
+                throw new \RuntimeException('无权操作该盈亏方案');
+            }
+            return;
+        }
+        if (!$profile->isOfficial()) {
+            throw new \RuntimeException('总后台只能维护官方盈亏方案');
+        }
+    }
+
+    private function assertRatesWithinOwner(Agent $owner, AgentProfitCommissionProfile $profile): void
+    {
+        $owner->loadMissing('profitCommissionProfile');
+        if (!$owner->profitCommissionProfile) throw new \RuntimeException('当前代理未关联盈亏佣金方案');
+        foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
+            if (bccomp((string) $profile->{$field}, (string) $owner->profitCommissionProfile->{$field}, 4) > 0) {
+                throw new \RuntimeException('下级盈亏方案不能高于当前代理方案');
+            }
+        }
+    }
+
+    private function assertHierarchy(array $agentIds, AgentProfitCommissionProfile $candidate): void
+    {
+        $agentIds = array_values(array_unique(array_map('intval', $agentIds)));
+        if ($agentIds === []) return;
+        $affected = array_fill_keys($agentIds, true);
+        $agents = Agent::query()->with([
+            'parent.profitCommissionProfile',
+            'children.profitCommissionProfile',
+        ])->whereIn('id', $agentIds)->get();
+        foreach ($agents as $agent) {
+            if ($agent->parent) {
+                foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
+                    if (!isset($affected[(int) $agent->parent->id]) && !$agent->parent->profitCommissionProfile) {
+                        throw new \RuntimeException('上级代理未关联盈亏佣金方案');
+                    }
+                    $parentRate = isset($affected[(int) $agent->parent->id])
+                        ? (string) $candidate->{$field}
+                        : (string) $agent->parent->profitCommissionProfile->{$field};
+                    if (bccomp((string) $candidate->{$field}, $parentRate, 4) > 0) {
+                        throw new \RuntimeException('修改后的盈亏方案会高于上级代理方案');
+                    }
+                }
+            }
+            foreach ($agent->children as $child) {
+                foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
+                    if (!isset($affected[(int) $child->id]) && !$child->profitCommissionProfile) {
+                        throw new \RuntimeException('下级代理未关联盈亏佣金方案');
+                    }
+                    $childRate = isset($affected[(int) $child->id])
+                        ? (string) $candidate->{$field}
+                        : (string) $child->profitCommissionProfile->{$field};
+                    if (bccomp((string) $candidate->{$field}, $childRate, 4) < 0) {
+                        throw new \RuntimeException('修改后的盈亏方案会低于下级代理方案');
+                    }
+                }
+            }
+        }
+    }
 }

+ 1 - 1
app/Services/Agent/AgentReportService.php

@@ -275,7 +275,7 @@ class AgentReportService
                 ->selectRaw("COUNT(*) bet_count, COALESCE(SUM({$amountExpression}),0) bet_amount, COALESCE(SUM({$amountExpression}),0) valid_bet_amount, COALESCE(SUM({$winLossExpression}),0) win_loss")->first();
         }
         if (Schema::hasTable('third_game_orders')) {
-            $sources[] = DB::table('third_game_orders')->whereIn('member_id', clone $memberIds)->where('status', 1)
+            $sources[] = DB::table('third_game_orders')->whereIn('user_id', (clone $members)->select('id'))->where('status', 1)
                 ->whereBetween('last_update_time', [$start, $end])
                 ->selectRaw('COUNT(*) bet_count, COALESCE(SUM(bet_amount),0) bet_amount, COALESCE(SUM(valid_amount),0) valid_bet_amount, COALESCE(SUM(settled_amount),0) win_loss')->first();
         }

+ 77 - 14
app/Services/Agent/AgentService.php

@@ -42,16 +42,17 @@ class AgentService
             }
             $flowProfileId = (int)($data['flow_commission_profile_id']
                 ?? $parent?->flow_commission_profile_id
-                ?? AgentFlowCommissionProfile::query()->where('is_default', 1)->value('id'));
+                ?? AgentFlowCommissionProfile::officialDefaultId());
             if ($flowProfileId <= 0) throw new \RuntimeException('请先配置默认代理流水佣金方案');
             $flowProfile = AgentFlowCommissionProfile::query()->findOrFail($flowProfileId);
-            if ($parent) $this->assertFlowProfileWithinParent($parent, $flowProfile);
+            $this->assertProfileAssignable($parent, $flowProfile, $scopeAgent);
 
             $profitProfileId = (int) ($data['profit_commission_profile_id']
                 ?? $parent?->profit_commission_profile_id
-                ?? AgentProfitCommissionProfile::query()->where('is_default', 1)->value('id'));
+                ?? AgentProfitCommissionProfile::officialDefaultId());
             if ($profitProfileId <= 0) throw new \RuntimeException('请先配置默认代理盈亏佣金比例');
             $profitProfile = AgentProfitCommissionProfile::query()->findOrFail($profitProfileId);
+            $this->assertProfileAssignable($parent, $profitProfile, $scopeAgent);
 
             $agent = Agent::query()->create([
                 'parent_id' => $parent?->id,
@@ -96,6 +97,7 @@ class AgentService
                 && (int) $data['profit_commission_profile_id'] !== (int) $agent->profit_commission_profile_id) {
                 $newProfitProfile = AgentProfitCommissionProfile::query()
                     ->findOrFail((int) $data['profit_commission_profile_id']);
+                $this->assertProfitProfileCoversChildren($agent, $newProfitProfile);
             }
             if ($scopeAgent) {
                 $this->assertVisible($scopeAgent, (int) $agent->id);
@@ -103,13 +105,13 @@ class AgentService
                 $this->move(
                     $agent,
                     $data['parent_id'] === null ? null : (int)$data['parent_id'],
-                    $newFlowProfile
+                    $newFlowProfile,
+                    $newProfitProfile
                 );
             }
-            if ($newFlowProfile && $agent->parent_id) {
-                $newParent = Agent::query()->findOrFail($agent->parent_id);
-                $this->assertFlowProfileWithinParent($newParent, $newFlowProfile);
-            }
+            $parent = $agent->parent_id ? Agent::query()->findOrFail($agent->parent_id) : null;
+            if ($newFlowProfile) $this->assertProfileAssignable($parent, $newFlowProfile, $scopeAgent);
+            if ($newProfitProfile) $this->assertProfileAssignable($parent, $newProfitProfile, $scopeAgent);
             if (array_key_exists('real_name', $data)) {
                 $agent->real_name = trim((string) ($data['real_name'] ?? ''));
             }
@@ -154,6 +156,27 @@ class AgentService
         return $code;
     }
 
+    private function assertProfileAssignable(
+        ?Agent $parent,
+        AgentFlowCommissionProfile|AgentProfitCommissionProfile $profile,
+        ?Agent $scopeAgent = null
+    ): void {
+        $isFlow = $profile instanceof AgentFlowCommissionProfile;
+        if ($scopeAgent) {
+            if (!$parent) throw new \RuntimeException('一级代理只能给直属下级指定方案');
+            if (!$profile->isOfficial() && !$profile->isOwnedBy((int) $parent->id)) {
+                throw new \RuntimeException($isFlow ? '只能使用官方方案或自己创建的流水方案' : '只能使用官方方案或自己创建的盈亏方案');
+            }
+        } elseif (!$profile->isOfficial()) {
+            throw new \RuntimeException($isFlow ? '总后台只能给代理指定官方流水方案' : '总后台只能给代理指定官方盈亏方案');
+        }
+        if ($parent) {
+            $isFlow
+                ? $this->assertFlowProfileWithinParent($parent, $profile)
+                : $this->assertProfitProfileWithinParent($parent, $profile);
+        }
+    }
+
     private function assertFlowProfileWithinParent(Agent $parent, AgentFlowCommissionProfile $profile): void
     {
         $parent->loadMissing('flowCommissionProfile');
@@ -166,6 +189,18 @@ class AgentService
         }
     }
 
+    private function assertProfitProfileWithinParent(Agent $parent, AgentProfitCommissionProfile $profile): void
+    {
+        $parent->loadMissing('profitCommissionProfile');
+        foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
+            if (!$parent->profitCommissionProfile) throw new \RuntimeException('上级代理未关联盈亏佣金方案');
+            $parentRate = (string) $parent->profitCommissionProfile->{$field};
+            if (bccomp((string) $profile->{$field}, $parentRate, 4) > 0) {
+                throw new \RuntimeException('下级代理盈亏佣金方案不能高于上级方案');
+            }
+        }
+    }
+
     private function assertFlowProfileCoversChildren(Agent $agent, AgentFlowCommissionProfile $profile): void
     {
         $children = Agent::query()->with('flowCommissionProfile')->where('parent_id', $agent->id)->get();
@@ -180,8 +215,26 @@ class AgentService
         }
     }
 
-    private function move(Agent $agent, ?int $parentId, ?AgentFlowCommissionProfile $flowProfile = null): void
+    private function assertProfitProfileCoversChildren(Agent $agent, AgentProfitCommissionProfile $profile): void
     {
+        $children = Agent::query()->with('profitCommissionProfile')->where('parent_id', $agent->id)->get();
+        foreach ($children as $child) {
+            foreach (AgentProfitCommissionProfile::RATE_FIELDS as $field) {
+                if (!$child->profitCommissionProfile) throw new \RuntimeException('下级代理未关联盈亏佣金方案');
+                $childRate = (string) $child->profitCommissionProfile->{$field};
+                if (bccomp((string) $profile->{$field}, $childRate, 4) < 0) {
+                    throw new \RuntimeException('代理盈亏佣金方案不能低于现有下级方案');
+                }
+            }
+        }
+    }
+
+    private function move(
+        Agent $agent,
+        ?int $parentId,
+        ?AgentFlowCommissionProfile $flowProfile = null,
+        ?AgentProfitCommissionProfile $profitProfile = null
+    ): void {
         if ($parentId === (int) $agent->id) {
             throw new \RuntimeException('上级代理不能是自己');
         }
@@ -197,12 +250,22 @@ class AgentService
             throw new \RuntimeException('二级代理不能作为上级代理');
         }
         if ($parent) {
-            $agent->loadMissing('flowCommissionProfile');
+            $agent->loadMissing(['flowCommissionProfile', 'profitCommissionProfile']);
             $effectiveFlowProfile = $flowProfile ?: $agent->flowCommissionProfile;
-            if ($effectiveFlowProfile) {
-                $this->assertFlowProfileWithinParent($parent, $effectiveFlowProfile);
-            } else {
-                throw new \RuntimeException('代理未关联流水佣金方案');
+            if (!$effectiveFlowProfile) throw new \RuntimeException('代理未关联流水佣金方案');
+            $this->assertProfileAssignable($parent, $effectiveFlowProfile);
+            $effectiveProfitProfile = $profitProfile ?: $agent->profitCommissionProfile;
+            if (!$effectiveProfitProfile) throw new \RuntimeException('代理未关联盈亏佣金方案');
+            $this->assertProfileAssignable($parent, $effectiveProfitProfile);
+        } else {
+            $agent->loadMissing(['flowCommissionProfile', 'profitCommissionProfile']);
+            $effectiveFlowProfile = $flowProfile ?: $agent->flowCommissionProfile;
+            if ($effectiveFlowProfile && !$effectiveFlowProfile->isOfficial()) {
+                throw new \RuntimeException('一级代理只能使用官方流水方案');
+            }
+            $effectiveProfitProfile = $profitProfile ?: $agent->profitCommissionProfile;
+            if ($effectiveProfitProfile && !$effectiveProfitProfile->isOfficial()) {
+                throw new \RuntimeException('一级代理只能使用官方盈亏方案');
             }
         }
         $oldPath = (string) $agent->path;

+ 91 - 0
database/migrations/2026_09_03_120000_add_owner_agent_id_to_commission_profiles.php

@@ -0,0 +1,91 @@
+<?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 TABLES = [
+        'agent_flow_commission_profiles',
+        'agent_profit_commission_profiles',
+    ];
+
+    public function up(): void
+    {
+        foreach (self::TABLES as $table) {
+            $this->migrateTable($table);
+        }
+    }
+
+    public function down(): void
+    {
+        foreach (self::TABLES as $table) {
+            if (!Schema::hasTable($table) || !Schema::hasColumn($table, 'owner_agent_id')) continue;
+            Schema::table($table, function (Blueprint $blueprint) {
+                $blueprint->dropColumn('owner_agent_id');
+            });
+        }
+    }
+
+    private function migrateTable(string $table): void
+    {
+        if (!Schema::hasTable($table)) return;
+        if (!Schema::hasColumn($table, 'owner_agent_id')) {
+            Schema::table($table, function (Blueprint $blueprint) {
+                $blueprint->unsignedBigInteger('owner_agent_id')->nullable()->index()->after('id');
+            });
+        }
+        $this->dropUniqueIfMatches($table, ['name']);
+        $ownerNameIndex = $table === 'agent_flow_commission_profiles'
+            ? 'idx_agent_flow_profile_owner_name'
+            : 'idx_agent_profit_profile_owner_name';
+        if (!$this->indexExists($table, $ownerNameIndex, ['owner_agent_id', 'name'], false)) {
+            Schema::table($table, function (Blueprint $blueprint) use ($ownerNameIndex) {
+                $blueprint->index(['owner_agent_id', 'name'], $ownerNameIndex);
+            });
+        }
+    }
+
+    private function dropUniqueIfMatches(string $table, array $columns): void
+    {
+        $physicalTable = DB::getTablePrefix() . $table;
+        $rows = DB::select(
+            'SELECT index_name, column_name, seq_in_index, non_unique FROM information_schema.statistics '
+            . 'WHERE table_schema = DATABASE() AND table_name = ? ORDER BY index_name, seq_in_index',
+            [$physicalTable]
+        );
+        $grouped = [];
+        foreach ($rows as $row) {
+            $grouped[$row->index_name]['columns'][] = (string) $row->column_name;
+            $grouped[$row->index_name]['unique'] = (int) $row->non_unique === 0;
+        }
+        foreach ($grouped as $name => $definition) {
+            if (!$definition['unique'] || $definition['columns'] !== $columns) continue;
+            Schema::table($table, function (Blueprint $blueprint) use ($name) {
+                $blueprint->dropUnique($name);
+            });
+        }
+    }
+
+    private function indexExists(string $table, string $index, array $columns, bool $unique): bool
+    {
+        $physicalTable = DB::getTablePrefix() . $table;
+        $rows = DB::select(
+            'SELECT index_name, column_name, seq_in_index, non_unique FROM information_schema.statistics '
+            . 'WHERE table_schema = DATABASE() AND table_name = ? ORDER BY index_name, seq_in_index',
+            [$physicalTable]
+        );
+        $grouped = [];
+        foreach ($rows as $row) {
+            $grouped[$row->index_name]['columns'][] = (string) $row->column_name;
+            $grouped[$row->index_name]['unique'] = (int) $row->non_unique === 0;
+        }
+        foreach ($grouped as $name => $definition) {
+            if ($name === $index) return true;
+            if ($definition['columns'] === $columns && (!$unique || $definition['unique'])) return true;
+        }
+        return false;
+    }
+};

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

@@ -1,45 +1,8 @@
-# 代理后台-流水佣金方案
+# 代理后台-佣金比例
 
-需要请求头 `Authorization: Bearer <agent_token>`。
+代理后台的佣金比例分成两套方案,和总后台对应:
 
-## 可选择方案
+- [流水佣金比例](./代理流水佣金比例.md)
+- [盈亏佣金比例](./代理盈亏佣金比例.md)
 
-### `GET /agent/flow-commission-profiles`
-
-无参数。仅返回七个分类比例都不高于当前代理方案的记录,可直接用于“新增下级代理”的 Select。
-
-返回字段:`id`、`name`、`live_rate`、`slot_rate`、`lottery_rate`、`sport_rate`、`esports_rate`、`fishing_rate`、`chess_rate`、`is_default`。
-
-```json
-{
-  "code": 0,
-  "data": [{
-    "id": 2,
-    "name": "二级代理流水方案",
-    "live_rate": "0.2000",
-    "slot_rate": "0.1500",
-    "lottery_rate": "0.1000",
-    "sport_rate": "0.1200",
-    "esports_rate": "0.1000",
-    "fishing_rate": "0.1500",
-    "chess_rate": "0.1200",
-    "is_default": 0
-  }]
-}
-```
-
-新增下级代理时向 `POST /agent/sub-agents` 传:
-
-```json
-{
-  "username": "sub001",
-  "password": "123456",
-  "flow_commission_profile_id": 2
-}
-```
-
-切换已有下级方案可通过 `POST /agent/sub-agents/update` 传 `id` 和 `flow_commission_profile_id`。后端仍会校验不能高于当前代理方案、不能低于该下级已有的下级方案。
-
-流水佣金按游戏分类计算:`对应分类有效投注额 × 对应分类返佣比例 ÷ 100`。
-
-不提供单代理 `flow_rate` 接口,所有流水佣金配置都通过方案完成。
+总后台先给一级代理指定官方方案;一级代理再在上述两个菜单里配置二级代理可用的方案。二级代理不能继续配置下级。

+ 147 - 0
docs/代理/代理流水佣金比例.md

@@ -0,0 +1,147 @@
+# 代理后台-流水佣金比例
+
+需要请求头 `Authorization: Bearer <agent_token>`。只有一级代理可以维护方案;二级代理调用保存/删除会返回业务错误,列表返回空。
+
+总后台给一级代理指定官方方案后,一级代理在自己的方案池里再为二级代理配置更低或持平的方案。一级代理自己使用的方案仍由总后台指定,不会因为这里设默认而改变。
+
+## 1. 方案列表
+
+### `GET /agent/flow-commission-profiles`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `name` | string | 否 | - | 方案名称模糊查询 |
+| `selectable` | boolean | 否 | `false` | `true/1` 时返回「新增/修改下级」可用的 Select 数据 |
+
+默认只返回当前一级代理自己创建的方案,用于方案管理页。
+
+```json
+{
+  "code": 0,
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [{
+      "id": 12,
+      "owner_agent_id": 5,
+      "name": "二级代理流水方案",
+      "live_rate": "0.2000",
+      "slot_rate": "0.1500",
+      "lottery_rate": "0.1000",
+      "sport_rate": "0.1200",
+      "esports_rate": "0.1000",
+      "fishing_rate": "0.1500",
+      "chess_rate": "0.1200",
+      "is_default": 1,
+      "editable": true,
+      "created_at": "2026-09-03 12:00:00"
+    }]
+  }
+}
+```
+
+字段翻译:
+
+| 字段 | 中文含义 |
+|---|---|
+| `name` | 返佣比例名称 |
+| `live_rate` | 真人返佣比例(%) |
+| `slot_rate` | 老虎/老虎机返佣比例(%) |
+| `lottery_rate` | 彩票返佣比例(%) |
+| `sport_rate` | 体育返佣比例(%) |
+| `esports_rate` | 电竞返佣比例(%) |
+| `fishing_rate` | 捕鱼返佣比例(%) |
+| `chess_rate` | 棋牌返佣比例(%) |
+| `is_default` | 是否默认;每个一级代理自己的方案池只能有一条默认 |
+| `editable` | 是否可编辑删除;自己创建的为 `true`,官方方案为 `false` |
+| `owner_agent_id` | 归属代理;官方方案为 `null` |
+
+## 2. 新增或修改方案
+
+### `POST /agent/flow-commission-profiles`
+
+修改传 `id`,新增不传。七个比例均必填,范围 0~100,最多 4 位小数,且每一项都不能高于当前一级代理自己的方案。
+
+```json
+{
+  "name": "二级代理流水方案",
+  "live_rate": "0.2",
+  "slot_rate": "0.15",
+  "lottery_rate": "0.1",
+  "sport_rate": "0.12",
+  "esports_rate": "0.1",
+  "fishing_rate": "0.15",
+  "chess_rate": "0.12",
+  "is_default": true
+}
+```
+
+规则:
+
+- 默认方案必须始终保留一条;本池第一条方案会自动成为默认。
+- 将其他方案设为默认后,旧默认自动取消;直属下级里未指定方案或仍使用旧默认的账号会切到新默认。一级代理自己被总后台指定的方案不受影响。
+- 不能修改或删除官方方案、其他代理创建的方案。
+- 修改比例从操作当天生效,后端为关联下级写入不可变日期快照;补结旧日期仍使用当时方案。
+- 若生效日期之后已有流水佣金入账,禁止修改相关代理的历史方案。
+- 下级方案各分类比例不能高于上级、上级不能低于现有下级。
+
+## 3. 删除方案
+
+### `POST /agent/flow-commission-profiles/delete`
+
+```json
+{"id":12}
+```
+
+默认方案不能删除。非默认方案删除后,使用该方案的直属下级会切换到当前一级代理方案池的默认方案,并从当天建立新快照。
+
+## 4. 新增下级时选择方案
+
+先请求可选方案列表,把 `data.list` 渲染为 Select:
+
+```http
+GET /agent/flow-commission-profiles?selectable=1&limit=100
+```
+
+这里会同时返回:
+
+- 当前一级代理自己创建的方案
+- 七个分类比例都不高于当前代理方案的官方方案
+
+`editable=true` 的才出现在方案管理页的编辑/删除按钮上;Select 只用 `id` 和 `name`。
+
+```text
+Select value = profile.id
+Select label = profile.name
+```
+
+新增下级代理:
+
+```json
+{
+  "username": "sub001",
+  "password": "123456",
+  "flow_commission_profile_id": 12,
+  "profit_commission_profile_id": 8,
+  "status": 1
+}
+```
+
+`POST /agent/sub-agents` 中 `flow_commission_profile_id` 必填。修改下级使用 `POST /agent/sub-agents/update` 切换方案。
+
+## 5. 结算口径
+
+流水佣金按级差计算,一级代理拿自己的全额比例,下级会员只拿「自己的比例 − 下级比例」。
+
+```text
+直属会员:流水佣金 = 该分类有效投注额 × 自己的返佣比例 ÷ 100
+下级会员:下级佣金 = 有效投注额 × 下级返佣比例 ÷ 100
+          上级佣金 = 有效投注额 × (上级比例 − 下级比例) ÷ 100
+```
+
+例如上级真人 5%、下级真人 4%:下级会员 1000 有效投注,下级拿 40,上级拿 10;上级自己的会员 1000 有效投注,上级拿 50。
+
+结算记录会保存 `flow_profile_id`、`flow_assignment_id` 以及下级方案快照。不提供单代理 `flow_rate` 接口。

+ 119 - 0
docs/代理/代理盈亏佣金比例.md

@@ -0,0 +1,119 @@
+# 代理后台-盈亏佣金比例
+
+需要请求头 `Authorization: Bearer <agent_token>`。只有一级代理可以维护方案;二级代理调用保存/删除会返回业务错误,列表返回空。
+
+总后台给一级代理指定官方盈亏方案后,一级代理在自己的方案池里再为二级代理配置更低或持平的方案。一级代理自己使用的方案仍由总后台指定,不会因为这里设默认而改变。
+
+## 1. 比例方案列表
+
+### `GET /agent/profit-commission-profiles`
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `page` | integer | 否 | `1` | 页码 |
+| `limit` | integer | 否 | `20` | 每页 1~100 |
+| `name` | string | 否 | - | 比例名称模糊查询 |
+| `selectable` | boolean | 否 | `false` | `true/1` 时返回「新增/修改下级」可用的 Select 数据 |
+
+默认只返回当前一级代理自己创建的方案,用于方案管理页。
+
+返回字段:`name`、`live_rate`、`slot_rate`、`lottery_rate`、`sport_rate`、`esports_rate`、`fishing_rate`、`chess_rate`、`is_default`、`editable`、`owner_agent_id`、创建时间。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1787911800,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [{
+      "id": 8,
+      "owner_agent_id": 5,
+      "name": "二级代理盈亏方案",
+      "live_rate": "20.0000",
+      "slot_rate": "20.0000",
+      "lottery_rate": "20.0000",
+      "sport_rate": "20.0000",
+      "esports_rate": "20.0000",
+      "fishing_rate": "20.0000",
+      "chess_rate": "20.0000",
+      "is_default": 1,
+      "editable": true,
+      "created_at": "2026-09-03 12:00:00"
+    }]
+  }
+}
+```
+
+## 2. 新增或修改比例方案
+
+### `POST /agent/profit-commission-profiles`
+
+修改时传 `id`,新增时不传。
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `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
+{
+  "name": "二级代理盈亏方案",
+  "live_rate": "20",
+  "slot_rate": "20",
+  "lottery_rate": "20",
+  "sport_rate": "20",
+  "esports_rate": "20",
+  "fishing_rate": "20",
+  "chess_rate": "20",
+  "is_default": true
+}
+```
+
+成功返回保存后的完整方案。
+
+将新方案设为默认时,旧默认会自动取消;直属下级里原先未指定方案或使用旧默认方案的账号会迁移到新默认。默认方案不能直接取消或删除,必须先把另一方案设为默认。不能修改或删除官方方案。
+
+比例数值修改从操作当天生效,后端会为所有使用该方案的下级保存一份不可变的日期快照;补结历史账期时仍使用当时的比例。若当天盈亏佣金已经入账,则不能再修改当天生效的比例。只修改方案名称不会改变历史比例。
+
+下级方案各分类比例不能高于上级、上级不能低于现有下级。
+
+## 3. 删除比例方案
+
+### `POST /agent/profit-commission-profiles/delete`
+
+```json
+{"id":8}
+```
+
+非默认方案可以删除;仍在使用该方案的直属下级会自动切换到当前一级代理方案池的默认方案。
+
+成功返回:
+
+```json
+{"code":0,"timestamp":1787911800,"msg":"OK","data":[]}
+```
+
+## 4. 给下级指定方案
+
+新增或修改下级时传 `profit_commission_profile_id`。不传时,二级代理继承一级代理自己正在使用的方案。
+
+可选方案列表:
+
+```http
+GET /agent/profit-commission-profiles?selectable=1&limit=100
+```
+
+这里会同时返回自己创建的方案,以及七个分类比例都不高于当前代理方案的官方方案。Select 使用 `id` 作为 value、`name` 作为 label。
+
+盈亏佣金同样按级差计算:`max(-会员输赢, 0) × 对应游戏类型比例 ÷ 100`。直属会员用自己的全额比例;下级会员由下级拿其比例,上级只拿差额。

+ 6 - 3
docs/代理/所有代理.md

@@ -75,7 +75,8 @@ GET /agent/sub-agents?page=1&limit=20&direct_only=1
 | `real_name` | string | 否 | 最长 128 |
 | `deposit_fee_rate` | decimal string | 否 | 0~100 |
 | `withdraw_fee_rate` | decimal string | 否 | 0~100 |
-| `flow_commission_profile_id` | integer | 是 | 从 `GET /agent/flow-commission-profiles` 选择;各分类不能高于上级方案 |
+| `flow_commission_profile_id` | integer | 是 | 从 `GET /agent/flow-commission-profiles?selectable=1` 选择;各分类不能高于上级方案 |
+| `profit_commission_profile_id` | integer | 否 | 从 `GET /agent/profit-commission-profiles?selectable=1` 选择;不传则继承当前代理方案 |
 | `status` | integer | 否 | `1` 启用,`0` 禁用;默认 `1` |
 
 ```json
@@ -85,11 +86,12 @@ GET /agent/sub-agents?page=1&limit=20&direct_only=1
   "withdraw_password": "654321",
   "real_name": "李四",
   "flow_commission_profile_id": 2,
+  "profit_commission_profile_id": 8,
   "status": 1
 }
 ```
 
-前端不要传 `parent_id`。流水方案必须选择;二级代理自动继承一级代理的盈亏比例方案。成功时 `data` 返回新代理对象及 `flow_commission_profile` 关系。
+前端不要传 `parent_id`。流水方案必须选择;盈亏方案可选,不传则二级代理继承一级代理正在使用的方案。成功时 `data` 返回新代理对象及方案关系。
 
 ## 3. 修改下级代理
 
@@ -104,9 +106,10 @@ GET /agent/sub-agents?page=1&limit=20&direct_only=1
 | `deposit_fee_rate` | decimal string | 否 | 0~100 |
 | `withdraw_fee_rate` | decimal string | 否 | 0~100 |
 | `flow_commission_profile_id` | integer | 否 | 切换流水佣金方案 |
+| `profit_commission_profile_id` | integer | 否 | 切换盈亏佣金方案 |
 | `status` | integer | 否 | `1` 启用,`0` 禁用 |
 
-账号和上级不能通过此接口修改;流水佣金通过方案 ID 切换。
+账号和上级不能通过此接口修改;流水和盈亏佣金通过方案 ID 切换。
 
 ```json
 {"id":15,"real_name":"李四","withdraw_fee_rate":"0.8","status":1}

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

@@ -67,6 +67,6 @@ GET /agent/commissions?page=1&limit=20&flow_status=credited&profit_status=credit
 
 每日 04:10 按游戏平台结算前一天数据,流水佣金和盈亏佣金分别写入代理资金流水。
 
-`flow_rate` 来自该结算日有效的 `flow_commission_profile` 对应游戏分类比例;记录快照保存 `flow_profile_id`、`flow_assignment_id`,后续修改方案不会改写历史返佣。
+`flow_rate` / `profit_rate` 是该代理自己的合同比例。佣金按级差入账:直属会员用全额比例,下级会员只拿差额。记录快照保存 `flow_profile_id`、`flow_assignment_id` 和下级方案,后续修改方案不会改写历史返佣。
 
 旧系统返佣记录迁移后显示为 `platform=legacy`、`game_type=0`、`game_type_text=历史汇总`,金额和入账状态保持不变。

+ 1 - 1
docs/前端接口/支付配置/与充值通道关联说明.md

@@ -27,7 +27,7 @@
 - `GET /admin/rechargeChannel/list?data_type=1`:充值通道;`data_type=2`:提现通道;`data_type=3`:活动通道。
 - `POST /admin/rechargeChannel/update`:修改通道费率、限额、固定金额、排序和实际启停。
 - `GET /admin/rechargeChannel/groupList`:通道组合列表。
-- `POST /admin/rechargeChannel/updateGroup`:修改组合包含的充值/提现/活动 `type`。
+- `POST /admin/rechargeChannel/updateGroup`:修改组合包含的充值/提现/活动 `type`。`activity_type` 可不传;省略时保留原值,传空数组才清空。
 
 ## 前端页面调整
 

+ 4 - 3
docs/总后台/代理流水佣金比例-前端变更说明.md

@@ -96,6 +96,7 @@ Select 数据请求 `GET /admin/agent/flow-commission-profiles?limit=100`,使
 
 ## 7. 代理端新增下级
 
-- 新增方案列表:`GET /agent/flow-commission-profiles`
-- `POST /agent/sub-agents`:删除 `flow_commission_rate`,新增必填 `flow_commission_profile_id`。
-- `POST /agent/sub-agents/update`:新增可选 `flow_commission_profile_id`。
+- 方案管理:`GET/POST /agent/flow-commission-profiles`、`POST /agent/flow-commission-profiles/delete`;盈亏同理 `/agent/profit-commission-profiles`。
+- 给下级选方案:`GET /agent/flow-commission-profiles?selectable=1&limit=100`(以及盈亏对应接口)。旧的无参数数组返回已改为分页 `{total,page,limit,list}`。
+- `POST /agent/sub-agents`:删除 `flow_commission_rate`,新增必填 `flow_commission_profile_id`,可选 `profit_commission_profile_id`。
+- `POST /agent/sub-agents/update`:新增可选 `flow_commission_profile_id`、`profit_commission_profile_id`。

+ 12 - 8
docs/总后台/代理流水佣金比例.md

@@ -1,6 +1,6 @@
 # 总后台-代理流水佣金比例
 
-该菜单维护可复用的“流水佣金方案”,不是直接修改某个代理的一条 `flow_rate`。
+该菜单维护可复用的官方“流水佣金方案”,不是直接修改某个代理的一条 `flow_rate`。一级代理给二级代理配置的自建方案不出现在此列表,由代理后台维护。
 
 每个代理通过 `flow_commission_profile_id` 关联一套方案;新增代理时前端必须使用 Select 选择方案。需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
 
@@ -50,7 +50,7 @@
 | `esports_rate` | 电竞返佣比例(%) |
 | `fishing_rate` | 捕鱼返佣比例(%) |
 | `chess_rate` | 棋牌返佣比例(%) |
-| `is_default` | 是否默认;系统只能有一条默认方案 |
+| `is_default` | 是否默认;官方方案池只能有一条默认方案 |
 
 ## 2. 新增或修改方案
 
@@ -75,8 +75,8 @@
 
 规则:
 
-- 默认方案必须始终保留一条。
-- 将其他方案设为默认后,旧默认自动取消;未指定方案或使用旧默认的代理切换到新默认。
+- 默认方案必须始终保留一条。官方方案池与各一级代理自建方案池相互独立。
+- 将其他方案设为默认后,旧默认自动取消;未指定方案或使用旧默认的代理切换到新默认。一级代理自建方案及其下级绑定不受官方默认切换影响。
 - 修改比例从操作当天生效,后端为关联代理写入不可变日期快照;补结旧日期仍使用当时方案。
 - 若生效日期之后已有流水佣金入账,禁止修改相关代理的历史方案。
 - 一级/二级代理仍遵守下级方案各分类比例不能高于上级、上级不能低于现有下级的规则。
@@ -112,7 +112,7 @@ Select label = profile.name
 }
 ```
 
-总后台 `POST /admin/agent/create` 中 `flow_commission_profile_id` 必填。修改代理使用 `POST /admin/agent/update` 切换方案。
+总后台 `POST /admin/agent/create` 中 `flow_commission_profile_id` 必填,且只能选择官方方案。修改代理使用 `POST /admin/agent/update` 切换方案,同样只能切到官方方案。一级代理给二级指定自建方案请走代理后台
 
 代理对象返回:
 
@@ -136,12 +136,16 @@ Select label = profile.name
 
 ## 5. 结算口径
 
-每日流水佣金按游戏分类计算:
+流水佣金按级差计算,一级代理拿自己的全额比例,下级会员只拿「自己的比例 − 下级比例」。
 
 ```text
-流水佣金 = 对应分类有效投注额 × 对应分类返佣比例 ÷ 100
+直属会员:流水佣金 = 该分类有效投注额 × 自己的返佣比例 ÷ 100
+下级会员:下级佣金 = 有效投注额 × 下级返佣比例 ÷ 100
+          上级佣金 = 有效投注额 × (上级比例 − 下级比例) ÷ 100
 ```
 
-结算记录会保存 `flow_profile_id` 和 `flow_assignment_id` 快照。
+例如上级真人 5%、下级真人 4%:下级会员 1000 有效投注,下级拿 40,上级拿 10;上级自己的会员 1000 有效投注,上级拿 50。
+
+结算记录会保存 `flow_profile_id`、`flow_assignment_id` 以及下级方案快照。`flow_rate` 是该代理自己的合同比例,`flow_commission` 已按级差入账。
 
 旧单比例接口和字段不保留。首次部署时,已有代理统一关联系统默认流水方案并建立初始快照。

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

@@ -1,6 +1,6 @@
 # 总后台-代理盈亏佣金比例
 
-需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。
+需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。此列表只维护官方盈亏方案;一级代理给二级配置的自建方案在代理后台。
 
 ## 1. 比例方案列表
 
@@ -49,7 +49,7 @@
 | 参数 | 类型 | 必填 | 说明 |
 |---|---|---:|---|
 | `id` | integer | 修改时是 | 方案 ID |
-| `name` | string | 是 | 盈亏比例名称,全局唯一,最长 128 |
+| `name` | string | 是 | 盈亏比例名称,官方方案池内唯一,最长 128 |
 | `live_rate` | decimal string | 是 | 真人盈亏比例,0~100 |
 | `slot_rate` | decimal string | 是 | 电子盈亏比例,0~100 |
 | `lottery_rate` | decimal string | 是 | 彩票盈亏比例,0~100 |
@@ -57,7 +57,7 @@
 | `esports_rate` | decimal string | 是 | 电竞盈亏比例,0~100 |
 | `fishing_rate` | decimal string | 是 | 捕鱼盈亏比例,0~100 |
 | `chess_rate` | decimal string | 是 | 棋牌盈亏比例,0~100 |
-| `is_default` | boolean | 是 | 是否默认;默认方案全系统只能有一条 |
+| `is_default` | boolean | 是 | 是否默认;官方方案池只能有一条默认 |
 
 ```json
 {
@@ -119,8 +119,8 @@
 
 ## 4. 给代理指定方案
 
-新增或修改代理时传 `profit_commission_profile_id`。不传时,一级代理使用默认方案;一级代理新增二级代理时,二级代理默认继承一级代理的方案。
+新增或修改代理时传 `profit_commission_profile_id`,只能选择官方方案。不传时,一级代理使用官方默认方案;一级代理新增二级代理时,不传则二级代理继承一级代理正在使用方案,也可改为指定一级代理自建方案。
 
-盈亏佣金按游戏平台所属游戏类型计算:`max(-会员输赢, 0) × 对应游戏类型比例 ÷ 100`。
+盈亏佣金同样按级差计算:`max(-会员输赢, 0) × 对应游戏类型比例 ÷ 100`。直属会员用自己的全额比例;下级会员由下级拿其比例,上级只拿差额。
 
 首次部署迁移会把旧 `profit_commission_rate` 转成七个分类相同比例的“历史盈亏比例”方案,并按旧 `agent_commission_rules.effective_from` 建立历史快照,不会把原有非零比例重置为 0。

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

@@ -63,6 +63,6 @@
 
 每条记录按“账期+代理+游戏平台+游戏类型”唯一。流水和盈亏佣金分别使用独立幂等流水入账,重复执行结算不会重复加余额。`start_date/end_date` 对应截图里的“创建日期”;需要按业务账期查时使用 `settlement_date`。
 
-`flow_rate` 按结算日有效的流水佣金方案和 `game_type` 读取,快照包含 `flow_profile_id`、`flow_assignment_id`;历史记录不会随方案修改而变化。
+`flow_rate` / `profit_rate` 是该代理自己的合同比例。佣金金额按级差入账:直属会员用全额比例,下级会员只拿「自己的比例 − 下级比例」。快照包含 `flow_profile_id`、`flow_assignment_id` 和下级方案;历史记录不会随方案修改而变化。
 
 旧 `agent_commissions` 会在迁移时合并为 `platform=legacy`、`game_type=0`、`game_type_text=历史汇总` 的记录继续展示。已经由旧系统入账的账期不会再次生成新平台返佣;若旧账期只完成了一部分,结算命令会停止并要求人工核对,避免重复加款。

+ 5 - 0
routes/agent.php

@@ -41,5 +41,10 @@ Route::middleware('agent.auth')->group(function () {
 
     Route::get('/commissions', [CommissionController::class, 'records']);
     Route::get('/flow-commission-profiles', [CommissionController::class, 'flowProfiles']);
+    Route::post('/flow-commission-profiles', [CommissionController::class, 'saveFlowCommissionProfile']);
+    Route::post('/flow-commission-profiles/delete', [CommissionController::class, 'deleteFlowCommissionProfile']);
+    Route::get('/profit-commission-profiles', [CommissionController::class, 'profitProfiles']);
+    Route::post('/profit-commission-profiles', [CommissionController::class, 'saveProfitCommissionProfile']);
+    Route::post('/profit-commission-profiles/delete', [CommissionController::class, 'deleteProfitCommissionProfile']);
     Route::get('/reports', [ReportController::class, 'index']);
 });

+ 38 - 0
tests/Unit/AgentCommissionSpreadTest.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\Agent\AgentCommissionService;
+use PHPUnit\Framework\TestCase;
+
+class AgentCommissionSpreadTest extends TestCase
+{
+    public function test_parent_keeps_the_rate_difference_on_child_volume(): void
+    {
+        $this->assertSame('1.0000', AgentCommissionService::rateSpread('5', '4'));
+        $this->assertSame('0.0000', AgentCommissionService::rateSpread('4', '4'));
+        $this->assertSame('0.0000', AgentCommissionService::rateSpread('4', '5'));
+    }
+
+    public function test_own_members_use_full_rate_and_child_members_use_spread(): void
+    {
+        $ownFlow = AgentCommissionService::amountByRate('1000', '5');
+        $childFlow = AgentCommissionService::amountByRate('1000', AgentCommissionService::rateSpread('5', '4'));
+        $this->assertSame('50.0000', $ownFlow);
+        $this->assertSame('10.0000', $childFlow);
+        $this->assertSame('60.0000', bcadd($ownFlow, $childFlow, 4));
+    }
+
+    public function test_profit_commission_uses_member_loss_only(): void
+    {
+        $this->assertSame('100.0000', AgentCommissionService::profitBase('-100'));
+        $this->assertSame('0.0000', AgentCommissionService::profitBase('80'));
+        $own = AgentCommissionService::amountByRate(AgentCommissionService::profitBase('-200'), '5');
+        $child = AgentCommissionService::amountByRate(
+            AgentCommissionService::profitBase('-200'),
+            AgentCommissionService::rateSpread('5', '4')
+        );
+        $this->assertSame('10.0000', $own);
+        $this->assertSame('2.0000', $child);
+    }
+}

+ 14 - 0
tests/Unit/AgentFlowCommissionProfileTest.php

@@ -25,6 +25,20 @@ class AgentFlowCommissionProfileTest extends TestCase
         $this->assertSame('0.0000', $profile->rateForGameType(99));
     }
 
+    public function test_official_and_owned_profile_flags(): void
+    {
+        $official = new AgentFlowCommissionProfile(['name' => '官方']);
+        $this->assertTrue($official->isOfficial());
+        $this->assertFalse($official->isOwnedBy(5));
+
+        $owned = new AgentFlowCommissionProfile(['name' => '自建']);
+        $owned->owner_agent_id = 5;
+        $this->assertFalse($owned->isOfficial());
+        $this->assertTrue($owned->isOwnedBy(5));
+        $this->assertFalse($owned->isOwnedBy(6));
+        $this->assertFalse($owned->isOwnedBy(null));
+    }
+
     public function test_historical_assignment_uses_its_flow_snapshot(): void
     {
         $assignment = new AgentFlowCommissionAssignment([

+ 14 - 0
tests/Unit/AgentProfitCommissionProfileTest.php

@@ -25,6 +25,20 @@ class AgentProfitCommissionProfileTest extends TestCase
         $this->assertSame('0.0000', $profile->rateForGameType(99));
     }
 
+    public function test_official_and_owned_profile_flags(): void
+    {
+        $official = new AgentProfitCommissionProfile(['name' => '官方']);
+        $this->assertTrue($official->isOfficial());
+        $this->assertFalse($official->isOwnedBy(5));
+
+        $owned = new AgentProfitCommissionProfile(['name' => '自建']);
+        $owned->owner_agent_id = 5;
+        $this->assertFalse($owned->isOfficial());
+        $this->assertTrue($owned->isOwnedBy(5));
+        $this->assertFalse($owned->isOwnedBy(6));
+        $this->assertFalse($owned->isOwnedBy(null));
+    }
+
     public function test_historical_assignment_uses_its_rate_snapshot(): void
     {
         $assignment = new AgentProfitCommissionAssignment([