feat: enhance agent settlement features and improve data access controls

- Added new section in AGENTS.md detailing learned workspace facts for better understanding of settlement processes.
- Updated AgentNodeDestroyController to remove unnecessary checks for admin users.
- Enhanced AgentSettlement controllers to assert permissions for finance adjustments and bill operations.
- Improved query scopes in AgentSettlement services to ensure proper data access based on admin roles.
- Refactored methods in SettlementPartyEnrichment for better bill row enrichment and data handling.
- Introduced new methods in AdminAgentSettlementScope for managing agent node visibility and finance adjustments.
This commit is contained in:
2026-06-12 15:59:05 +08:00
parent e14b7b4569
commit 980f3c9593
47 changed files with 2403 additions and 187 deletions

View File

@@ -33,3 +33,16 @@
- **禁止**在生产关账路径使用 `DesignDocExample12` 硬编码账单;仅单元/Feature 测试可引用。 - **禁止**在生产关账路径使用 `DesignDocExample12` 硬编码账单;仅单元/Feature 测试可引用。
- 非 `testing` 环境关账受 `AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE`(默认 `true`)控制;预发可设为 `false` 门禁。 - 非 `testing` 环境关账受 `AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE`(默认 `true`)控制;预发可设为 `false` 门禁。
- 占成账单聚合必须读注单**快照**`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。 - 占成账单聚合必须读注单**快照**`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。
## Learned Workspace Facts
- 期号 `close_time` / `draw_time` 以 UTC 存储与比较;后台展示转浏览器本地时区,创建/编辑表单提交前须转回 UTC。
- 下注是否开放由 `DrawHallSnapshotBuilder::isBettingOpen()` / `effectiveHallDisplayStatus()` 实时判定,不只看 `draws.status`
- 后台期号列表展示数据库 `status`;详情 API 另提供 `hall_preview_status` 供与大厅预览态对比。
- 绑定经营代理主账号统一绑平台角色 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对 **所有绑定代理主账号** 自动补足 `settlement.agent.manage``AgentProfileCapabilityFilter`),实际操作仍受直属边 + 收款方校验。
- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage``canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。
- 结算账单可见范围(绑定代理):**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点``counterparty=本节点`(不含下级玩家的账单、不含更深层代理链)。**账务流水/账期 pipeline** 的玩家维度同样仅直属玩家。站点财务/超管仍见全站。
- 登记收付/确认:绑定代理仅可操作 **收款方**(玩家账单=直属 counterparty代理账单=按 net_amount 方向的 payee。上级不能代登下级玩家收付下级也不能代登向上级的代理账单。
- 收付/调账/坏账后端落库 `payment_records``settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。
- 代理仪表盘/账期列表「输赢」用本级占成(`share_profit`),不可看 `platform_pnl` 全站报表。
- 开/关账期仅未绑定代理的站点财务(`canManagePeriods = canOperateBills && boundAgent === null`)。

View File

@@ -47,10 +47,6 @@ final class AgentNodeDestroyController extends Controller
return ApiMessage::errorResponse($request, 'admin.agent_node_has_players_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); return ApiMessage::errorResponse($request, 'admin.agent_node_has_players_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
} }
if (DB::table('admin_user_agents')->where('agent_node_id', $agent_node->id)->exists()) {
return ApiMessage::errorResponse($request, 'admin.agent_node_has_admin_users_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
}
if ($service->hasBlockingCustomRoles($agent_node)) { if ($service->hasBlockingCustomRoles($agent_node)) {
return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
} }

View File

@@ -7,6 +7,7 @@ use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
final class AgentSettlementAdjustmentIndexController extends Controller final class AgentSettlementAdjustmentIndexController extends Controller
@@ -55,6 +56,19 @@ final class AgentSettlementAdjustmentIndexController extends Controller
} }
} }
$actorId = AdminAgentSettlementScope::boundAgentNodeId($admin);
if ($actorId !== null) {
$query->where(function (Builder $outer) use ($admin): void {
$outer->whereNull('sa.original_bill_id')
->orWhereExists(function (Builder $exists) use ($admin): void {
$exists->selectRaw('1')
->from('settlement_bills as sb')
->whereColumn('sb.id', 'sa.original_bill_id');
AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($exists, $admin, 'sb');
});
});
}
return ApiResponse::success([ return ApiResponse::success([
'items' => $query->limit(200)->get(), 'items' => $query->limit(200)->get(),
]); ]);

View File

@@ -22,6 +22,7 @@ final class AgentSettlementBillAdjustmentController extends Controller
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
AdminAgentSettlementScope::assertCanPerformFinanceAdjustments($admin);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); $before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404); abort_if($before === null, 404);

View File

@@ -22,6 +22,7 @@ final class AgentSettlementBillBadDebtWriteOffController extends Controller
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
AdminAgentSettlementScope::assertCanPerformFinanceAdjustments($admin);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); $before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404); abort_if($before === null, 404);

View File

@@ -22,7 +22,7 @@ final class AgentSettlementBillConfirmController extends Controller
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); AdminAgentSettlementScope::assertCanOperateBill($admin, $settlement_bill);
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); $bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($bill === null, 404); abort_if($bill === null, 404);

View File

@@ -81,7 +81,7 @@ final class AgentSettlementBillIndexController extends Controller
$items = collect($paginator->items()); $items = collect($paginator->items());
return ApiResponse::success([ return ApiResponse::success([
'items' => $this->enrichBillRows($items), 'items' => $this->partyEnrichment->enrichBillRows($items),
'total' => $paginator->total(), 'total' => $paginator->total(),
'page' => $paginator->currentPage(), 'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(), 'per_page' => $paginator->perPage(),
@@ -138,112 +138,4 @@ final class AgentSettlementBillIndexController extends Controller
}); });
}); });
} }
/**
* @param Collection<int, object> $items
* @return list<array<string, mixed>>
*/
private function enrichBillRows(Collection $items): array
{
if ($items->isEmpty()) {
return [];
}
$playerIds = [];
$agentIds = [];
foreach ($items as $row) {
if ((string) $row->owner_type === 'player') {
$playerIds[] = (int) $row->owner_id;
} elseif ((string) $row->owner_type === 'agent') {
$agentIds[] = (int) $row->owner_id;
}
if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) {
$agentIds[] = (int) $row->counterparty_id;
}
}
$players = $playerIds !== []
? DB::table('players')
->whereIn('id', array_unique($playerIds))
->select(['id', 'username', 'site_player_id', 'agent_node_id', 'funding_mode', 'auth_source'])
->get()
->keyBy('id')
: collect();
foreach ($players as $player) {
$aid = (int) ($player->agent_node_id ?? 0);
if ($aid > 0) {
$agentIds[] = $aid;
}
}
$agents = $this->partyEnrichment->loadAgents($agentIds);
$out = [];
foreach ($items as $row) {
$item = (array) $row;
$ownerType = (string) $row->owner_type;
$counterType = (string) $row->counterparty_type;
$counterId = (int) $row->counterparty_id;
$item['owner_label'] = $this->legacyOwnerLabel($ownerType, (int) $row->owner_id, $players, $agents);
$item['counterparty_label'] = $this->partyEnrichment->formatCounterpartyLabel($counterType, $counterId, $agents);
$item['player_username'] = null;
$item['player_site_player_id'] = null;
$item['player_id_display'] = null;
$item['direct_agent_label'] = null;
$item['superior_agent_label'] = null;
$item['owner_party_label'] = null;
if ($ownerType === 'player') {
$player = $players->get((int) $row->owner_id);
$item['player_username'] = $this->partyEnrichment->formatPlayerUsername($player);
$item['player_site_player_id'] = $this->partyEnrichment->formatPlayerSiteId($player);
$item['player_id_display'] = (int) $row->owner_id;
$item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null;
$item['owner_auth_source'] = $player !== null ? $player->auth_source : null;
$directId = $counterType === 'agent' ? $counterId : (int) ($player->agent_node_id ?? 0);
$line = $this->partyEnrichment->agentLineLabels($directId > 0 ? $directId : null, $agents);
$item['direct_agent_label'] = $line['direct_agent_label'];
$item['superior_agent_label'] = $line['parent_agent_label'];
} elseif ($ownerType === 'agent') {
$ownerAgentId = (int) $row->owner_id;
$item['owner_party_label'] = $this->partyEnrichment->formatAgent($agents->get($ownerAgentId), $ownerAgentId);
$item['superior_agent_label'] = $counterType === 'platform'
? 'platform'
: $this->partyEnrichment->formatCounterpartyLabel($counterType, $counterId, $agents);
}
$out[] = $item;
}
return $out;
}
/**
* @param Collection<int, object> $players
* @param Collection<int, object> $agents
*/
private function legacyOwnerLabel(
string $type,
int $id,
Collection $players,
Collection $agents,
): string {
if ($type === 'player') {
$player = $players->get($id);
return $player !== null
? (string) ($player->username ?: $player->site_player_id ?: "player#{$id}")
: "player#{$id}";
}
if ($type === 'agent') {
return $this->partyEnrichment->formatAgent($agents->get($id), $id);
}
return "{$type}#{$id}";
}
} }

View File

@@ -21,7 +21,7 @@ final class AgentSettlementBillPaymentController extends Controller
): JsonResponse { ): JsonResponse {
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); AdminAgentSettlementScope::assertCanOperateBill($admin, $settlement_bill);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); $before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404); abort_if($before === null, 404);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\SettlementBillDownlineShareBuilder;
use App\Services\AgentSettlement\SettlementPartyEnrichment; use App\Services\AgentSettlement\SettlementPartyEnrichment;
use App\Support\AdminAgentSettlementScope; use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse; use App\Support\ApiResponse;
@@ -14,6 +15,7 @@ final class AgentSettlementBillShowController extends Controller
{ {
public function __construct( public function __construct(
private readonly SettlementPartyEnrichment $partyEnrichment, private readonly SettlementPartyEnrichment $partyEnrichment,
private readonly SettlementBillDownlineShareBuilder $downlineShareBuilder,
) {} ) {}
public function __invoke(Request $request, int $settlement_bill): JsonResponse public function __invoke(Request $request, int $settlement_bill): JsonResponse
@@ -77,11 +79,12 @@ final class AgentSettlementBillShowController extends Controller
} }
return ApiResponse::success([ return ApiResponse::success([
'bill' => $bill, 'bill' => $this->partyEnrichment->enrichBillRow($bill),
'payments' => $payments, 'payments' => $payments,
'rebate_allocations' => $rebateAllocations, 'rebate_allocations' => $rebateAllocations,
'adjustments' => $adjustments, 'adjustments' => $adjustments,
'tier_edge' => $tierSettlements, 'tier_edge' => $tierSettlements,
'downline_shares' => $this->downlineShareBuilder->forBill($bill),
]); ]);
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -51,6 +52,8 @@ final class AgentSettlementPaymentIndexController extends Controller
} }
} }
AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($query, $admin, 'sb');
return ApiResponse::success([ return ApiResponse::success([
'items' => $query->limit(200)->get(), 'items' => $query->limit(200)->get(),
]); ]);

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\SettlementPeriodOpenHintsService;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/** GET /api/v1/admin/settlement-periods/open-hints */
final class AgentSettlementPeriodOpenHintsController extends Controller
{
public function __invoke(
Request $request,
SettlementPeriodOpenHintsService $hintsService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$siteId = (int) $request->query('admin_site_id', 0);
abort_if($siteId <= 0, 422);
abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $siteId), 404);
return ApiResponse::success($hintsService->hints($siteId));
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\AgentSettlementReportQueryService; use App\Services\AgentSettlement\AgentSettlementReportQueryService;
use App\Support\AdminAgentScope;
use App\Support\AgentSettlementPeriodWindow; use App\Support\AgentSettlementPeriodWindow;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -31,6 +32,10 @@ final class AgentSettlementReportShowController extends Controller
$type = (string) $request->query('type', 'summary'); $type = (string) $request->query('type', 'summary');
abort_unless(in_array($type, self::TYPES, true), 404); abort_unless(in_array($type, self::TYPES, true), 404);
if ($type === 'platform_pnl' && AdminAgentScope::primaryAgentNode($admin) !== null) {
abort(403, 'agent_cannot_view_platform_pnl');
}
$periodId = (int) $request->query('settlement_period_id', 0); $periodId = (int) $request->query('settlement_period_id', 0);
$period = $this->resolvePeriod($periodId, $request); $period = $this->resolvePeriod($periodId, $request);

