$payload */ public function upsertForNode(AgentNode $node, array $payload, ?AgentNode $parent = null): AgentProfile { $parent = $parent ?? ($node->parent_id !== null ? AgentNode::query()->find($node->parent_id) : null); $existingProfile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); $totalShare = array_key_exists('total_share_rate', $payload) ? (float) $payload['total_share_rate'] : (float) ($existingProfile->total_share_rate ?? 0); $creditLimit = array_key_exists('credit_limit', $payload) ? (int) $payload['credit_limit'] : (int) ($existingProfile->credit_limit ?? 0); $rebateLimit = array_key_exists('rebate_limit', $payload) ? (float) $payload['rebate_limit'] / 100 : (float) ($existingProfile->rebate_limit ?? 0); $defaultRebate = array_key_exists('default_player_rebate', $payload) ? (float) $payload['default_player_rebate'] / 100 : (float) ($existingProfile->default_player_rebate ?? 0); $settlementCycle = array_key_exists('settlement_cycle', $payload) ? (string) $payload['settlement_cycle'] : (string) ($existingProfile->settlement_cycle ?? config('agent_line_defaults.settlement_cycle', 'weekly')); if ($settlementCycle === '') { $settlementCycle = 'weekly'; } $useRelative = $parent !== null && array_key_exists('relative_share_rate', $payload); // 如果提供了相对占成比例,计算绝对总占成 if ($useRelative) { $relativeShare = (float) $payload['relative_share_rate']; $this->shareRateValidator->assertRelativeShareWithinBounds($relativeShare); $parentRate = $this->shareRateValidator->totalShareRateForNode($parent); $totalShare = round($parentRate * $relativeShare / 100, 2); } if ($parent !== null) { $this->shareRateValidator->assertChildWithinParent( $parent, $totalShare, $useRelative ? 'relative_share_rate' : 'total_share_rate', ); } return DB::transaction(function () use ($node, $payload, $parent, $totalShare, $creditLimit, $rebateLimit, $defaultRebate, $settlementCycle): AgentProfile { $profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $node->id]); $previousCredit = (int) $profile->credit_limit; $isNew = ! $profile->exists; if ($parent !== null && ! $isNew) { $this->allocatedSync->syncForAgent($parent); } if (! $isNew) { $this->allocatedSync->syncForAgent($node); if ($creditLimit < (int) $profile->allocated_credit) { throw ValidationException::withMessages([ 'credit_limit' => ['below_allocated'], ]); } } if ($parent !== null) { $this->creditAllocationValidator->assertChildCreditLimitWithinParent( $parent, $isNew ? 0 : $previousCredit, $creditLimit, $isNew, ); } if ($defaultRebate > $rebateLimit) { throw ValidationException::withMessages([ 'default_player_rebate' => ['exceeds_limit'], ]); } if ($parent !== null) { $this->rebateLimitValidator->assertChildRebateLimitWithinParent($parent, $rebateLimit); } $profile->fill([ 'total_share_rate' => $totalShare, 'credit_limit' => $creditLimit, 'rebate_limit' => $rebateLimit, 'default_player_rebate' => $defaultRebate, 'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? $profile->can_grant_extra_rebate ?? false), 'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? ($isNew ? false : $profile->can_create_child_agent)), 'can_create_player' => (bool) ($payload['can_create_player'] ?? ($isNew ? true : $profile->can_create_player ?? true)), 'settlement_cycle' => $settlementCycle, ]); if (! $profile->exists) { $profile->allocated_credit = 0; $profile->used_credit = 0; } $profile->save(); $this->clampDescendantAgentShareRates($node, (float) $profile->total_share_rate); $this->clampDirectPlayerRebates( $node, (float) $profile->rebate_limit, (bool) $profile->can_grant_extra_rebate, ); $this->clampDescendantAgentRebates( $node, (float) $profile->rebate_limit, (bool) $profile->can_grant_extra_rebate, ); if ($parent !== null) { $this->allocatedSync->syncForAgent($parent); } return $profile; }); } /** * @return array */ public function present(AgentProfile $profile): array { $this->allocatedSync->syncForAgentId((int) $profile->agent_node_id); $this->usedCreditSync->syncForAgentId((int) $profile->agent_node_id); $profile->refresh(); $available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit); $totalShareRate = (float) $profile->total_share_rate; $parentTotalShareRate = $this->parentTotalShareRateForProfile($profile); return [ 'agent_node_id' => (int) $profile->agent_node_id, 'total_share_rate' => $totalShareRate, 'relative_share_rate' => $parentTotalShareRate > 0 ? round($totalShareRate / $parentTotalShareRate * 100, 2) : null, 'credit_limit' => (int) $profile->credit_limit, 'allocated_credit' => (int) $profile->allocated_credit, 'used_credit' => (int) $profile->used_credit, 'available_credit' => $available, 'rebate_limit' => round((float) $profile->rebate_limit * 100, 4), 'default_player_rebate' => round((float) $profile->default_player_rebate * 100, 4), 'can_grant_extra_rebate' => (bool) $profile->can_grant_extra_rebate, 'can_create_child_agent' => (bool) $profile->can_create_child_agent, 'can_create_player' => (bool) $profile->can_create_player, 'settlement_cycle' => (string) ($profile->settlement_cycle ?? 'weekly'), ]; } private function parentTotalShareRateForProfile(AgentProfile $profile): float { $node = AgentNode::query()->find((int) $profile->agent_node_id); if ($node === null || $node->parent_id === null) { return 0.0; } $parentProfile = AgentProfile::query() ->where('agent_node_id', (int) $node->parent_id) ->first(); return $parentProfile !== null ? (float) $parentProfile->total_share_rate : 100.0; } public function profileForNode(int $agentNodeId): ?AgentProfile { return AgentProfile::query()->where('agent_node_id', $agentNodeId)->first(); } /** * @return array|null */ public function parentCapsForNode(?AgentNode $parent): ?array { if ($parent === null) { return null; } $this->allocatedSync->syncForAgent($parent); $profile = $this->profileForNode((int) $parent->id); if ($profile === null) { return null; } return [ 'agent_node_id' => (int) $parent->id, 'total_share_rate' => (float) $profile->total_share_rate, 'rebate_limit' => round((float) $profile->rebate_limit * 100, 4), 'available_credit' => max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit), ]; } /** 玩家授信写入前:校验代理可下发是否足够(按当前库内已占用重算)。 */ public function assertMayIncreasePlayerCredit(AgentNode $agent, int $additionalCredit): void { if ($additionalCredit <= 0) { return; } $this->assertAgentProfileExists($agent); $this->allocatedSync->syncForAgent($agent); $this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $additionalCredit); } /** 玩家授信变更后:按直属玩家+直属下级代理重算已下发额度(无 profile 时跳过)。 */ public function refreshAllocatedCredit(AgentNode $agent): void { $this->allocatedSync->syncForAgent($agent); } public function adjustPlayerCreditAllocation(AgentNode $agent, int $previousLimit, int $newLimit, int $playerUsedCredit = 0): void { if ($newLimit < $playerUsedCredit) { throw ValidationException::withMessages([ 'credit_limit' => ['below_player_used'], ]); } $delta = $newLimit - $previousLimit; $this->assertAgentProfileExists($agent); $this->allocatedSync->syncForAgent($agent); if ($delta > 0) { $this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $delta); } } public function assertActorMayCreateChildAgent(AdminUser $admin): void { if ($admin->isSuperAdmin()) { return; } $node = AdminAgentScope::primaryAgentNode($admin); if ($node === null) { return; } if (! $this->nodeMayCreateChildAgent($node->id)) { throw ValidationException::withMessages([ 'parent_id' => ['cannot_create_child_agent'], ]); } if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) { throw ValidationException::withMessages([ 'parent_id' => ['agent_overdue'], ]); } } public function assertActorMayCreatePlayer(AdminUser $admin): void { if ($admin->isSuperAdmin()) { return; } $node = AdminAgentScope::primaryAgentNode($admin); if ($node === null) { return; } if (! $this->nodeMayCreatePlayer($node->id)) { throw ValidationException::withMessages([ 'site_code' => ['cannot_create_player'], ]); } if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) { throw ValidationException::withMessages([ 'site_code' => ['agent_overdue'], ]); } } /** * @param array $childPayload */ public function assertChildCapabilityGrantsWithinParent(AgentNode $parent, array $childPayload, AdminUser $actor): void { $parentProfile = $this->profileForNode((int) $parent->id); if ((bool) ($childPayload['can_grant_extra_rebate'] ?? false) && ! ($parentProfile?->can_grant_extra_rebate ?? false)) { throw ValidationException::withMessages([ 'can_grant_extra_rebate' => ['parent_cannot_delegate'], ]); } if ($actor->isSuperAdmin() || \App\Support\AdminAgentSettlementScope::canManageSitePeriods($actor)) { return; } if ((bool) ($childPayload['can_create_child_agent'] ?? false) && ! ($parentProfile?->can_create_child_agent ?? false)) { throw ValidationException::withMessages([ 'can_create_child_agent' => ['parent_cannot_delegate'], ]); } if ((bool) ($childPayload['can_create_player'] ?? true) && ! ($parentProfile?->can_create_player ?? false)) { throw ValidationException::withMessages([ 'can_create_player' => ['parent_cannot_delegate'], ]); } } public function nodeMayCreateChildAgent(int $agentNodeId): bool { $profile = $this->profileForNode($agentNodeId); return (bool) ($profile?->can_create_child_agent ?? false); } public function nodeMayCreatePlayer(int $agentNodeId): bool { $profile = $this->profileForNode($agentNodeId); return (bool) ($profile?->can_create_player ?? false); } private function clampDescendantAgentShareRates(AgentNode $parent, float $parentTotalShareRate): void { $children = AgentNode::query() ->where('parent_id', (int) $parent->id) ->orderBy('id') ->get(); foreach ($children as $child) { $profile = AgentProfile::query() ->where('agent_node_id', (int) $child->id) ->first(); if ($profile === null) { continue; } $totalShareRate = max(0.0, (float) $profile->total_share_rate); $nextTotalShareRate = min($totalShareRate, $parentTotalShareRate); if (abs($nextTotalShareRate - $totalShareRate) >= 1e-9) { $profile->forceFill([ 'total_share_rate' => $nextTotalShareRate, ])->save(); } $this->clampDescendantAgentShareRates($child, $nextTotalShareRate); } } private function clampDirectPlayerRebates(AgentNode $agent, float $rebateLimit, bool $canGrantExtraRebate): void { $rows = DB::table('player_rebate_profiles as prp') ->join('players as p', 'p.id', '=', 'prp.player_id') ->where('p.agent_node_id', (int) $agent->id) ->where('prp.inherit_from_agent', false) ->select([ 'prp.id', 'prp.rebate_rate', 'prp.extra_rebate_rate', ]) ->get(); $now = now(); foreach ($rows as $row) { $rebateRate = max(0.0, (float) $row->rebate_rate); $extraRate = max(0.0, (float) $row->extra_rebate_rate); $nextRebate = min($rebateRate, $rebateLimit); $remaining = max(0.0, $rebateLimit - $nextRebate); $nextExtra = $canGrantExtraRebate ? min($extraRate, $remaining) : 0.0; if (abs($nextRebate - $rebateRate) < 1e-9 && abs($nextExtra - $extraRate) < 1e-9) { continue; } DB::table('player_rebate_profiles') ->where('id', (int) $row->id) ->update([ 'rebate_rate' => $nextRebate, 'extra_rebate_rate' => $nextExtra, 'updated_at' => $now, ]); } } private function clampDescendantAgentRebates( AgentNode $parent, float $parentRebateLimit, bool $parentCanGrantExtraRebate, ): void { $children = AgentNode::query() ->where('parent_id', (int) $parent->id) ->orderBy('id') ->get(); foreach ($children as $child) { $profile = AgentProfile::query() ->where('agent_node_id', (int) $child->id) ->first(); if ($profile === null) { continue; } $rebateLimit = max(0.0, (float) $profile->rebate_limit); $defaultRebate = max(0.0, (float) $profile->default_player_rebate); $canGrantExtraRebate = (bool) $profile->can_grant_extra_rebate; $nextRebateLimit = min($rebateLimit, $parentRebateLimit); $nextDefaultRebate = min($defaultRebate, $nextRebateLimit); $nextCanGrantExtraRebate = $parentCanGrantExtraRebate && $canGrantExtraRebate; if (abs($nextRebateLimit - $rebateLimit) >= 1e-9 || abs($nextDefaultRebate - $defaultRebate) >= 1e-9 || $nextCanGrantExtraRebate !== $canGrantExtraRebate) { $profile->forceFill([ 'rebate_limit' => $nextRebateLimit, 'default_player_rebate' => $nextDefaultRebate, 'can_grant_extra_rebate' => $nextCanGrantExtraRebate, ])->save(); } $this->clampDirectPlayerRebates($child, $nextRebateLimit, $nextCanGrantExtraRebate); $this->clampDescendantAgentRebates($child, $nextRebateLimit, $nextCanGrantExtraRebate); } } private function assertAgentProfileExists(AgentNode $agent): void { if (AgentProfile::query()->where('agent_node_id', $agent->id)->exists()) { return; } throw ValidationException::withMessages([ 'credit_limit' => ['agent_profile_required'], ]); } }