feat: 增强代理结算和账单管理功能

- 在多个控制器中引入 SettlementPartyEnrichment 服务,以优化代理结算和账单的处理逻辑。
- 更新 AgentSettlementBillIndexController 和 AgentSettlementBillShowController,支持根据账单 ID 和关键字进行查询。
- 在 AgentSettlementPeriodCloseController 中添加对站点管理权限的验证,确保只有具备相应权限的管理员能够关闭账期。
- 在 AgentSettlementPeriodIndexController 中更新账期数据的返回格式,提升数据的完整性和可用性。
- 引入对相对占成比例的支持,增强代理资料的管理能力,确保数据一致性。
This commit is contained in:
2026-06-05 18:00:56 +08:00
parent a44679665d
commit 2d32f006c5
63 changed files with 4893 additions and 288 deletions

View File

@@ -3,11 +3,35 @@
namespace App\Support;
use App\Models\AdminUser;
use App\Models\AgentNode;
use Illuminate\Database\Query\Builder;
/** 代理账单按管理员可访问站点过滤。 */
/** 代理账单按管理员可访问站点 + 代理子树过滤。 */
final class AdminAgentSettlementScope
{
/**
* @return list<int>|null null = 不限制子树(超管或未绑定代理)
*/
public static function subtreeAgentNodeIds(AdminUser $admin): ?array
{
if ($admin->isSuperAdmin()) {
return null;
}
$actor = AdminAgentScope::primaryAgentNode($admin);
if ($actor === null) {
return null;
}
$ids = AgentNode::query()
->where('path', 'like', $actor->path.'%')
->pluck('id')
->map(static fn ($id): int => (int) $id)
->all();
return $ids;
}
public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void
{
$siteIds = $admin->accessibleAdminSiteIds();
@@ -28,6 +52,8 @@ final class AdminAgentSettlementScope
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
self::applySubtreeToBillsQuery($query, $admin, $billsAlias);
return;
}
@@ -43,6 +69,38 @@ final class AdminAgentSettlementScope
->whereColumn('settlement_periods.id', $billsAlias.'.settlement_period_id')
->whereIn('settlement_periods.admin_site_id', $siteIds);
});
self::applySubtreeToBillsQuery($query, $admin, $billsAlias);
}
/** 绑定代理仅见本子树玩家账单 + owner 为本子树节点的代理账单。 */
public static function applySubtreeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
{
$subtreeIds = self::subtreeAgentNodeIds($admin);
if ($subtreeIds === null) {
return;
}
if ($subtreeIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->where(function (Builder $outer) use ($billsAlias, $subtreeIds): void {
$outer->where(function (Builder $player) use ($billsAlias, $subtreeIds): void {
$player->where($billsAlias.'.owner_type', 'player')
->whereExists(function (Builder $exists) use ($billsAlias, $subtreeIds): void {
$exists->selectRaw('1')
->from('players')
->whereColumn('players.id', $billsAlias.'.owner_id')
->whereIn('players.agent_node_id', $subtreeIds);
});
})->orWhere(function (Builder $agent) use ($billsAlias, $subtreeIds): void {
$agent->where($billsAlias.'.owner_type', 'agent')
->whereIn($billsAlias.'.owner_id', $subtreeIds);
});
});
}
public static function periodAccessible(AdminUser $admin, int $settlementPeriodId): bool
@@ -72,21 +130,61 @@ final class AdminAgentSettlementScope
return in_array($adminSiteId, $siteIds, true);
}
public static function billAccessible(AdminUser $admin, int $settlementBillId): bool
/** 绑定代理账号不可开/关全站账期(仅站点财务或超管)。 */
public static function canManageSitePeriods(AdminUser $admin): bool
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
if ($admin->isSuperAdmin()) {
return true;
}
if ($siteIds === []) {
return AdminAgentScope::primaryAgentNode($admin) === null;
}
public static function assertCanManageSitePeriods(AdminUser $admin): void
{
if (! self::canManageSitePeriods($admin)) {
abort(403, 'agent_bound_cannot_manage_periods');
}
}
public static function billAccessible(AdminUser $admin, int $settlementBillId): bool
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null && $siteIds === []) {
return false;
}
return \Illuminate\Support\Facades\DB::table('settlement_bills')
->join('settlement_periods', 'settlement_periods.id', '=', 'settlement_bills.settlement_period_id')
->where('settlement_bills.id', $settlementBillId)
->whereIn('settlement_periods.admin_site_id', $siteIds)
->exists();
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills as sb')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->where('sb.id', $settlementBillId)
->select(['sb.owner_type', 'sb.owner_id', 'sp.admin_site_id'])
->first();
if ($bill === null) {
return false;
}
if ($siteIds !== null && ! in_array((int) $bill->admin_site_id, $siteIds, true)) {
return false;
}
$subtreeIds = self::subtreeAgentNodeIds($admin);
if ($subtreeIds === null) {
return true;
}
if ((string) $bill->owner_type === 'player') {
$agentNodeId = (int) (\Illuminate\Support\Facades\DB::table('players')
->where('id', (int) $bill->owner_id)
->value('agent_node_id') ?? 0);
return $agentNodeId > 0 && in_array($agentNodeId, $subtreeIds, true);
}
if ((string) $bill->owner_type === 'agent') {
return in_array((int) $bill->owner_id, $subtreeIds, true);
}
return false;
}
}