View File

@@ -409,7 +409,11 @@ final class AdminUser extends Authenticatable
$codes = array_keys($merged); $codes = array_keys($merged);
return AgentProfileCapabilityFilter::applyToMenuActionCodes($codes, $this->primaryAgentProfile()); return AgentProfileCapabilityFilter::applyToMenuActionCodes(
$codes,
$this->primaryAgentProfile(),
$this->primaryAgentNode(),
);
} }
private function primaryAgentProfile(): ?AgentProfile private function primaryAgentProfile(): ?AgentProfile

View File

@@ -3,6 +3,7 @@
namespace App\Services\Admin; namespace App\Services\Admin;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Services\AgentSettlement\ShareLedgerScopedProfitAggregator;
use App\Support\AdminScopeContextResolver; use App\Support\AdminScopeContextResolver;
/** /**
@@ -12,6 +13,7 @@ final class AdminDashboardAnalyticsBuilder
{ {
public function __construct( public function __construct(
private readonly AdminReportQueryService $reportQuery, private readonly AdminReportQueryService $reportQuery,
private readonly ShareLedgerScopedProfitAggregator $shareProfitAggregator,
) {} ) {}
/** /**
@@ -53,6 +55,27 @@ final class AdminDashboardAnalyticsBuilder
$dateTo = $range['date_to']; $dateTo = $range['date_to'];
$trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scope: $scope); $trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scope: $scope);
$summary = $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $scope);
$dailySeries = $trend['series'];
$profitScope = 'house_gross';
if ($admin->primaryAgentNode() !== null) {
$profitScope = 'share_profit';
$shareByDate = $this->shareProfitAggregator->shareProfitByBusinessDate($admin, $dateFrom, $dateTo);
$summary['approx_house_gross_minor'] = $this->shareProfitAggregator->sumShareProfitForAdmin(
$admin,
$dateFrom,
$dateTo,
);
$dailySeries = array_map(
static function (array $row) use ($shareByDate): array {
$row['approx_house_gross_minor'] = $shareByDate[(string) $row['business_date']] ?? 0;
return $row;
},
$dailySeries,
);
}
return [ return [
'period' => $period, 'period' => $period,
@@ -60,9 +83,10 @@ final class AdminDashboardAnalyticsBuilder
'play_code' => $playCode, 'play_code' => $playCode,
'date_from' => $dateFrom, 'date_from' => $dateFrom,
'date_to' => $dateTo, 'date_to' => $dateTo,
'profit_scope' => $profitScope,
'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $scope), 'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $scope),
'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $scope), 'summary' => $summary,
'daily_series' => $trend['series'], 'daily_series' => $dailySeries,
'chart_meta' => [ 'chart_meta' => [
'chart_date_from' => $trend['chart_date_from'], 'chart_date_from' => $trend['chart_date_from'],
'chart_date_to' => $trend['chart_date_to'], 'chart_date_to' => $trend['chart_date_to'],

View File

@@ -8,6 +8,7 @@ use App\Models\AgentProfile;
use App\Models\Player; use App\Models\Player;
use App\Support\AdminScopeContext; use App\Support\AdminScopeContext;
use App\Support\AdminScopeContextResolver; use App\Support\AdminScopeContextResolver;
use App\Services\AgentSettlement\ShareLedgerScopedProfitAggregator;
use App\Support\AdminAgentSettlementScope; use App\Support\AdminAgentSettlementScope;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -18,6 +19,7 @@ final class AgentDashboardOverviewBuilder
{ {
public function __construct( public function __construct(
private readonly AdminReportQueryService $reportQuery, private readonly AdminReportQueryService $reportQuery,
private readonly ShareLedgerScopedProfitAggregator $shareProfitAggregator,
) {} ) {}
/** /**
@@ -58,6 +60,8 @@ final class AgentDashboardOverviewBuilder
$sevenDayFrom = now()->subDays(6)->toDateString(); $sevenDayFrom = now()->subDays(6)->toDateString();
$todayTotals = $this->reportQuery->periodFinanceTotals($today, $today, $scope); $todayTotals = $this->reportQuery->periodFinanceTotals($today, $today, $scope);
$sevenDayTotals = $this->reportQuery->periodFinanceTotals($sevenDayFrom, $today, $scope); $sevenDayTotals = $this->reportQuery->periodFinanceTotals($sevenDayFrom, $today, $scope);
$todayShareProfit = $this->shareProfitAggregator->sumShareProfitForAdmin($admin, $today, $today);
$sevenDayShareProfit = $this->shareProfitAggregator->sumShareProfitForAdmin($admin, $sevenDayFrom, $today);
$currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scope) $currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scope)
?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scope); ?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scope);
$teamPlayerStats = $this->teamPlayerStats($subtreeIds); $teamPlayerStats = $this->teamPlayerStats($subtreeIds);
@@ -87,10 +91,11 @@ final class AgentDashboardOverviewBuilder
'bet_order_count_today' => $todayActivityStats['order_count'], 'bet_order_count_today' => $todayActivityStats['order_count'],
'today_bet_minor' => $todayTotals['total_bet_minor'], 'today_bet_minor' => $todayTotals['total_bet_minor'],
'today_payout_minor' => $todayTotals['total_payout_minor'], 'today_payout_minor' => $todayTotals['total_payout_minor'],
'today_profit_minor' => $todayTotals['approx_house_gross_minor'], 'today_profit_minor' => $todayShareProfit,
'seven_day_bet_minor' => $sevenDayTotals['total_bet_minor'], 'seven_day_bet_minor' => $sevenDayTotals['total_bet_minor'],
'seven_day_payout_minor' => $sevenDayTotals['total_payout_minor'], 'seven_day_payout_minor' => $sevenDayTotals['total_payout_minor'],
'seven_day_profit_minor' => $sevenDayTotals['approx_house_gross_minor'], 'seven_day_profit_minor' => $sevenDayShareProfit,
'profit_scope' => 'share_profit',
'currency_code' => $currencyCode, 'currency_code' => $currencyCode,
'pending_bill_count' => $pendingBillStats['count'], 'pending_bill_count' => $pendingBillStats['count'],
'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'], 'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'],

View File

@@ -31,6 +31,7 @@ final class AgentPeriodAggregator
$rows = DB::table('share_ledger as sl') $rows = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id') ->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode) ->where('p.site_code', $siteCode)
->whereNull('sl.settlement_period_id')
->whereBetween('sl.settled_at', [$periodStart, $periodEnd]) ->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->select([ ->select([
'sl.player_id', 'sl.player_id',

View File

@@ -79,6 +79,7 @@ final class AgentSettlementPeriodCloseService
->from('share_ledger as sl') ->from('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id') ->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode) ->where('p.site_code', $siteCode)
->whereNull('sl.settlement_period_id')
->whereBetween('sl.settled_at', [$periodStart, $periodEnd]); ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]);
}) })
->update(['settlement_period_id' => $periodId]); ->update(['settlement_period_id' => $periodId]);

View File

@@ -2,6 +2,7 @@
namespace App\Services\AgentSettlement; namespace App\Services\AgentSettlement;
use App\Support\AgentSettlementPeriodWindow;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -14,8 +15,10 @@ final class AgentSettlementPeriodOpenService
public function open(array $data): object public function open(array $data): object
{ {
$siteId = (int) $data['admin_site_id']; $siteId = (int) $data['admin_site_id'];
$start = (string) $data['period_start']; [$start, $end] = AgentSettlementPeriodWindow::normalizeInputBounds(
$end = (string) $data['period_end']; (string) $data['period_start'],
(string) $data['period_end'],
);
$existingSameRange = DB::table('settlement_periods') $existingSameRange = DB::table('settlement_periods')
->where('admin_site_id', $siteId) ->where('admin_site_id', $siteId)
@@ -43,6 +46,12 @@ final class AgentSettlementPeriodOpenService
]); ]);
} }
if ($this->overlapsExistingPeriod($siteId, $start, $end)) {
throw ValidationException::withMessages([
'period_start' => ['period_overlaps_existing'],
]);
}
$id = (int) DB::table('settlement_periods')->insertGetId([ $id = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId, 'admin_site_id' => $siteId,
'period_start' => $start, 'period_start' => $start,
@@ -59,4 +68,13 @@ final class AgentSettlementPeriodOpenService
return $row; return $row;
} }
private function overlapsExistingPeriod(int $siteId, string $start, string $end): bool
{
return DB::table('settlement_periods')
->where('admin_site_id', $siteId)
->where('period_start', '<=', $end)
->where('period_end', '>=', $start)
->exists();
}
} }

View File

@@ -3,7 +3,7 @@
namespace App\Services\AgentSettlement; namespace App\Services\AgentSettlement;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Support\AdminDataScope; use App\Support\AdminAgentSettlementScope;
use App\Support\AgentSettlementPeriodWindow; use App\Support\AgentSettlementPeriodWindow;
use App\Support\PlayerFundingMode; use App\Support\PlayerFundingMode;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -71,17 +71,18 @@ final class AgentSettlementPeriodPipelineService
->whereBetween('cl.created_at', [$start, $end]); ->whereBetween('cl.created_at', [$start, $end]);
if ($admin !== null) { if ($admin !== null) {
AdminDataScope::applyToPlayersAlias($creditQuery, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($creditQuery, $admin, 'p');
} }
$shareQuery = DB::table('share_ledger as sl') $shareQuery = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id') ->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode) ->where('p.site_code', $siteCode)
->whereNull('sl.settlement_period_id')
->whereBetween('sl.settled_at', [$start, $end]) ->whereBetween('sl.settled_at', [$start, $end])
->whereNull('sl.reversal_of_id'); ->whereNull('sl.reversal_of_id');
if ($admin !== null) { if ($admin !== null) {
AdminDataScope::applyToPlayersAlias($shareQuery, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($shareQuery, $admin, 'p');
} }
$shareAgg = (clone $shareQuery) $shareAgg = (clone $shareQuery)

View File

@@ -332,23 +332,17 @@ final class AgentSettlementReportQueryService
private function applyPlayerSubtree(Builder $query, AdminUser $admin, string $alias = 'p'): void private function applyPlayerSubtree(Builder $query, AdminUser $admin, string $alias = 'p'): void
{ {
AdminDataScope::applyToPlayersAlias($query, $admin, $alias); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, $alias);
} }
private function applyAgentSubtree(Builder $query, AdminUser $admin, string $agentNodeColumn): void private function applyAgentSubtree(Builder $query, AdminUser $admin, string $agentNodeColumn): void
{ {
$subtreeIds = AdminAgentSettlementScope::subtreeAgentNodeIds($admin); $actorId = AdminAgentSettlementScope::boundAgentNodeId($admin);
if ($subtreeIds === null) { if ($actorId === null) {
return; return;
} }
if ($subtreeIds === []) { $query->where($agentNodeColumn, $actorId);
$query->whereRaw('0 = 1');
return;
}
$query->whereIn($agentNodeColumn, $subtreeIds);
} }
private function siteCodeForAdmin(AdminUser $admin, int $periodId): string private function siteCodeForAdmin(AdminUser $admin, int $periodId): string

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\AgentNode;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/** 代理账单:汇总下级代理在本期保留的占成。 */
final class SettlementBillDownlineShareBuilder
{
public function __construct(
private readonly SettlementPartyEnrichment $partyEnrichment,
) {}
/**
* @return array{
* total: int,
* items: list<array{owner_id: int, owner_label: string, share_profit: int}>
* }
*/
public function forBill(object $bill): array
{
if ((string) $bill->bill_type !== 'agent' || (string) $bill->owner_type !== 'agent') {
return ['total' => 0, 'items' => []];
}
$ownerId = (int) $bill->owner_id;
$periodId = (int) $bill->settlement_period_id;
if ($ownerId <= 0 || $periodId <= 0) {
return ['total' => 0, 'items' => []];
}
$owner = AgentNode::query()->find($ownerId);
if ($owner === null) {
return ['total' => 0, 'items' => []];
}
$descendantIds = AgentNode::query()
->where('admin_site_id', (int) $owner->admin_site_id)
->where('id', '!=', $ownerId)
->where('path', 'like', $owner->path.'%')
->pluck('id')
->map(static fn ($id): int => (int) $id)
->all();
if ($descendantIds === []) {
return ['total' => 0, 'items' => []];
}
$rows = DB::table('settlement_bills')
->where('settlement_period_id', $periodId)
->where('bill_type', 'agent')
->where('owner_type', 'agent')
->whereIn('owner_id', $descendantIds)
->orderBy('owner_id')
->get(['owner_id', 'meta_json']);
if ($rows->isEmpty()) {
return ['total' => 0, 'items' => []];
}
$agentIds = $rows->pluck('owner_id')->map(static fn ($id): int => (int) $id)->all();
$agents = $this->partyEnrichment->loadAgents($agentIds);
$items = [];
$total = 0;
foreach ($rows as $row) {
$shareProfit = $this->shareProfitFromMeta($row->meta_json ?? null);
if ($shareProfit === 0) {
continue;
}
$agentId = (int) $row->owner_id;
$items[] = [
'owner_id' => $agentId,
'owner_label' => $this->partyEnrichment->formatAgent($agents->get($agentId), $agentId),
'share_profit' => $shareProfit,
];
$total += $shareProfit;
}
usort($items, static fn (array $a, array $b): int => $b['share_profit'] <=> $a['share_profit']
?: $a['owner_label'] <=> $b['owner_label']);
return [
'total' => $total,
'items' => $items,
];
}
private function shareProfitFromMeta(mixed $metaJson): int
{
if ($metaJson === null || $metaJson === '') {
return 0;
}
$decoded = is_string($metaJson) ? json_decode($metaJson, true) : $metaJson;
return is_array($decoded) ? (int) ($decoded['share_profit'] ?? 0) : 0;
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Services\AgentSettlement; namespace App\Services\AgentSettlement;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Support\AdminDataScope;
use App\Support\AdminAgentSettlementScope; use App\Support\AdminAgentSettlementScope;
use App\Support\AgentSettlementPeriodWindow; use App\Support\AgentSettlementPeriodWindow;
use App\Support\CurrencyFormatter; use App\Support\CurrencyFormatter;
@@ -246,7 +245,7 @@ final class SettlementCenterLedgerService
->whereNull('sl.reversal_of_id') ->whereNull('sl.reversal_of_id')
->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at"); ->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at");
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$this->applyLedgerPlayerFilters($query, 'p', $filters); $this->applyLedgerPlayerFilters($query, 'p', $filters);
if ($range !== null) { if ($range !== null) {
@@ -276,7 +275,7 @@ final class SettlementCenterLedgerService
->where('p.funding_mode', PlayerFundingMode::CREDIT) ->where('p.funding_mode', PlayerFundingMode::CREDIT)
->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at"); ->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at");
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$this->applyLedgerPlayerFilters($query, 'p', $filters); $this->applyLedgerPlayerFilters($query, 'p', $filters);
if ($range !== null) { if ($range !== null) {
@@ -408,7 +407,7 @@ final class SettlementCenterLedgerService
$outer->whereNull('p.id') $outer->whereNull('p.id')
->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void { ->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void {
$scoped->where('p.site_code', $siteCode); $scoped->where('p.site_code', $siteCode);
AdminDataScope::applyToPlayersAlias($scoped, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p');
$this->applyLedgerPlayerFilters($scoped, 'p', $filters); $this->applyLedgerPlayerFilters($scoped, 'p', $filters);
}); });
}); });
@@ -767,7 +766,7 @@ final class SettlementCenterLedgerService
'sla.name as share_agent_name', 'sla.name as share_agent_name',
]); ]);
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
return $query->get()->all(); return $query->get()->all();
} }
@@ -840,7 +839,7 @@ final class SettlementCenterLedgerService
$query->where('sb.settlement_period_id', $periodId); $query->where('sb.settlement_period_id', $periodId);
} }
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$map = []; $map = [];
foreach ($query->limit(500)->get() as $bill) { foreach ($query->limit(500)->get() as $bill) {
@@ -907,7 +906,7 @@ final class SettlementCenterLedgerService
]) ])
->orderByDesc('cl.id'); ->orderByDesc('cl.id');
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
if ($playerId !== null && $playerId > 0) { if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId); $query->where('p.id', $playerId);
@@ -969,7 +968,7 @@ final class SettlementCenterLedgerService
]) ])
->orderByDesc('cl.id'); ->orderByDesc('cl.id');
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$this->applyLedgerPlayerFilters($query, 'p', $filters); $this->applyLedgerPlayerFilters($query, 'p', $filters);
if ($range !== null) { if ($range !== null) {
@@ -1023,7 +1022,7 @@ final class SettlementCenterLedgerService
'pa.name as parent_agent_name', 'pa.name as parent_agent_name',
]); ]);
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
return $query->get()->all(); return $query->get()->all();
} }
@@ -1082,7 +1081,7 @@ final class SettlementCenterLedgerService
$query->where('p.id', $playerId); $query->where('p.id', $playerId);
} }
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$siteIds = $admin->accessibleAdminSiteIds(); $siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) { if ($siteIds !== null) {
@@ -1212,7 +1211,7 @@ final class SettlementCenterLedgerService
$query->where('p.id', $playerId); $query->where('p.id', $playerId);
} }
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$siteIds = $admin->accessibleAdminSiteIds(); $siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) { if ($siteIds !== null) {
@@ -1272,7 +1271,7 @@ final class SettlementCenterLedgerService
'pa.name as parent_agent_name', 'pa.name as parent_agent_name',
]); ]);
AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
$this->applyLedgerSiteScope($query, $admin, 'sp'); $this->applyLedgerSiteScope($query, $admin, 'sp');
return $query->get()->all(); return $query->get()->all();

