- 添加ensureSuperAdmin方法,限制管理员设置操作权限 - 在AdminSettingController接口中新增权限检查,防止非超级管理员操作 - AdminPlayerIndexController新增direct agent筛选支持 - AdminPlayerUpdateController新增对信用玩家默认币别变更的拒绝逻辑 - 新增WalletSettlementBillsController,实现玩家信用盘账期账单摘要接口 - AgentProfileService调整,新增返点限额和分享比例自动下调机制,保持子代理及玩家配置不超父级 - AgentProfileService增加can_grant_extra_rebate权限继承限制,阻止无权限代理开启 - AgentSettlementPeriodCloseService增加玩家账单回水信用释放逻辑,确保信用额度同步 - PlayerCreditService新增释放账单回水对应信用逻辑,维护账期信用一致性 - TicketPlacementService和TicketPreviewService新增信用玩家投注币别匹配校验,防止币别不符 - PlayerLedgerLogsService优化信用额度计算,增加可用额度上下限限制,防止负值和超限 - 调整后台管理导航,仅超管可见设置入口,强化权限隔离 - 路由新增玩家信用账单查询接口 - 补充多项AgentProfile相关单元测试覆盖额度限制、返点继承、返点下调场景及权限限制 - 增加站点管理员登录测试,验证系统设置菜单不可见,提升用户权限体验
463 lines
17 KiB
PHP
463 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Agent;
|
|
|
|
use App\Models\AdminUser;
|
|
use App\Models\AgentNode;
|
|
use App\Models\AgentProfile;
|
|
use App\Support\AdminAgentScope;
|
|
use App\Support\AgentOverdueGuard;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
final class AgentProfileService
|
|
{
|
|
public function __construct(
|
|
private readonly ShareRateValidator $shareRateValidator,
|
|
private readonly CreditAllocationValidator $creditAllocationValidator,
|
|
private readonly RebateLimitValidator $rebateLimitValidator,
|
|
private readonly AgentCreditAllocatedSyncService $allocatedSync,
|
|
private readonly AgentUsedCreditSyncService $usedCreditSync,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $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<string, mixed>
|
|
*/
|
|
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<string, mixed>|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<string, mixed> $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'],
|
|
]);
|
|
}
|
|
}
|