View File

@@ -22,6 +22,7 @@ final class AdminAuthProfile
* href: string,
* nav_group: string,
* platform_only?: bool,
* agent_hidden?: bool,
* activeMatchPrefix?: string,
* requiredAny?: list<string>
* }>,

View File

@@ -129,6 +129,7 @@ final class AdminAuthorizationRegistry
* 后台菜单注册表。前端侧栏与面包屑都消费这里派生的结果。
*
* platform_only仅超管可见全局 RBAC、接入站点、赔率规则等代理账号走代理控制台与子级授权。
* agent_hidden代理账号不可见如主站钱包流水、对账等仅平台管理员可见的菜单
*
* @return list<array{
* segment: string,
@@ -136,6 +137,7 @@ final class AdminAuthorizationRegistry
* href: string,
* nav_group: string,
* platform_only?: bool,
* agent_hidden?: bool,
* activeMatchPrefix?: string,
* requiredAny?: list<string>
* }>
@@ -150,8 +152,8 @@ final class AdminAuthorizationRegistry
['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'nav_group' => 'operations', 'requiredAny' => ['prd.tickets.view']],
['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'nav_group' => 'operations', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']],
['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'nav_group' => 'operations', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'nav_group' => 'finance', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']],
['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'nav_group' => 'finance', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'nav_group' => 'finance', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance'], 'agent_hidden' => true],
['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'nav_group' => 'finance', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], 'agent_hidden' => true],
['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'nav_group' => 'finance', 'requiredAny' => ['prd.report.view']],
['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']],
['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']],
@@ -273,6 +275,9 @@ final class AdminAuthorizationRegistry
* segment: string,
* label: string,
* href: string,
* nav_group: string,
* platform_only?: bool,
* agent_hidden?: bool,
* activeMatchPrefix?: string,
* requiredAny?: list<string>
* }>
@@ -290,6 +295,7 @@ final class AdminAuthorizationRegistry
* href: string,
* nav_group: string,
* platform_only?: bool,
* agent_hidden?: bool,
* activeMatchPrefix?: string,
* requiredAny?: list<string>
* }>
@@ -298,14 +304,19 @@ final class AdminAuthorizationRegistry
{
$granted = array_fill_keys($permissionSlugs, true);
$isSuperAdmin = $admin === null || $admin->isSuperAdmin();
$isAgent = $admin !== null && $admin->isAgentAccount();
return array_values(array_filter(
self::navigationItems(),
static function (array $item) use ($granted, $isSuperAdmin): bool {
static function (array $item) use ($granted, $isSuperAdmin, $isAgent): bool {
if (($item['platform_only'] ?? false) && ! $isSuperAdmin) {
return false;
}
if (($item['agent_hidden'] ?? false) && $isAgent) {
return false;
}
$required = $item['requiredAny'] ?? [];
if ($required === []) {
return true;

View File

@@ -16,10 +16,14 @@ final class AdminDataScope
*/
public static function applyToPlayersAlias(
Builder $query,
AdminUser $admin,
?AdminUser $admin,
string $alias = 'p',
?int $requestedAgentNodeId = null,
): void {
if ($admin === null) {
return;
}
if ($admin->isSuperAdmin()) {
if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) {
self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId);

View File

@@ -20,6 +20,57 @@ final class AgentOverdueGuard
->exists();
}
public static function agentHasSevereOverdueBills(int $agentNodeId, int $days = 7): bool
{
if ($agentNodeId <= 0) {
return false;
}
$cutoff = now()->subDays($days);
return DB::table('settlement_bills')
->where('owner_type', 'agent')
->where('owner_id', $agentNodeId)
->where('status', 'overdue')
->where('unpaid_amount', '>', 0)
->where('updated_at', '<', $cutoff)
->exists();
}
public static function agentLineHasSevereOverdueBills(int $agentNodeId, int $days = 7): bool
{
if ($agentNodeId <= 0) {
return false;
}
$agent = DB::table('agent_nodes')->where('id', $agentNodeId)->first();
if ($agent === null) {
return false;
}
// 获取该代理的所有祖先节点ID包括自己
$path = (string) $agent->path;
if ($path === '') {
// 根节点,只检查自己
$ancestorIds = [$agentNodeId];
} else {
// 解析 path 获取所有祖先ID
$parts = explode('/', trim($path, '/'));
$ancestorIds = array_map('intval', $parts);
$ancestorIds[] = $agentNodeId;
}
$cutoff = now()->subDays($days);
return DB::table('settlement_bills')
->where('owner_type', 'agent')
->where('status', 'overdue')
->where('unpaid_amount', '>', 0)
->where('updated_at', '<', $cutoff)
->whereIn('owner_id', $ancestorIds)
->exists();
}
public static function assertAgentMayGrantCredit(int $agentNodeId): void
{
if (self::agentHasOverdueBills($agentNodeId)) {
@@ -28,4 +79,46 @@ final class AgentOverdueGuard
]);
}
}
public static function assertAgentLineMayPlaceBet(int $agentNodeId, ?int $severeOverdueDays = null): void
{
$days = $severeOverdueDays ?? config('agent_line_defaults.overdue.severe_days_threshold', 7);
if (self::agentLineHasSevereOverdueBills($agentNodeId, $days)) {
throw \Illuminate\Validation\ValidationException::withMessages([
'credit' => ['agent_line_severe_overdue'],
]);
}
}
public static function parentHasOverdueBills(int $agentNodeId): bool
{
if ($agentNodeId <= 0) {
return false;
}
$agent = DB::table('agent_nodes')->where('id', $agentNodeId)->first();
if ($agent === null || $agent->parent_id === null) {
return false;
}
return DB::table('settlement_bills')
->where('owner_type', 'agent')
->where('owner_id', $agent->parent_id)
->where('status', 'overdue')
->where('unpaid_amount', '>', 0)
->exists();
}
public static function assertMayOperateWhenParentOverdue(int $agentNodeId): void
{
if (! config('agent_line_defaults.overdue.cascade_freeze_on_parent_overdue', false)) {
return;
}
if (self::parentHasOverdueBills($agentNodeId)) {
throw \Illuminate\Validation\ValidationException::withMessages([
'parent_id' => ['parent_overdue'],
]);
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Support;
use Carbon\Carbon;
/** 账期起止统一为日界startOfDay / endOfDay供聚合、流水、关账回填共用。 */
final class AgentSettlementPeriodWindow
{
/**
* @return array{0: Carbon, 1: Carbon}
*/
public static function bounds(string $periodStart, string $periodEnd): array
{
return [
Carbon::parse($periodStart)->startOfDay(),
Carbon::parse($periodEnd)->endOfDay(),
];
}
/**
* @return array{0: string, 1: string}
*/
public static function boundStrings(string $periodStart, string $periodEnd): array
{
[$start, $end] = self::bounds($periodStart, $periodEnd);
return [$start->toDateTimeString(), $end->toDateTimeString()];
}
}

View File

@@ -10,8 +10,12 @@ final class AgentSettlementProductionGuard
return;
}
if (config('agent_settlement.allow_demo_close', false)) {
if (config('agent_settlement.allow_production_close', true)) {
return;
}
throw new \RuntimeException(
'Agent settlement period close is disabled. Set AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE=true to enable.',
);
}
}

View File

@@ -7,7 +7,17 @@ use App\Models\SettlementBatch;
final class SettlementBatchFinancialSummary
{
/**
* @return array{total_bet_amount: int, total_actual_deduct: int, platform_profit: int, currency_code: ?string}
* 所有金额字段均为最小货币单位minor
*
* @return array{
* total_bet_amount: int,
* total_actual_deduct: int,
* platform_profit: int,
* total_bet_amount_minor: int,
* total_actual_deduct_minor: int,
* platform_profit_minor: int,
* currency_code: ?string
* }
*/
public static function forBatch(SettlementBatch $batch): array
{
@@ -27,6 +37,10 @@ final class SettlementBatchFinancialSummary
'total_bet_amount' => $totalBet,
'total_actual_deduct' => $totalActualDeduct,
'platform_profit' => $totalActualDeduct - $totalPayout,
// 显式别名:避免调用方误解为主货币单位。
'total_bet_amount_minor' => $totalBet,
'total_actual_deduct_minor' => $totalActualDeduct,
'platform_profit_minor' => $totalActualDeduct - $totalPayout,
'currency_code' => is_string($totals?->currency_code) && $totals->currency_code !== ''
? $totals->currency_code
: null,