View File

@@ -100,6 +100,7 @@ final class SettlementPartyEnrichment
return $map; return $map;
} }
/** 结算展示:仅代理名称;编号为内部标识,无名称时才回退 code。 */
public function formatAgent(?object $agent, int $fallbackId): string public function formatAgent(?object $agent, int $fallbackId): string
{ {
if ($agent === null) { if ($agent === null) {
@@ -107,13 +108,13 @@ final class SettlementPartyEnrichment
} }
$name = trim((string) ($agent->name ?? '')); $name = trim((string) ($agent->name ?? ''));
$code = trim((string) ($agent->code ?? '')); if ($name !== '') {
return $name;
if ($name !== '' && $code !== '') {
return "{$name} ({$code})";
} }
return $name !== '' ? $name : ($code !== '' ? $code : "agent#{$fallbackId}"); $code = trim((string) ($agent->code ?? ''));
return $code !== '' ? $code : "agent#{$fallbackId}";
} }
public function formatPlayerUsername(?object $player): ?string public function formatPlayerUsername(?object $player): ?string
@@ -150,4 +151,129 @@ final class SettlementPartyEnrichment
return "{$type}#{$id}"; return "{$type}#{$id}";
} }
/**
* @param iterable<int, object> $rows
* @return list<array<string, mixed>>
*/
public function enrichBillRows(iterable $rows): array
{
$items = collect($rows);
if ($items->isEmpty()) {
return [];
}
$playerIds = [];
$agentIds = [];
foreach ($items as $row) {
if ((string) $row->owner_type === 'player') {
$playerIds[] = (int) $row->owner_id;
} elseif ((string) $row->owner_type === 'agent') {
$agentIds[] = (int) $row->owner_id;
}
if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) {
$agentIds[] = (int) $row->counterparty_id;
}
}
$players = $playerIds !== []
? DB::table('players')
->whereIn('id', array_unique($playerIds))
->select(['id', 'username', 'site_player_id', 'agent_node_id', 'funding_mode', 'auth_source'])
->get()
->keyBy('id')
: collect();
foreach ($players as $player) {
$aid = (int) ($player->agent_node_id ?? 0);
if ($aid > 0) {
$agentIds[] = $aid;
}
}
$agents = $this->loadAgents($agentIds);
$out = [];
foreach ($items as $row) {
$out[] = $this->enrichBillRowWithLookups($row, $players, $agents);
}
return $out;
}
/** @return array<string, mixed> */
public function enrichBillRow(object $row): array
{
return $this->enrichBillRows([$row])[0];
}
/**
* @param Collection<int, object> $players
* @param Collection<int, object> $agents
* @return array<string, mixed>
*/
private function enrichBillRowWithLookups(object $row, Collection $players, Collection $agents): array
{
$item = (array) $row;
$ownerType = (string) $row->owner_type;
$counterType = (string) $row->counterparty_type;
$counterId = (int) $row->counterparty_id;
$item['owner_label'] = $this->legacyOwnerLabel($ownerType, (int) $row->owner_id, $players, $agents);
$item['counterparty_label'] = $this->formatCounterpartyLabel($counterType, $counterId, $agents);
$item['player_username'] = null;
$item['player_site_player_id'] = null;
$item['player_id_display'] = null;
$item['direct_agent_label'] = null;
$item['superior_agent_label'] = null;
$item['owner_party_label'] = null;
if ($ownerType === 'player') {
$player = $players->get((int) $row->owner_id);
$item['player_username'] = $this->formatPlayerUsername($player);
$item['player_site_player_id'] = $this->formatPlayerSiteId($player);
$item['player_id_display'] = (int) $row->owner_id;
$item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null;
$item['owner_auth_source'] = $player !== null ? $player->auth_source : null;
$directId = $counterType === 'agent' ? $counterId : (int) ($player->agent_node_id ?? 0);
$line = $this->agentLineLabels($directId > 0 ? $directId : null, $agents);
$item['direct_agent_label'] = $line['direct_agent_label'];
$item['superior_agent_label'] = $line['parent_agent_label'];
} elseif ($ownerType === 'agent') {
$ownerAgentId = (int) $row->owner_id;
$item['owner_party_label'] = $this->formatAgent($agents->get($ownerAgentId), $ownerAgentId);
$item['superior_agent_label'] = $counterType === 'platform'
? 'platform'
: $this->formatCounterpartyLabel($counterType, $counterId, $agents);
}
return $item;
}
/**
* @param Collection<int, object> $players
* @param Collection<int, object> $agents
*/
private function legacyOwnerLabel(
string $type,
int $id,
Collection $players,
Collection $agents,
): string {
if ($type === 'player') {
$player = $players->get($id);
return $player !== null
? (string) ($player->username ?: $player->site_player_id ?: "player#{$id}")
: "player#{$id}";
}
if ($type === 'agent') {
return $this->formatAgent($agents->get($id), $id);
}
return "{$type}#{$id}";
}
} }

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Services\AgentSettlement;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/** 开账弹窗:建议账期与日历标记(已有账期 / 待入账 / 未结清)。 */
final class SettlementPeriodOpenHintsService
{
/**
* @return array{
* suggested_start: string,
* suggested_end: string,
* occupied_period_dates: list<string>,
* pending_activity_dates: list<string>,
* unpaid_bill_dates: list<string>
* }
*/
public function hints(int $adminSiteId): array
{
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
if ($siteCode === '') {
return $this->emptyHints();
}
$periodRows = DB::table('settlement_periods')
->where('admin_site_id', $adminSiteId)
->orderBy('period_start')
->get(['period_start', 'period_end', 'status']);
$occupiedPeriodDates = [];
foreach ($periodRows as $row) {
foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) {
$occupiedPeriodDates[$day] = true;
}
}
$lastPeriod = DB::table('settlement_periods')
->where('admin_site_id', $adminSiteId)
->whereIn('status', ['closed', 'completed'])
->orderByDesc('period_end')
->first();
$pendingActivityDates = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereNull('sl.settlement_period_id')
->whereNull('sl.reversal_of_id')
->selectRaw('DATE(sl.settled_at) as activity_day')
->groupBy('activity_day')
->orderBy('activity_day')
->pluck('activity_day')
->map(static fn ($day): string => (string) $day)
->values()
->all();
$unpaidPeriodRows = DB::table('settlement_periods as sp')
->where('sp.admin_site_id', $adminSiteId)
->whereIn('sp.status', ['closed', 'completed'])
->whereExists(function ($query): void {
$query->selectRaw('1')
->from('settlement_bills as sb')
->whereColumn('sb.settlement_period_id', 'sp.id')
->where('sb.unpaid_amount', '>', 0)
->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']);
})
->orderBy('sp.period_start')
->get(['sp.period_start', 'sp.period_end']);
$unpaidBillDates = [];
foreach ($unpaidPeriodRows as $row) {
foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) {
$unpaidBillDates[$day] = true;
}
}
$suggested = $this->suggestRange($lastPeriod, $pendingActivityDates, $occupiedPeriodDates);
return [
'suggested_start' => $suggested['start'],
'suggested_end' => $suggested['end'],
'occupied_period_dates' => array_keys($occupiedPeriodDates),
'pending_activity_dates' => $pendingActivityDates,
'unpaid_bill_dates' => array_keys($unpaidBillDates),
];
}
/**
* @param list<string> $pendingActivityDates UTC `Y-m-d`
* @param array<string, true> $occupiedPeriodDates
* @return array{start: string, end: string}
*/
private function suggestRange(?object $lastPeriod, array $pendingActivityDates, array $occupiedPeriodDates): array
{
$lastEndDay = $lastPeriod !== null
? Carbon::parse((string) $lastPeriod->period_end)->utc()->startOfDay()
: null;
$freePending = array_values(array_filter(
$pendingActivityDates,
static fn (string $day): bool => ! isset($occupiedPeriodDates[$day]),
));
if ($freePending !== []) {
$minDay = Carbon::parse($freePending[0])->utc()->startOfDay();
$maxDay = Carbon::parse($freePending[array_key_last($freePending)])->utc()->startOfDay();
$startDay = $lastEndDay !== null
? ($lastEndDay->copy()->addDay()->lessThanOrEqualTo($minDay) ? $lastEndDay->copy()->addDay() : $minDay)
: $minDay;
$candidate = [
'start' => $startDay->format('Y-m-d'),
'end' => $maxDay->format('Y-m-d'),
];
return $this->withoutOccupiedOverlap($candidate, $occupiedPeriodDates);
}
if ($lastEndDay !== null) {
$startDay = $lastEndDay->copy()->addDay();
$endDay = Carbon::now('UTC')->subDay()->startOfDay();
if ($endDay->lessThan($startDay)) {
return ['start' => '', 'end' => ''];
}
return $this->withoutOccupiedOverlap([
'start' => $startDay->format('Y-m-d'),
'end' => $endDay->format('Y-m-d'),
], $occupiedPeriodDates);
}
return ['start' => '', 'end' => ''];
}
/**
* @param array{start: string, end: string} $candidate
* @param array<string, true> $occupiedPeriodDates
* @return array{start: string, end: string}
*/
private function withoutOccupiedOverlap(array $candidate, array $occupiedPeriodDates): array
{
if ($candidate['start'] === '' || $candidate['end'] === '') {
return ['start' => '', 'end' => ''];
}
if ($this->rangeOverlapsOccupied($candidate['start'], $candidate['end'], $occupiedPeriodDates)) {
return ['start' => '', 'end' => ''];
}
return $candidate;
}
/**
* @param array<string, true> $occupiedPeriodDates
*/
private function rangeOverlapsOccupied(string $startYmd, string $endYmd, array $occupiedPeriodDates): bool
{
$cursor = Carbon::parse($startYmd)->utc()->startOfDay();
$end = Carbon::parse($endYmd)->utc()->startOfDay();
while ($cursor->lessThanOrEqualTo($end)) {
if (isset($occupiedPeriodDates[$cursor->format('Y-m-d')])) {
return true;
}
$cursor->addDay();
}
return false;
}
/** @return list<string> 站点本地日历 `Y-m-d`(东八区,与后台开账日期选择一致) */
private function expandPeriodToUtcDays(string $periodStart, string $periodEnd): array
{
$dates = [];
$tz = 'Asia/Shanghai';
$cursor = Carbon::parse($periodStart)->timezone($tz)->startOfDay();
$end = Carbon::parse($periodEnd)->timezone($tz)->startOfDay();
while ($cursor->lessThanOrEqualTo($end)) {
$dates[] = $cursor->format('Y-m-d');
$cursor->addDay();
}
return $dates;
}
/** @return array{suggested_start: string, suggested_end: string, occupied_period_dates: list<string>, pending_activity_dates: list<string>, unpaid_bill_dates: list<string>} */
private function emptyHints(): array
{
return [
'suggested_start' => '',
'suggested_end' => '',
'occupied_period_dates' => [],
'pending_activity_dates' => [],
'unpaid_bill_dates' => [],
];
}
}

