feat: 增强代理结算和账单管理功能
- 在多个控制器中引入 SettlementPartyEnrichment 服务,以优化代理结算和账单的处理逻辑。 - 更新 AgentSettlementBillIndexController 和 AgentSettlementBillShowController,支持根据账单 ID 和关键字进行查询。 - 在 AgentSettlementPeriodCloseController 中添加对站点管理权限的验证,确保只有具备相应权限的管理员能够关闭账期。 - 在 AgentSettlementPeriodIndexController 中更新账期数据的返回格式,提升数据的完整性和可用性。 - 引入对相对占成比例的支持,增强代理资料的管理能力,确保数据一致性。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ final class AdminAuthProfile
|
||||
* href: string,
|
||||
* nav_group: string,
|
||||
* platform_only?: bool,
|
||||
* agent_hidden?: bool,
|
||||
* activeMatchPrefix?: string,
|
||||
* requiredAny?: list<string>
|
||||
* }>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Support/AgentSettlementPeriodWindow.php
Normal file
30
app/Support/AgentSettlementPeriodWindow.php
Normal 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()];
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user