View File

@@ -4,7 +4,9 @@ namespace App\Services\AgentSettlement;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Support\AdminAgentScope; use App\Support\AdminAgentScope;
use App\Support\AdminDataScope;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
/** 按登录视角(平台 / 绑定代理)汇总占成流水 allocations 中的本级输赢。 */ /** 按登录视角(平台 / 绑定代理)汇总占成流水 allocations 中的本级输赢。 */
final class ShareLedgerScopedProfitAggregator final class ShareLedgerScopedProfitAggregator
@@ -54,6 +56,56 @@ final class ShareLedgerScopedProfitAggregator
return (int) ((clone $shareQuery)->sum('sl.game_win_loss') ?? 0); return (int) ((clone $shareQuery)->sum('sl.game_win_loss') ?? 0);
} }
/** 绑定代理账号区间内本级占成收益合计minor。 */
public function sumShareProfitForAdmin(AdminUser $admin, string $dateFrom, string $dateTo): int
{
$viewer = $this->resolveViewer($admin);
if ($viewer['scope'] !== 'agent') {
return 0;
}
return $this->sumForShareQuery(
$this->shareLedgerBaseQuery($admin, $dateFrom, $dateTo),
$viewer['key'],
);
}
/**
* @return array<string, int> business_date (Y-m-d) => share_profit_minor
*/
public function shareProfitByBusinessDate(AdminUser $admin, string $dateFrom, string $dateTo): array
{
$viewer = $this->resolveViewer($admin);
if ($viewer['scope'] !== 'agent') {
return [];
}
$rows = $this->shareLedgerBaseQuery($admin, $dateFrom, $dateTo)
->selectRaw('DATE(sl.settled_at) as business_date')
->addSelect(['sl.allocations_json', 'sl.game_win_loss', 'sl.basic_rebate', 'sl.share_snapshot'])
->get();
$byDate = [];
foreach ($rows as $row) {
$date = (string) $row->business_date;
$byDate[$date] = ($byDate[$date] ?? 0) + $this->profitFromRow($row, $viewer['key']);
}
return $byDate;
}
private function shareLedgerBaseQuery(AdminUser $admin, string $dateFrom, string $dateTo): Builder
{
$query = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->whereNull('sl.reversal_of_id')
->whereDate('sl.settled_at', '>=', $dateFrom)
->whereDate('sl.settled_at', '<=', $dateTo);
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
return $query;
}
private function profitFromRow(object $row, string $profitKey): int private function profitFromRow(object $row, string $profitKey): int
{ {
$allocations = $this->decodeJsonObject($row->allocations_json ?? null); $allocations = $this->decodeJsonObject($row->allocations_json ?? null);

View File

@@ -6,7 +6,7 @@ use App\Models\AdminUser;
use App\Models\AgentNode; use App\Models\AgentNode;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
/** 代理账单按管理员可访问站点 + 代理子树过滤。 */ /** 结算中心账单:站点范围 + 绑定代理仅见直属边(玩家↔直属代理、代理↔直接上下级)。 */
final class AdminAgentSettlementScope final class AdminAgentSettlementScope
{ {
/** /**
@@ -32,6 +32,17 @@ final class AdminAgentSettlementScope
return $ids; return $ids;
} }
public static function boundAgentNodeId(AdminUser $admin): ?int
{
if ($admin->isSuperAdmin()) {
return null;
}
$actor = AdminAgentScope::primaryAgentNode($admin);
return $actor !== null ? (int) $actor->id : null;
}
public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void
{ {
$siteIds = $admin->accessibleAdminSiteIds(); $siteIds = $admin->accessibleAdminSiteIds();
@@ -52,7 +63,7 @@ final class AdminAgentSettlementScope
{ {
$siteIds = $admin->accessibleAdminSiteIds(); $siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) { if ($siteIds === null) {
self::applySubtreeToBillsQuery($query, $admin, $billsAlias); self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
return; return;
} }
@@ -70,35 +81,72 @@ final class AdminAgentSettlementScope
->whereIn('settlement_periods.admin_site_id', $siteIds); ->whereIn('settlement_periods.admin_site_id', $siteIds);
}); });
self::applySubtreeToBillsQuery($query, $admin, $billsAlias); self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
} }
/** 绑定代理仅见本子树玩家账单 + owner 为本子树节点的代理账单。 */ /** @deprecated 使用 {@see applyDirectEdgeScopeToBillsQuery} */
public static function applySubtreeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void public static function applySubtreeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
{ {
$subtreeIds = self::subtreeAgentNodeIds($admin); self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias);
if ($subtreeIds === null) { }
/**
* 绑定代理:
* - 玩家账单仅直属玩家players.agent_node_id = 本节点)
* - 代理账单owner=本节点(向上)或 counterparty=本节点(下级向我结)
*/
/**
* 结算中心玩家维度:绑定代理仅见直属玩家;站点财务/超管见全站(由调用方再限 site_code
*/
public static function applyDirectPlayersToAlias(Builder $query, AdminUser $admin, string $alias = 'p'): void
{
if ($admin->isSuperAdmin() || self::canManageSitePeriods($admin)) {
return; return;
} }
if ($subtreeIds === []) { $actorId = self::boundAgentNodeId($admin);
if ($actorId === null) {
$query->whereRaw('0 = 1'); $query->whereRaw('0 = 1');
return; return;
} }
$query->where(function (Builder $outer) use ($billsAlias, $subtreeIds): void { if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
$outer->where(function (Builder $player) use ($billsAlias, $subtreeIds): void { return;
}
$query->where($alias.'.agent_node_id', $actorId);
}
public static function applyDirectEdgeScopeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
{
$actorId = self::boundAgentNodeId($admin);
if ($actorId === null) {
return;
}
$query->where(function (Builder $outer) use ($billsAlias, $actorId): void {
$outer->where(function (Builder $player) use ($billsAlias, $actorId): void {
$player->where($billsAlias.'.owner_type', 'player') $player->where($billsAlias.'.owner_type', 'player')
->whereExists(function (Builder $exists) use ($billsAlias, $subtreeIds): void { ->whereExists(function (Builder $exists) use ($billsAlias, $actorId): void {
$exists->selectRaw('1') $exists->selectRaw('1')
->from('players') ->from('players')
->whereColumn('players.id', $billsAlias.'.owner_id') ->whereColumn('players.id', $billsAlias.'.owner_id')
->whereIn('players.agent_node_id', $subtreeIds); ->where('players.agent_node_id', $actorId);
}); });
})->orWhere(function (Builder $agent) use ($billsAlias, $subtreeIds): void { })->orWhere(function (Builder $agent) use ($billsAlias, $actorId): void {
$agent->where($billsAlias.'.owner_type', 'agent') $agent->where($billsAlias.'.owner_type', 'agent')
->whereIn($billsAlias.'.owner_id', $subtreeIds); ->where(function (Builder $edge) use ($billsAlias, $actorId): void {
$edge->where($billsAlias.'.owner_id', $actorId)
->orWhere(function (Builder $incoming) use ($billsAlias, $actorId): void {
$incoming->where($billsAlias.'.counterparty_type', 'agent')
->where($billsAlias.'.counterparty_id', $actorId);
})
->orWhere(function (Builder $platform) use ($billsAlias, $actorId): void {
$platform->where($billsAlias.'.counterparty_type', 'platform')
->where($billsAlias.'.owner_id', $actorId);
});
});
}); });
}); });
} }
@@ -147,6 +195,19 @@ final class AdminAgentSettlementScope
} }
} }
/** 坏账核销 / 补差冲正仅站点财务或超管(绑定代理不可操作)。 */
public static function canPerformFinanceAdjustments(AdminUser $admin): bool
{
return self::canManageSitePeriods($admin);
}
public static function assertCanPerformFinanceAdjustments(AdminUser $admin): void
{
if (! self::canPerformFinanceAdjustments($admin)) {
abort(403, 'agent_bound_cannot_finance_adjust');
}
}
public static function billAccessible(AdminUser $admin, int $settlementBillId): bool public static function billAccessible(AdminUser $admin, int $settlementBillId): bool
{ {
$siteIds = $admin->accessibleAdminSiteIds(); $siteIds = $admin->accessibleAdminSiteIds();
@@ -157,7 +218,13 @@ final class AdminAgentSettlementScope
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills as sb') $bill = \Illuminate\Support\Facades\DB::table('settlement_bills as sb')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->where('sb.id', $settlementBillId) ->where('sb.id', $settlementBillId)
->select(['sb.owner_type', 'sb.owner_id', 'sp.admin_site_id']) ->select([
'sb.owner_type',
'sb.owner_id',
'sb.counterparty_type',
'sb.counterparty_id',
'sp.admin_site_id',
])
->first(); ->first();
if ($bill === null) { if ($bill === null) {
@@ -168,23 +235,127 @@ final class AdminAgentSettlementScope
return false; return false;
} }
$subtreeIds = self::subtreeAgentNodeIds($admin); $actorId = self::boundAgentNodeId($admin);
if ($subtreeIds === null) { if ($actorId === null) {
return true; return true;
} }
return self::billMatchesDirectEdgeScope($actorId, $bill);
}
public static function canOperateBill(AdminUser $admin, int $settlementBillId): bool
{
if (! self::billAccessible($admin, $settlementBillId)) {
return false;
}
if (self::canManageSitePeriods($admin)) {
return true;
}
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills')
->where('id', $settlementBillId)
->select(['owner_type', 'owner_id', 'counterparty_type', 'counterparty_id', 'net_amount'])
->first();
if ($bill === null) {
return false;
}
$actorId = self::boundAgentNodeId($admin);
if ($actorId === null) {
return true;
}
return self::billOperableByBoundAgent($actorId, $bill);
}
public static function assertCanOperateBill(AdminUser $admin, int $settlementBillId): void
{
abort_if(! self::billAccessible($admin, $settlementBillId), 404);
if (self::canManageSitePeriods($admin)) {
return;
}
$bill = \Illuminate\Support\Facades\DB::table('settlement_bills')
->where('id', $settlementBillId)
->select(['owner_type', 'owner_id', 'counterparty_type', 'counterparty_id', 'net_amount'])
->first();
abort_if($bill === null, 404);
$actorId = self::boundAgentNodeId($admin);
abort_if($actorId === null, 403, 'agent_cannot_operate_bill');
abort_if(
! self::billOperableByBoundAgent($actorId, $bill),
403,
(string) $bill->owner_type === 'player' ? 'agent_cannot_operate_player_bill' : 'agent_cannot_operate_bill',
);
}
private static function billMatchesDirectEdgeScope(int $actorId, object $bill): bool
{
if ((string) $bill->owner_type === 'player') { if ((string) $bill->owner_type === 'player') {
$agentNodeId = (int) (\Illuminate\Support\Facades\DB::table('players') $agentNodeId = (int) (\Illuminate\Support\Facades\DB::table('players')
->where('id', (int) $bill->owner_id) ->where('id', (int) $bill->owner_id)
->value('agent_node_id') ?? 0); ->value('agent_node_id') ?? 0);
return $agentNodeId > 0 && in_array($agentNodeId, $subtreeIds, true); return $agentNodeId === $actorId;
} }
if ((string) $bill->owner_type === 'agent') { if ((string) $bill->owner_type === 'agent') {
return in_array((int) $bill->owner_id, $subtreeIds, true); return self::agentBillOnDirectEdge($actorId, $bill);
} }
return false; return false;
} }
private static function billOperableByBoundAgent(int $actorId, object $bill): bool
{
if ((string) $bill->owner_type === 'player') {
return (string) $bill->counterparty_type === 'agent'
&& (int) $bill->counterparty_id === $actorId;
}
if ((string) $bill->owner_type === 'agent') {
if (! self::agentBillOnDirectEdge($actorId, $bill)) {
return false;
}
[$payeeType, $payeeId] = self::billPayeeParty($bill);
return $payeeType === 'agent' && $payeeId === $actorId;
}
return false;
}
/** net>0counterparty 为收款方net<0owner 为收款方。 */
private static function billPayeeParty(object $bill): array
{
if ((int) $bill->net_amount < 0) {
return [(string) $bill->owner_type, (int) $bill->owner_id];
}
return [(string) $bill->counterparty_type, (int) $bill->counterparty_id];
}
private static function agentBillOnDirectEdge(int $actorId, object $bill): bool
{
$ownerId = (int) $bill->owner_id;
$counterType = (string) $bill->counterparty_type;
$counterId = (int) $bill->counterparty_id;
if ($ownerId === $actorId) {
return true;
}
if ($counterType === 'agent' && $counterId === $actorId) {
return true;
}
return $counterType === 'platform' && $ownerId === $actorId;
}
} }

View File

@@ -29,6 +29,7 @@ final class AdminAuthProfile
* agent: ?array{ * agent: ?array{
* id: int, * id: int,
* admin_site_id: int, * admin_site_id: int,
* admin_site_name: string,
* site_code: string, * site_code: string,
* path: string, * path: string,
* code: string, * code: string,
@@ -73,6 +74,7 @@ final class AdminAuthProfile
* @return array{ * @return array{
* id: int, * id: int,
* admin_site_id: int, * admin_site_id: int,
* admin_site_name: string,
* site_code: string, * site_code: string,
* path: string, * path: string,
* code: string, * code: string,
@@ -93,13 +95,18 @@ final class AdminAuthProfile
return null; return null;
} }
$siteCode = AdminSite::query()->where('id', (int) $node->admin_site_id)->value('code'); $site = AdminSite::query()
->where('id', (int) $node->admin_site_id)
->first(['code', 'name']);
$siteCode = is_string($site?->code) ? $site->code : '';
$siteName = is_string($site?->name) ? $site->name : '';
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
return [ return [
'id' => (int) $node->id, 'id' => (int) $node->id,
'admin_site_id' => (int) $node->admin_site_id, 'admin_site_id' => (int) $node->admin_site_id,
'site_code' => is_string($siteCode) && $siteCode !== '' ? $siteCode : '', 'admin_site_name' => $siteName,
'site_code' => $siteCode !== '' ? $siteCode : '',
'path' => (string) $node->path, 'path' => (string) $node->path,
'code' => (string) $node->code, 'code' => (string) $node->code,
'name' => (string) $node->name, 'name' => (string) $node->name,

View File

@@ -447,6 +447,7 @@ final class AdminAuthorizationRegistry
['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage', 'agent.node.view'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage', 'prd.agent.view']], ['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage', 'agent.node.view'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage', 'prd.agent.view']],
['code' => 'admin.agent-nodes.profile.update', 'module_code' => 'agent', 'name' => '代理占成授信更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']], ['code' => 'admin.agent-nodes.profile.update', 'module_code' => 'agent', 'name' => '代理占成授信更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']],
['code' => 'admin.settlement-periods.index', 'module_code' => 'settlement', 'name' => '代理账期列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.settlement-periods.index', 'module_code' => 'settlement', 'name' => '代理账期列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-periods.open-hints', 'module_code' => 'settlement', 'name' => '开账建议与日历标记', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods/open-hints', 'route_name' => 'api.v1.admin.settlement-periods.open-hints', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], ['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], ['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],

View File

@@ -21,7 +21,6 @@ final class AgentDefaultRolePermissions
'prd.agent.role.view', 'prd.agent.role.view',
'prd.agent.user.view', 'prd.agent.user.view',
'prd.tickets.view', 'prd.tickets.view',
'prd.report.view',
'prd.settlement.agent.view', 'prd.settlement.agent.view',
]; ];

View File

@@ -2,6 +2,7 @@
namespace App\Support; namespace App\Support;
use App\Models\AgentNode;
use App\Models\AgentProfile; use App\Models\AgentProfile;
/** /**
@@ -36,13 +37,25 @@ final class AgentProfileCapabilityFilter
'prd.player_freeze.manage', 'prd.player_freeze.manage',
]; ];
private const SETTLEMENT_AGENT_MANAGE_CODE = 'settlement.agent.manage';
/** 绑定代理主账号均可登记/确认与本节点直属边相关的账单(玩家↔直属代理、代理↔直接上下级)。 */
public static function qualifiesForSettlementAgentManage(?AgentNode $node): bool
{
return $node !== null;
}
/** /**
* Profile 能力收紧或补足登录态 permission_code平台 agent 角色模板未必含 manage * Profile 能力收紧或补足登录态 permission_code平台 agent 角色模板未必含 manage
* *
* @param list<string> $permissionCodes * @param list<string> $permissionCodes
* @return list<string> * @return list<string>
*/ */
public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array public static function applyToMenuActionCodes(
array $permissionCodes,
?AgentProfile $profile,
?AgentNode $node = null,
): array
{ {
$set = []; $set = [];
foreach ($permissionCodes as $code) { foreach ($permissionCodes as $code) {
@@ -71,6 +84,10 @@ final class AgentProfileCapabilityFilter
} }
} }
if (self::qualifiesForSettlementAgentManage($node)) {
$set[self::SETTLEMENT_AGENT_MANAGE_CODE] = true;
}
$out = array_keys($set); $out = array_keys($set);
sort($out); sort($out);
@@ -81,9 +98,12 @@ final class AgentProfileCapabilityFilter
* @param list<string> $permissionCodes * @param list<string> $permissionCodes
* @return list<string> * @return list<string>
*/ */
public static function filterMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array public static function filterMenuActionCodes(
{ array $permissionCodes,
return self::applyToMenuActionCodes($permissionCodes, $profile); ?AgentProfile $profile,
?AgentNode $node = null,
): array {
return self::applyToMenuActionCodes($permissionCodes, $profile, $node);
} }
/** /**

View File

@@ -3,8 +3,9 @@
namespace App\Support; namespace App\Support;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Validation\ValidationException;
/** 账期起止统一为日界startOfDay / endOfDay聚合流水、关账回填共用。 */ /** 账期起止边界:开账时规范化写入,关账/聚合/流水筛选共用同一对 UTC 时刻。 */
final class AgentSettlementPeriodWindow final class AgentSettlementPeriodWindow
{ {
/** /**
@@ -13,8 +14,8 @@ final class AgentSettlementPeriodWindow
public static function bounds(string $periodStart, string $periodEnd): array public static function bounds(string $periodStart, string $periodEnd): array
{ {
return [ return [
Carbon::parse($periodStart)->startOfDay(), Carbon::parse($periodStart)->utc(),
Carbon::parse($periodEnd)->endOfDay(), Carbon::parse($periodEnd)->utc(),
]; ];
} }
@@ -27,4 +28,42 @@ final class AgentSettlementPeriodWindow
return [$start->toDateTimeString(), $end->toDateTimeString()]; return [$start->toDateTimeString(), $end->toDateTimeString()];
} }
/**
* 开账 API支持 `Y-m-d` 或带时刻字符串;前者按 UTC 自然日扩界,后者按 UTC 解释。
*
* @return array{0: string, 1: string}
*/
public static function normalizeInputBounds(string $periodStart, string $periodEnd): array
{
$startRaw = trim($periodStart);
$endRaw = trim($periodEnd);
if ($startRaw === '' || $endRaw === '') {
throw ValidationException::withMessages([
'period_start' => ['required'],
]);
}
$startAt = self::isDateOnly($startRaw)
? Carbon::parse($startRaw.' 00:00:00', 'UTC')
: Carbon::parse($startRaw)->utc();
$endAt = self::isDateOnly($endRaw)
? Carbon::parse($endRaw.' 23:59:59', 'UTC')
: Carbon::parse($endRaw)->utc();
if ($endAt->lessThan($startAt)) {
throw ValidationException::withMessages([
'period_end' => ['after:period_start'],
]);
}
return [$startAt->toDateTimeString(), $endAt->toDateTimeString()];
}
private static function isDateOnly(string $value): bool
{
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value);
}
} }

View File

@@ -0,0 +1,86 @@
<?php
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 开账建议 API 注册到 admin_api_resources已有库增量同步避免 api_resource_not_configured
*/
return new class extends Migration
{
private const RESOURCE_CODE = 'admin.settlement-periods.open-hints';
public function up(): void
{
$now = Carbon::now();
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
$resource = collect(AdminAuthorizationRegistry::resources())
->firstWhere('code', self::RESOURCE_CODE);
if (! is_array($resource)) {
return;
}
$resourceId = DB::table('admin_api_resources')
->where('code', $resource['code'])
->value('id');
$payload = [
'module_code' => $resource['module_code'],
'name' => $resource['name'],
'http_method' => $resource['http_method'],
'uri_pattern' => $resource['uri_pattern'],
'route_name' => $resource['route_name'],
'auth_mode' => $resource['auth_mode'],
'is_audit_required' => $resource['is_audit_required'],
'status' => 1,
'meta_json' => null,
'updated_at' => $now,
];
if ($resourceId === null) {
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
'code' => $resource['code'],
'created_at' => $now,
]);
} else {
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->update($payload);
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] as $permissionCode) {
$menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
public function down(): void
{
$resourceId = DB::table('admin_api_resources')
->where('code', self::RESOURCE_CODE)
->value('id');
if ($resourceId === null) {
return;
}
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
}
};

View File

@@ -0,0 +1,32 @@
<?php
use App\Models\AdminRole;
use App\Models\AgentNode;
use App\Support\AgentDefaultRolePermissions;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
AgentDefaultRolePermissions::ensurePlatformAgentRole();
AgentNode::query()->each(static function (AgentNode $node): void {
$ownerRole = AdminRole::query()
->where('owner_agent_id', $node->id)
->where('slug', 'agent_owner_'.$node->id)
->first();
if ($ownerRole === null) {
return;
}
$ownerRole->syncLegacyPermissionSlugs(AgentDefaultRolePermissions::ownerSlugsForNode($node));
});
}
public function down(): void
{
// 产品策略调整,回滚不恢复报表中心权限。
}
};

View File

@@ -0,0 +1,145 @@
<?php
namespace Database\Seeders;
use App\Services\AgentSettlement\AgentSettlementBillAdjustmentService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 为结算中心「调账 / 冲正」Tab 写入代理账单补差、冲正演示数据。
*
* ```bash
* php artisan db:seed --class="Database\\Seeders\\AgentSettlementAdjustmentDemoSeeder"
* ```
*/
final class AgentSettlementAdjustmentDemoSeeder extends Seeder
{
private const DEMO_REASON_PREFIX = '[demo]';
public function run(): void
{
if (app()->environment('production') && ! config('app.debug')) {
$this->command?->warn('跳过production 且 APP_DEBUG=false 时不写入演示调账数据。');
return;
}
$adminUserId = (int) (DB::table('admin_users')->where('username', 'admin')->value('id') ?? 0);
/** @var AgentSettlementBillAdjustmentService $adjustments */
$adjustments = app(AgentSettlementBillAdjustmentService::class);
$scenarios = [
[
'original_bill_id' => 35,
'amount' => 12000,
'adjustment_type' => 'adjustment',
'reason' => self::DEMO_REASON_PREFIX.' 代理 good 回水漏计,人工补差',
'confirm' => false,
],
[
'original_bill_id' => 34,
'amount' => -5600,
'adjustment_type' => 'reversal',
'reason' => self::DEMO_REASON_PREFIX.' 代理 321321 占成分摊复核有误,冲正',
'confirm' => true,
],
[
'original_bill_id' => 31,
'amount' => 800,
'adjustment_type' => 'adjustment',
'reason' => self::DEMO_REASON_PREFIX.' 代理 good 平台四舍五入补差',
'confirm' => false,
],
];
DB::transaction(function () use ($scenarios, $adjustments, $adminUserId): void {
foreach ($scenarios as $scenario) {
$this->seedScenario($adjustments, $adminUserId, $scenario);
}
});
$this->command?->info('代理结算补差/冲正演示数据已写入(前缀 '.self::DEMO_REASON_PREFIX.')。');
}
/**
* @param array{original_bill_id:int,amount:int,adjustment_type:string,reason:string,confirm:bool} $scenario
*/
private function seedScenario(
AgentSettlementBillAdjustmentService $adjustments,
int $adminUserId,
array $scenario,
): void {
$originalBillId = (int) $scenario['original_bill_id'];
$reason = (string) $scenario['reason'];
if ($this->demoAdjustmentExists($originalBillId, $reason)) {
$this->command?->line("跳过 bill #{$originalBillId}:已存在相同演示记录。");
return;
}
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
if ($original === null) {
$this->command?->warn("跳过 bill #{$originalBillId}:原账单不存在。");
return;
}
if ((string) $original->bill_type !== 'agent') {
$this->command?->warn("跳过 bill #{$originalBillId}:非代理账单。");
return;
}
if ($original->locked_at === null) {
DB::table('settlement_bills')->where('id', $originalBillId)->update([
'locked_at' => now(),
'updated_at' => now(),
]);
}
if ((string) $original->status === 'pending_confirm') {
DB::table('settlement_bills')->where('id', $originalBillId)->update([
'status' => 'confirmed',
'confirmed_at' => now(),
'locked_at' => now(),
'updated_at' => now(),
]);
}
$newBillId = $adjustments->createAdjustment(
$originalBillId,
(int) $scenario['amount'],
(string) $scenario['adjustment_type'],
$reason,
$adminUserId,
);
if ($scenario['confirm']) {
$now = now();
DB::table('settlement_bills')->where('id', $newBillId)->update([
'status' => 'confirmed',
'confirmed_at' => $now,
'locked_at' => $now,
'updated_at' => $now,
]);
}
$this->command?->line(sprintf(
'已创建 %s bill #%d ← 原代理账单 #%d%s',
(string) $scenario['adjustment_type'],
$newBillId,
$originalBillId,
$reason,
));
}
private function demoAdjustmentExists(int $originalBillId, string $reason): bool
{
return DB::table('settlement_adjustments')
->where('original_bill_id', $originalBillId)
->where('reason', $reason)
->exists();
}
}

View File

@@ -27,6 +27,7 @@ return [
'parent_overdue' => 'The parent agent has overdue bills. This operation is not allowed.', 'parent_overdue' => 'The parent agent has overdue bills. This operation is not allowed.',
'period_already_open' => 'A period with this date range is already open. Close it instead of opening again.', 'period_already_open' => 'A period with this date range is already open. Close it instead of opening again.',
'period_site_has_open' => 'This site already has an open period. Close it before opening a new one.', 'period_site_has_open' => 'This site already has an open period. Close it before opening a new one.',
'period_overlaps_existing' => 'This period overlaps an existing one. Adjust the start and end dates.',
'period_not_found' => 'Settlement period not found or not accessible.', 'period_not_found' => 'Settlement period not found or not accessible.',
'period_already_closed' => 'This period is already closed.', 'period_already_closed' => 'This period is already closed.',
'share_snapshot_missing' => 'Some ledger rows are missing share snapshots. Complete draw settlement first.', 'share_snapshot_missing' => 'Some ledger rows are missing share snapshots. Complete draw settlement first.',

View File

@@ -31,6 +31,7 @@ return [
'parent_overdue' => '上级代理存在逾期未结账单,禁止此操作。', 'parent_overdue' => '上级代理存在逾期未结账单,禁止此操作。',
'period_already_open' => '该时间范围的账期已在进行中,请直接关账,勿重复开期。', 'period_already_open' => '该时间范围的账期已在进行中,请直接关账,勿重复开期。',
'period_site_has_open' => '本站已有进行中账期,请先关账后再开新账期。', 'period_site_has_open' => '本站已有进行中账期,请先关账后再开新账期。',
'period_overlaps_existing' => '账期时间与已有账期重叠,请调整起止日期。',
'period_not_found' => '账期不存在或无权访问。', 'period_not_found' => '账期不存在或无权访问。',
'period_already_closed' => '该账期已关账,请勿重复操作。', 'period_already_closed' => '该账期已关账,请勿重复操作。',
'share_snapshot_missing' => '账期内存在缺少占成快照的流水,无法关账。请先完成开奖结算或联系技术支持。', 'share_snapshot_missing' => '账期内存在缺少占成快照的流水,无法关账。请先完成开奖结算或联系技术支持。',

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillIndexCo
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillPaymentController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillPaymentController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillShowController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillShowController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodCloseController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodCloseController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodOpenHintsController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodIndexController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodIndexController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportIndexController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportIndexController;
@@ -18,6 +19,8 @@ use Illuminate\Support\Facades\Route;
Route::middleware('admin.api-resource') Route::middleware('admin.api-resource')
->group(function (): void { ->group(function (): void {
Route::get('settlement-periods/open-hints', AgentSettlementPeriodOpenHintsController::class)
->name('api.v1.admin.settlement-periods.open-hints');
Route::get('settlement-periods', AgentSettlementPeriodIndexController::class) Route::get('settlement-periods', AgentSettlementPeriodIndexController::class)
->name('api.v1.admin.settlement-periods.index'); ->name('api.v1.admin.settlement-periods.index');
Route::post('settlement-periods', AgentSettlementPeriodStoreController::class) Route::post('settlement-periods', AgentSettlementPeriodStoreController::class)

View File

@@ -0,0 +1,165 @@
<?php
/**
* 一次性清理 default_site 重叠账期脏数据,并补齐可演示的连续账期。
* 用法php scripts/dev-cleanup-settlement-periods.php
*/
use Illuminate\Support\Facades\DB;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$siteId = 1;
$keepPeriodIds = [5, 8, 9];
$deletePeriodIds = [1, 2, 3, 4, 6, 7];
DB::transaction(function () use ($siteId, $keepPeriodIds, $deletePeriodIds): void {
$deleteBillIds = DB::table('settlement_bills')
->whereIn('settlement_period_id', $deletePeriodIds)
->pluck('id')
->map(static fn ($id): int => (int) $id)
->all();
if ($deleteBillIds !== []) {
DB::table('payment_records')->whereIn('settlement_bill_id', $deleteBillIds)->delete();
DB::table('settlement_adjustments')
->where(function ($query) use ($deletePeriodIds, $deleteBillIds): void {
$query->whereIn('settlement_period_id', $deletePeriodIds)
->orWhereIn('original_bill_id', $deleteBillIds);
})
->delete();
DB::table('settlement_bills')->whereIn('id', $deleteBillIds)->delete();
}
DB::table('settlement_periods')->whereIn('id', $deletePeriodIds)->delete();
// 规范化保留账期UTC 时刻 = 东八区本地自然日边界,与开账 API 一致)
DB::table('settlement_periods')->where('id', 5)->update([
'admin_site_id' => $siteId,
'period_start' => '2026-05-24 16:00:00',
'period_end' => '2026-05-31 15:59:59',
'status' => 'closed',
'updated_at' => now(),
]);
DB::table('settlement_periods')->where('id', 9)->update([
'admin_site_id' => $siteId,
'period_start' => '2026-05-31 16:00:00',
'period_end' => '2026-06-07 15:59:59',
'status' => 'completed',
'updated_at' => now(),
]);
DB::table('settlement_periods')->where('id', 8)->update([
'admin_site_id' => $siteId,
'period_start' => '2026-06-07 16:00:00',
'period_end' => '2026-06-14 15:59:59',
'status' => 'closed',
'updated_at' => now(),
]);
// 6/86/14 流水归属第二周账期
DB::table('share_ledger')
->whereIn('id', [910, 911, 912, 913, 914])
->update([
'settlement_period_id' => 8,
'updated_at' => now(),
]);
// 待入账流水改到 6/15、6/17供下一期开账演示
DB::table('share_ledger')->where('id', 915)->update([
'settlement_period_id' => null,
'settled_at' => '2026-06-15 10:00:00',
'updated_at' => now(),
]);
DB::table('share_ledger')->where('id', 916)->update([
'settlement_period_id' => null,
'settled_at' => '2026-06-17 14:30:00',
'updated_at' => now(),
]);
// 第二周账期账单(待收付,用于日历「未结清」标记)
DB::table('settlement_bills')->where('settlement_period_id', 8)->delete();
$now = now();
DB::table('settlement_bills')->insert([
[
'settlement_period_id' => 8,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => 5,
'counterparty_type' => 'agent',
'counterparty_id' => 4,
'gross_win_loss' => 8000,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'platform_rounding_adjustment' => 0,
'net_amount' => 8000,
'paid_amount' => 0,
'unpaid_amount' => 8000,
'status' => 'confirmed',
'confirmed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'settlement_period_id' => 8,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => 4,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'gross_win_loss' => 8000,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'platform_rounding_adjustment' => 0,
'net_amount' => 6400,
'paid_amount' => 0,
'unpaid_amount' => 6400,
'status' => 'confirmed',
'confirmed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
],
[
'settlement_period_id' => 8,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => 1,
'counterparty_type' => 'platform',
'counterparty_id' => 0,
'gross_win_loss' => 8000,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'platform_rounding_adjustment' => 0,
'net_amount' => 1600,
'paid_amount' => 0,
'unpaid_amount' => 1600,
'status' => 'pending_confirm',
'confirmed_at' => null,
'created_at' => $now,
'updated_at' => $now,
],
]);
});
$rows = DB::select(
'SELECT id, period_start::date AS ps, period_end::date AS pe, status FROM settlement_periods WHERE admin_site_id = ? ORDER BY period_start',
[$siteId],
);
echo "Settlement periods after cleanup:\n";
foreach ($rows as $row) {
echo sprintf(" #%d %s ~ %s [%s]\n", $row->id, $row->ps, $row->pe, $row->status);
}
$unassigned = (int) DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', 'default_site')
->whereNull('sl.settlement_period_id')
->whereNull('sl.reversal_of_id')
->count();
echo "\nUnassigned share_ledger (default_site): {$unassigned}\n";

View File

@@ -2,7 +2,9 @@
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Models\Player;
use App\Support\AgentPlatformRole; use App\Support\AgentPlatformRole;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -65,3 +67,160 @@ test('agent dashboard returns agent overview for operator with dashboard permiss
->assertJsonPath('data.agent_overview.agent_node_id', $branch->id) ->assertJsonPath('data.agent_overview.agent_node_id', $branch->id)
->assertJsonPath('data.agent_overview.agent_code', 'dash-branch'); ->assertJsonPath('data.agent_overview.agent_code', 'dash-branch');
}); });
test('agent dashboard profit uses share profit not team house gross', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'super_dash_share',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'dash-share-branch',
'name' => 'Dash Share Branch',
'can_create_player' => true,
]);
$operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first();
expect($operator)->not->toBeNull();
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'native:dash-share-player',
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'dash_share_player',
'default_currency' => 'NPR',
'status' => 0,
'agent_node_id' => $branch->id,
]);
$draw = \App\Models\Draw::query()->create([
'draw_no' => 'DRAW-DASH-SHARE',
'business_date' => now()->toDateString(),
'sequence_no' => random_int(1, 9999),
'status' => \App\Lottery\DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-DASH-SHARE',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 10_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 10_000,
'total_estimated_payout' => 0,
'status' => 'confirmed',
'submit_source' => 'h5',
'client_trace_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$ticketItemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-DASH-SHARE',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => null,
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 2,
'digit_slot' => null,
'bet_mode' => null,
'unit_bet_amount' => 10_000,
'total_bet_amount' => 10_000,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 10_000,
'odds_snapshot_json' => null,
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'settled_lose',
'win_amount' => 0,
'jackpot_win_amount' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$settledAt = now()->toDateTimeString();
DB::table('share_ledger')->insert([
'ticket_item_id' => $ticketItemId,
'player_id' => $player->id,
'agent_node_id' => $branch->id,
'agent_path' => json_encode([$branch->id]),
'share_snapshot' => json_encode([
'total_shares' => [(string) $branch->code => 30.0],
'chain_codes' => [(string) $branch->code],
]),
'game_win_loss' => 1_000,
'basic_rebate' => 0,
'shared_net_win_loss' => 1_000,
'allocations_json' => json_encode([
(string) $branch->code => 300,
'platform' => 700,
]),
'settled_at' => $settledAt,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard')
->assertOk()
->assertJsonPath('data.agent_overview.profit_scope', 'share_profit')
->assertJsonPath('data.agent_overview.today_profit_minor', 300);
});
test('agent bound admin cannot open platform pnl settlement report', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'super_report_block',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'report-block-branch',
'name' => 'Report Block Branch',
]);
$operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first();
expect($operator)->not->toBeNull();
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subDay()->toDateString(),
'period_end' => now()->addDay()->toDateString(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-reports?type=platform_pnl&settlement_period_id='.$periodId)
->assertForbidden();
});

View File

@@ -69,6 +69,8 @@ test('agent profile switches strip create player and child manage from effective
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeFalse(); expect($fresh->hasPermissionCode('agent.node.manage'))->toBeFalse();
expect($fresh->hasPermissionCode('service.players.manage'))->toBeFalse(); expect($fresh->hasPermissionCode('service.players.manage'))->toBeFalse();
expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue();
expect($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage');
expect($profile['agent']['can_create_child_agent'])->toBeFalse(); expect($profile['agent']['can_create_child_agent'])->toBeFalse();
expect($profile['agent']['can_create_player'])->toBeFalse(); expect($profile['agent']['can_create_player'])->toBeFalse();
}); });
@@ -123,5 +125,96 @@ test('agent profile switches on grant create capabilities even when platform age
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue(); expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue();
expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue(); expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue();
expect($fresh->adminPermissionSlugs())->toContain('prd.agent.manage') expect($fresh->adminPermissionSlugs())->toContain('prd.agent.manage')
->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage'); ->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage')
->and($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage');
expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue();
});
test('line root bound agent receives settlement manage at login', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$admin = AdminUser::query()->create([
'username' => 'settle_root_agent',
'name' => 'Settle Root Agent',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $rootId,
'is_primary' => true,
'granted_at' => now(),
]);
$admin->syncPrimaryPlatformAgentRole($rootId);
$fresh = $admin->fresh();
expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue();
expect($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage');
});
test('agent with downline children receives settlement manage at login', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$parent = AgentNode::query()->create([
'admin_site_id' => $siteId,
'parent_id' => $rootId,
'path' => '/',
'depth' => 1,
'code' => 'settle-parent',
'name' => 'Settle Parent',
'status' => 1,
]);
$parent->path = "/{$rootId}/{$parent->id}/";
$parent->save();
$child = AgentNode::query()->create([
'admin_site_id' => $siteId,
'parent_id' => $parent->id,
'path' => "/{$rootId}/{$parent->id}/",
'depth' => 2,
'code' => 'settle-child',
'name' => 'Settle Child',
'status' => 1,
]);
$child->path = "/{$rootId}/{$parent->id}/{$child->id}/";
$child->save();
AgentProfile::query()->create([
'agent_node_id' => $parent->id,
'total_share_rate' => 20,
'credit_limit' => 0,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0,
'default_player_rebate' => 0,
'can_grant_extra_rebate' => false,
'can_create_child_agent' => false,
'can_create_player' => false,
]);
$admin = AdminUser::query()->create([
'username' => 'settle_parent_agent',
'name' => 'Settle Parent Agent',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $parent->id,
'is_primary' => true,
'granted_at' => now(),
]);
$admin->syncPrimaryPlatformAgentRole($parent->id);
$fresh = $admin->fresh();
expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue();
expect($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage');
}); });

View File

@@ -31,3 +31,162 @@ test('settlement bills index api resource is configured after migrations', funct
->assertOk() ->assertOk()
->assertJsonPath('data.items', fn ($items) => is_array($items)); ->assertJsonPath('data.items', fn ($items) => is_array($items));
}); });
test('settlement bill show returns enriched party labels', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$childId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => $rootId,
'code' => 'bill_show_child',
'name' => 'Bill Show Child',
'depth' => 1,
'path' => '/'.$rootId.'/',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('agent_nodes')->where('id', $childId)->update([
'path' => '/'.$rootId.'/'.$childId.'/',
]);
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subWeek(),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => $childId,
'counterparty_type' => 'agent',
'counterparty_id' => $rootId,
'net_amount' => 6400,
'unpaid_amount' => 6400,
'paid_amount' => 0,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'bill_show_super',
'name' => 'Bill Show Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$rootName = (string) DB::table('agent_nodes')->where('id', $rootId)->value('name');
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-bills/'.$billId)
->assertOk()
->assertJsonPath('data.bill.owner_party_label', 'Bill Show Child')
->assertJsonPath('data.bill.superior_agent_label', $rootName);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-bills?bill_id='.$billId)
->assertOk()
->assertJsonPath('data.items.0.owner_party_label', 'Bill Show Child')
->assertJsonPath('data.items.0.superior_agent_label', $rootName);
});
test('settlement bill show returns downline share breakdown for parent agent bill', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$parentId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => $rootId,
'code' => 'downline_parent',
'name' => 'Downline Parent',
'depth' => 1,
'path' => '/'.$rootId.'/',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('agent_nodes')->where('id', $parentId)->update([
'path' => '/'.$rootId.'/'.$parentId.'/',
]);
$childId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => $parentId,
'code' => 'downline_child',
'name' => 'Downline Child',
'depth' => 2,
'path' => '/'.$rootId.'/'.$parentId.'/',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('agent_nodes')->where('id', $childId)->update([
'path' => '/'.$rootId.'/'.$parentId.'/'.$childId.'/',
]);
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subWeek(),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_bills')->insert([
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => $childId,
'counterparty_type' => 'agent',
'counterparty_id' => $parentId,
'net_amount' => 3916,
'unpaid_amount' => 3916,
'paid_amount' => 0,
'status' => 'confirmed',
'meta_json' => json_encode(['share_profit' => 484]),
'created_at' => now(),
'updated_at' => now(),
]);
$parentBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => $parentId,
'counterparty_type' => 'agent',
'counterparty_id' => $rootId,
'gross_win_loss' => 4400,
'net_amount' => 3520,
'unpaid_amount' => 3520,
'paid_amount' => 0,
'status' => 'confirmed',
'meta_json' => json_encode(['share_profit' => 396]),
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'downline_share_super',
'name' => 'Downline Share Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-bills/'.$parentBillId)
->assertOk()
->assertJsonPath('data.downline_shares.total', 484)
->assertJsonPath('data.downline_shares.items.0.owner_label', 'Downline Child')
->assertJsonPath('data.downline_shares.items.0.share_profit', 484);
});

View File

@@ -88,3 +88,56 @@ test('admin can write off player bill bad debt and complete period when all sett
'status' => 'completed', 'status' => 'completed',
]); ]);
}); });
test('bound agent with settlement manage cannot write off bad debt', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subDays(7),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => $rootId,
'counterparty_type' => 'platform',
'counterparty_id' => 0,
'net_amount' => 5000,
'paid_amount' => 0,
'unpaid_amount' => 5000,
'status' => 'confirmed',
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'bad_debt_bound_root',
'name' => 'Bad Debt Bound Root',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $rootId,
'is_primary' => true,
'granted_at' => now(),
]);
$admin->syncPrimaryPlatformAgentRole($rootId);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/settlement-bills/'.$billId.'/bad-debt-write-off', [
'reason' => 'should fail',
])
->assertForbidden();
});

View File

@@ -0,0 +1,351 @@
<?php
use App\Models\AdminUser;
use App\Models\Player;
use App\Support\PlayerFundingMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$this->artisan('lottery:agent-roles-sync')->assertExitCode(0);
});
test('bound parent agent cannot see player bills under direct child agent', function (): void {
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
$playerBillId = insertPlayerBill($periodId, $player->id, $child->id, 4400);
insertAgentBill($periodId, $child->id, $parent->id, 4400);
insertAgentBill($periodId, $parent->id, $rootId, 3520);
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_scope');
$childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_scope');
$parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
expect(\App\Support\AdminAgentSettlementScope::billAccessible($childAdmin, $playerBillId))->toBeTrue()
->and(\App\Support\AdminAgentSettlementScope::billAccessible($parentAdmin, $playerBillId))->toBeFalse();
$this->withHeader('Authorization', 'Bearer '.$parentToken)
->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId)
->assertOk()
->assertJsonPath('data.items', function (array $items) use ($playerBillId): bool {
$ids = array_column($items, 'id');
return ! in_array($playerBillId, $ids, true);
});
$this->withHeader('Authorization', 'Bearer '.$parentToken)
->getJson('/api/v1/admin/settlement-bills/'.$playerBillId)
->assertNotFound();
$this->actingAs($childAdmin, 'sanctum')
->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId.'&bill_id='.$playerBillId)
->assertOk()
->assertJsonPath('data.items.0.id', $playerBillId);
$this->actingAs($childAdmin, 'sanctum')
->getJson('/api/v1/admin/settlement-bills/'.$playerBillId)
->assertOk()
->assertJsonPath('data.bill.id', $playerBillId);
});
test('bound parent agent sees direct child agent bill but not deeper chain bills', function (): void {
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
$childToParentBillId = insertAgentBill($periodId, $child->id, $parent->id, 4400);
$parentToRootBillId = insertAgentBill($periodId, $parent->id, $rootId, 3520);
$super = AdminUser::query()->create([
'username' => 'edge_grand_super_'.uniqid(),
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$grandchild = app(\App\Services\Agent\AgentNodeService::class)->createChild(
$super,
[
'parent_id' => $child->id,
'code' => 'edge-grandchild',
'name' => 'Edge Grandchild',
],
);
$deepBillId = insertAgentBill($periodId, $grandchild->id, $child->id, 1200);
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_edge');
$parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$parentToken)
->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId)
->assertOk();
$ids = array_column($response->json('data.items'), 'id');
expect($ids)->toContain($childToParentBillId)
->and($ids)->toContain($parentToRootBillId)
->and($ids)->not->toContain($deepBillId);
});
test('bound parent agent cannot confirm or pay player bill under child', function (): void {
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
$playerBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $player->id,
'counterparty_type' => 'agent',
'counterparty_id' => $child->id,
'net_amount' => 4400,
'unpaid_amount' => 4400,
'paid_amount' => 0,
'status' => 'pending_confirm',
'created_at' => now(),
'updated_at' => now(),
]);
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_pay');
$parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$parentToken)
->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/confirm')
->assertNotFound();
DB::table('settlement_bills')->where('id', $playerBillId)->update(['status' => 'confirmed']);
$this->withHeader('Authorization', 'Bearer '.$parentToken)
->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/payments', [
'amount' => 4400,
])
->assertNotFound();
});
test('parent agent can register payment on child agent bill where parent is payee', function (): void {
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
$agentBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => $child->id,
'counterparty_type' => 'agent',
'counterparty_id' => $parent->id,
'net_amount' => 4400,
'unpaid_amount' => 4400,
'paid_amount' => 0,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
]);
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_payee');
$childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_payer');
$this->actingAs($parentAdmin, 'sanctum')
->postJson('/api/v1/admin/settlement-bills/'.$agentBillId.'/payments', ['amount' => 4400])
->assertOk();
$this->actingAs($childAdmin, 'sanctum')
->postJson('/api/v1/admin/settlement-bills/'.$agentBillId.'/payments', ['amount' => 4400])
->assertForbidden();
});
test('settlement credit ledger excludes players under child agent for parent viewer', function (): void {
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -100,
'reason' => 'bet_hold',
'created_at' => now(),
'updated_at' => now(),
]);
$parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_ledger');
$this->actingAs($parentAdmin, 'sanctum')
->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId)
->assertOk()
->assertJsonPath('data.items', fn (array $items): bool => count($items) === 0);
});
test('direct child agent can confirm and pay own player bill', function (): void {
[$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture();
$playerBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $player->id,
'counterparty_type' => 'agent',
'counterparty_id' => $child->id,
'net_amount' => 4400,
'unpaid_amount' => 4400,
'paid_amount' => 0,
'status' => 'pending_confirm',
'created_at' => now(),
'updated_at' => now(),
]);
$childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_pay');
$this->actingAs($childAdmin, 'sanctum')
->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/confirm')
->assertOk();
$this->actingAs($childAdmin, 'sanctum')
->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/payments', [
'amount' => 4400,
])
->assertOk()
->assertJsonPath('data.bill.status', 'settled');
});
/**
* @return array{0: int, 1: string, 2: int, 3: \App\Models\AgentNode, 4: \App\Models\AgentNode, 5: Player, 6: int}
*/
function seedDirectEdgeSettlementFixture(): array
{
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$super = AdminUser::query()->create([
'username' => 'edge_scope_super_'.uniqid(),
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$service = app(\App\Services\Agent\AgentNodeService::class);
$parent = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'edge-parent-'.uniqid(),
'name' => 'Edge Parent',
]);
$child = $service->createChild($super, [
'parent_id' => $parent->id,
'code' => 'edge-child-'.uniqid(),
'name' => 'Edge Child',
]);
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'native:edge-'.uniqid(),
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'edge_player_'.uniqid(),
'default_currency' => 'NPR',
'status' => 0,
'agent_node_id' => $child->id,
]);
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subWeek(),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
return [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId];
}
function insertPlayerBill(int $periodId, int $playerId, int $agentId, int $amount): int
{
return (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $playerId,
'counterparty_type' => 'agent',
'counterparty_id' => $agentId,
'net_amount' => $amount,
'unpaid_amount' => $amount,
'paid_amount' => 0,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
]);
}
function insertAgentBill(int $periodId, int $ownerId, int $counterpartyId, int $amount): int
{
return (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => $ownerId,
'counterparty_type' => 'agent',
'counterparty_id' => $counterpartyId,
'net_amount' => $amount,
'unpaid_amount' => $amount,
'paid_amount' => 0,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
]);
}
function createBoundSettlementAgentAdmin(int $siteId, \App\Models\AgentNode $agent, string $prefix): AdminUser
{
$admin = AdminUser::query()->create([
'username' => $prefix.'_'.uniqid(),
'name' => ucfirst($prefix),
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => (int) $agent->id,
'is_primary' => true,
'granted_at' => now(),
]);
$admin->syncPrimaryPlatformAgentRole((int) $agent->id);
return $admin->fresh();
}
function grantSettlementManageToAgentAdmin(AdminUser $admin, \App\Models\AgentNode $agent): void
{
$now = now();
$roleId = DB::table('admin_roles')->insertGetId([
'slug' => 'settle_manage_'.$admin->id,
'code' => 'settle_manage_'.$admin->id,
'name' => 'Settlement Manage',
'status' => 1,
'is_system' => false,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
$actionIds = DB::table('admin_menu_actions')
->whereIn('permission_code', ['settlement.agent.manage', 'prd.settlement.agent.manage'])
->pluck('id');
foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $roleId,
'menu_action_id' => (int) $actionId,
]);
}
DB::table('admin_user_agent_roles')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => (int) $agent->id,
'role_id' => $roleId,
'granted_at' => $now,
]);
}

View File

@@ -7,8 +7,25 @@ use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('settlement payments and adjustments index return items', function (): void { test('settlement payments and adjustments index return items', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$playerId = (int) DB::table('players')->insertGetId([
'site_code' => (string) DB::table('admin_sites')->where('id', $siteId)->value('code'),
'site_player_id' => 'lists-player',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'lists_player',
'default_currency' => 'NPR',
'status' => 0,
'agent_node_id' => $rootId,
'created_at' => now(),
'updated_at' => now(),
]);
$periodId = (int) DB::table('settlement_periods')->insertGetId([ $periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId, 'admin_site_id' => $siteId,
'period_start' => now()->subWeek(), 'period_start' => now()->subWeek(),
@@ -22,9 +39,9 @@ test('settlement payments and adjustments index return items', function (): void
'settlement_period_id' => $periodId, 'settlement_period_id' => $periodId,
'bill_type' => 'player', 'bill_type' => 'player',
'owner_type' => 'player', 'owner_type' => 'player',
'owner_id' => 1, 'owner_id' => $playerId,
'counterparty_type' => 'agent', 'counterparty_type' => 'agent',
'counterparty_id' => 1, 'counterparty_id' => $rootId,
'net_amount' => 1000, 'net_amount' => 1000,
'unpaid_amount' => 0, 'unpaid_amount' => 0,
'paid_amount' => 1000, 'paid_amount' => 1000,
@@ -36,9 +53,9 @@ test('settlement payments and adjustments index return items', function (): void
DB::table('payment_records')->insert([ DB::table('payment_records')->insert([
'settlement_bill_id' => $billId, 'settlement_bill_id' => $billId,
'payer_type' => 'player', 'payer_type' => 'player',
'payer_id' => 1, 'payer_id' => $playerId,
'payee_type' => 'agent', 'payee_type' => 'agent',
'payee_id' => 1, 'payee_id' => $rootId,
'amount' => 1000, 'amount' => 1000,
'method' => 'cash', 'method' => 'cash',
'status' => 'confirmed', 'status' => 'confirmed',

View File

@@ -0,0 +1,197 @@
<?php
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\Player;
use App\Lottery\DrawStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('settlement period open hints api resource is configured after migrations', function (): void {
expect(
DB::table('admin_api_resources')
->where('route_name', 'api.v1.admin.settlement-periods.open-hints')
->where('status', 1)
->exists(),
)->toBeTrue();
});
test('settlement period open hints returns suggested range and calendar markers', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
DB::table('settlement_periods')->insert([
'admin_site_id' => $siteId,
'period_start' => '2026-04-30 16:00:00',
'period_end' => '2026-05-31 15:59:59',
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$unpaidPeriodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => '2026-03-31 16:00:00',
'period_end' => '2026-04-03 15:59:59',
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_bills')->insert([
'settlement_period_id' => $unpaidPeriodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => $rootId,
'counterparty_type' => 'platform',
'counterparty_id' => 0,
'net_amount' => 1000,
'unpaid_amount' => 1000,
'paid_amount' => 0,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $rootId,
'site_player_id' => 'hints-player',
'username' => 'hints_player',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$draw = Draw::query()->create([
'draw_no' => 'DRAW-HINTS',
'business_date' => '2026-06-05',
'sequence_no' => 1,
'status' => DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-HINTS',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 10_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 10_000,
'total_estimated_payout' => 0,
'status' => 'confirmed',
'submit_source' => 'h5',
'client_trace_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$itemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-HINTS',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => null,
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 2,
'digit_slot' => null,
'bet_mode' => null,
'unit_bet_amount' => 10_000,
'total_bet_amount' => 10_000,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 10_000,
'odds_snapshot_json' => null,
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'settled_lose',
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$settledAt = '2026-06-05 12:00:00';
DB::table('share_ledger')->insert([
'ticket_item_id' => $itemId,
'player_id' => $player->id,
'agent_node_id' => $rootId,
'agent_path' => json_encode([$rootId]),
'share_snapshot' => json_encode(['total_shares' => [$siteCode => 100]]),
'game_win_loss' => 1000,
'basic_rebate' => 0,
'shared_net_win_loss' => 1000,
'allocations_json' => json_encode([]),
'settled_at' => $settledAt,
'settlement_period_id' => null,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
$admin = AdminUser::query()->create([
'username' => 'open_hints_admin',
'name' => 'Hints',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-periods/open-hints?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.suggested_start', '2026-06-01')
->assertJsonPath('data.suggested_end', '2026-06-05')
->assertJsonPath('data.pending_activity_dates.0', '2026-06-05')
->assertJsonFragment(['2026-05-01'])
->assertJsonFragment(['2026-04-01'])
->assertJsonFragment(['2026-04-02'])
->assertJsonFragment(['2026-04-03']);
});
test('settlement period open hints does not suggest range overlapping occupied periods', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
DB::table('settlement_periods')->insert([
'admin_site_id' => $siteId,
'period_start' => '2026-05-31 16:00:00',
'period_end' => '2026-06-30 15:59:59',
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'open_hints_overlap_admin',
'name' => 'Hints Overlap',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-periods/open-hints?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.suggested_start', '')
->assertJsonPath('data.suggested_end', '')
->assertJsonFragment(['2026-06-01'])
->assertJsonFragment(['2026-06-30']);
});

View File

@@ -40,6 +40,40 @@ test('cannot open duplicate settlement period for same range', function (): void
); );
}); });
test('cannot open settlement period overlapping a closed period', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$super = \App\Models\AdminUser::query()->create([
'username' => 'period_overlap_super',
'name' => 'Super',
'email' => null,
'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken;
DB::table('settlement_periods')->insert([
'admin_site_id' => $siteId,
'period_start' => '2026-05-01 00:00:00',
'period_end' => '2026-05-31 23:59:59',
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/settlement-periods', [
'admin_site_id' => $siteId,
'period_start' => '2026-05-15 00:00:00',
'period_end' => '2026-06-15 23:59:59',
])
->assertStatus(422)
->assertJsonPath(
'data.errors.period_start.0',
trans('validation.business.period_overlaps_existing'),
);
});
test('cannot open second settlement period while another is open on same site', function (): void { test('cannot open second settlement period while another is open on same site', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$super = \App\Models\AdminUser::query()->create([ $super = \App\Models\AdminUser::query()->create([

View File

@@ -3,12 +3,13 @@
use App\Models\AgentProfile; use App\Models\AgentProfile;
use App\Support\AgentDefaultRolePermissions; use App\Support\AgentDefaultRolePermissions;
test('base owner slugs include dashboard and settlement view but not wallet reconcile', function (): void { test('base owner slugs include dashboard and settlement view but not wallet reconcile or platform reports', function (): void {
$slugs = AgentDefaultRolePermissions::baseSlugs(); $slugs = AgentDefaultRolePermissions::baseSlugs();
expect($slugs) expect($slugs)
->toContain('prd.dashboard.view') ->toContain('prd.dashboard.view')
->toContain('prd.settlement.agent.view') ->toContain('prd.settlement.agent.view')
->not->toContain('prd.report.view')
->not->toContain('prd.wallet_reconcile.view') ->not->toContain('prd.wallet_reconcile.view')
->not->toContain('prd.wallet_reconcile.view_cs'); ->not->toContain('prd.wallet_reconcile.view_cs');
}); });