From 2d32f006c5166266740f115744672f60f84d7374 Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 5 Jun 2026 18:00:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E5=92=8C=E8=B4=A6=E5=8D=95=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在多个控制器中引入 SettlementPartyEnrichment 服务,以优化代理结算和账单的处理逻辑。 - 更新 AgentSettlementBillIndexController 和 AgentSettlementBillShowController,支持根据账单 ID 和关键字进行查询。 - 在 AgentSettlementPeriodCloseController 中添加对站点管理权限的验证,确保只有具备相应权限的管理员能够关闭账期。 - 在 AgentSettlementPeriodIndexController 中更新账期数据的返回格式,提升数据的完整性和可用性。 - 引入对相对占成比例的支持,增强代理资料的管理能力,确保数据一致性。 --- AGENTS.md | 1 + .../BackfillCreditSettlementWinsCommand.php | 101 ++ .../AdminCreditLedgerIndexController.php | 10 +- .../AgentSettlementBillIndexController.php | 148 ++- .../AgentSettlementBillShowController.php | 34 + .../AgentSettlementPeriodCloseController.php | 1 + .../AgentSettlementPeriodIndexController.php | 2 +- .../AgentSettlementPeriodStoreController.php | 21 +- .../AgentSettlementReportShowController.php | 11 +- .../AdminPlayerTicketItemsIndexController.php | 8 + .../AdminSettlementBatchIndexController.php | 9 + .../AdminSettlementBatchShowController.php | 9 + .../Ticket/AdminTicketItemIndexController.php | 8 + .../V1/Player/PlayerAuthLoginController.php | 2 +- .../Admin/Concerns/AgentProfileFieldRules.php | 1 + .../Player/PlayerAuthLoginRequest.php | 2 +- app/Services/Agent/AgentNodeService.php | 8 +- app/Services/Agent/AgentProfileService.php | 16 +- app/Services/Agent/ShareRateValidator.php | 15 +- .../AgentGameSettlementRecorder.php | 4 +- .../AgentSettlement/AgentPeriodAggregator.php | 14 +- .../AgentSettlementBillGuard.php | 16 + .../AgentSettlementPeriodCloseService.php | 49 +- .../AgentSettlementPeriodOpenService.php | 62 + .../AgentSettlementPeriodPipelineService.php | 75 +- .../AgentSettlementPeriodSummaryService.php | 34 +- .../AgentSettlementReportQueryService.php | 77 +- .../CreditLedgerBetFlowPresenter.php | 237 ++++ .../SettlementBillGenerator.php | 2 +- .../SettlementCenterLedgerService.php | 1106 +++++++++++++++-- .../SettlementLedgerListFilters.php | 16 + .../SettlementPartyEnrichment.php | 153 +++ .../SettlementPaymentService.php | 44 +- .../ShareLedgerScopedProfitAggregator.php | 123 ++ .../UnsettledTicketPeriodWarning.php | 15 +- app/Services/Player/PlayerCreditService.php | 47 + .../Player/PlayerNativeAuthService.php | 6 +- .../Wallet/PlayerLedgerLogsService.php | 116 +- app/Support/AdminAgentSettlementScope.php | 118 +- app/Support/AdminAuthProfile.php | 1 + app/Support/AdminAuthorizationRegistry.php | 17 +- app/Support/AdminDataScope.php | 6 +- app/Support/AgentOverdueGuard.php | 93 ++ app/Support/AgentSettlementPeriodWindow.php | 30 + .../AgentSettlementProductionGuard.php | 6 +- .../SettlementBatchFinancialSummary.php | 16 +- config/agent_line_defaults.php | 8 + config/agent_settlement.php | 4 + lang/en/validation_business.php | 8 + lang/zh/validation_business.php | 8 + tests/Feature/AdminAgentDelegationApiTest.php | 12 +- tests/Feature/AdminCreditLedgerFilterTest.php | 269 ++++ tests/Feature/AdminCreditLedgerIndexTest.php | 78 ++ ...AgentPeriodCloseFromGameSettlementTest.php | 172 +++ tests/Feature/AgentRelativeShareRateTest.php | 128 ++ ...gentSettlementFinancialConsistencyTest.php | 238 ++++ .../AgentSettlementPeriodCloseP0FixesTest.php | 399 ++++++ .../AgentSettlementPeriodManageScopeTest.php | 197 +++ .../Feature/AgentSettlementPeriodOpenTest.php | 74 ++ .../AgentSettlementPeriodSummaryTest.php | 289 +++++ tests/Feature/PlayerNativeAuthTest.php | 26 + .../SettlementPaymentDirectionTest.php | 100 ++ tests/Feature/SevereOverdueFreezeLineTest.php | 281 +++++ 63 files changed, 4893 insertions(+), 288 deletions(-) create mode 100644 app/Console/Commands/BackfillCreditSettlementWinsCommand.php create mode 100644 app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php create mode 100644 app/Services/AgentSettlement/CreditLedgerBetFlowPresenter.php create mode 100644 app/Services/AgentSettlement/SettlementPartyEnrichment.php create mode 100644 app/Services/AgentSettlement/ShareLedgerScopedProfitAggregator.php create mode 100644 app/Support/AgentSettlementPeriodWindow.php create mode 100644 tests/Feature/AdminCreditLedgerFilterTest.php create mode 100644 tests/Feature/AgentPeriodCloseFromGameSettlementTest.php create mode 100644 tests/Feature/AgentRelativeShareRateTest.php create mode 100644 tests/Feature/AgentSettlementFinancialConsistencyTest.php create mode 100644 tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php create mode 100644 tests/Feature/AgentSettlementPeriodManageScopeTest.php create mode 100644 tests/Feature/AgentSettlementPeriodOpenTest.php create mode 100644 tests/Feature/SettlementPaymentDirectionTest.php create mode 100644 tests/Feature/SevereOverdueFreezeLineTest.php diff --git a/AGENTS.md b/AGENTS.md index b6f6b43..9295429 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,4 +31,5 @@ - 业务真理源:`docs/信用占成盘代理系统设计说明文档.md`;实施路线:`docs/信用占成盘代理体系改造计划.md`。 - **代理账期**代码包:`App\Services\AgentSettlement\`(勿与彩票开奖 `App\Services\Settlement\` / `SettlementBatch` 混用)。 - **禁止**在生产关账路径使用 `DesignDocExample12` 硬编码账单;仅单元/Feature 测试可引用。 +- 非 `testing` 环境关账受 `AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE`(默认 `true`)控制;预发可设为 `false` 门禁。 - 占成账单聚合必须读注单**快照**(`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。 diff --git a/app/Console/Commands/BackfillCreditSettlementWinsCommand.php b/app/Console/Commands/BackfillCreditSettlementWinsCommand.php new file mode 100644 index 0000000..0ef45f3 --- /dev/null +++ b/app/Console/Commands/BackfillCreditSettlementWinsCommand.php @@ -0,0 +1,101 @@ +option('player-id') ?? 0); + $ticketItemId = (int) ($this->option('ticket-item-id') ?? 0); + $dryRun = (bool) $this->option('dry-run'); + + $query = DB::table('ticket_items as ti') + ->join('players as p', 'p.id', '=', 'ti.player_id') + ->leftJoin('credit_ledger as cl', function ($join): void { + $join->on('cl.ref_id', '=', 'ti.id') + ->where('cl.owner_type', '=', 'player') + ->where('cl.ref_type', '=', 'ticket_item') + ->where('cl.reason', '=', 'game_settlement_win'); + }) + ->where('p.funding_mode', PlayerFundingMode::CREDIT) + ->where('ti.status', 'settled_win') + ->where('ti.win_amount', '>', 0) + ->whereNull('cl.id') + ->select([ + 'ti.id', + 'ti.player_id', + 'ti.win_amount', + 'ti.updated_at', + ]) + ->orderBy('ti.id'); + + if ($playerId > 0) { + $query->where('ti.player_id', $playerId); + } + + if ($ticketItemId > 0) { + $query->where('ti.id', $ticketItemId); + } + + $rows = $query->get(); + if ($rows->isEmpty()) { + $this->info('没有发现需要回填的信用盘中奖流水。'); + + return self::SUCCESS; + } + + $this->info('待回填条数:'.$rows->count()); + + foreach ($rows as $row) { + $this->line(sprintf( + 'ticket_item=%d player=%d win=%d', + (int) $row->id, + (int) $row->player_id, + (int) $row->win_amount, + )); + } + + if ($dryRun) { + $this->comment('dry-run 模式,未写入任何数据。'); + + return self::SUCCESS; + } + + DB::transaction(function () use ($rows): void { + foreach ($rows as $row) { + $player = Player::query()->find((int) $row->player_id); + if ($player === null || ! PlayerFundingMode::usesCredit($player)) { + continue; + } + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => (int) $row->player_id, + 'amount' => (int) $row->win_amount, + 'reason' => 'game_settlement_win', + 'ref_type' => 'ticket_item', + 'ref_id' => (int) $row->id, + 'created_at' => $row->updated_at ?? now(), + 'updated_at' => now(), + ]); + } + }); + + $this->info('回填完成。'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AdminCreditLedgerIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AdminCreditLedgerIndexController.php index 4945765..0cea2dd 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AdminCreditLedgerIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AdminCreditLedgerIndexController.php @@ -48,13 +48,9 @@ final class AdminCreditLedgerIndexController extends Controller $perPage = $this->perPage($request, 'per_page', 20, 100); $page = $this->page($request); - $result = $this->ledgerService->listUnified( - $admin, - $siteCode, - $page, - $perPage, - $filters, - ); + $result = $filters->betFlowDisplaySimple + ? $this->ledgerService->listBetFlowSimplified($admin, $siteCode, $page, $perPage, $filters) + : $this->ledgerService->listUnified($admin, $siteCode, $page, $perPage, $filters); return ApiResponse::success($result); } diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php index e17113d..b68d0ed 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; use App\Http\Controllers\Controller; +use App\Services\AgentSettlement\SettlementPartyEnrichment; use App\Support\AdminAgentSettlementScope; use App\Support\ApiResponse; +use App\Support\PaginationTrait; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -12,6 +14,12 @@ use Illuminate\Support\Facades\DB; final class AgentSettlementBillIndexController extends Controller { + use PaginationTrait; + + public function __construct( + private readonly SettlementPartyEnrichment $partyEnrichment, + ) {} + public function __invoke(Request $request): JsonResponse { $admin = $request->lotteryAdmin(); @@ -38,6 +46,11 @@ final class AgentSettlementBillIndexController extends Controller $query->where('sp.admin_site_id', $adminSiteId); } + $billId = (int) $request->query('bill_id', 0); + if ($billId > 0) { + $query->where('sb.id', $billId); + } + $billType = (string) $request->query('bill_type', ''); if ($billType !== '') { $query->where('sb.bill_type', $billType); @@ -54,16 +67,78 @@ final class AgentSettlementBillIndexController extends Controller default => null, }; + $keyword = trim((string) $request->query('keyword', '')); + if ($keyword !== '') { + $this->applyKeywordFilter($query, $keyword); + } + AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb'); + $meta = $this->paginationMeta($request, defaultPerPage: 20, maxPerPage: 100); + $paginator = $query->paginate($meta['per_page'], ['sb.*', 'sp.period_start', 'sp.period_end', 'sp.admin_site_id'], 'page', $meta['page']); + /** @var Collection $items */ - $items = $query->limit(200)->get(); + $items = collect($paginator->items()); return ApiResponse::success([ 'items' => $this->enrichBillRows($items), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), ]); } + private function applyKeywordFilter(\Illuminate\Database\Query\Builder $query, string $keyword): void + { + $like = '%'.addcslashes($keyword, '%_\\').'%'; + + $query->where(function (\Illuminate\Database\Query\Builder $outer) use ($keyword, $like): void { + if (ctype_digit($keyword)) { + $outer->orWhere('sb.id', (int) $keyword) + ->orWhere('sb.owner_id', (int) $keyword); + } + + $outer->orWhere(function (\Illuminate\Database\Query\Builder $player) use ($like): void { + $player->where('sb.owner_type', 'player') + ->whereExists(function (\Illuminate\Database\Query\Builder $exists) use ($like): void { + $exists->selectRaw('1') + ->from('players as p') + ->whereColumn('p.id', 'sb.owner_id') + ->where(function (\Illuminate\Database\Query\Builder $match) use ($like): void { + $match->where('p.username', 'like', $like) + ->orWhere('p.site_player_id', 'like', $like); + }); + }); + }); + + $outer->orWhere(function (\Illuminate\Database\Query\Builder $agent) use ($like): void { + $agent->where('sb.owner_type', 'agent') + ->whereExists(function (\Illuminate\Database\Query\Builder $exists) use ($like): void { + $exists->selectRaw('1') + ->from('agent_nodes as a') + ->whereColumn('a.id', 'sb.owner_id') + ->where(function (\Illuminate\Database\Query\Builder $match) use ($like): void { + $match->where('a.name', 'like', $like) + ->orWhere('a.code', 'like', $like); + }); + }); + }); + + $outer->orWhere(function (\Illuminate\Database\Query\Builder $counter) use ($like): void { + $counter->where('sb.counterparty_type', 'agent') + ->whereExists(function (\Illuminate\Database\Query\Builder $exists) use ($like): void { + $exists->selectRaw('1') + ->from('agent_nodes as a') + ->whereColumn('a.id', 'sb.counterparty_id') + ->where(function (\Illuminate\Database\Query\Builder $match) use ($like): void { + $match->where('a.name', 'like', $like) + ->orWhere('a.code', 'like', $like); + }); + }); + }); + }); + } + /** * @param Collection $items * @return list> @@ -90,34 +165,57 @@ final class AgentSettlementBillIndexController extends Controller $players = $playerIds !== [] ? DB::table('players') ->whereIn('id', array_unique($playerIds)) - ->select(['id', 'username', 'site_player_id', 'funding_mode', 'auth_source']) + ->select(['id', 'username', 'site_player_id', 'agent_node_id', 'funding_mode', 'auth_source']) ->get() ->keyBy('id') : collect(); - $agents = $agentIds !== [] - ? DB::table('agent_nodes')->whereIn('id', array_unique($agentIds))->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; - $item['owner_label'] = $this->resolvePartyLabel( - (string) $row->owner_type, - (int) $row->owner_id, - $players, - $agents, - ); - $item['counterparty_label'] = $this->resolvePartyLabel( - (string) $row->counterparty_type, - (int) $row->counterparty_id, - $players, - $agents, - ); - if ((string) $row->owner_type === 'player') { + $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; } @@ -128,30 +226,22 @@ final class AgentSettlementBillIndexController extends Controller * @param Collection $players * @param Collection $agents */ - private function resolvePartyLabel( + private function legacyOwnerLabel( string $type, int $id, Collection $players, Collection $agents, ): string { - if ($type === 'platform' || $id <= 0) { - return 'platform'; - } - if ($type === 'player') { $player = $players->get($id); return $player !== null - ? (string) ($player->username ?: $player->site_player_id) + ? (string) ($player->username ?: $player->site_player_id ?: "player#{$id}") : "player#{$id}"; } if ($type === 'agent') { - $agent = $agents->get($id); - - return $agent !== null - ? (string) ($agent->name ?: $agent->code) - : "agent#{$id}"; + return $this->partyEnrichment->formatAgent($agents->get($id), $id); } return "{$type}#{$id}"; diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php index dc2dc89..2c0fbb1 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; use App\Http\Controllers\Controller; +use App\Services\AgentSettlement\SettlementPartyEnrichment; use App\Support\AdminAgentSettlementScope; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; @@ -11,6 +12,10 @@ use Illuminate\Support\Facades\DB; final class AgentSettlementBillShowController extends Controller { + public function __construct( + private readonly SettlementPartyEnrichment $partyEnrichment, + ) {} + public function __invoke(Request $request, int $settlement_bill): JsonResponse { $admin = $request->lotteryAdmin(); @@ -30,6 +35,35 @@ final class AgentSettlementBillShowController extends Controller ->orderBy('id') ->get(); + $agentIds = $rebateAllocations + ->filter(static fn (object $row): bool => (string) $row->participant_type === 'agent') + ->pluck('participant_id') + ->map(static fn ($id): int => (int) $id) + ->filter(static fn (int $id): bool => $id > 0) + ->unique() + ->values() + ->all(); + $agents = $this->partyEnrichment->loadAgents($agentIds); + $rebateAllocations = $rebateAllocations + ->map(function (object $row) use ($agents): array { + $type = (string) $row->participant_type; + $id = (int) $row->participant_id; + + return [ + 'id' => (int) $row->id, + 'rebate_record_id' => (int) $row->rebate_record_id, + 'settlement_bill_id' => (int) $row->settlement_bill_id, + 'participant_type' => $type, + 'participant_id' => $id, + 'participant_label' => $this->partyEnrichment->formatCounterpartyLabel($type, $id, $agents), + 'actual_share_rate' => (float) $row->actual_share_rate, + 'allocated_amount' => (int) $row->allocated_amount, + 'allocation_rule' => (string) $row->allocation_rule, + ]; + }) + ->values() + ->all(); + $adjustments = DB::table('settlement_adjustments') ->where('original_bill_id', $settlement_bill) ->orderByDesc('id') diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodCloseController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodCloseController.php index ff5dbb6..2519e3d 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodCloseController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodCloseController.php @@ -22,6 +22,7 @@ final class AgentSettlementPeriodCloseController extends Controller $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $settlement_period), 404); + AdminAgentSettlementScope::assertCanManageSitePeriods($admin); $before = DB::table('settlement_periods')->where('id', $settlement_period)->first(); $result = $service->closePeriod($settlement_period); diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodIndexController.php index 73bfa85..19baee8 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodIndexController.php @@ -30,7 +30,7 @@ final class AgentSettlementPeriodIndexController extends Controller $periods = $query->limit(100)->get(); return ApiResponse::success([ - 'items' => $summaryService->attachToPeriodRows($periods), + 'items' => $summaryService->attachToPeriodRows($periods, $admin), ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodStoreController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodStoreController.php index 374413f..764367e 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodStoreController.php @@ -6,15 +6,17 @@ use App\Http\Controllers\Controller; use App\Http\Middleware\RecordAdminApiAudit; use App\Http\Requests\Admin\AdminSettlementPeriodStoreRequest; use App\Services\AuditLogger; +use App\Services\AgentSettlement\AgentSettlementPeriodOpenService; use App\Support\AdminAgentSettlementScope; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\DB; final class AgentSettlementPeriodStoreController extends Controller { - public function __invoke(AdminSettlementPeriodStoreRequest $request): JsonResponse - { + public function __invoke( + AdminSettlementPeriodStoreRequest $request, + AgentSettlementPeriodOpenService $openService, + ): JsonResponse { $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); @@ -23,17 +25,10 @@ final class AgentSettlementPeriodStoreController extends Controller ! AdminAgentSettlementScope::siteAccessible($admin, (int) $data['admin_site_id']), 404, ); + AdminAgentSettlementScope::assertCanManageSitePeriods($admin); - $id = DB::table('settlement_periods')->insertGetId([ - 'admin_site_id' => (int) $data['admin_site_id'], - 'period_start' => $data['period_start'], - 'period_end' => $data['period_end'], - 'status' => 'open', - 'created_at' => now(), - 'updated_at' => now(), - ]); - - $row = DB::table('settlement_periods')->where('id', $id)->first(); + $row = $openService->open($data); + $id = (int) $row->id; AuditLogger::recordForAdmin( $admin, diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php index c0b726a..a04146b 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; use App\Http\Controllers\Controller; use App\Services\AgentSettlement\AgentSettlementReportQueryService; +use App\Support\AgentSettlementPeriodWindow; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -79,10 +80,12 @@ final class AgentSettlementReportShowController extends Controller $row = DB::table('settlement_periods')->where('id', $periodId)->first(); abort_if($row === null, 404); - return [ - 'start' => (string) $row->period_start, - 'end' => (string) $row->period_end, - ]; + [$start, $end] = AgentSettlementPeriodWindow::boundStrings( + (string) $row->period_start, + (string) $row->period_end, + ); + + return ['start' => $start, 'end' => $end]; } $request->validate([ diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php index 8c50c29..a377e45 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php @@ -84,16 +84,24 @@ final class AdminPlayerTicketItemsIndexController extends Controller 'currency_code' => $row->order?->currency_code, 'play_code' => $row->play_code, 'original_number' => $row->original_number, + // 历史字段:保留兼容,实际单位为 minor。 'total_bet_amount' => $totalBet, + 'total_bet_amount_minor' => $totalBet, 'total_bet_amount_formatted' => CurrencyFormatter::fromMinor($totalBet), + // 历史字段:保留兼容,实际单位为 minor。 'actual_deduct_amount' => $actualDeduct, + 'actual_deduct_amount_minor' => $actualDeduct, 'actual_deduct_amount_formatted' => CurrencyFormatter::fromMinor($actualDeduct), 'status' => $row->status, 'fail_reason_code' => $row->fail_reason_code, 'fail_reason_text' => $row->fail_reason_text, + // 历史字段:保留兼容,实际单位为 minor。 'win_amount' => $winAmount, + 'win_amount_minor' => $winAmount, 'win_amount_formatted' => CurrencyFormatter::fromMinor($winAmount), + // 历史字段:保留兼容,实际单位为 minor。 'jackpot_win_amount' => $jackpotWin, + 'jackpot_win_amount_minor' => $jackpotWin, 'jackpot_win_amount_formatted' => CurrencyFormatter::fromMinor($jackpotWin), 'placed_at' => $row->order?->created_at?->toIso8601String(), 'updated_at' => $row->updated_at?->toIso8601String(), diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php index e82b09c..2181a3b 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php @@ -66,11 +66,20 @@ final class AdminSettlementBatchIndexController extends Controller 'paid_at' => $b->paid_at?->toIso8601String(), 'total_ticket_count' => (int) $b->total_ticket_count, 'total_win_count' => (int) $b->total_win_count, + // 历史字段:保留兼容,实际单位为 minor。 'total_bet_amount' => $financial['total_bet_amount'], + 'total_bet_amount_minor' => $financial['total_bet_amount_minor'], + // 历史字段:保留兼容,实际单位为 minor。 'total_actual_deduct' => $financial['total_actual_deduct'], + 'total_actual_deduct_minor' => $financial['total_actual_deduct_minor'], + // settlement_batches 表内派彩金额一直按 minor 存储。 'total_payout_amount' => (int) $b->total_payout_amount, + 'total_payout_amount_minor' => (int) $b->total_payout_amount, 'total_jackpot_payout_amount' => (int) $b->total_jackpot_payout_amount, + 'total_jackpot_payout_amount_minor' => (int) $b->total_jackpot_payout_amount, + // 历史字段:保留兼容,实际单位为 minor。 'platform_profit' => $financial['platform_profit'], + 'platform_profit_minor' => $financial['platform_profit_minor'], 'started_at' => $b->started_at?->toIso8601String(), 'finished_at' => $b->finished_at?->toIso8601String(), 'created_at' => $b->created_at?->toIso8601String(), diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php index cd070e2..1d4b45d 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php @@ -36,11 +36,20 @@ final class AdminSettlementBatchShowController extends Controller 'paid_at' => $batch->paid_at?->toIso8601String(), 'total_ticket_count' => (int) $batch->total_ticket_count, 'total_win_count' => (int) $batch->total_win_count, + // 历史字段:保留兼容,实际单位为 minor。 'total_bet_amount' => $financial['total_bet_amount'], + 'total_bet_amount_minor' => $financial['total_bet_amount_minor'], + // 历史字段:保留兼容,实际单位为 minor。 'total_actual_deduct' => $financial['total_actual_deduct'], + 'total_actual_deduct_minor' => $financial['total_actual_deduct_minor'], + // settlement_batches 表内派彩金额一直按 minor 存储。 'total_payout_amount' => (int) $batch->total_payout_amount, + 'total_payout_amount_minor' => (int) $batch->total_payout_amount, 'total_jackpot_payout_amount' => (int) $batch->total_jackpot_payout_amount, + 'total_jackpot_payout_amount_minor' => (int) $batch->total_jackpot_payout_amount, + // 历史字段:保留兼容,实际单位为 minor。 'platform_profit' => $financial['platform_profit'], + 'platform_profit_minor' => $financial['platform_profit_minor'], 'started_at' => $batch->started_at?->toIso8601String(), 'finished_at' => $batch->finished_at?->toIso8601String(), 'created_at' => $batch->created_at?->toIso8601String(), diff --git a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php index da20a96..8da0315 100644 --- a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php @@ -113,16 +113,24 @@ final class AdminTicketItemIndexController extends Controller 'currency_code' => $row->order?->currency_code, 'play_code' => $row->play_code, 'original_number' => $row->original_number, + // 历史字段:保留兼容,实际单位为 minor。 'total_bet_amount' => $totalBet, + 'total_bet_amount_minor' => $totalBet, 'total_bet_amount_formatted' => CurrencyFormatter::fromMinor($totalBet), + // 历史字段:保留兼容,实际单位为 minor。 'actual_deduct_amount' => $actualDeduct, + 'actual_deduct_amount_minor' => $actualDeduct, 'actual_deduct_amount_formatted' => CurrencyFormatter::fromMinor($actualDeduct), 'status' => $row->status, 'fail_reason_code' => $row->fail_reason_code, 'fail_reason_text' => $row->fail_reason_text, + // 历史字段:保留兼容,实际单位为 minor。 'win_amount' => $winAmount, + 'win_amount_minor' => $winAmount, 'win_amount_formatted' => CurrencyFormatter::fromMinor($winAmount), + // 历史字段:保留兼容,实际单位为 minor。 'jackpot_win_amount' => $jackpotWin, + 'jackpot_win_amount_minor' => $jackpotWin, 'jackpot_win_amount_formatted' => CurrencyFormatter::fromMinor($jackpotWin), 'placed_at' => $row->order?->created_at?->toIso8601String(), 'updated_at' => $row->updated_at?->toIso8601String(), diff --git a/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php b/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php index 6b1566c..5bfd402 100644 --- a/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php +++ b/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php @@ -16,7 +16,7 @@ final class PlayerAuthLoginController extends Controller { try { $data = $auth->login( - (string) $request->validated('site_code'), + (string) $request->validated('site_code', ''), (string) $request->validated('username'), (string) $request->validated('password'), ); diff --git a/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php b/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php index cbbd3fc..146ae5c 100644 --- a/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php +++ b/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php @@ -11,6 +11,7 @@ trait AgentProfileFieldRules { return [ 'total_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'], + 'relative_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'], 'credit_limit' => ['sometimes', 'integer', 'min:0'], 'rebate_limit' => ['sometimes', 'numeric', 'min:0', 'max:1'], 'default_player_rebate' => ['sometimes', 'numeric', 'min:0', 'max:1'], diff --git a/app/Http/Requests/Player/PlayerAuthLoginRequest.php b/app/Http/Requests/Player/PlayerAuthLoginRequest.php index fcc3950..604ef97 100644 --- a/app/Http/Requests/Player/PlayerAuthLoginRequest.php +++ b/app/Http/Requests/Player/PlayerAuthLoginRequest.php @@ -14,7 +14,7 @@ final class PlayerAuthLoginRequest extends ApiFormRequest public function rules(): array { return [ - 'site_code' => ['required', 'string', 'max:64'], + 'site_code' => ['sometimes', 'nullable', 'string', 'max:64'], 'username' => ['required', 'string', 'max:128'], 'password' => ['required', 'string', 'min:6', 'max:128'], ]; diff --git a/app/Services/Agent/AgentNodeService.php b/app/Services/Agent/AgentNodeService.php index 0ea8ffa..dc5d5f1 100644 --- a/app/Services/Agent/AgentNodeService.php +++ b/app/Services/Agent/AgentNodeService.php @@ -120,7 +120,7 @@ final class AgentNodeService ]); AgentPlatformRole::assignPrimaryOperator($user, $node); - $this->agentProfileService->upsertForNode($node, [ + $profilePayload = [ 'total_share_rate' => (float) ($payload['total_share_rate'] ?? 0), 'credit_limit' => (int) ($payload['credit_limit'] ?? 0), 'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0), @@ -129,7 +129,11 @@ final class AgentNodeService 'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? false), 'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? false), 'can_create_player' => (bool) ($payload['can_create_player'] ?? true), - ], $parent); + ]; + if (array_key_exists('relative_share_rate', $payload)) { + $profilePayload['relative_share_rate'] = (float) $payload['relative_share_rate']; + } + $this->agentProfileService->upsertForNode($node, $profilePayload, $parent); return $node->fresh(['adminSite']); }); diff --git a/app/Services/Agent/AgentProfileService.php b/app/Services/Agent/AgentProfileService.php index f462caf..84e05d6 100644 --- a/app/Services/Agent/AgentProfileService.php +++ b/app/Services/Agent/AgentProfileService.php @@ -32,8 +32,22 @@ final class AgentProfileService $rebateLimit = (float) ($payload['rebate_limit'] ?? 0); $defaultRebate = (float) ($payload['default_player_rebate'] ?? 0); + $useRelative = $parent !== null && array_key_exists('relative_share_rate', $payload); + + // 如果提供了相对占成比例,计算绝对总占成 + if ($useRelative) { + $relativeShare = (float) $payload['relative_share_rate']; + $this->shareRateValidator->assertRelativeShareWithinBounds($relativeShare); + $parentRate = $this->shareRateValidator->totalShareRateForNode($parent); + $totalShare = round($parentRate * $relativeShare / 100, 2); + } + if ($parent !== null) { - $this->shareRateValidator->assertChildWithinParent($parent, $totalShare); + $this->shareRateValidator->assertChildWithinParent( + $parent, + $totalShare, + $useRelative ? 'relative_share_rate' : 'total_share_rate', + ); } return DB::transaction(function () use ($node, $payload, $parent, $totalShare, $creditLimit, $rebateLimit, $defaultRebate): AgentProfile { diff --git a/app/Services/Agent/ShareRateValidator.php b/app/Services/Agent/ShareRateValidator.php index 89d9ef4..cb09687 100644 --- a/app/Services/Agent/ShareRateValidator.php +++ b/app/Services/Agent/ShareRateValidator.php @@ -8,17 +8,26 @@ use Illuminate\Validation\ValidationException; final class ShareRateValidator { - public function assertChildWithinParent(AgentNode $parent, float $childTotalShareRate): void + public function assertChildWithinParent(AgentNode $parent, float $childTotalShareRate, string $field = 'total_share_rate'): void { $parentRate = $this->totalShareRateForNode($parent); if ($childTotalShareRate > $parentRate) { throw ValidationException::withMessages([ - 'total_share_rate' => ['exceeds_parent'], + $field => ['exceeds_parent'], ]); } if ($childTotalShareRate < 0 || $childTotalShareRate > 100) { throw ValidationException::withMessages([ - 'total_share_rate' => ['invalid_range'], + $field => ['invalid_range'], + ]); + } + } + + public function assertRelativeShareWithinBounds(float $relativeShareRate, string $field = 'relative_share_rate'): void + { + if ($relativeShareRate < 0 || $relativeShareRate > 100) { + throw ValidationException::withMessages([ + $field => ['invalid_range'], ]); } } diff --git a/app/Services/AgentSettlement/AgentGameSettlementRecorder.php b/app/Services/AgentSettlement/AgentGameSettlementRecorder.php index 1315aac..3f0b402 100644 --- a/app/Services/AgentSettlement/AgentGameSettlementRecorder.php +++ b/app/Services/AgentSettlement/AgentGameSettlementRecorder.php @@ -66,7 +66,7 @@ final class AgentGameSettlementRecorder $settledAt = now(); - DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate): void { + DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate, $gameType): void { $item->forceFill([ 'agent_node_id' => $snapshot['agent_node_id'], 'share_snapshot' => [ @@ -132,6 +132,8 @@ final class AgentGameSettlementRecorder if ($gameWinLoss > 0) { $this->playerCreditService->applySettledLoss($player, (int) round($gameWinLoss), $item->id); + } elseif ($gameWinLoss < 0) { + $this->playerCreditService->applySettledWin($player, (int) round(abs($gameWinLoss)), $item->id); } }); } diff --git a/app/Services/AgentSettlement/AgentPeriodAggregator.php b/app/Services/AgentSettlement/AgentPeriodAggregator.php index 1582793..c119c25 100644 --- a/app/Services/AgentSettlement/AgentPeriodAggregator.php +++ b/app/Services/AgentSettlement/AgentPeriodAggregator.php @@ -3,14 +3,12 @@ namespace App\Services\AgentSettlement; use App\Models\AgentNode; -use App\Models\Player; use Illuminate\Support\Facades\DB; final class AgentPeriodAggregator { public function __construct( private readonly ShareSettlementCalculator $calculator, - private readonly BetSettlementSnapshotBuilder $snapshotBuilder, ) {} /** @@ -54,15 +52,9 @@ final class AgentPeriodAggregator $playerId = (int) $row->player_id; $snapshot = $this->resolveSnapshotFromLedgerRow($row); if ($snapshot === null) { - $player = Player::query()->find($playerId); - if ($player === null) { - continue; - } - $built = $this->snapshotBuilder->buildForPlayer($player); - $snapshot = [ - 'total_shares' => $built['total_shares'], - 'chain_codes' => $built['chain_codes'], - ]; + throw new \InvalidArgumentException( + 'share_snapshot_missing:ticket_item_id='.(int) $row->ticket_item_id, + ); } $gameWinLoss = (int) $row->game_win_loss; diff --git a/app/Services/AgentSettlement/AgentSettlementBillGuard.php b/app/Services/AgentSettlement/AgentSettlementBillGuard.php index e328e55..b48c764 100644 --- a/app/Services/AgentSettlement/AgentSettlementBillGuard.php +++ b/app/Services/AgentSettlement/AgentSettlementBillGuard.php @@ -9,6 +9,8 @@ final class AgentSettlementBillGuard { private const LOCKED_STATUSES = ['confirmed', 'partial_paid', 'settled', 'overdue', 'reversed']; + private const PAYABLE_STATUSES = ['confirmed', 'partial_paid', 'overdue']; + public function __construct( private readonly AgentSettlementPeriodCompletionService $periodCompletion, ) {} @@ -37,6 +39,20 @@ final class AgentSettlementBillGuard } } + public function assertPayable(int $billId): void + { + $bill = DB::table('settlement_bills')->where('id', $billId)->first(); + if ($bill === null) { + throw new \InvalidArgumentException('bill_not_found'); + } + + if (! in_array((string) $bill->status, self::PAYABLE_STATUSES, true)) { + throw ValidationException::withMessages([ + 'bill' => ['not_payable'], + ]); + } + } + public function markConfirmed(int $billId): void { $this->assertPeriodMutable($billId); diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php index 4d718c1..c659cad 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php @@ -4,8 +4,10 @@ namespace App\Services\AgentSettlement; use App\Models\AgentNode; use App\Services\Agent\AgentCreditAllocatedSyncService; +use App\Support\AgentSettlementPeriodWindow; use App\Support\AgentSettlementProductionGuard; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; final class AgentSettlementPeriodCloseService { @@ -27,47 +29,58 @@ final class AgentSettlementPeriodCloseService $period = DB::table('settlement_periods')->where('id', $periodId)->first(); if ($period === null) { - throw new \InvalidArgumentException('period_not_found'); + throw ValidationException::withMessages([ + 'period' => ['period_not_found'], + ]); } - if ((string) $period->status === 'closed') { - throw new \InvalidArgumentException('period_already_closed'); + if ((string) $period->status === 'closed' || (string) $period->status === 'completed') { + throw ValidationException::withMessages([ + 'period' => ['period_already_closed'], + ]); } $adminSiteId = (int) $period->admin_site_id; - $aggregate = $this->aggregator->aggregate( - $adminSiteId, + [$periodStart, $periodEnd] = AgentSettlementPeriodWindow::boundStrings( (string) $period->period_start, (string) $period->period_end, ); - if ($aggregate['players'] === []) { - throw new \InvalidArgumentException('period_no_ledger_rows'); + try { + $aggregate = $this->aggregator->aggregate($adminSiteId, $periodStart, $periodEnd); + } catch (\InvalidArgumentException $e) { + if (str_starts_with($e->getMessage(), 'share_snapshot_missing')) { + throw ValidationException::withMessages([ + 'period' => ['share_snapshot_missing'], + ]); + } + + throw $e; } $billIds = $this->billGenerator->generate($periodId, $adminSiteId, $aggregate); $roundingDiff = $this->platformRounding->apply($periodId, $aggregate); - $rebateStats = $this->periodCloseRebate->dispatchAndAllocate( - $periodId, - (string) $period->period_start, - (string) $period->period_end, - ); + $rebateStats = $this->periodCloseRebate->dispatchAndAllocate($periodId, $periodStart, $periodEnd); - $unsettled = $this->unsettledWarning->countForSite( - $adminSiteId, - (string) $period->period_start, - (string) $period->period_end, - ); + $unsettled = $this->unsettledWarning->countForSite($adminSiteId, $periodStart, $periodEnd); DB::table('settlement_periods')->where('id', $periodId)->update([ 'status' => 'closed', 'updated_at' => now(), ]); + $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); + DB::table('share_ledger') - ->whereBetween('settled_at', [$period->period_start, $period->period_end]) + ->whereIn('id', function ($query) use ($siteCode, $periodStart, $periodEnd): void { + $query->select('sl.id') + ->from('share_ledger as sl') + ->join('players as p', 'p.id', '=', 'sl.player_id') + ->where('p.site_code', $siteCode) + ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]); + }) ->update(['settlement_period_id' => $periodId]); $this->reconcileAllocatedCreditForSite($adminSiteId); diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php b/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php new file mode 100644 index 0000000..b417927 --- /dev/null +++ b/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php @@ -0,0 +1,62 @@ +where('admin_site_id', $siteId) + ->where('status', 'open') + ->where('period_start', $start) + ->where('period_end', $end) + ->orderByDesc('id') + ->first(); + + if ($existingSameRange !== null) { + throw ValidationException::withMessages([ + 'period_start' => ['period_already_open'], + ]); + } + + $otherOpen = DB::table('settlement_periods') + ->where('admin_site_id', $siteId) + ->where('status', 'open') + ->orderByDesc('id') + ->first(); + + if ($otherOpen !== null) { + throw ValidationException::withMessages([ + 'period_start' => ['period_site_has_open'], + ]); + } + + $id = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => $start, + 'period_end' => $end, + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $row = DB::table('settlement_periods')->where('id', $id)->first(); + if ($row === null) { + throw new \RuntimeException('period_insert_failed'); + } + + return $row; + } +} diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php b/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php index 7ac185d..895795e 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php @@ -2,19 +2,32 @@ namespace App\Services\AgentSettlement; +use App\Models\AdminUser; +use App\Support\AdminDataScope; +use App\Support\AgentSettlementPeriodWindow; use App\Support\PlayerFundingMode; -use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; /** 账期窗口内信用流水与占成流水笔数(关账前诊断)。 */ final class AgentSettlementPeriodPipelineService { + public function __construct( + private readonly UnsettledTicketPeriodWarning $unsettledWarning, + private readonly ShareLedgerScopedProfitAggregator $scopedProfitAggregator, + ) {} /** * @param Collection $periods settlement_periods 行,须含 id、period_start、period_end、admin_site_id - * @return array + * @return array */ - public function countsForPeriods(Collection $periods): array + public function countsForPeriods(Collection $periods, ?AdminUser $admin = null): array { if ($periods->isEmpty()) { return []; @@ -26,37 +39,73 @@ final class AgentSettlementPeriodPipelineService ->pluck('code', 'id'); $out = []; + $viewer = $this->scopedProfitAggregator->resolveViewer($admin); foreach ($periods as $period) { $periodId = (int) $period->id; $siteCode = (string) ($siteCodes[(int) $period->admin_site_id] ?? ''); if ($siteCode === '') { - $out[$periodId] = ['credit_ledger_count' => 0, 'share_ledger_count' => 0]; + $out[$periodId] = [ + 'credit_ledger_count' => 0, + 'share_ledger_count' => 0, + 'game_win_loss_total' => 0, + 'win_loss_scope' => $viewer['scope'], + 'basic_rebate_total' => 0, + 'unsettled_ticket_count' => 0, + ]; continue; } - $start = Carbon::parse($period->period_start)->startOfDay(); - $end = Carbon::parse($period->period_end)->endOfDay(); + [$start, $end] = AgentSettlementPeriodWindow::bounds( + (string) $period->period_start, + (string) $period->period_end, + ); - $creditCount = (int) DB::table('credit_ledger as cl') + $creditQuery = DB::table('credit_ledger as cl') ->join('players as p', function ($join): void { $join->on('p.id', '=', 'cl.owner_id') ->where('cl.owner_type', '=', 'player'); }) ->where('p.site_code', $siteCode) ->where('p.funding_mode', PlayerFundingMode::CREDIT) - ->whereBetween('cl.created_at', [$start, $end]) - ->count(); + ->whereBetween('cl.created_at', [$start, $end]); - $shareCount = (int) DB::table('share_ledger as sl') + if ($admin !== null) { + AdminDataScope::applyToPlayersAlias($creditQuery, $admin, 'p'); + } + + $shareQuery = DB::table('share_ledger as sl') ->join('players as p', 'p.id', '=', 'sl.player_id') ->where('p.site_code', $siteCode) ->whereBetween('sl.settled_at', [$start, $end]) - ->count(); + ->whereNull('sl.reversal_of_id'); + + if ($admin !== null) { + AdminDataScope::applyToPlayersAlias($shareQuery, $admin, 'p'); + } + + $shareAgg = (clone $shareQuery) + ->selectRaw('COUNT(*) as share_ledger_count') + ->selectRaw('COALESCE(SUM(sl.basic_rebate), 0) as basic_rebate_total') + ->first(); + + $scopedWinLoss = $viewer['scope'] === 'platform' + ? $this->scopedProfitAggregator->sumRawGameWinLoss($shareQuery) + : $this->scopedProfitAggregator->sumForShareQuery($shareQuery, $viewer['key']); + + $unsettled = $this->unsettledWarning->countForSite( + (int) $period->admin_site_id, + (string) $period->period_start, + (string) $period->period_end, + ); $out[$periodId] = [ - 'credit_ledger_count' => $creditCount, - 'share_ledger_count' => $shareCount, + 'credit_ledger_count' => (int) $creditQuery->count(), + 'share_ledger_count' => (int) ($shareAgg->share_ledger_count ?? 0), + 'game_win_loss_total' => $scopedWinLoss, + 'win_loss_scope' => $viewer['scope'], + 'basic_rebate_total' => (int) ($shareAgg->basic_rebate_total ?? 0), + 'unsettled_ticket_count' => $unsettled['count'], ]; } diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodSummaryService.php b/app/Services/AgentSettlement/AgentSettlementPeriodSummaryService.php index 0c7ae10..d1f1bef 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodSummaryService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodSummaryService.php @@ -2,6 +2,8 @@ namespace App\Services\AgentSettlement; +use App\Models\AdminUser; +use App\Support\AdminAgentSettlementScope; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -10,19 +12,26 @@ final class AgentSettlementPeriodSummaryService { public function __construct( private readonly AgentSettlementPeriodPipelineService $pipelineService, + private readonly ShareLedgerScopedProfitAggregator $scopedProfitAggregator, ) {} /** * @param list $periodIds * @return array> */ - public function summariesForPeriodIds(array $periodIds): array + public function summariesForPeriodIds(array $periodIds, ?AdminUser $admin = null): array { if ($periodIds === []) { return []; } - $rows = DB::table('settlement_bills') - ->whereIn('settlement_period_id', $periodIds) + $query = DB::table('settlement_bills') + ->whereIn('settlement_period_id', $periodIds); + + if ($admin !== null) { + AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin); + } + + $rows = $query ->groupBy('settlement_period_id') ->selectRaw('settlement_period_id') ->selectRaw("SUM(CASE WHEN bill_type = 'player' THEN 1 ELSE 0 END) as player_bills") @@ -32,6 +41,7 @@ final class AgentSettlementPeriodSummaryService ->selectRaw("SUM(CASE WHEN status IN ('confirmed', 'partial_paid', 'overdue') AND unpaid_amount > 0 THEN 1 ELSE 0 END) as awaiting_payment") ->selectRaw("SUM(CASE WHEN status = 'settled' THEN 1 ELSE 0 END) as settled") ->selectRaw('COALESCE(SUM(unpaid_amount), 0) as total_unpaid') + ->selectRaw('COALESCE(SUM(net_amount), 0) as total_net') ->get(); $out = []; @@ -45,6 +55,7 @@ final class AgentSettlementPeriodSummaryService 'awaiting_payment' => (int) $row->awaiting_payment, 'settled' => (int) $row->settled, 'total_unpaid' => (int) $row->total_unpaid, + 'total_net' => (int) $row->total_net, ]; } @@ -55,11 +66,11 @@ final class AgentSettlementPeriodSummaryService * @param Collection $periods * @return list> */ - public function attachToPeriodRows(Collection $periods): array + public function attachToPeriodRows(Collection $periods, ?AdminUser $admin = null): array { $ids = $periods->pluck('id')->map(static fn ($id): int => (int) $id)->all(); - $summaries = $this->summariesForPeriodIds($ids); - $pipelines = $this->pipelineService->countsForPeriods($periods); + $summaries = $this->summariesForPeriodIds($ids, $admin); + $pipelines = $this->pipelineService->countsForPeriods($periods, $admin); $empty = [ 'player_bills' => 0, 'agent_bills' => 0, @@ -68,8 +79,17 @@ final class AgentSettlementPeriodSummaryService 'awaiting_payment' => 0, 'settled' => 0, 'total_unpaid' => 0, + 'total_net' => 0, + ]; + $viewerScope = $this->scopedProfitAggregator->resolveViewer($admin)['scope']; + $emptyPipeline = [ + 'credit_ledger_count' => 0, + 'share_ledger_count' => 0, + 'game_win_loss_total' => 0, + 'win_loss_scope' => $viewerScope, + 'basic_rebate_total' => 0, + 'unsettled_ticket_count' => 0, ]; - $emptyPipeline = ['credit_ledger_count' => 0, 'share_ledger_count' => 0]; $items = []; foreach ($periods as $period) { diff --git a/app/Services/AgentSettlement/AgentSettlementReportQueryService.php b/app/Services/AgentSettlement/AgentSettlementReportQueryService.php index 76c2661..2fd0411 100644 --- a/app/Services/AgentSettlement/AgentSettlementReportQueryService.php +++ b/app/Services/AgentSettlement/AgentSettlementReportQueryService.php @@ -4,6 +4,7 @@ namespace App\Services\AgentSettlement; use App\Models\AdminUser; use App\Support\AdminAgentSettlementScope; +use App\Support\AdminDataScope; use Illuminate\Database\Query\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -39,12 +40,15 @@ final class AgentSettlementReportQueryService { $siteCode = $this->siteCodeForAdmin($admin, $periodId); - return DB::table('share_ledger as sl') + $query = DB::table('share_ledger as sl') ->join('players as p', 'p.id', '=', 'sl.player_id') ->leftJoin('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id') ->where('p.site_code', $siteCode) ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]) - ->whereNull('sl.reversal_of_id') + ->whereNull('sl.reversal_of_id'); + $this->applyPlayerSubtree($query, $admin, 'p'); + + return $query ->groupBy('sl.player_id', 'p.username', 'p.agent_node_id', 'ti.play_code') ->selectRaw('sl.player_id, p.username, p.agent_node_id, COALESCE(ti.play_code, ?) as game_type', ['*']) ->selectRaw('SUM(sl.game_win_loss) as game_win_loss') @@ -69,11 +73,14 @@ final class AgentSettlementReportQueryService { $siteCode = $this->siteCodeForAdmin($admin, 0); - return DB::table('share_ledger as sl') + $query = DB::table('share_ledger as sl') ->join('players as p', 'p.id', '=', 'sl.player_id') ->where('p.site_code', $siteCode) ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]) - ->whereNull('sl.reversal_of_id') + ->whereNull('sl.reversal_of_id'); + $this->applyAgentSubtree($query, $admin, 'sl.agent_node_id'); + + return $query ->groupBy('sl.agent_node_id') ->selectRaw('sl.agent_node_id, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as entry_count') ->orderByDesc('game_win_loss') @@ -97,26 +104,32 @@ final class AgentSettlementReportQueryService $base = DB::table('rebate_records as rr') ->join('players as p', 'p.id', '=', 'rr.player_id') ->where('p.site_code', $siteCode); + $this->applyPlayerSubtree($base, $admin, 'p'); $accrued = (clone $base)->where('rr.status', 'accrued')->sum('rr.rebate_amount'); $inBill = (clone $base)->where('rr.status', 'in_bill')->sum('rr.rebate_amount'); $settled = (clone $base)->where('rr.status', 'settled')->sum('rr.rebate_amount'); - $allocated = (int) DB::table('rebate_allocations as ra') + + $allocatedQuery = DB::table('rebate_allocations as ra') ->join('rebate_records as rr', 'rr.id', '=', 'ra.rebate_record_id') ->join('players as p', 'p.id', '=', 'rr.player_id') ->where('p.site_code', $siteCode) - ->when($periodId > 0, fn (Builder $q) => $q->where('rr.settlement_period_id', $periodId)) - ->sum('ra.allocated_amount'); + ->when($periodId > 0, fn (Builder $q) => $q->where('rr.settlement_period_id', $periodId)); + $this->applyPlayerSubtree($allocatedQuery, $admin, 'p'); + $allocated = (int) $allocatedQuery->sum('ra.allocated_amount'); return [ 'accrued_total' => (int) $accrued, 'in_bill_total' => (int) $inBill, 'settled_total' => (int) $settled, 'allocated_total' => $allocated, - 'by_type' => DB::table('rebate_records as rr') - ->join('players as p', 'p.id', '=', 'rr.player_id') - ->where('p.site_code', $siteCode) - ->whereBetween('rr.created_at', [$periodStart, $periodEnd]) + 'by_type' => tap( + DB::table('rebate_records as rr') + ->join('players as p', 'p.id', '=', 'rr.player_id') + ->where('p.site_code', $siteCode) + ->whereBetween('rr.created_at', [$periodStart, $periodEnd]), + fn (Builder $q) => $this->applyPlayerSubtree($q, $admin, 'p'), + ) ->groupBy('rr.rebate_type', 'rr.status') ->selectRaw('rr.rebate_type, rr.status, SUM(rr.rebate_amount) as total, COUNT(*) as cnt') ->get() @@ -137,10 +150,13 @@ final class AgentSettlementReportQueryService { $siteCode = $this->siteCodeForAdmin($admin, 0); - $agents = DB::table('agent_profiles as ap') + $agentsQuery = DB::table('agent_profiles as ap') ->join('agent_nodes as an', 'an.id', '=', 'ap.agent_node_id') ->join('admin_sites as s', 's.id', '=', 'an.admin_site_id') - ->where('s.code', $siteCode) + ->where('s.code', $siteCode); + $this->applyAgentSubtree($agentsQuery, $admin, 'ap.agent_node_id'); + + $agents = $agentsQuery ->selectRaw('ap.agent_node_id, an.code, an.name, ap.credit_limit, ap.allocated_credit, (ap.credit_limit - ap.allocated_credit) as available_credit') ->orderBy('an.depth') ->get() @@ -154,9 +170,12 @@ final class AgentSettlementReportQueryService ]) ->all(); - $players = DB::table('player_credit_accounts as pc') + $playersQuery = DB::table('player_credit_accounts as pc') ->join('players as p', 'p.id', '=', 'pc.player_id') - ->where('p.site_code', $siteCode) + ->where('p.site_code', $siteCode); + $this->applyPlayerSubtree($playersQuery, $admin, 'p'); + + $players = $playersQuery ->selectRaw('pc.player_id, p.username, pc.credit_limit, pc.used_credit, pc.frozen_credit, (pc.credit_limit - pc.used_credit - pc.frozen_credit) as available_credit') ->orderByDesc('pc.used_credit') ->limit(500) @@ -287,13 +306,16 @@ final class AgentSettlementReportQueryService { $siteCode = $this->siteCodeForAdmin($admin, 0); - return DB::table('share_ledger as sl') + $query = DB::table('share_ledger as sl') ->join('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id') ->join('players as p', 'p.id', '=', 'sl.player_id') ->join('draws as d', 'd.id', '=', 'ti.draw_id') ->where('p.site_code', $siteCode) ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]) - ->whereNull('sl.reversal_of_id') + ->whereNull('sl.reversal_of_id'); + $this->applyPlayerSubtree($query, $admin, 'p'); + + return $query ->groupBy('ti.draw_id', 'd.draw_no') ->selectRaw('ti.draw_id, d.draw_no, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as ticket_count') ->orderBy('d.draw_no') @@ -308,6 +330,27 @@ final class AgentSettlementReportQueryService ->all(); } + private function applyPlayerSubtree(Builder $query, AdminUser $admin, string $alias = 'p'): void + { + AdminDataScope::applyToPlayersAlias($query, $admin, $alias); + } + + private function applyAgentSubtree(Builder $query, AdminUser $admin, string $agentNodeColumn): void + { + $subtreeIds = AdminAgentSettlementScope::subtreeAgentNodeIds($admin); + if ($subtreeIds === null) { + return; + } + + if ($subtreeIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereIn($agentNodeColumn, $subtreeIds); + } + private function siteCodeForAdmin(AdminUser $admin, int $periodId): string { if ($periodId > 0) { diff --git a/app/Services/AgentSettlement/CreditLedgerBetFlowPresenter.php b/app/Services/AgentSettlement/CreditLedgerBetFlowPresenter.php new file mode 100644 index 0000000..2df659a --- /dev/null +++ b/app/Services/AgentSettlement/CreditLedgerBetFlowPresenter.php @@ -0,0 +1,237 @@ + $rows credit_ledger 行(含 reason、ref_type、ref_id、amount、created_at) + * @param array $ticketRefs + * @return list> + */ + public function simplifyCreditRows( + array $rows, + array $ticketRefs, + callable $formatHold, + callable $formatSettlement, + ): array { + /** @var list $holdRows */ + $holdRows = []; + /** @var array> $byTicket */ + $byTicket = []; + + foreach ($rows as $row) { + $reason = (string) ($row->reason ?? ''); + if ($reason === self::DISPLAY_BET_HOLD) { + $holdRows[] = $row; + + continue; + } + + if (! in_array($reason, self::SETTLEMENT_REASONS, true)) { + continue; + } + + $ticketId = $this->ticketItemId($row); + if ($ticketId <= 0) { + continue; + } + + $byTicket[$ticketId][] = $row; + } + + /** @var list $mergedSettlements */ + $mergedSettlements = []; + foreach ($byTicket as $ticketId => $entries) { + $merged = $this->mergeSettlementEntries($ticketId, $entries, $ticketRefs); + if ($merged !== null) { + $mergedSettlements[] = $merged; + } + } + + $visibleHolds = $this->holdsWithoutSettledMatch($holdRows, $mergedSettlements, $ticketRefs); + + $holdItems = array_map($formatHold, $visibleHolds); + $settlementItems = array_map( + fn (object $merged): array => $formatSettlement($merged, $ticketRefs), + $mergedSettlements, + ); + + $all = array_merge($holdItems, $settlementItems); + usort($all, static function (array $a, array $b): int { + $ta = isset($a['created_at']) ? strtotime((string) $a['created_at']) : 0; + $tb = isset($b['created_at']) ? strtotime((string) $b['created_at']) : 0; + if ($ta === $tb) { + return (int) ($b['id'] ?? 0) <=> (int) ($a['id'] ?? 0); + } + + return $tb <=> $ta; + }); + + return $all; + } + + /** + * 已开奖注单不再展示下注占用,避免与开奖结算同额时出现「扣两次」误解。 + * + * @param list $holdRows + * @param list $mergedSettlements + * @param array $ticketRefs + * @return list + */ + private function holdsWithoutSettledMatch( + array $holdRows, + array $mergedSettlements, + array $ticketRefs, + ): array { + if ($holdRows === [] || $mergedSettlements === []) { + return $holdRows; + } + + $sortedHolds = $holdRows; + usort($sortedHolds, static function (object $a, object $b): int { + $ta = $a->created_at ?? null; + $tb = $b->created_at ?? null; + if ($ta === null || $tb === null) { + return (int) ($a->id ?? 0) <=> (int) ($b->id ?? 0); + } + + return Carbon::parse($ta)->getTimestamp() <=> Carbon::parse($tb)->getTimestamp(); + }); + + $consumedHoldIds = []; + + foreach ($mergedSettlements as $settlement) { + $playerId = (int) ($settlement->player_id ?? 0); + $ticketId = (int) ($settlement->ref_id ?? 0); + $stake = $this->stakeMinorForSettlement($settlement, $ticketId, $ticketRefs); + if ($playerId <= 0 || $stake <= 0) { + continue; + } + + $settledAt = $settlement->created_at ?? null; + + foreach ($sortedHolds as $hold) { + $holdId = (int) ($hold->id ?? 0); + if ($holdId <= 0 || isset($consumedHoldIds[$holdId])) { + continue; + } + + if ((int) ($hold->player_id ?? 0) !== $playerId) { + continue; + } + + if (abs((int) ($hold->amount ?? 0)) !== $stake) { + continue; + } + + $holdAt = $hold->created_at ?? null; + if ($holdAt !== null && $settledAt !== null + && Carbon::parse($holdAt)->gt(Carbon::parse($settledAt))) { + continue; + } + + $consumedHoldIds[$holdId] = true; + + break; + } + } + + return array_values(array_filter( + $holdRows, + static fn (object $hold): bool => ! isset($consumedHoldIds[(int) ($hold->id ?? 0)]), + )); + } + + /** + * @param array $ticketRefs + */ + private function stakeMinorForSettlement(object $settlement, int $ticketId, array $ticketRefs): int + { + $fromLoss = abs((int) ($settlement->amount ?? 0)); + if ($fromLoss > 0) { + return $fromLoss; + } + + return (int) ($ticketRefs[$ticketId]['actual_deduct_amount'] ?? 0); + } + + /** + * @param list $entries + * @param array $ticketRefs + */ + private function mergeSettlementEntries(int $ticketId, array $entries, array $ticketRefs): ?object + { + $loss = null; + $release = null; + $latestAt = null; + + foreach ($entries as $entry) { + $reason = (string) ($entry->reason ?? ''); + if ($reason === 'game_settlement_loss') { + $loss = $entry; + } elseif ($reason === 'bet_hold_release') { + $release = $entry; + } + + $at = $entry->created_at ?? null; + if ($at !== null && ($latestAt === null || Carbon::parse($at)->gt(Carbon::parse($latestAt)))) { + $latestAt = $at; + } + } + + $primary = $loss ?? $release; + if ($primary === null) { + return null; + } + + $signed = $loss !== null + ? (int) $loss->amount + : 0; + + return (object) [ + 'id' => (int) ($loss->id ?? $release->id ?? 0), + 'amount' => $signed, + 'reason' => self::DISPLAY_GAME_SETTLEMENT, + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketId, + 'created_at' => $latestAt ?? $primary->created_at, + 'player_id' => $primary->player_id ?? null, + 'site_code' => $primary->site_code ?? null, + 'site_player_id' => $primary->site_player_id ?? null, + 'username' => $primary->username ?? null, + 'nickname' => $primary->nickname ?? null, + 'agent_node_id' => $primary->agent_node_id ?? null, + 'funding_mode' => $primary->funding_mode ?? null, + 'auth_source' => $primary->auth_source ?? null, + 'default_currency' => $primary->default_currency ?? null, + 'direct_agent_id' => $primary->direct_agent_id ?? null, + 'direct_agent_code' => $primary->direct_agent_code ?? null, + 'direct_agent_name' => $primary->direct_agent_name ?? null, + 'parent_agent_id' => $primary->parent_agent_id ?? null, + 'parent_agent_code' => $primary->parent_agent_code ?? null, + 'parent_agent_name' => $primary->parent_agent_name ?? null, + 'stake_minor' => (int) ($ticketRefs[$ticketId]['actual_deduct_amount'] ?? abs($signed)), + ]; + } + + private function ticketItemId(object $row): int + { + if ((string) ($row->ref_type ?? '') !== 'ticket_item') { + return 0; + } + + return (int) ($row->ref_id ?? 0); + } +} diff --git a/app/Services/AgentSettlement/SettlementBillGenerator.php b/app/Services/AgentSettlement/SettlementBillGenerator.php index 3a465cb..a3df031 100644 --- a/app/Services/AgentSettlement/SettlementBillGenerator.php +++ b/app/Services/AgentSettlement/SettlementBillGenerator.php @@ -69,7 +69,7 @@ final class SettlementBillGenerator 'platform_rounding_adjustment' => 0, 'net_amount' => $amount, 'paid_amount' => 0, - 'unpaid_amount' => $amount, + 'unpaid_amount' => abs($amount), 'status' => 'pending_confirm', 'meta_json' => json_encode([ 'edge' => $edge, diff --git a/app/Services/AgentSettlement/SettlementCenterLedgerService.php b/app/Services/AgentSettlement/SettlementCenterLedgerService.php index 42e49d3..70b0f2c 100644 --- a/app/Services/AgentSettlement/SettlementCenterLedgerService.php +++ b/app/Services/AgentSettlement/SettlementCenterLedgerService.php @@ -5,6 +5,7 @@ namespace App\Services\AgentSettlement; use App\Models\AdminUser; use App\Support\AdminDataScope; use App\Support\AdminAgentSettlementScope; +use App\Support\AgentSettlementPeriodWindow; use App\Support\CurrencyFormatter; use App\Support\PlayerFundingMode; use Carbon\Carbon; @@ -13,6 +14,25 @@ use Illuminate\Support\Facades\DB; /** 结算中心统一账务流水(credit_ledger + 收付 + 调账)。 */ final class SettlementCenterLedgerService { + private const CREDIT_BIZ_TYPES = [ + 'bet_hold', + 'bet_hold_release', + 'game_settlement_loss', + 'settlement_confirm', + ]; + + private const ADJUSTMENT_BIZ_TYPES = [ + 'adjustment', + 'reversal', + 'bad_debt', + ]; + + private const SHARE_BIZ_TYPE = 'share_ledger'; + + public function __construct( + private readonly SettlementPartyEnrichment $partyEnrichment, + private readonly CreditLedgerBetFlowPresenter $betFlowPresenter, + ) {} /** * @return array{ * items: list>, @@ -31,54 +51,522 @@ final class SettlementCenterLedgerService ): array { $periodId = $filters->settlementPeriodId; $range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo); + $settledRange = $this->resolveSettledRange($periodId, $filters->createdFrom, $filters->createdTo); $playerBills = $this->playerBillsMap($admin, $siteCode, $periodId); - $items = []; - $includeCredit = $this->includeEntryKind($filters, 'credit'); - $includePayment = $this->includeEntryKind($filters, 'payment'); - $includeAdjustment = $this->includeEntryKind($filters, 'adjustment'); - - if ($includeCredit) { - $creditRows = $this->fetchCreditRows($admin, $siteCode, $range, $filters->playerId); - foreach ($creditRows as $row) { - $pid = (int) $row->player_id; - $bill = $playerBills[$pid] ?? null; - $items[] = $this->formatCreditEntry($row, $bill); + $stubQueries = []; + if ($this->shouldIncludeLedgerStub($filters, 'credit')) { + $creditStub = $this->creditStubQuery($admin, $siteCode, $range, $filters); + if ($creditStub !== null) { + $stubQueries[] = $creditStub; } } - if ($includePayment) { - foreach ($this->fetchPaymentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) { - $items[] = $this->formatPaymentEntry($row); + if ($this->shouldIncludeLedgerStub($filters, 'payment')) { + $paymentStub = $this->paymentStubQuery($admin, $siteCode, $periodId, $filters); + if ($paymentStub !== null) { + $stubQueries[] = $paymentStub; } } - if ($includeAdjustment) { - foreach ($this->fetchAdjustmentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) { - if ($filters->badDebtOnly && (string) $row->adjustment_type !== 'bad_debt') { - continue; - } - $items[] = $this->formatAdjustmentEntry($row); + if ($this->shouldIncludeLedgerStub($filters, 'adjustment')) { + $adjustmentStub = $this->adjustmentStubQuery($admin, $siteCode, $periodId, $filters); + if ($adjustmentStub !== null) { + $stubQueries[] = $adjustmentStub; + } + } + if ($this->shouldIncludeLedgerStub($filters, 'share')) { + $shareStub = $this->shareStubQuery($admin, $siteCode, $settledRange, $filters); + if ($shareStub !== null) { + $stubQueries[] = $shareStub; } } + if ($stubQueries === []) { + return [ + 'items' => [], + 'total' => 0, + 'page' => $page, + 'per_page' => $perPage, + 'ledger_source' => 'settlement_ledger', + ]; + } + + $offset = max(0, ($page - 1) * $perPage); + if (count($stubQueries) === 1) { + $base = $stubQueries[0]; + $total = (int) (clone $base)->count(); + $stubs = (clone $base) + ->orderByDesc('sort_at') + ->orderByDesc('entry_id') + ->offset($offset) + ->limit($perPage) + ->get(); + } else { + $union = null; + foreach ($stubQueries as $stubQuery) { + $union = $union === null ? $stubQuery : $union->unionAll($stubQuery); + } + $wrapped = DB::query()->fromSub($union, 'ledger_page'); + $total = (int) (clone $wrapped)->count(); + $stubs = $wrapped + ->orderByDesc('sort_at') + ->orderByDesc('entry_id') + ->offset($offset) + ->limit($perPage) + ->get(); + } + + $items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills); $items = $this->applyFilters($items, $filters); + $filteredCount = count($items); - usort($items, static function (array $a, array $b): int { - return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? '')); - }); + if ($filteredCount !== count($stubs)) { + $total = $filteredCount; + } + + return [ + 'items' => array_values($items), + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'ledger_source' => 'settlement_ledger', + ]; + } + + /** + * 下注流水简化展示:下注占用 + 按注单合并的开奖结算。 + * + * @return array{ + * items: list>, + * total: int, + * page: int, + * per_page: int, + * ledger_source: string, + * } + */ + public function listBetFlowSimplified( + AdminUser $admin, + string $siteCode, + int $page, + int $perPage, + SettlementLedgerListFilters $filters = new SettlementLedgerListFilters, + ): array { + $periodId = $filters->settlementPeriodId; + $range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo); + $rows = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters); + $playerBills = $this->playerBillsMap($admin, $siteCode, $periodId); + + $ticketIds = []; + foreach ($rows as $row) { + if ((string) ($row->ref_type ?? '') === 'ticket_item') { + $ticketId = (int) ($row->ref_id ?? 0); + if ($ticketId > 0) { + $ticketIds[] = $ticketId; + } + } + } + + $ticketRefs = $this->partyEnrichment->loadTicketRefs(array_values(array_unique($ticketIds))); + + $items = $this->betFlowPresenter->simplifyCreditRows( + $rows, + $ticketRefs, + function (object $row) use ($playerBills, $ticketRefs): array { + $pid = (int) ($row->player_id ?? 0); + + return $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs); + }, + function (object $row) use ($playerBills, $ticketRefs): array { + $pid = (int) ($row->player_id ?? 0); + $formatted = $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs); + $ticketId = (int) ($row->ref_id ?? 0); + if ($ticketId > 0) { + $formatted['txn_no'] = 'CLS-T'.$ticketId; + $formatted['row_key'] = 'settlement-'.$ticketId; + } + + return $formatted; + }, + ); + + if ($filters->bizType === CreditLedgerBetFlowPresenter::DISPLAY_BET_HOLD + || $filters->bizType === CreditLedgerBetFlowPresenter::DISPLAY_GAME_SETTLEMENT) { + $items = array_values(array_filter( + $items, + static fn (array $item): bool => ($item['biz_type'] ?? '') === $filters->bizType, + )); + } $total = count($items); $offset = max(0, ($page - 1) * $perPage); $pageItems = array_slice($items, $offset, $perPage); return [ - 'items' => array_values($pageItems), + 'items' => $pageItems, 'total' => $total, 'page' => $page, 'per_page' => $perPage, - 'ledger_source' => 'settlement_ledger', + 'ledger_source' => 'credit_ledger', ]; } + private function shouldIncludeLedgerStub(SettlementLedgerListFilters $filters, string $kind): bool + { + if (! $this->includeEntryKind($filters, $kind)) { + return false; + } + + if ($filters->bizType === null || $filters->bizType === '') { + return true; + } + + return match ($kind) { + 'credit' => in_array($filters->bizType, self::CREDIT_BIZ_TYPES, true), + 'payment' => $filters->bizType === 'payment_record', + 'adjustment' => in_array($filters->bizType, self::ADJUSTMENT_BIZ_TYPES, true), + 'share' => $filters->bizType === self::SHARE_BIZ_TYPE + || ($filters->entryKind === 'share' && ($filters->bizType === null || $filters->bizType === '')), + default => false, + }; + } + + /** + * @param array{0: Carbon, 1: Carbon}|null $range + */ + private function shareStubQuery( + AdminUser $admin, + string $siteCode, + ?array $range, + SettlementLedgerListFilters $filters, + ): ?\Illuminate\Database\Query\Builder { + $query = DB::table('share_ledger as sl') + ->join('players as p', 'p.id', '=', 'sl.player_id') + ->where('p.site_code', $siteCode) + ->whereNull('sl.reversal_of_id') + ->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at"); + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + $this->applyLedgerPlayerFilters($query, 'p', $filters); + + if ($range !== null) { + $query->whereBetween('sl.settled_at', $range); + } + + $this->applyTxnNoStubFilter($query, 'sl.id', 'SL', $filters->txnNo); + + return $query; + } + + /** + * @param array{0: Carbon, 1: Carbon}|null $range + */ + private function creditStubQuery( + AdminUser $admin, + string $siteCode, + ?array $range, + SettlementLedgerListFilters $filters, + ): ?\Illuminate\Database\Query\Builder { + $query = DB::table('credit_ledger as cl') + ->join('players as p', function ($join): void { + $join->on('p.id', '=', 'cl.owner_id') + ->where('cl.owner_type', '=', 'player'); + }) + ->where('p.site_code', $siteCode) + ->where('p.funding_mode', PlayerFundingMode::CREDIT) + ->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at"); + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + $this->applyLedgerPlayerFilters($query, 'p', $filters); + + if ($range !== null) { + $query->whereBetween('cl.created_at', $range); + } + + if ($filters->bizType !== null && $filters->bizType !== '') { + $query->where('cl.reason', $filters->bizType); + } elseif ($filters->betFlowOnly) { + $query->whereIn('cl.reason', [ + 'bet_hold', + 'bet_hold_release', + 'game_settlement_loss', + ]); + } + + $this->applyTxnNoStubFilter($query, 'cl.id', 'CL', $filters->txnNo); + + return $query; + } + + private function paymentStubQuery( + AdminUser $admin, + string $siteCode, + ?int $periodId, + SettlementLedgerListFilters $filters, + ): ?\Illuminate\Database\Query\Builder { + $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id'); + if ($adminSiteId <= 0) { + return null; + } + + $query = DB::table('payment_records as pr') + ->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->where('sp.admin_site_id', $adminSiteId) + ->leftJoin('players as p', function ($join): void { + $join->on('p.id', '=', 'sb.owner_id') + ->where('sb.owner_type', '=', 'player'); + }) + ->selectRaw("'payment' as entry_kind, pr.id as entry_id, pr.created_at as sort_at"); + + if ($periodId !== null && $periodId > 0) { + $query->where('sb.settlement_period_id', $periodId); + } + + $this->applyLedgerSiteScope($query, $admin, 'sp'); + AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb'); + $this->applyPaymentPlayerFilters($query, $filters); + + if ($filters->billStatus !== null && $filters->billStatus !== '') { + $query->where('sb.status', $filters->billStatus); + } + + $this->applyTxnNoStubFilter($query, 'pr.id', 'PAY', $filters->txnNo); + + return $query; + } + + private function applyPaymentPlayerFilters( + \Illuminate\Database\Query\Builder $query, + SettlementLedgerListFilters $filters, + ): void { + if ($filters->playerId !== null && $filters->playerId > 0) { + $query->where('sb.owner_type', 'player') + ->where('sb.owner_id', $filters->playerId); + } + + if ($filters->playerAccount !== null && $filters->playerAccount !== '') { + $query->where('sb.owner_type', 'player'); + $this->applyLedgerPlayerFilters($query, 'p', $filters); + } + } + + private function adjustmentStubQuery( + AdminUser $admin, + string $siteCode, + ?int $periodId, + SettlementLedgerListFilters $filters, + ): ?\Illuminate\Database\Query\Builder { + $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id'); + if ($adminSiteId <= 0) { + return null; + } + + $query = DB::table('settlement_adjustments as sa') + ->join('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id') + ->where('sp.admin_site_id', $adminSiteId) + ->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id') + ->leftJoin('players as p', function ($join): void { + $join->on('p.id', '=', 'sb.owner_id') + ->where('sb.owner_type', '=', 'player'); + }) + ->selectRaw("'adjustment' as entry_kind, sa.id as entry_id, sa.created_at as sort_at"); + + if ($periodId !== null && $periodId > 0) { + $query->where('sa.settlement_period_id', $periodId); + } + + if ($filters->badDebtOnly) { + $query->where('sa.adjustment_type', 'bad_debt'); + } elseif ($filters->entryKind === 'adjustment') { + $query->where('sa.adjustment_type', '!=', 'bad_debt'); + } + + $this->applyLedgerSiteScope($query, $admin, 'sp'); + $this->applyAdjustmentPlayerScope($query, $admin, $siteCode, $filters); + + if ($filters->billStatus !== null && $filters->billStatus !== '') { + $query->where('sb.status', $filters->billStatus); + } + + if ($filters->bizType !== null && $filters->bizType !== '') { + $query->where('sa.adjustment_type', $filters->bizType); + } + + $this->applyTxnNoStubFilter($query, 'sa.id', 'ADJ', $filters->txnNo); + + return $query; + } + + private function applyAdjustmentPlayerScope( + \Illuminate\Database\Query\Builder $query, + AdminUser $admin, + string $siteCode, + SettlementLedgerListFilters $filters, + ): void { + $query->where(function (\Illuminate\Database\Query\Builder $outer) use ($admin, $siteCode, $filters): void { + $outer->whereNull('p.id') + ->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void { + $scoped->where('p.site_code', $siteCode); + AdminDataScope::applyToPlayersAlias($scoped, $admin, 'p'); + $this->applyLedgerPlayerFilters($scoped, 'p', $filters); + }); + }); + } + + private function applyTxnNoStubFilter( + \Illuminate\Database\Query\Builder $query, + string $idColumn, + string $prefix, + ?string $txnNo, + ): void { + if ($txnNo === null || $txnNo === '') { + return; + } + + $needle = strtolower(trim($txnNo)); + $query->where(function (\Illuminate\Database\Query\Builder $match) use ($idColumn, $prefix, $needle): void { + if (ctype_digit($needle)) { + $match->where($idColumn, (int) $needle); + } + + $match->orWhereRaw( + 'LOWER(CONCAT(?, \'-\', '.$idColumn.')) LIKE ?', + [$prefix, '%'.$needle.'%'], + ); + }); + } + + private function applyLedgerSiteScope(\Illuminate\Database\Query\Builder $query, AdminUser $admin, string $periodsAlias): void + { + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds === null) { + return; + } + + if ($siteIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereIn($periodsAlias.'.admin_site_id', $siteIds); + } + + private function applyLedgerPlayerFilters( + \Illuminate\Database\Query\Builder $query, + string $playerAlias, + SettlementLedgerListFilters $filters, + ): void { + if ($filters->playerId !== null && $filters->playerId > 0) { + $query->where("{$playerAlias}.id", $filters->playerId); + } + + if ($filters->playerAccount !== null && $filters->playerAccount !== '') { + $like = '%'.addcslashes($filters->playerAccount, '%_\\').'%'; + $query->where(function (\Illuminate\Database\Query\Builder $match) use ($playerAlias, $like): void { + $match->where("{$playerAlias}.username", 'like', $like) + ->orWhere("{$playerAlias}.site_player_id", 'like', $like) + ->orWhere("{$playerAlias}.nickname", 'like', $like); + }); + } + } + + /** + * @param \Illuminate\Support\Collection $stubs + * @param array $playerBills + * @return list> + */ + private function hydrateLedgerStubs( + AdminUser $admin, + string $siteCode, + \Illuminate\Support\Collection $stubs, + array $playerBills, + ): array { + if ($stubs->isEmpty()) { + return []; + } + + $creditIds = []; + $paymentIds = []; + $adjustmentIds = []; + $shareIds = []; + foreach ($stubs as $stub) { + $kind = (string) $stub->entry_kind; + $id = (int) $stub->entry_id; + if ($kind === 'credit') { + $creditIds[] = $id; + } elseif ($kind === 'payment') { + $paymentIds[] = $id; + } elseif ($kind === 'adjustment') { + $adjustmentIds[] = $id; + } elseif ($kind === 'share') { + $shareIds[] = $id; + } + } + + $creditById = []; + if ($creditIds !== []) { + foreach ($this->fetchCreditRowsByIds($admin, $siteCode, $creditIds) as $row) { + $creditById[(int) $row->id] = $row; + } + } + + $ticketRefs = $this->partyEnrichment->loadTicketRefs( + array_values(array_filter(array_map( + static fn (object $row): int => (string) ($row->ref_type ?? '') === 'ticket_item' + ? (int) ($row->ref_id ?? 0) + : 0, + array_values($creditById), + ), static fn (int $id): bool => $id > 0)), + ); + + $paymentById = []; + if ($paymentIds !== []) { + foreach ($this->fetchPaymentRowsByIds($admin, $siteCode, $paymentIds) as $row) { + $paymentById[(int) $row->id] = $row; + } + } + + $adjustmentById = []; + if ($adjustmentIds !== []) { + foreach ($this->fetchAdjustmentRowsByIds($admin, $siteCode, $adjustmentIds) as $row) { + $adjustmentById[(int) $row->id] = $row; + } + } + + $shareById = []; + if ($shareIds !== []) { + foreach ($this->fetchShareRowsByIds($admin, $siteCode, $shareIds) as $row) { + $shareById[(int) $row->id] = $row; + } + } + + $shareTicketRefs = $this->partyEnrichment->loadTicketRefs( + array_values(array_filter(array_map( + static fn (object $row): int => (int) ($row->ticket_item_id ?? 0), + array_values($shareById), + ), static fn (int $id): bool => $id > 0)), + ); + + $items = []; + foreach ($stubs as $stub) { + $kind = (string) $stub->entry_kind; + $id = (int) $stub->entry_id; + if ($kind === 'credit' && isset($creditById[$id])) { + $row = $creditById[$id]; + $pid = (int) $row->player_id; + $items[] = $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs); + } elseif ($kind === 'payment' && isset($paymentById[$id])) { + $items[] = $this->formatPaymentEntry($paymentById[$id]); + } elseif ($kind === 'adjustment' && isset($adjustmentById[$id])) { + $items[] = $this->formatAdjustmentEntry($adjustmentById[$id]); + } elseif ($kind === 'share' && isset($shareById[$id])) { + $items[] = $this->formatShareEntry($shareById[$id], $shareTicketRefs); + } + } + + return $items; + } + /** * @return array{0: Carbon|null, 1: Carbon|null} */ @@ -113,34 +601,6 @@ final class SettlementCenterLedgerService } } - if ($filters->txnNo !== null) { - $needle = strtolower($filters->txnNo); - $hay = strtolower((string) ($row['txn_no'] ?? '')); - if (! str_contains($hay, $needle)) { - return false; - } - } - - if ($filters->playerAccount !== null) { - $needle = strtolower($filters->playerAccount); - $haystack = strtolower(implode(' ', array_filter([ - (string) ($row['username'] ?? ''), - (string) ($row['nickname'] ?? ''), - (string) ($row['site_player_id'] ?? ''), - ]))); - if (! str_contains($haystack, $needle)) { - return false; - } - } - - if ($filters->bizType !== null && ($row['biz_type'] ?? '') !== $filters->bizType) { - return false; - } - - if ($filters->billStatus !== null && ($row['bill_status'] ?? '') !== $filters->billStatus) { - return false; - } - if ($filters->actionableOnly) { $actions = $row['available_actions'] ?? []; $operational = array_filter( @@ -160,6 +620,28 @@ final class SettlementCenterLedgerService ?int $settlementPeriodId, ?string $createdFrom, ?string $createdTo, + ): ?array { + return $this->resolvePeriodRange($settlementPeriodId, $createdFrom, $createdTo); + } + + /** + * @return array{0: Carbon, 1: Carbon}|null + */ + private function resolveSettledRange( + ?int $settlementPeriodId, + ?string $createdFrom, + ?string $createdTo, + ): ?array { + return $this->resolvePeriodRange($settlementPeriodId, $createdFrom, $createdTo); + } + + /** + * @return array{0: Carbon, 1: Carbon}|null + */ + private function resolvePeriodRange( + ?int $settlementPeriodId, + ?string $rangeFrom, + ?string $rangeTo, ): ?array { if ($settlementPeriodId !== null && $settlementPeriodId > 0) { $period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first(); @@ -167,17 +649,17 @@ final class SettlementCenterLedgerService return null; } - return [ - Carbon::parse($period->period_start)->startOfDay(), - Carbon::parse($period->period_end)->endOfDay(), - ]; + return AgentSettlementPeriodWindow::bounds( + (string) $period->period_start, + (string) $period->period_end, + ); } - $from = $createdFrom !== null && $createdFrom !== '' - ? Carbon::parse($createdFrom)->startOfDay() + $from = $rangeFrom !== null && $rangeFrom !== '' + ? Carbon::parse($rangeFrom)->startOfDay() : null; - $to = $createdTo !== null && $createdTo !== '' - ? Carbon::parse($createdTo)->endOfDay() + $to = $rangeTo !== null && $rangeTo !== '' + ? Carbon::parse($rangeTo)->endOfDay() : null; if ($from === null && $to === null) { @@ -190,6 +672,97 @@ final class SettlementCenterLedgerService ]; } + /** + * @param list $ids + * @return list + */ + private function fetchShareRowsByIds(AdminUser $admin, string $siteCode, array $ids): array + { + if ($ids === []) { + return []; + } + + $query = DB::table('share_ledger as sl') + ->join('players as p', 'p.id', '=', 'sl.player_id') + ->leftJoin('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id') + ->leftJoin('draws as d', 'd.id', '=', 'ti.draw_id') + ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') + ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') + ->leftJoin('agent_nodes as sla', 'sla.id', '=', 'sl.agent_node_id') + ->where('p.site_code', $siteCode) + ->whereIn('sl.id', $ids) + ->select([ + 'sl.id', + 'sl.ticket_item_id', + 'sl.player_id', + 'sl.agent_node_id as share_agent_node_id', + 'sl.shared_net_win_loss', + 'sl.game_win_loss', + 'sl.settled_at', + 'p.site_code', + 'p.site_player_id', + 'p.username', + 'p.nickname', + 'p.agent_node_id', + 'p.funding_mode', + 'p.auth_source', + 'p.default_currency', + 'ti.play_code', + 'd.draw_no', + 'da.id as direct_agent_id', + 'da.code as direct_agent_code', + 'da.name as direct_agent_name', + 'pa.id as parent_agent_id', + 'pa.code as parent_agent_code', + 'pa.name as parent_agent_name', + 'sla.code as share_agent_code', + 'sla.name as share_agent_name', + ]); + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + + return $query->get()->all(); + } + + /** + * @param array $ticketRefs + * @return array + */ + private function formatShareEntry(object $row, array $ticketRefs): array + { + $ticketId = (int) ($row->ticket_item_id ?? 0); + $ticketRef = $ticketRefs[$ticketId] ?? null; + $signed = (int) ($row->shared_net_win_loss ?? 0); + + return array_merge( + $this->baseRow( + entryKind: 'share', + entryId: (int) $row->id, + txnPrefix: 'SL', + playerId: (int) $row->player_id, + row: $row, + bizType: self::SHARE_BIZ_TYPE, + signedAmount: $signed, + createdAt: $row->settled_at, + ledgerSource: 'share_ledger', + settlementBillId: null, + billStatus: null, + billType: null, + billUnpaid: null, + refLabel: $ticketId > 0 ? '#'.$ticketId : null, + refType: $ticketId > 0 ? 'ticket_item' : null, + refId: $ticketId > 0 ? $ticketId : null, + ), + $this->partyFieldsFromRow($row), + [ + 'play_code' => $ticketRef['play_code'] ?? $row->play_code ?? null, + 'draw_no' => $ticketRef['draw_no'] ?? $row->draw_no ?? null, + 'ticket_item_id' => $ticketId > 0 ? $ticketId : null, + 'available_actions' => ['view_player'], + ], + ); + } + /** * @return array */ @@ -257,6 +830,8 @@ final class SettlementCenterLedgerService $join->on('p.id', '=', 'cl.owner_id') ->where('cl.owner_type', '=', 'player'); }) + ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') + ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') ->where('p.site_code', $siteCode) ->where('p.funding_mode', PlayerFundingMode::CREDIT) ->select([ @@ -271,9 +846,16 @@ final class SettlementCenterLedgerService 'p.site_player_id', 'p.username', 'p.nickname', + 'p.agent_node_id', 'p.funding_mode', 'p.auth_source', 'p.default_currency', + 'da.id as direct_agent_id', + 'da.code as direct_agent_code', + 'da.name as direct_agent_name', + 'pa.id as parent_agent_id', + 'pa.code as parent_agent_code', + 'pa.name as parent_agent_name', ]) ->orderByDesc('cl.id'); @@ -290,6 +872,114 @@ final class SettlementCenterLedgerService return $query->limit(500)->get()->all(); } + /** + * @param array{0: Carbon, 1: Carbon}|null $range + * @return list + */ + private function fetchBetFlowCreditRows( + AdminUser $admin, + string $siteCode, + ?array $range, + SettlementLedgerListFilters $filters, + ): array { + $query = DB::table('credit_ledger as cl') + ->join('players as p', function ($join): void { + $join->on('p.id', '=', 'cl.owner_id') + ->where('cl.owner_type', '=', 'player'); + }) + ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') + ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') + ->where('p.site_code', $siteCode) + ->where('p.funding_mode', PlayerFundingMode::CREDIT) + ->whereIn('cl.reason', [ + 'bet_hold', + 'bet_hold_release', + 'game_settlement_loss', + ]) + ->select([ + 'cl.id', + 'cl.amount', + 'cl.reason', + 'cl.ref_type', + 'cl.ref_id', + 'cl.created_at', + 'p.id as player_id', + 'p.site_code', + 'p.site_player_id', + 'p.username', + 'p.nickname', + 'p.agent_node_id', + 'p.funding_mode', + 'p.auth_source', + 'p.default_currency', + 'da.id as direct_agent_id', + 'da.code as direct_agent_code', + 'da.name as direct_agent_name', + 'pa.id as parent_agent_id', + 'pa.code as parent_agent_code', + 'pa.name as parent_agent_name', + ]) + ->orderByDesc('cl.id'); + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + $this->applyLedgerPlayerFilters($query, 'p', $filters); + + if ($range !== null) { + $query->whereBetween('cl.created_at', $range); + } + + return $query->limit(5000)->get()->all(); + } + + /** + * @param list $ids + * @return list + */ + private function fetchCreditRowsByIds(AdminUser $admin, string $siteCode, array $ids): array + { + if ($ids === []) { + return []; + } + + $query = DB::table('credit_ledger as cl') + ->join('players as p', function ($join): void { + $join->on('p.id', '=', 'cl.owner_id') + ->where('cl.owner_type', '=', 'player'); + }) + ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') + ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') + ->where('p.site_code', $siteCode) + ->where('p.funding_mode', PlayerFundingMode::CREDIT) + ->whereIn('cl.id', $ids) + ->select([ + 'cl.id', + 'cl.amount', + 'cl.reason', + 'cl.ref_type', + 'cl.ref_id', + 'cl.created_at', + 'p.id as player_id', + 'p.site_code', + 'p.site_player_id', + 'p.username', + 'p.nickname', + 'p.agent_node_id', + 'p.funding_mode', + 'p.auth_source', + 'p.default_currency', + 'da.id as direct_agent_id', + 'da.code as direct_agent_code', + 'da.name as direct_agent_name', + 'pa.id as parent_agent_id', + 'pa.code as parent_agent_code', + 'pa.name as parent_agent_name', + ]); + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + + return $query->get()->all(); + } + /** * @return list */ @@ -307,6 +997,8 @@ final class SettlementCenterLedgerService ->where('sb.owner_type', '=', 'player'); }) ->where('p.site_code', $siteCode) + ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') + ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') ->select([ 'pr.id', 'pr.amount', @@ -321,9 +1013,16 @@ final class SettlementCenterLedgerService 'p.site_player_id', 'p.username', 'p.nickname', + 'p.agent_node_id', 'p.auth_source', 'p.funding_mode', 'p.default_currency', + 'da.id as direct_agent_id', + 'da.code as direct_agent_code', + 'da.name as direct_agent_name', + 'pa.id as parent_agent_id', + 'pa.code as parent_agent_code', + 'pa.name as parent_agent_name', ]) ->orderByDesc('pr.id'); @@ -348,6 +1047,69 @@ final class SettlementCenterLedgerService return $query->limit(300)->get()->all(); } + /** + * @param list $ids + * @return list + */ + private function fetchPaymentRowsByIds(AdminUser $admin, string $siteCode, array $ids): array + { + if ($ids === []) { + return []; + } + + $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id'); + if ($adminSiteId <= 0) { + return []; + } + + $query = DB::table('payment_records as pr') + ->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->where('sp.admin_site_id', $adminSiteId) + ->leftJoin('players as p', function ($join): void { + $join->on('p.id', '=', 'sb.owner_id') + ->where('sb.owner_type', '=', 'player'); + }) + ->leftJoin('agent_nodes as owner_an', function ($join): void { + $join->on('owner_an.id', '=', 'sb.owner_id') + ->where('sb.owner_type', '=', 'agent'); + }) + ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') + ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') + ->whereIn('pr.id', $ids) + ->select([ + 'pr.id', + 'pr.amount', + 'pr.method', + 'pr.status', + 'pr.created_at', + 'pr.settlement_bill_id', + 'sb.status as bill_status', + 'sb.bill_type', + 'sb.unpaid_amount', + 'p.id as player_id', + 'p.site_player_id', + 'p.username', + 'p.nickname', + 'p.agent_node_id', + 'p.auth_source', + 'p.funding_mode', + 'p.default_currency', + DB::raw('COALESCE(da.id, owner_an.id) as direct_agent_id'), + DB::raw('COALESCE(da.code, owner_an.code) as direct_agent_code'), + DB::raw('COALESCE(da.name, owner_an.name) as direct_agent_name'), + 'pa.id as parent_agent_id', + 'pa.code as parent_agent_code', + 'pa.name as parent_agent_name', + ]) + ->selectRaw('COALESCE(p.site_code, ?) as site_code', [$siteCode]); + + AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb'); + $this->applyLedgerSiteScope($query, $admin, 'sp'); + + return $query->get()->all(); + } + /** * @return list */ @@ -365,6 +1127,8 @@ final class SettlementCenterLedgerService ->where('sb.owner_type', '=', 'player'); }) ->where('p.site_code', $siteCode) + ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') + ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') ->select([ 'sa.id', 'sa.amount', @@ -379,9 +1143,16 @@ final class SettlementCenterLedgerService 'p.site_player_id', 'p.username', 'p.nickname', + 'p.agent_node_id', 'p.auth_source', 'p.funding_mode', 'p.default_currency', + 'da.id as direct_agent_id', + 'da.code as direct_agent_code', + 'da.name as direct_agent_name', + 'pa.id as parent_agent_id', + 'pa.code as parent_agent_code', + 'pa.name as parent_agent_name', ]) ->orderByDesc('sa.id'); @@ -406,28 +1177,91 @@ final class SettlementCenterLedgerService return $query->limit(300)->get()->all(); } + /** + * @param list $ids + * @return list + */ + private function fetchAdjustmentRowsByIds(AdminUser $admin, string $siteCode, array $ids): array + { + if ($ids === []) { + return []; + } + + $query = DB::table('settlement_adjustments as sa') + ->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id') + ->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id') + ->leftJoin('players as p', function ($join): void { + $join->on('p.id', '=', 'sb.owner_id') + ->where('sb.owner_type', '=', 'player'); + }) + ->where('p.site_code', $siteCode) + ->whereIn('sa.id', $ids) + ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') + ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') + ->select([ + 'sa.id', + 'sa.amount', + 'sa.adjustment_type', + 'sa.reason', + 'sa.created_at', + 'sa.original_bill_id as settlement_bill_id', + 'sb.status as bill_status', + 'sb.bill_type', + 'sb.unpaid_amount', + 'p.id as player_id', + 'p.site_player_id', + 'p.username', + 'p.nickname', + 'p.agent_node_id', + 'p.auth_source', + 'p.funding_mode', + 'p.default_currency', + 'da.id as direct_agent_id', + 'da.code as direct_agent_code', + 'da.name as direct_agent_name', + 'pa.id as parent_agent_id', + 'pa.code as parent_agent_code', + 'pa.name as parent_agent_name', + ]); + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + $this->applyLedgerSiteScope($query, $admin, 'sp'); + + return $query->get()->all(); + } + /** * @return array */ - private function formatCreditEntry(object $row, ?object $bill): array + /** + * @param array $ticketRefs + */ + private function formatCreditEntry(object $row, ?object $bill, array $ticketRefs = []): array { $amount = (int) $row->amount; $billId = $bill !== null ? (int) $bill->id : null; + $ticketRef = $this->resolveTicketRef($row, $ticketRefs); - return $this->baseRow( - entryKind: 'credit', - entryId: (int) $row->id, - txnPrefix: 'CL', - playerId: (int) $row->player_id, - row: $row, - bizType: (string) $row->reason, - signedAmount: $amount, - createdAt: $row->created_at, - ledgerSource: 'credit_ledger', - settlementBillId: $billId, - billStatus: $bill !== null ? (string) $bill->status : null, - billType: $bill !== null ? (string) $bill->bill_type : null, - billUnpaid: $bill !== null ? (int) $bill->unpaid_amount : null, + return array_merge( + $this->baseRow( + entryKind: 'credit', + entryId: (int) $row->id, + txnPrefix: 'CL', + playerId: (int) ($row->player_id ?? 0), + row: $row, + bizType: (string) $row->reason, + signedAmount: $amount, + createdAt: $row->created_at, + ledgerSource: 'credit_ledger', + settlementBillId: $billId, + billStatus: $bill !== null ? (string) $bill->status : null, + billType: $bill !== null ? (string) $bill->bill_type : null, + billUnpaid: $bill !== null ? (int) $bill->unpaid_amount : null, + refType: isset($row->ref_type) ? (string) $row->ref_type : null, + refId: isset($row->ref_id) ? (int) $row->ref_id : null, + ), + $this->partyFieldsFromRow($row), + $ticketRef, ); } @@ -438,21 +1272,24 @@ final class SettlementCenterLedgerService { $amount = (int) $row->amount; - return $this->baseRow( - entryKind: 'payment', - entryId: (int) $row->id, - txnPrefix: 'PAY', - playerId: (int) $row->player_id, - row: $row, - bizType: 'payment_record', - signedAmount: $amount, - createdAt: $row->created_at, - ledgerSource: 'payment_record', - settlementBillId: (int) $row->settlement_bill_id, - billStatus: (string) ($row->bill_status ?? ''), - billType: (string) ($row->bill_type ?? ''), - billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null, - refLabel: 'bill#'.$row->settlement_bill_id.($row->method ? ' · '.$row->method : ''), + return array_merge( + $this->baseRow( + entryKind: 'payment', + entryId: (int) $row->id, + txnPrefix: 'PAY', + playerId: (int) ($row->player_id ?? 0), + row: $row, + bizType: 'payment_record', + signedAmount: $amount, + createdAt: $row->created_at, + ledgerSource: 'payment_record', + settlementBillId: (int) $row->settlement_bill_id, + billStatus: (string) ($row->bill_status ?? ''), + billType: (string) ($row->bill_type ?? ''), + billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null, + refLabel: 'bill#'.$row->settlement_bill_id.($row->method ? ' · '.$row->method : ''), + ), + $this->partyFieldsFromRow($row), ); } @@ -464,26 +1301,75 @@ final class SettlementCenterLedgerService $amount = (int) $row->amount; $type = (string) $row->adjustment_type; - return $this->baseRow( - entryKind: 'adjustment', - entryId: (int) $row->id, - txnPrefix: 'ADJ', - playerId: (int) $row->player_id, - row: $row, - bizType: $type, - signedAmount: $amount, - createdAt: $row->created_at, - ledgerSource: 'settlement_adjustment', - settlementBillId: (int) $row->settlement_bill_id, - billStatus: (string) ($row->bill_status ?? ''), - billType: (string) ($row->bill_type ?? ''), - billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null, - refLabel: $row->reason !== null && $row->reason !== '' - ? (string) $row->reason - : 'bill#'.$row->settlement_bill_id, + return array_merge( + $this->baseRow( + entryKind: 'adjustment', + entryId: (int) $row->id, + txnPrefix: 'ADJ', + playerId: (int) ($row->player_id ?? 0), + row: $row, + bizType: $type, + signedAmount: $amount, + createdAt: $row->created_at, + ledgerSource: 'settlement_adjustment', + settlementBillId: (int) $row->settlement_bill_id, + billStatus: (string) ($row->bill_status ?? ''), + billType: (string) ($row->bill_type ?? ''), + billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null, + refLabel: $row->reason !== null && $row->reason !== '' + ? (string) $row->reason + : 'bill#'.$row->settlement_bill_id, + ), + $this->partyFieldsFromRow($row), ); } + /** + * @param array $ticketRefs + * @return array{play_code: string|null, draw_no: string|null, ticket_item_id: int|null} + */ + private function resolveTicketRef(object $row, array $ticketRefs): array + { + if ((string) ($row->ref_type ?? '') !== 'ticket_item') { + return ['play_code' => null, 'draw_no' => null, 'ticket_item_id' => null]; + } + + $ticketId = (int) ($row->ref_id ?? 0); + $ref = $ticketRefs[$ticketId] ?? null; + + return [ + 'play_code' => $ref['play_code'] ?? null, + 'draw_no' => $ref['draw_no'] ?? null, + 'ticket_item_id' => $ticketId > 0 ? $ticketId : null, + ]; + } + + /** + * @return array + */ + private function partyFieldsFromRow(object $row): array + { + $directId = (int) ($row->direct_agent_id ?? $row->agent_node_id ?? 0); + $parentId = (int) ($row->parent_agent_id ?? 0); + + return [ + 'direct_agent_id' => $directId > 0 ? $directId : null, + 'direct_agent_label' => $directId > 0 + ? $this->partyEnrichment->formatAgent((object) [ + 'name' => $row->direct_agent_name ?? null, + 'code' => $row->direct_agent_code ?? null, + ], $directId) + : null, + 'parent_agent_id' => $parentId > 0 ? $parentId : null, + 'parent_agent_label' => $parentId > 0 + ? $this->partyEnrichment->formatAgent((object) [ + 'name' => $row->parent_agent_name ?? null, + 'code' => $row->parent_agent_code ?? null, + ], $parentId) + : null, + ]; + } + /** * @return array */ @@ -502,6 +1388,8 @@ final class SettlementCenterLedgerService ?string $billType, ?int $billUnpaid, ?string $refLabel = null, + ?string $refType = null, + ?int $refId = null, ): array { $amountAbs = abs($signedAmount); $currency = (string) ($row->default_currency ?? ''); @@ -517,6 +1405,8 @@ final class SettlementCenterLedgerService 'username' => $row->username ?? null, 'nickname' => $row->nickname ?? null, 'biz_type' => $bizType, + 'ref_type' => $refType, + 'ref_id' => $refId, 'biz_no' => $refLabel ?? $this->creditRefLabel($row), 'direction' => $signedAmount >= 0 ? 1 : 2, 'amount' => $amountAbs, diff --git a/app/Services/AgentSettlement/SettlementLedgerListFilters.php b/app/Services/AgentSettlement/SettlementLedgerListFilters.php index 677a0b1..45d8bb0 100644 --- a/app/Services/AgentSettlement/SettlementLedgerListFilters.php +++ b/app/Services/AgentSettlement/SettlementLedgerListFilters.php @@ -17,6 +17,8 @@ final class SettlementLedgerListFilters public readonly ?string $createdFrom = null, public readonly ?string $createdTo = null, public readonly bool $badDebtOnly = false, + public readonly bool $betFlowOnly = false, + public readonly bool $betFlowDisplaySimple = false, ) {} public static function fromQuery(array $query): self @@ -35,9 +37,23 @@ final class SettlementLedgerListFilters createdFrom: self::nonEmptyString($query['created_from'] ?? null), createdTo: self::nonEmptyString($query['created_to'] ?? null), badDebtOnly: filter_var($query['bad_debt_only'] ?? false, FILTER_VALIDATE_BOOLEAN), + betFlowOnly: filter_var($query['bet_flow_only'] ?? false, FILTER_VALIDATE_BOOLEAN), + betFlowDisplaySimple: self::betFlowDisplaySimple($query), ); } + /** + * @param array $query + */ + private static function betFlowDisplaySimple(array $query): bool + { + if (filter_var($query['bet_flow_only'] ?? false, FILTER_VALIDATE_BOOLEAN)) { + return true; + } + + return trim((string) ($query['bet_flow_display'] ?? '')) === 'simple'; + } + private static function positiveInt(mixed $value): ?int { $id = (int) $value; diff --git a/app/Services/AgentSettlement/SettlementPartyEnrichment.php b/app/Services/AgentSettlement/SettlementPartyEnrichment.php new file mode 100644 index 0000000..01421e5 --- /dev/null +++ b/app/Services/AgentSettlement/SettlementPartyEnrichment.php @@ -0,0 +1,153 @@ + $agents keyed by id + * @return array{ + * direct_agent_id: int|null, + * direct_agent_label: string|null, + * parent_agent_id: int|null, + * parent_agent_label: string|null, + * } + */ + public function agentLineLabels(?int $directAgentId, Collection $agents): array + { + if ($directAgentId === null || $directAgentId <= 0) { + return [ + 'direct_agent_id' => null, + 'direct_agent_label' => null, + 'parent_agent_id' => null, + 'parent_agent_label' => null, + ]; + } + + $direct = $agents->get($directAgentId); + $directLabel = $this->formatAgent($direct, $directAgentId); + $parentId = $direct !== null ? (int) ($direct->parent_id ?? 0) : 0; + $parent = $parentId > 0 ? $agents->get($parentId) : null; + + return [ + 'direct_agent_id' => $directAgentId, + 'direct_agent_label' => $directLabel, + 'parent_agent_id' => $parentId > 0 ? $parentId : null, + 'parent_agent_label' => $parentId > 0 ? $this->formatAgent($parent, $parentId) : null, + ]; + } + + /** + * @param list $agentIds + * @return Collection + */ + public function loadAgents(array $agentIds): Collection + { + $ids = array_values(array_unique(array_filter($agentIds, static fn (int $id): bool => $id > 0))); + if ($ids === []) { + return collect(); + } + + $rows = DB::table('agent_nodes')->whereIn('id', $ids)->get()->keyBy('id'); + $parentIds = $rows + ->pluck('parent_id') + ->map(static fn ($id): int => (int) $id) + ->filter(static fn (int $id): bool => $id > 0) + ->unique() + ->values() + ->all(); + + $missingParents = array_diff($parentIds, $ids); + if ($missingParents !== []) { + $parents = DB::table('agent_nodes')->whereIn('id', $missingParents)->get()->keyBy('id'); + foreach ($parents as $id => $row) { + $rows->put((int) $id, $row); + } + } + + return $rows; + } + + /** + * @param list $ticketItemIds + * @return array + */ + public function loadTicketRefs(array $ticketItemIds): array + { + $ids = array_values(array_unique(array_filter($ticketItemIds, static fn (int $id): bool => $id > 0))); + if ($ids === []) { + return []; + } + + $map = []; + foreach (DB::table('ticket_items as ti') + ->leftJoin('draws as d', 'd.id', '=', 'ti.draw_id') + ->whereIn('ti.id', $ids) + ->select(['ti.id', 'ti.play_code', 'ti.actual_deduct_amount', 'd.draw_no']) + ->get() as $row) { + $map[(int) $row->id] = [ + 'ticket_item_id' => (int) $row->id, + 'play_code' => $row->play_code !== null ? (string) $row->play_code : null, + 'draw_no' => $row->draw_no !== null ? (string) $row->draw_no : null, + 'actual_deduct_amount' => (int) ($row->actual_deduct_amount ?? 0), + ]; + } + + return $map; + } + + public function formatAgent(?object $agent, int $fallbackId): string + { + if ($agent === null) { + return "agent#{$fallbackId}"; + } + + $name = trim((string) ($agent->name ?? '')); + $code = trim((string) ($agent->code ?? '')); + + if ($name !== '' && $code !== '') { + return "{$name} ({$code})"; + } + + return $name !== '' ? $name : ($code !== '' ? $code : "agent#{$fallbackId}"); + } + + public function formatPlayerUsername(?object $player): ?string + { + if ($player === null) { + return null; + } + + $username = trim((string) ($player->username ?? '')); + + return $username !== '' ? $username : null; + } + + public function formatPlayerSiteId(?object $player): ?string + { + if ($player === null) { + return null; + } + + $sitePlayerId = trim((string) ($player->site_player_id ?? '')); + + return $sitePlayerId !== '' ? $sitePlayerId : null; + } + + public function formatCounterpartyLabel(string $type, int $id, Collection $agents): string + { + if ($type === 'platform' || $id <= 0) { + return 'platform'; + } + + if ($type === 'agent') { + return $this->formatAgent($agents->get($id), $id); + } + + return "{$type}#{$id}"; + } +} diff --git a/app/Services/AgentSettlement/SettlementPaymentService.php b/app/Services/AgentSettlement/SettlementPaymentService.php index b58ab05..8fb58a3 100644 --- a/app/Services/AgentSettlement/SettlementPaymentService.php +++ b/app/Services/AgentSettlement/SettlementPaymentService.php @@ -31,18 +31,21 @@ final class SettlementPaymentService } $this->billGuard->assertPeriodMutable($billId); + $this->billGuard->assertPayable($billId); - $amount = min($amount, (int) $bill->unpaid_amount); + $amount = min($amount, abs((int) $bill->unpaid_amount)); if ($amount <= 0) { return; } + [$payerType, $payerId, $payeeType, $payeeId] = $this->resolvePayerPayee($bill); + DB::table('payment_records')->insert([ 'settlement_bill_id' => $billId, - 'payer_type' => (string) $bill->owner_type, - 'payer_id' => (int) $bill->owner_id, - 'payee_type' => (string) $bill->counterparty_type, - 'payee_id' => (int) $bill->counterparty_id, + 'payer_type' => $payerType, + 'payer_id' => $payerId, + 'payee_type' => $payeeType, + 'payee_id' => $payeeId, 'amount' => $amount, 'method' => $meta['method'] ?? null, 'proof' => $meta['proof'] ?? null, @@ -69,7 +72,12 @@ final class SettlementPaymentService if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) { $player = Player::query()->find((int) $bill->owner_id); if ($player !== null) { - $this->playerCreditService->releaseFromSettlement($player, $amount, $billId); + if ((int) $bill->net_amount > 0) { + $this->playerCreditService->releaseFromSettlement($player, $amount, $billId); + } elseif ((int) $bill->net_amount < 0) { + $this->playerCreditService->applySettlementPayout($player, $amount, $billId); + } + if ($status === 'settled') { $this->periodCloseRebate->markRebatesSettledForBill($billId); } @@ -78,4 +86,28 @@ final class SettlementPaymentService $this->periodCompletion->syncIfReady((int) $bill->settlement_period_id); } + + /** + * net_amount > 0:owner 应付 counterparty;< 0:counterparty 应付 owner。 + * + * @return array{0: string, 1: int, 2: string, 3: int} + */ + private function resolvePayerPayee(object $bill): array + { + if ((int) $bill->net_amount < 0) { + return [ + (string) $bill->counterparty_type, + (int) $bill->counterparty_id, + (string) $bill->owner_type, + (int) $bill->owner_id, + ]; + } + + return [ + (string) $bill->owner_type, + (int) $bill->owner_id, + (string) $bill->counterparty_type, + (int) $bill->counterparty_id, + ]; + } } diff --git a/app/Services/AgentSettlement/ShareLedgerScopedProfitAggregator.php b/app/Services/AgentSettlement/ShareLedgerScopedProfitAggregator.php new file mode 100644 index 0000000..20e6cbe --- /dev/null +++ b/app/Services/AgentSettlement/ShareLedgerScopedProfitAggregator.php @@ -0,0 +1,123 @@ + 'platform', 'scope' => 'platform']; + } + + $node = AdminAgentScope::primaryAgentNode($admin); + if ($node === null) { + return ['key' => 'platform', 'scope' => 'platform']; + } + + return ['key' => (string) $node->code, 'scope' => 'agent']; + } + + public function sumForShareQuery(Builder $shareQuery, string $profitKey): int + { + $rows = (clone $shareQuery) + ->select([ + 'sl.allocations_json', + 'sl.game_win_loss', + 'sl.basic_rebate', + 'sl.share_snapshot', + ]) + ->get(); + + $total = 0; + foreach ($rows as $row) { + $total += $this->profitFromRow($row, $profitKey); + } + + return $total; + } + + public function sumRawGameWinLoss(Builder $shareQuery): int + { + return (int) ((clone $shareQuery)->sum('sl.game_win_loss') ?? 0); + } + + private function profitFromRow(object $row, string $profitKey): int + { + $allocations = $this->decodeJsonObject($row->allocations_json ?? null); + if (array_key_exists($profitKey, $allocations)) { + return (int) round((float) $allocations[$profitKey]); + } + + $snapshot = $this->resolveSnapshot($row->share_snapshot ?? null); + if ($snapshot === null) { + return 0; + } + + $result = $this->calculator->calculate( + sharedNetWinLoss: 0, + totalSharesByCode: $snapshot['total_shares'], + gameWinLoss: (int) $row->game_win_loss, + basicRebate: (int) $row->basic_rebate, + chainFromPlayer: $snapshot['chain_codes'], + ); + + return (int) round($result->finalProfits[$profitKey] ?? 0); + } + + /** + * @return array + */ + private function decodeJsonObject(mixed $raw): array + { + if ($raw === null || $raw === '') { + return []; + } + + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (! is_array($decoded)) { + return []; + } + + return $decoded; + } + + /** + * @return array{total_shares: array, chain_codes: list}|null + */ + private function resolveSnapshot(mixed $raw): ?array + { + $decoded = $this->decodeJsonObject($raw); + if ($decoded === []) { + return null; + } + + $totalShares = $decoded['total_shares'] ?? null; + $chainCodes = $decoded['chain_codes'] ?? null; + if (! is_array($totalShares) || ! is_array($chainCodes) || $chainCodes === []) { + return null; + } + + $shares = []; + foreach ($totalShares as $code => $rate) { + $shares[(string) $code] = (float) $rate; + } + + return [ + 'total_shares' => $shares, + 'chain_codes' => array_values(array_map(strval(...), $chainCodes)), + ]; + } +} diff --git a/app/Services/AgentSettlement/UnsettledTicketPeriodWarning.php b/app/Services/AgentSettlement/UnsettledTicketPeriodWarning.php index fe999d9..c0eac3f 100644 --- a/app/Services/AgentSettlement/UnsettledTicketPeriodWarning.php +++ b/app/Services/AgentSettlement/UnsettledTicketPeriodWarning.php @@ -2,22 +2,35 @@ namespace App\Services\AgentSettlement; +use App\Support\AgentSettlementPeriodWindow; use Illuminate\Support\Facades\DB; final class UnsettledTicketPeriodWarning { /** + * 未结算注单:优先按游戏结算落账时间(settled_at)归属账期;未开奖仍按下注时间(created_at)。 + * * @return array{count: int, ticket_item_ids: list} */ public function countForSite(int $adminSiteId, string $periodStart, string $periodEnd): array { $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); + [$start, $end] = AgentSettlementPeriodWindow::boundStrings($periodStart, $periodEnd); $rows = DB::table('ticket_items as ti') ->join('players as p', 'p.id', '=', 'ti.player_id') ->where('p.site_code', $siteCode) ->whereIn('ti.status', ['pending_draw', 'pending_confirm', 'pending_payout']) - ->whereBetween('ti.created_at', [$periodStart, $periodEnd]) + ->whereNull('ti.agent_settled_at') + ->where(function ($query) use ($start, $end): void { + $query->where(function ($settled) use ($start, $end): void { + $settled->whereNotNull('ti.settled_at') + ->whereBetween('ti.settled_at', [$start, $end]); + })->orWhere(function ($pending) use ($start, $end): void { + $pending->whereNull('ti.settled_at') + ->whereBetween('ti.created_at', [$start, $end]); + }); + }) ->pluck('ti.id') ->map(fn ($id): int => (int) $id) ->all(); diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index b76f136..71e0249 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -133,6 +133,30 @@ final class PlayerCreditService ]); } + public function applySettledWin(Player $player, int $amountMinor, int $ticketItemId): void + { + if ($amountMinor <= 0) { + return; + } + + if (! PlayerFundingMode::usesCredit($player)) { + return; + } + + $this->decreaseUsedCredit($player, $amountMinor); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amountMinor, + 'reason' => 'game_settlement_win', + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketItemId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + public function assertMayPlaceBet(Player $player, int $amountMinor): void { if (! PlayerFundingMode::usesCredit($player)) { @@ -155,6 +179,7 @@ final class PlayerCreditService $agentNodeId = (int) ($player->agent_node_id ?? 0); if ($agentNodeId > 0) { AgentOverdueGuard::assertAgentMayGrantCredit($agentNodeId); + AgentOverdueGuard::assertAgentLineMayPlaceBet($agentNodeId); } $this->holdForBet($player, $amountMinor); @@ -200,6 +225,28 @@ final class PlayerCreditService ]); } + public function applySettlementPayout(Player $player, int $amountMinor, int $billId): void + { + if ($amountMinor <= 0) { + return; + } + + if (! PlayerFundingMode::usesCredit($player)) { + return; + } + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amountMinor, + 'reason' => 'settlement_payout', + 'ref_type' => 'settlement_bill', + 'ref_id' => $billId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + private function decreaseUsedCredit(Player $player, int $amountMinor): void { if ($amountMinor <= 0) { diff --git a/app/Services/Player/PlayerNativeAuthService.php b/app/Services/Player/PlayerNativeAuthService.php index cf4834b..76b8f65 100644 --- a/app/Services/Player/PlayerNativeAuthService.php +++ b/app/Services/Player/PlayerNativeAuthService.php @@ -18,13 +18,17 @@ final class PlayerNativeAuthService { $username = trim($username); $siteCode = trim($siteCode); - if ($siteCode === '' || $username === '' || $password === '') { + if ($username === '' || $password === '') { throw new PlayerAuthenticationException( '账号或密码错误', ErrorCode::PlayerCredentialsInvalid->value, ); } + if ($siteCode === '') { + $siteCode = trim((string) config('lottery.integration.default_site_code', '')); + } + $player = Player::query() ->where('site_code', $siteCode) ->where('username', $username) diff --git a/app/Services/Wallet/PlayerLedgerLogsService.php b/app/Services/Wallet/PlayerLedgerLogsService.php index a69c38f..96f1a28 100644 --- a/app/Services/Wallet/PlayerLedgerLogsService.php +++ b/app/Services/Wallet/PlayerLedgerLogsService.php @@ -10,6 +10,8 @@ use Illuminate\Support\Str; use App\Support\AdminDataScope; use App\Support\CurrencyFormatter; use App\Support\PlayerFundingMode; +use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter; +use App\Services\AgentSettlement\SettlementPartyEnrichment; use App\Services\Player\PlayerCreditService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; @@ -34,13 +36,15 @@ final class PlayerLedgerLogsService 'bet' => ['bet_hold', 'game_settlement_loss'], 'reversal' => ['bet_hold_release'], 'refund' => ['settlement_confirm'], - 'prize' => [], + 'prize' => ['game_settlement_win', 'settlement_payout'], 'transfer_in' => [], 'transfer_out' => [], ]; public function __construct( private readonly PlayerCreditService $playerCreditService, + private readonly CreditLedgerBetFlowPresenter $betFlowPresenter, + private readonly SettlementPartyEnrichment $partyEnrichment, ) {} /** @@ -93,31 +97,102 @@ final class PlayerLedgerLogsService return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage]; } - $reasonFilter = $bizType !== null && $bizType !== '' - ? [trim($bizType)] - : null; - - $paginator = $this->creditLedgerQuery($player->id, $reasonFilter) - ->paginate($perPage, ['*'], 'page', $page); - $currency = (string) $player->default_currency; - $runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency); - $items = $paginator->getCollection() - ->map(function (object $row) use (&$runningMinor, $player, $currency): array { - $amount = (int) $row->amount; - $formatted = $this->formatAdminCreditRow($row, $player, $currency, $runningMinor); - $runningMinor -= $amount; + $rawRows = $this->creditLedgerQuery($player->id, [ + 'bet_hold', + 'bet_hold_release', + 'game_settlement_loss', + 'game_settlement_win', + 'settlement_payout', + ])->limit(5000)->get()->all(); + + $enriched = array_map(function (object $row) use ($player): object { + return (object) [ + 'id' => (int) $row->id, + 'amount' => (int) $row->amount, + 'reason' => (string) $row->reason, + 'ref_type' => $row->ref_type ?? null, + 'ref_id' => $row->ref_id ?? null, + 'created_at' => $row->created_at ?? null, + 'updated_at' => $row->updated_at ?? null, + 'player_id' => (int) $player->id, + 'site_code' => $player->site_code, + 'site_player_id' => $player->site_player_id, + 'username' => $player->username, + 'nickname' => $player->nickname, + 'agent_node_id' => $player->agent_node_id, + 'funding_mode' => $player->funding_mode, + 'auth_source' => $player->auth_source, + 'default_currency' => $player->default_currency, + 'direct_agent_id' => null, + 'direct_agent_code' => null, + 'direct_agent_name' => null, + 'parent_agent_id' => null, + 'parent_agent_code' => null, + 'parent_agent_name' => null, + ]; + }, $rawRows); + + $ticketIds = []; + foreach ($enriched as $row) { + if ((string) ($row->ref_type ?? '') === 'ticket_item') { + $ticketId = (int) ($row->ref_id ?? 0); + if ($ticketId > 0) { + $ticketIds[] = $ticketId; + } + } + } + + $ticketRefs = $this->partyEnrichment->loadTicketRefs(array_values(array_unique($ticketIds))); + + $simplified = $this->betFlowPresenter->simplifyCreditRows( + $enriched, + $ticketRefs, + fn (object $row): array => $this->formatAdminCreditRow($row, $player, $currency, 0), + function (object $row) use ($player, $currency): array { + $formatted = $this->formatAdminCreditRow($row, $player, $currency, 0); + $ticketId = (int) ($row->ref_id ?? 0); + if ($ticketId > 0) { + $formatted['txn_no'] = 'CLS-T'.$ticketId; + } return $formatted; - }) - ->values() - ->all(); + }, + ); + + if ($bizType !== null && $bizType !== '') { + $filterType = trim($bizType); + $simplified = array_values(array_filter( + $simplified, + static fn (array $item): bool => ($item['biz_type'] ?? '') === $filterType + || ($filterType === 'bet_hold' && ($item['biz_type'] ?? '') === CreditLedgerBetFlowPresenter::DISPLAY_BET_HOLD) + || ($filterType === 'game_settlement' && ($item['biz_type'] ?? '') === CreditLedgerBetFlowPresenter::DISPLAY_GAME_SETTLEMENT), + )); + } + + $total = count($simplified); + $offset = max(0, ($page - 1) * $perPage); + $pageRows = array_slice($simplified, $offset, $perPage); + + $runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency); + $items = []; + foreach ($pageRows as $formatted) { + $signed = (int) ($formatted['direction'] === 1 ? $formatted['amount'] : -$formatted['amount']); + $items[] = array_merge($formatted, [ + 'balance_after' => $runningMinor, + 'balance_after_formatted' => CurrencyFormatter::fromMinor($runningMinor), + 'balance_before' => $signed >= 0 + ? max(0, $runningMinor - $signed) + : $runningMinor + abs($signed), + ]); + $runningMinor -= $signed; + } return [ 'items' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'per_page' => $paginator->perPage(), + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, ]; } @@ -535,6 +610,7 @@ final class PlayerLedgerLogsService 'bet_hold', 'game_settlement_loss' => 'bet', 'bet_hold_release' => 'reversal', 'settlement_confirm' => 'refund', + 'game_settlement_win', 'settlement_payout' => 'prize', default => $reason, }; } diff --git a/app/Support/AdminAgentSettlementScope.php b/app/Support/AdminAgentSettlementScope.php index 82a0601..c8465eb 100644 --- a/app/Support/AdminAgentSettlementScope.php +++ b/app/Support/AdminAgentSettlementScope.php @@ -3,11 +3,35 @@ namespace App\Support; use App\Models\AdminUser; +use App\Models\AgentNode; use Illuminate\Database\Query\Builder; -/** 代理账单按管理员可访问站点过滤。 */ +/** 代理账单按管理员可访问站点 + 代理子树过滤。 */ final class AdminAgentSettlementScope { + /** + * @return list|null null = 不限制子树(超管或未绑定代理) + */ + public static function subtreeAgentNodeIds(AdminUser $admin): ?array + { + if ($admin->isSuperAdmin()) { + return null; + } + + $actor = AdminAgentScope::primaryAgentNode($admin); + if ($actor === null) { + return null; + } + + $ids = AgentNode::query() + ->where('path', 'like', $actor->path.'%') + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + return $ids; + } + public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void { $siteIds = $admin->accessibleAdminSiteIds(); @@ -28,6 +52,8 @@ final class AdminAgentSettlementScope { $siteIds = $admin->accessibleAdminSiteIds(); if ($siteIds === null) { + self::applySubtreeToBillsQuery($query, $admin, $billsAlias); + return; } @@ -43,6 +69,38 @@ final class AdminAgentSettlementScope ->whereColumn('settlement_periods.id', $billsAlias.'.settlement_period_id') ->whereIn('settlement_periods.admin_site_id', $siteIds); }); + + self::applySubtreeToBillsQuery($query, $admin, $billsAlias); + } + + /** 绑定代理仅见本子树玩家账单 + owner 为本子树节点的代理账单。 */ + public static function applySubtreeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void + { + $subtreeIds = self::subtreeAgentNodeIds($admin); + if ($subtreeIds === null) { + return; + } + + if ($subtreeIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->where(function (Builder $outer) use ($billsAlias, $subtreeIds): void { + $outer->where(function (Builder $player) use ($billsAlias, $subtreeIds): void { + $player->where($billsAlias.'.owner_type', 'player') + ->whereExists(function (Builder $exists) use ($billsAlias, $subtreeIds): void { + $exists->selectRaw('1') + ->from('players') + ->whereColumn('players.id', $billsAlias.'.owner_id') + ->whereIn('players.agent_node_id', $subtreeIds); + }); + })->orWhere(function (Builder $agent) use ($billsAlias, $subtreeIds): void { + $agent->where($billsAlias.'.owner_type', 'agent') + ->whereIn($billsAlias.'.owner_id', $subtreeIds); + }); + }); } public static function periodAccessible(AdminUser $admin, int $settlementPeriodId): bool @@ -72,21 +130,61 @@ final class AdminAgentSettlementScope return in_array($adminSiteId, $siteIds, true); } - public static function billAccessible(AdminUser $admin, int $settlementBillId): bool + /** 绑定代理账号不可开/关全站账期(仅站点财务或超管)。 */ + public static function canManageSitePeriods(AdminUser $admin): bool { - $siteIds = $admin->accessibleAdminSiteIds(); - if ($siteIds === null) { + if ($admin->isSuperAdmin()) { return true; } - if ($siteIds === []) { + return AdminAgentScope::primaryAgentNode($admin) === null; + } + + public static function assertCanManageSitePeriods(AdminUser $admin): void + { + if (! self::canManageSitePeriods($admin)) { + abort(403, 'agent_bound_cannot_manage_periods'); + } + } + + public static function billAccessible(AdminUser $admin, int $settlementBillId): bool + { + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds !== null && $siteIds === []) { return false; } - return \Illuminate\Support\Facades\DB::table('settlement_bills') - ->join('settlement_periods', 'settlement_periods.id', '=', 'settlement_bills.settlement_period_id') - ->where('settlement_bills.id', $settlementBillId) - ->whereIn('settlement_periods.admin_site_id', $siteIds) - ->exists(); + $bill = \Illuminate\Support\Facades\DB::table('settlement_bills as sb') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->where('sb.id', $settlementBillId) + ->select(['sb.owner_type', 'sb.owner_id', 'sp.admin_site_id']) + ->first(); + + if ($bill === null) { + return false; + } + + if ($siteIds !== null && ! in_array((int) $bill->admin_site_id, $siteIds, true)) { + return false; + } + + $subtreeIds = self::subtreeAgentNodeIds($admin); + if ($subtreeIds === null) { + return true; + } + + if ((string) $bill->owner_type === 'player') { + $agentNodeId = (int) (\Illuminate\Support\Facades\DB::table('players') + ->where('id', (int) $bill->owner_id) + ->value('agent_node_id') ?? 0); + + return $agentNodeId > 0 && in_array($agentNodeId, $subtreeIds, true); + } + + if ((string) $bill->owner_type === 'agent') { + return in_array((int) $bill->owner_id, $subtreeIds, true); + } + + return false; } } diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php index 0b9ba7f..97e3043 100644 --- a/app/Support/AdminAuthProfile.php +++ b/app/Support/AdminAuthProfile.php @@ -22,6 +22,7 @@ final class AdminAuthProfile * href: string, * nav_group: string, * platform_only?: bool, + * agent_hidden?: bool, * activeMatchPrefix?: string, * requiredAny?: list * }>, diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 38cd68f..2976969 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -129,6 +129,7 @@ final class AdminAuthorizationRegistry * 后台菜单注册表。前端侧栏与面包屑都消费这里派生的结果。 * * platform_only:仅超管可见(全局 RBAC、接入站点、赔率规则等);代理账号走代理控制台与子级授权。 + * agent_hidden:代理账号不可见(如主站钱包流水、对账等仅平台管理员可见的菜单)。 * * @return list * }> @@ -150,8 +152,8 @@ final class AdminAuthorizationRegistry ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'nav_group' => 'operations', 'requiredAny' => ['prd.tickets.view']], ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'nav_group' => 'operations', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], ['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'nav_group' => 'operations', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], - ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'nav_group' => 'finance', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']], - ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'nav_group' => 'finance', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], + ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'nav_group' => 'finance', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance'], 'agent_hidden' => true], + ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'nav_group' => 'finance', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], 'agent_hidden' => true], ['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'nav_group' => 'finance', 'requiredAny' => ['prd.report.view']], ['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], ['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']], @@ -273,6 +275,9 @@ final class AdminAuthorizationRegistry * segment: string, * label: string, * href: string, + * nav_group: string, + * platform_only?: bool, + * agent_hidden?: bool, * activeMatchPrefix?: string, * requiredAny?: list * }> @@ -290,6 +295,7 @@ final class AdminAuthorizationRegistry * href: string, * nav_group: string, * platform_only?: bool, + * agent_hidden?: bool, * activeMatchPrefix?: string, * requiredAny?: list * }> @@ -298,14 +304,19 @@ final class AdminAuthorizationRegistry { $granted = array_fill_keys($permissionSlugs, true); $isSuperAdmin = $admin === null || $admin->isSuperAdmin(); + $isAgent = $admin !== null && $admin->isAgentAccount(); return array_values(array_filter( self::navigationItems(), - static function (array $item) use ($granted, $isSuperAdmin): bool { + static function (array $item) use ($granted, $isSuperAdmin, $isAgent): bool { if (($item['platform_only'] ?? false) && ! $isSuperAdmin) { return false; } + if (($item['agent_hidden'] ?? false) && $isAgent) { + return false; + } + $required = $item['requiredAny'] ?? []; if ($required === []) { return true; diff --git a/app/Support/AdminDataScope.php b/app/Support/AdminDataScope.php index 82a9547..18eec87 100644 --- a/app/Support/AdminDataScope.php +++ b/app/Support/AdminDataScope.php @@ -16,10 +16,14 @@ final class AdminDataScope */ public static function applyToPlayersAlias( Builder $query, - AdminUser $admin, + ?AdminUser $admin, string $alias = 'p', ?int $requestedAgentNodeId = null, ): void { + if ($admin === null) { + return; + } + if ($admin->isSuperAdmin()) { if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) { self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId); diff --git a/app/Support/AgentOverdueGuard.php b/app/Support/AgentOverdueGuard.php index f659460..c73143b 100644 --- a/app/Support/AgentOverdueGuard.php +++ b/app/Support/AgentOverdueGuard.php @@ -20,6 +20,57 @@ final class AgentOverdueGuard ->exists(); } + public static function agentHasSevereOverdueBills(int $agentNodeId, int $days = 7): bool + { + if ($agentNodeId <= 0) { + return false; + } + + $cutoff = now()->subDays($days); + + return DB::table('settlement_bills') + ->where('owner_type', 'agent') + ->where('owner_id', $agentNodeId) + ->where('status', 'overdue') + ->where('unpaid_amount', '>', 0) + ->where('updated_at', '<', $cutoff) + ->exists(); + } + + public static function agentLineHasSevereOverdueBills(int $agentNodeId, int $days = 7): bool + { + if ($agentNodeId <= 0) { + return false; + } + + $agent = DB::table('agent_nodes')->where('id', $agentNodeId)->first(); + if ($agent === null) { + return false; + } + + // 获取该代理的所有祖先节点ID(包括自己) + $path = (string) $agent->path; + if ($path === '') { + // 根节点,只检查自己 + $ancestorIds = [$agentNodeId]; + } else { + // 解析 path 获取所有祖先ID + $parts = explode('/', trim($path, '/')); + $ancestorIds = array_map('intval', $parts); + $ancestorIds[] = $agentNodeId; + } + + $cutoff = now()->subDays($days); + + return DB::table('settlement_bills') + ->where('owner_type', 'agent') + ->where('status', 'overdue') + ->where('unpaid_amount', '>', 0) + ->where('updated_at', '<', $cutoff) + ->whereIn('owner_id', $ancestorIds) + ->exists(); + } + public static function assertAgentMayGrantCredit(int $agentNodeId): void { if (self::agentHasOverdueBills($agentNodeId)) { @@ -28,4 +79,46 @@ final class AgentOverdueGuard ]); } } + + public static function assertAgentLineMayPlaceBet(int $agentNodeId, ?int $severeOverdueDays = null): void + { + $days = $severeOverdueDays ?? config('agent_line_defaults.overdue.severe_days_threshold', 7); + if (self::agentLineHasSevereOverdueBills($agentNodeId, $days)) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'credit' => ['agent_line_severe_overdue'], + ]); + } + } + + public static function parentHasOverdueBills(int $agentNodeId): bool + { + if ($agentNodeId <= 0) { + return false; + } + + $agent = DB::table('agent_nodes')->where('id', $agentNodeId)->first(); + if ($agent === null || $agent->parent_id === null) { + return false; + } + + return DB::table('settlement_bills') + ->where('owner_type', 'agent') + ->where('owner_id', $agent->parent_id) + ->where('status', 'overdue') + ->where('unpaid_amount', '>', 0) + ->exists(); + } + + public static function assertMayOperateWhenParentOverdue(int $agentNodeId): void + { + if (! config('agent_line_defaults.overdue.cascade_freeze_on_parent_overdue', false)) { + return; + } + + if (self::parentHasOverdueBills($agentNodeId)) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'parent_id' => ['parent_overdue'], + ]); + } + } } diff --git a/app/Support/AgentSettlementPeriodWindow.php b/app/Support/AgentSettlementPeriodWindow.php new file mode 100644 index 0000000..ef2afd5 --- /dev/null +++ b/app/Support/AgentSettlementPeriodWindow.php @@ -0,0 +1,30 @@ +startOfDay(), + Carbon::parse($periodEnd)->endOfDay(), + ]; + } + + /** + * @return array{0: string, 1: string} + */ + public static function boundStrings(string $periodStart, string $periodEnd): array + { + [$start, $end] = self::bounds($periodStart, $periodEnd); + + return [$start->toDateTimeString(), $end->toDateTimeString()]; + } +} diff --git a/app/Support/AgentSettlementProductionGuard.php b/app/Support/AgentSettlementProductionGuard.php index c819803..3bb6ea4 100644 --- a/app/Support/AgentSettlementProductionGuard.php +++ b/app/Support/AgentSettlementProductionGuard.php @@ -10,8 +10,12 @@ final class AgentSettlementProductionGuard return; } - if (config('agent_settlement.allow_demo_close', false)) { + if (config('agent_settlement.allow_production_close', true)) { return; } + + throw new \RuntimeException( + 'Agent settlement period close is disabled. Set AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE=true to enable.', + ); } } diff --git a/app/Support/SettlementBatchFinancialSummary.php b/app/Support/SettlementBatchFinancialSummary.php index 5cc45bb..a9c9c5d 100644 --- a/app/Support/SettlementBatchFinancialSummary.php +++ b/app/Support/SettlementBatchFinancialSummary.php @@ -7,7 +7,17 @@ use App\Models\SettlementBatch; final class SettlementBatchFinancialSummary { /** - * @return array{total_bet_amount: int, total_actual_deduct: int, platform_profit: int, currency_code: ?string} + * 所有金额字段均为最小货币单位(minor)。 + * + * @return array{ + * total_bet_amount: int, + * total_actual_deduct: int, + * platform_profit: int, + * total_bet_amount_minor: int, + * total_actual_deduct_minor: int, + * platform_profit_minor: int, + * currency_code: ?string + * } */ public static function forBatch(SettlementBatch $batch): array { @@ -27,6 +37,10 @@ final class SettlementBatchFinancialSummary 'total_bet_amount' => $totalBet, 'total_actual_deduct' => $totalActualDeduct, 'platform_profit' => $totalActualDeduct - $totalPayout, + // 显式别名:避免调用方误解为主货币单位。 + 'total_bet_amount_minor' => $totalBet, + 'total_actual_deduct_minor' => $totalActualDeduct, + 'platform_profit_minor' => $totalActualDeduct - $totalPayout, 'currency_code' => is_string($totals?->currency_code) && $totals->currency_code !== '' ? $totals->currency_code : null, diff --git a/config/agent_line_defaults.php b/config/agent_line_defaults.php index d252141..d2221a0 100644 --- a/config/agent_line_defaults.php +++ b/config/agent_line_defaults.php @@ -6,4 +6,12 @@ return [ 'rebate_limit' => (float) env('AGENT_LINE_DEFAULT_REBATE_LIMIT', 0.005), 'default_player_rebate' => (float) env('AGENT_LINE_DEFAULT_PLAYER_REBATE', 0.005), 'settlement_cycle' => env('AGENT_LINE_DEFAULT_SETTLEMENT_CYCLE', 'weekly'), + + // 逾期冻结配置 (§21.9) + 'overdue' => [ + // 严重逾期天数阈值(超过此天数冻结整条代理线下注) + 'severe_days_threshold' => (int) env('AGENT_OVERDUE_SEVERE_DAYS', 7), + // 上级逾期是否连带冻结下级(默认否,仅展示提示) + 'cascade_freeze_on_parent_overdue' => (bool) env('AGENT_OVERDUE_CASCADE_FREEZE', false), + ], ]; diff --git a/config/agent_settlement.php b/config/agent_settlement.php index ed51be0..f0210a4 100644 --- a/config/agent_settlement.php +++ b/config/agent_settlement.php @@ -1,5 +1,9 @@ (bool) env('AGENT_SETTLEMENT_ALLOW_DEMO_CLOSE', false), + + /** 非 testing 环境是否允许账期关账;默认 true,预发可设为 false 门禁 */ + 'allow_production_close' => (bool) env('AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE', true), ]; diff --git a/lang/en/validation_business.php b/lang/en/validation_business.php index c0c5542..7a87f02 100644 --- a/lang/en/validation_business.php +++ b/lang/en/validation_business.php @@ -22,4 +22,12 @@ return [ 'cannot_create_child_agent' => 'You are not allowed to create sub-agents.', 'cannot_create_player' => 'You are not allowed to create players.', 'primary_account_missing' => 'This agent has no bound login account; username cannot be updated.', + 'agent_overdue' => 'This agent has overdue bills. This operation is not allowed.', + 'agent_line_severe_overdue' => 'This agent line has severe overdue (over 7 days). Betting is frozen.', + '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_site_has_open' => 'This site already has an open period. Close it before opening a new one.', + 'period_not_found' => 'Settlement period not found or not accessible.', + 'period_already_closed' => 'This period is already closed.', + 'share_snapshot_missing' => 'Some ledger rows are missing share snapshots. Complete draw settlement first.', ]; diff --git a/lang/zh/validation_business.php b/lang/zh/validation_business.php index 748d320..d16905f 100644 --- a/lang/zh/validation_business.php +++ b/lang/zh/validation_business.php @@ -26,4 +26,12 @@ return [ 'cannot_create_player' => '当前账号无权创建玩家。', 'primary_account_missing' => '该代理尚未绑定登录账号,无法修改登录名。', 'site_root_exists' => '该接入站点已绑定一级代理,请选择其他站点。', + 'agent_overdue' => '该代理存在逾期未结账单,禁止此操作。', + 'agent_line_severe_overdue' => '该代理线路存在严重逾期(超过7天),已冻结下注。', + 'parent_overdue' => '上级代理存在逾期未结账单,禁止此操作。', + 'period_already_open' => '该时间范围的账期已在进行中,请直接关账,勿重复开期。', + 'period_site_has_open' => '本站已有进行中账期,请先关账后再开新账期。', + 'period_not_found' => '账期不存在或无权访问。', + 'period_already_closed' => '该账期已关账,请勿重复操作。', + 'share_snapshot_missing' => '账期内存在缺少占成快照的流水,无法关账。请先完成开奖结算或联系技术支持。', ]; diff --git a/tests/Feature/AdminAgentDelegationApiTest.php b/tests/Feature/AdminAgentDelegationApiTest.php index eb3431b..2ccb0a7 100644 --- a/tests/Feature/AdminAgentDelegationApiTest.php +++ b/tests/Feature/AdminAgentDelegationApiTest.php @@ -84,16 +84,16 @@ test('parent agent can sync delegation grants for direct child', function (): vo ]); grantSuperAdminRole($super); - $parent = $service->createChild($super, [ + $parent = $service->createChild($super, agentChildPayload([ 'parent_id' => $rootId, 'code' => 'deleg-parent', 'name' => 'Deleg Parent', - ]); - $child = $service->createChild($super, [ + ])); + $child = $service->createChild($super, agentChildPayload([ 'parent_id' => $parent->id, 'code' => 'deleg-child', 'name' => 'Deleg Child', - ]); + ])); $parentAdmin = AdminUser::query()->create([ 'username' => 'deleg_parent_admin', @@ -139,11 +139,11 @@ test('delegation ceiling blocks role permissions beyond child grants', function ]); grantSuperAdminRole($super); - $branch = $service->createChild($super, [ + $branch = $service->createChild($super, agentChildPayload([ 'parent_id' => $rootId, 'code' => 'ceil-branch', 'name' => 'Ceil Branch', - ]); + ])); $viewActionId = (int) DB::table('admin_menu_actions') ->where('permission_code', 'agent.node.view') diff --git a/tests/Feature/AdminCreditLedgerFilterTest.php b/tests/Feature/AdminCreditLedgerFilterTest.php new file mode 100644 index 0000000..06aace6 --- /dev/null +++ b/tests/Feature/AdminCreditLedgerFilterTest.php @@ -0,0 +1,269 @@ +where('is_default', true)->first(); + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subDay(), + 'period_end' => now()->addDay(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:ledger-filter', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'ledger_filter_user', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -100, + 'reason' => 'bet_hold', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $billId = (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' => 1, + 'net_amount' => 100, + 'unpaid_amount' => 0, + 'paid_amount' => 100, + 'status' => 'settled', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $paymentId = (int) DB::table('payment_records')->insertGetId([ + 'settlement_bill_id' => $billId, + 'payer_type' => 'player', + 'payer_id' => $player->id, + 'payee_type' => 'agent', + 'payee_id' => 1, + 'amount' => 100, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'ledger_filter_super', + 'name' => 'Ledger Filter', + '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/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&reason=payment_record') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.entry_kind', 'payment') + ->assertJsonPath('data.items.0.id', $paymentId); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&txn_no=CL') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.entry_kind', 'credit'); +}); + +test('credit ledger index includes payment on agent bill', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $siteId = (int) $site->id; + $agentId = (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()->subDay(), + 'period_end' => now()->addDay(), + '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' => $agentId, + 'counterparty_type' => 'platform', + 'counterparty_id' => 0, + 'net_amount' => 3000, + 'unpaid_amount' => 0, + 'paid_amount' => 3000, + 'status' => 'settled', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $paymentId = (int) DB::table('payment_records')->insertGetId([ + 'settlement_bill_id' => $billId, + 'payer_type' => 'agent', + 'payer_id' => $agentId, + 'payee_type' => 'platform', + 'payee_id' => 0, + 'amount' => 3000, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'ledger_agent_bill_super', + 'name' => 'Agent Bill Ledger', + '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/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&reason=payment_record') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.entry_kind', 'payment') + ->assertJsonPath('data.items.0.id', $paymentId) + ->assertJsonPath('data.items.0.bill_type', 'agent'); +}); + +test('credit ledger entry_kind share returns share ledger rows', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + $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()->subDay(), + 'period_end' => now()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:share-ledger', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'share_ledger_user', + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $rootId, + ]); + + $settledAt = now()->toDateTimeString(); + $shareId = (int) DB::table('share_ledger')->insertGetId([ + 'ticket_item_id' => createShareLedgerTicketItem($player), + 'player_id' => $player->id, + 'agent_node_id' => $rootId, + 'agent_path' => json_encode([$rootId]), + 'share_snapshot' => json_encode([ + 'total_shares' => ['ROOT' => 1.0], + 'chain_codes' => ['ROOT'], + ]), + 'game_win_loss' => 1200, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 1200, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'share_ledger_super', + 'name' => 'Share Ledger', + '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/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&entry_kind=share') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.entry_kind', 'share') + ->assertJsonPath('data.items.0.id', $shareId) + ->assertJsonPath('data.items.0.biz_type', 'share_ledger') + ->assertJsonPath('data.items.0.signed_amount', 1200); +}); + +function createShareLedgerTicketItem(Player $player): int +{ + $draw = \App\Models\Draw::query()->create([ + 'draw_no' => 'DRAW-SHARE-LEDGER', + '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-SHARE-LEDGER', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 1000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 1000, + 'total_estimated_payout' => 0, + 'status' => 'confirmed', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => 'T-SHARE-LEDGER', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 2, + 'unit_bet_amount' => 1000, + 'total_bet_amount' => 1000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 1000, + '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(), + ]); +} diff --git a/tests/Feature/AdminCreditLedgerIndexTest.php b/tests/Feature/AdminCreditLedgerIndexTest.php index e261c3d..6710136 100644 --- a/tests/Feature/AdminCreditLedgerIndexTest.php +++ b/tests/Feature/AdminCreditLedgerIndexTest.php @@ -68,6 +68,84 @@ test('admin credit ledger index returns credit player ledger rows', function (): ->assertJsonPath('data.items.0.available_actions', ['view_player']); }); +test('admin credit ledger simple display merges release and loss per ticket', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subDay(), + 'period_end' => now()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:simple-flow', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'simple_flow', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $now = now(); + DB::table('credit_ledger')->insert([ + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -1200, + 'reason' => 'bet_hold', + 'ref_type' => 'bet', + 'ref_id' => null, + 'created_at' => $now->copy()->subMinutes(2), + 'updated_at' => $now->copy()->subMinutes(2), + ], + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => 1200, + 'reason' => 'bet_hold_release', + 'ref_type' => 'ticket_item', + 'ref_id' => 88, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -1200, + 'reason' => 'game_settlement_loss', + 'ref_type' => 'ticket_item', + 'ref_id' => 88, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'simple_flow_super', + 'name' => 'Simple Flow', + '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/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&bet_flow_display=simple') + ->assertOk() + ->assertJsonPath('data.ledger_source', 'credit_ledger') + ->assertJsonPath('data.total', 1) + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.biz_type', 'game_settlement') + ->assertJsonPath('data.items.0.signed_amount', -1200); +}); + test('settlement periods include pipeline credit and share counts', function (): void { $site = DB::table('admin_sites')->where('is_default', true)->first(); $siteId = (int) $site->id; diff --git a/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php b/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php new file mode 100644 index 0000000..df271b8 --- /dev/null +++ b/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php @@ -0,0 +1,172 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('period close aggregates share ledger written by game settlement recorder', 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'); + + $super = \App\Models\AdminUser::query()->create([ + 'username' => 'pipe_super', + 'name' => 'Super', + 'email' => null, + 'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $leaf = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'PIPE-C', + 'name' => 'Pipe C', + 'username' => 'pipe_c', + 'total_share_rate' => 25, + 'credit_limit' => 100_000, + 'default_player_rebate' => 0.005, + ])); + + AgentProfile::query()->where('agent_node_id', $rootId)->update([ + 'total_share_rate' => 100, + 'default_player_rebate' => 0.005, + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $leaf->id, + 'site_player_id' => 'pipe-p1', + 'username' => 'pipeplayer', + 'nickname' => null, + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 50_000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('player_rebate_profiles')->insert([ + 'player_id' => $player->id, + 'game_type' => '*', + 'rebate_rate' => 0.005, + 'extra_rebate_rate' => 0, + 'inherit_from_agent' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $betMinor = 10_000; + $draw = Draw::query()->create([ + 'draw_no' => 'PIPE-DRAW-1', + 'business_date' => now()->toDateString(), + 'sequence_no' => 88, + 'status' => DrawStatus::Open->value, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $order = TicketOrder::query()->create([ + 'order_no' => 'ORD-PIPE-1', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => $betMinor, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => $betMinor, + 'total_estimated_payout' => 0, + 'status' => 'confirmed', + 'submit_source' => 'h5', + 'client_trace_id' => 'pipe-trace-1', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $item = TicketItem::query()->create([ + 'ticket_no' => 'T-PIPE-1', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 2, + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => $betMinor, + 'total_bet_amount' => $betMinor, + 'rebate_rate_snapshot' => '0.0000', + 'commission_rate_snapshot' => '0.0000', + 'actual_deduct_amount' => $betMinor, + '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, + ]); + $item->setRelation('player', $player); + + $recorder = app(AgentGameSettlementRecorder::class); + expect($recorder->shouldRecord($item))->toBeTrue(); + + $recorder->recordForTicketItem($item, 0, 'settled_lose'); + + $item->refresh(); + expect($item->agent_settled_at)->not->toBeNull() + ->and($item->share_snapshot)->not->toBeNull(); + + $ledger = DB::table('share_ledger')->where('ticket_item_id', $item->id)->first(); + expect($ledger)->not->toBeNull() + ->and((int) $ledger->game_win_loss)->toBe($betMinor); + + $settledAt = (string) $ledger->settled_at; + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->parse($settledAt)->subDay(), + 'period_end' => now()->parse($settledAt)->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $close = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); + + expect($close['player_count'])->toBe(1) + ->and($close['bill_ids'])->not->toBeEmpty(); + + $playerBill = DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->where('bill_type', 'player') + ->where('owner_id', $player->id) + ->first(); + + expect($playerBill)->not->toBeNull() + ->and((int) $playerBill->gross_win_loss)->toBe($betMinor) + ->and((int) $playerBill->rebate_amount)->toBeGreaterThan(0); +}); diff --git a/tests/Feature/AgentRelativeShareRateTest.php b/tests/Feature/AgentRelativeShareRateTest.php new file mode 100644 index 0000000..a09149f --- /dev/null +++ b/tests/Feature/AgentRelativeShareRateTest.php @@ -0,0 +1,128 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('creating child agent with relative_share_rate calculates total_share_rate correctly', 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'); + + $super = AdminUser::query()->create([ + 'username' => 'rel_super', + 'name' => 'Rel', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + // 创建 A,总占成 20% + $agentA = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'REL_A', + 'name' => 'Agent A', + 'username' => 'rel_agent_a', + 'total_share_rate' => 20, + 'credit_limit' => 100000, + ])); + + // 用 relative_share_rate 创建 B,输入 50(即 A 的 50% = 10%) + $agentB = app(AgentNodeService::class)->createChild($super, array_merge( + agentChildPayload([ + 'parent_id' => $agentA->id, + 'code' => 'REL_B', + 'name' => 'Agent B', + 'username' => 'rel_agent_b', + 'credit_limit' => 50000, + ]), + ['relative_share_rate' => 50] + )); + + // 验证 B 的实际总占成是 10% + $profileB = DB::table('agent_profiles')->where('agent_node_id', $agentB->id)->first(); + expect($profileB)->not->toBeNull(); + expect((float) $profileB->total_share_rate)->toBe(10.0); +}); + +test('relative_share_rate 100 gives same total as parent', 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'); + + $super = AdminUser::query()->create([ + 'username' => 'rel_super2', + 'name' => 'Rel2', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $agentA = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'REL2_A', + 'name' => 'Agent A', + 'username' => 'rel2_agent_a', + 'total_share_rate' => 30, + 'credit_limit' => 100000, + ])); + + $agentB = app(AgentNodeService::class)->createChild($super, array_merge( + agentChildPayload([ + 'parent_id' => $agentA->id, + 'code' => 'REL2_B', + 'name' => 'Agent B', + 'username' => 'rel2_agent_b', + 'credit_limit' => 50000, + ]), + ['relative_share_rate' => 100] + )); + + $profileB = DB::table('agent_profiles')->where('agent_node_id', $agentB->id)->first(); + expect((float) $profileB->total_share_rate)->toBe(30.0); +}); + +test('relative_share_rate 0 creates agent with zero share', 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'); + + $super = AdminUser::query()->create([ + 'username' => 'rel_super3', + 'name' => 'Rel3', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $agentA = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'REL3_A', + 'name' => 'Agent A', + 'username' => 'rel3_agent_a', + 'total_share_rate' => 40, + 'credit_limit' => 100000, + ])); + + $agentB = app(AgentNodeService::class)->createChild($super, array_merge( + agentChildPayload([ + 'parent_id' => $agentA->id, + 'code' => 'REL3_B', + 'name' => 'Agent B', + 'username' => 'rel3_agent_b', + 'credit_limit' => 50000, + ]), + ['relative_share_rate' => 0] + )); + + $profileB = DB::table('agent_profiles')->where('agent_node_id', $agentB->id)->first(); + expect((float) $profileB->total_share_rate)->toBe(0.0); +}); diff --git a/tests/Feature/AgentSettlementFinancialConsistencyTest.php b/tests/Feature/AgentSettlementFinancialConsistencyTest.php new file mode 100644 index 0000000..a49a81c --- /dev/null +++ b/tests/Feature/AgentSettlementFinancialConsistencyTest.php @@ -0,0 +1,238 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('record payment rejects bill that is not confirmed', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('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(), + ]); + + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => 1, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'net_amount' => 500, + 'unpaid_amount' => 500, + 'paid_amount' => 0, + 'status' => 'pending_confirm', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'pay_guard_super', + 'name' => 'PayGuard', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + expect(fn () => app(SettlementPaymentService::class)->recordPayment($billId, 100, (int) $admin->id)) + ->toThrow(ValidationException::class); + + expect(DB::table('payment_records')->where('settlement_bill_id', $billId)->exists())->toBeFalse(); +}); + +test('settlement reports scope player win loss to agent subtree', 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' => 'report_scope_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'report-branch', + 'name' => 'Report Branch', + ]); + $otherBranch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'report-other', + 'name' => 'Report Other', + ]); + + $periodStart = now()->subDay()->startOfDay()->toDateTimeString(); + $periodEnd = now()->addDay()->endOfDay()->toDateTimeString(); + $settledAt = now()->toDateTimeString(); + + foreach ([$branch, $otherBranch] as $node) { + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:report-'.$node->code, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'report_'.$node->code, + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $node->id, + ]); + + $ticketItemId = createReportScopeTicketItem($player, 'T-'.$node->code); + + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $ticketItemId, + 'player_id' => $player->id, + 'agent_node_id' => $node->id, + 'agent_path' => json_encode([$node->id]), + 'share_snapshot' => json_encode([ + 'total_shares' => [(string) $node->code => 1.0], + 'chain_codes' => [(string) $node->code], + ]), + 'game_win_loss' => $node->id === $branch->id ? 1000 : 2000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => $node->id === $branch->id ? 1000 : 2000, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + } + + $operator = AdminUser::query()->create([ + 'username' => 'report_scope_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantReportScopeAgentOperator($operator, $branch); + + $reports = app(AgentSettlementReportQueryService::class); + $scoped = $reports->playerWinLoss($operator, 0, $periodStart, $periodEnd); + $all = $reports->playerWinLoss($super, 0, $periodStart, $periodEnd); + + expect($scoped)->toHaveCount(1) + ->and((int) $scoped[0]['game_win_loss'])->toBe(1000) + ->and($all)->toHaveCount(2); +}); + +function createReportScopeTicketItem(Player $player, string $ticketNo): int +{ + $draw = Draw::query()->create([ + 'draw_no' => 'DRAW-'.$ticketNo, + 'business_date' => now()->toDateString(), + 'sequence_no' => random_int(1, 9999), + 'status' => DrawStatus::Open->value, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-'.$ticketNo, + '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(), + ]); + + return (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => $ticketNo, + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 2, + 'unit_bet_amount' => 10_000, + 'total_bet_amount' => 10_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 10_000, + '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(), + ]); +} + +function grantReportScopeAgentOperator(AdminUser $admin, \App\Models\AgentNode $agent): void +{ + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'report_ops_'.$admin->id, + 'code' => 'report_ops_'.$admin->id, + 'name' => 'Report Ops', + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $actionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', ['agent.node.view']) + ->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_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => (int) $agent->admin_site_id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'is_primary' => true, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agent_roles')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); +} diff --git a/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php b/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php new file mode 100644 index 0000000..610b3b9 --- /dev/null +++ b/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php @@ -0,0 +1,399 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function createSiteWithRoot(string $code): array +{ + $siteId = (int) DB::table('admin_sites')->insertGetId([ + 'code' => $code, + 'name' => $code, + 'is_default' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $rootId = (int) DB::table('agent_nodes')->insertGetId([ + 'admin_site_id' => $siteId, + 'parent_id' => null, + 'depth' => 0, + 'path' => '/'.$code, + 'code' => $code, + 'name' => 'Root '.$code, + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + AgentProfile::query()->create([ + 'agent_node_id' => $rootId, + 'total_share_rate' => 100, + 'credit_limit' => 500_000, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0.01, + 'default_player_rebate' => 0.005, + 'settlement_cycle' => 'weekly', + ]); + + return ['site_id' => $siteId, 'site_code' => $code, 'root_id' => $rootId]; +} + +function createTicketItemForPlayer(Player $player, string $ticketNo): int +{ + $draw = Draw::query()->create([ + 'draw_no' => 'DRAW-'.$ticketNo, + 'business_date' => now()->toDateString(), + 'sequence_no' => random_int(1, 9999), + 'status' => DrawStatus::Open->value, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-'.$ticketNo, + '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(), + ]); + + return (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => $ticketNo, + '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(), + ]); +} + +test('period close only tags share ledger rows for the closing site', function (): void { + $siteA = createSiteWithRoot('close-site-a'); + $siteB = createSiteWithRoot('close-site-b'); + + $playerA = Player::query()->create([ + 'site_code' => $siteA['site_code'], + 'agent_node_id' => $siteA['root_id'], + 'site_player_id' => 'p-close-a', + 'username' => 'close_a', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $playerB = Player::query()->create([ + 'site_code' => $siteB['site_code'], + 'agent_node_id' => $siteB['root_id'], + 'site_player_id' => 'p-close-b', + 'username' => 'close_b', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $settledAt = now(); + $snapshot = json_encode([ + 'total_shares' => ['close-site-a' => 100], + 'chain_codes' => ['close-site-a'], + 'agent_path' => [$siteA['root_id']], + ]); + $snapshotB = json_encode([ + 'total_shares' => ['close-site-b' => 100], + 'chain_codes' => ['close-site-b'], + 'agent_path' => [$siteB['root_id']], + ]); + + $ticketAId = createTicketItemForPlayer($playerA, 'T-P0-A'); + $ticketBId = createTicketItemForPlayer($playerB, 'T-P0-B'); + + $ledgerAId = (int) DB::table('share_ledger')->insertGetId([ + 'ticket_item_id' => $ticketAId, + 'player_id' => $playerA->id, + 'agent_node_id' => $siteA['root_id'], + 'agent_path' => json_encode([$siteA['root_id']]), + 'share_snapshot' => $snapshot, + 'game_win_loss' => 1000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 1000, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + $ledgerBId = (int) DB::table('share_ledger')->insertGetId([ + 'ticket_item_id' => $ticketBId, + 'player_id' => $playerB->id, + 'agent_node_id' => $siteB['root_id'], + 'agent_path' => json_encode([$siteB['root_id']]), + 'share_snapshot' => $snapshotB, + 'game_win_loss' => 2000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 2000, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteA['site_id'], + 'period_start' => $settledAt->copy()->subDay(), + 'period_end' => $settledAt->copy()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); + + expect((int) DB::table('share_ledger')->where('id', $ledgerAId)->value('settlement_period_id')) + ->toBe($periodId); + expect(DB::table('share_ledger')->where('id', $ledgerBId)->value('settlement_period_id')) + ->toBeNull(); +}); + +test('agent bill unpaid amount uses abs net so player-wins chain can be paid', 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'); + + $super = AdminUser::query()->create([ + 'username' => 'p0_agent_pay', + 'name' => 'P0', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $service = app(AgentNodeService::class); + $a = $service->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'P0A', + 'name' => 'P0A', + 'username' => 'p0_a', + 'total_share_rate' => 60, + 'credit_limit' => 500_000, + ])); + $b = $service->createChild($super, agentChildPayload([ + 'parent_id' => $a->id, + 'code' => 'P0B', + 'name' => 'P0B', + 'username' => 'p0_b', + 'total_share_rate' => 40, + 'credit_limit' => 200_000, + ])); + $c = $service->createChild($super, agentChildPayload([ + 'parent_id' => $b->id, + 'code' => 'P0C', + 'name' => 'P0C', + 'username' => 'p0_c', + 'total_share_rate' => 25, + 'credit_limit' => 100_000, + ])); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $c->id, + 'site_player_id' => 'p0-win-p1', + 'username' => 'p0win', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $settledAt = now(); + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => $settledAt->copy()->subDay(), + 'period_end' => $settledAt->copy()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $ticketItemId = createTicketItemForPlayer($player, 'T-P0-WIN'); + + // Player wins: negative game_win_loss produces negative agent edge settlements. + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $ticketItemId, + 'player_id' => $player->id, + 'agent_node_id' => $c->id, + 'agent_path' => json_encode([$a->id, $b->id, $c->id]), + 'share_snapshot' => json_encode([ + 'total_shares' => ['P0C' => 25, 'P0B' => 40, 'P0A' => 60], + 'actual_shares' => ['P0C' => 25, 'P0B' => 15, 'P0A' => 20, 'platform' => 40], + 'chain_codes' => ['P0C', 'P0B', 'P0A'], + 'agent_path' => [$a->id, $b->id, $c->id], + ]), + 'game_win_loss' => -DesignDocExample12::GAME_WIN_LOSS, + 'basic_rebate' => DesignDocExample12::BASIC_REBATE, + 'shared_net_win_loss' => -DesignDocExample12::SHARED_NET_WIN_LOSS, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); + + $agentBill = DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->where('bill_type', 'agent') + ->where('meta_json', 'like', '%P0C_to_P0B%') + ->first(); + + expect($agentBill)->not->toBeNull(); + expect((int) $agentBill->net_amount)->toBeLessThan(0); + expect((int) $agentBill->unpaid_amount)->toBe(abs((int) $agentBill->net_amount)); + + DB::table('settlement_bills')->where('id', $agentBill->id)->update([ + 'status' => 'confirmed', + 'updated_at' => now(), + ]); + + app(SettlementPaymentService::class)->recordPayment( + (int) $agentBill->id, + (int) $agentBill->unpaid_amount, + (int) $super->id, + ['method' => 'cash'], + ); + + $record = DB::table('payment_records')->where('settlement_bill_id', $agentBill->id)->first(); + expect($record)->not->toBeNull(); + expect((string) $record->payer_type)->toBe('agent'); + expect((int) $record->payer_id)->toBe((int) $agentBill->counterparty_id); + expect((string) $record->payee_type)->toBe('agent'); + expect((int) $record->payee_id)->toBe((int) $agentBill->owner_id); + + $refreshed = DB::table('settlement_bills')->where('id', $agentBill->id)->first(); + expect((int) $refreshed->unpaid_amount)->toBe(0); + expect((string) $refreshed->status)->toBe('settled'); +}); + +test('period close fails when share ledger row is missing snapshot', 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'); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $rootId, + 'site_player_id' => 'p0-missing-snap', + 'username' => 'p0snap', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $settledAt = now(); + $ticketItemId = createTicketItemForPlayer($player, 'T-P0-NOSNAP'); + + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $ticketItemId, + 'player_id' => $player->id, + 'agent_node_id' => $rootId, + 'agent_path' => json_encode([$rootId]), + 'share_snapshot' => null, + 'game_win_loss' => 500, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 500, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => $settledAt->copy()->subDay(), + 'period_end' => $settledAt->copy()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $caught = null; + try { + app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); + } catch (\Illuminate\Validation\ValidationException $e) { + $caught = $e; + } + + expect($caught)->toBeInstanceOf(\Illuminate\Validation\ValidationException::class); + expect($caught?->errors()['period'][0] ?? null)->toBe('share_snapshot_missing'); + + expect((string) DB::table('settlement_periods')->where('id', $periodId)->value('status')) + ->toBe('open'); + expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count()) + ->toBe(0); +}); + +test('period close succeeds with no share ledger rows in window', function (): void { + ['site_id' => $siteId] = createSiteWithRoot('empty-close'); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => '2026-06-01 00:00:00', + 'period_end' => '2026-06-07 23:59:59', + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); + + expect($result['bill_ids'])->toBe([]); + expect($result['player_count'])->toBe(0); + expect((string) DB::table('settlement_periods')->where('id', $periodId)->value('status')) + ->toBe('closed'); + expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count()) + ->toBe(0); +}); diff --git a/tests/Feature/AgentSettlementPeriodManageScopeTest.php b/tests/Feature/AgentSettlementPeriodManageScopeTest.php new file mode 100644 index 0000000..56e36d0 --- /dev/null +++ b/tests/Feature/AgentSettlementPeriodManageScopeTest.php @@ -0,0 +1,197 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('bound agent cannot open or close site settlement period', 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' => 'period_scope_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'period-scope-branch', + 'name' => 'Period Scope Branch', + ]); + + $operator = AdminUser::query()->create([ + 'username' => 'period_scope_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantPeriodScopeAgentOperator($operator, $branch); + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/settlement-periods', [ + 'admin_site_id' => $siteId, + 'period_start' => '2026-06-01 00:00:00', + 'period_end' => '2026-06-30 23:59:59', + ]) + ->assertForbidden(); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeek(), + 'period_end' => now()->addWeek(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/settlement-periods/'.$periodId.'/close') + ->assertForbidden(); +}); + +test('unsettled ticket warning uses settled_at when game result is posted', 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'); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:unsettled-scope', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'unsettled_scope', + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $rootId, + ]); + + $periodStart = now()->subDays(3)->toDateString(); + $periodEnd = now()->addDay()->toDateString(); + $settledAt = now()->subDay(); + + $draw = Draw::query()->create([ + 'draw_no' => 'DRAW-UNSETTLED-SCOPE', + 'business_date' => now()->toDateString(), + 'sequence_no' => random_int(1, 9999), + 'status' => DrawStatus::Open->value, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-UNSETTLED-SCOPE', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 1000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 1000, + 'total_estimated_payout' => 0, + 'status' => 'confirmed', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'created_at' => now()->subMonth(), + 'updated_at' => now(), + ]); + + DB::table('ticket_items')->insert([ + 'ticket_no' => 'T-UNSETTLED-SCOPE', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 2, + 'unit_bet_amount' => 1000, + 'total_bet_amount' => 1000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 1000, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'pending_payout', + 'win_amount' => 5000, + 'jackpot_win_amount' => 0, + 'created_at' => now()->subMonth(), + 'updated_at' => now(), + 'settled_at' => $settledAt, + 'agent_settled_at' => null, + ]); + + $warning = app(UnsettledTicketPeriodWarning::class); + $inPeriod = $warning->countForSite($siteId, $periodStart, $periodEnd); + $outPeriod = $warning->countForSite( + $siteId, + now()->subMonth()->toDateString(), + now()->subMonth()->addDay()->toDateString(), + ); + + expect($inPeriod['count'])->toBe(1) + ->and($outPeriod['count'])->toBe(0); +}); + +function grantPeriodScopeAgentOperator(AdminUser $admin, \App\Models\AgentNode $agent): void +{ + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'period_scope_ops_'.$admin->id, + 'code' => 'period_scope_ops_'.$admin->id, + 'name' => 'Period Scope Ops', + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $manageActionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', ['prd.settlement.agent.manage', 'settlement.agent.manage']) + ->pluck('id'); + + foreach ($manageActionIds as $actionId) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $actionId, + ]); + } + + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => (int) $agent->admin_site_id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'is_primary' => true, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agent_roles')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); +} diff --git a/tests/Feature/AgentSettlementPeriodOpenTest.php b/tests/Feature/AgentSettlementPeriodOpenTest.php new file mode 100644 index 0000000..10ba0e2 --- /dev/null +++ b/tests/Feature/AgentSettlementPeriodOpenTest.php @@ -0,0 +1,74 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('cannot open duplicate settlement period for same range', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $super = \App\Models\AdminUser::query()->create([ + 'username' => 'period_dup_super', + 'name' => 'Super', + 'email' => null, + 'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $body = [ + 'admin_site_id' => $siteId, + 'period_start' => '2026-06-01 00:00:00', + 'period_end' => '2026-06-30 23:59:59', + ]; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/settlement-periods', $body) + ->assertCreated(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/settlement-periods', $body) + ->assertStatus(422) + ->assertJsonPath( + 'data.errors.period_start.0', + trans('validation.business.period_already_open'), + ); +}); + +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'); + $super = \App\Models\AdminUser::query()->create([ + 'username' => 'period_one_open_super', + 'name' => 'Super', + 'email' => null, + 'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/settlement-periods', [ + 'admin_site_id' => $siteId, + 'period_start' => '2026-06-01 00:00:00', + 'period_end' => '2026-06-07 23:59:59', + ]) + ->assertCreated(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/settlement-periods', [ + 'admin_site_id' => $siteId, + 'period_start' => '2026-06-08 00:00:00', + 'period_end' => '2026-06-14 23:59:59', + ]) + ->assertStatus(422) + ->assertJsonPath( + 'data.errors.period_start.0', + trans('validation.business.period_site_has_open'), + ); +}); diff --git a/tests/Feature/AgentSettlementPeriodSummaryTest.php b/tests/Feature/AgentSettlementPeriodSummaryTest.php index 32f1e3a..f1c4913 100644 --- a/tests/Feature/AgentSettlementPeriodSummaryTest.php +++ b/tests/Feature/AgentSettlementPeriodSummaryTest.php @@ -1,13 +1,20 @@ artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + test('settlement periods index includes bill summary per period', function (): void { $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $periodId = (int) DB::table('settlement_periods')->insertGetId([ @@ -74,3 +81,285 @@ test('settlement periods index includes bill summary per period', function (): v expect($summaries[$periodId]['player_bills'])->toBe(1); expect($summaries[$periodId]['agent_bills'])->toBe(1); }); + +test('pipeline counts respect agent subtree when admin is bound', 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' => 'pipe_scope_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'pipe-branch', + 'name' => 'Pipeline Branch', + ]); + $otherBranch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'pipe-other', + 'name' => 'Pipeline Other', + ]); + + $periodStart = now()->subDay()->toDateString(); + $periodEnd = now()->addDay()->toDateString(); + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => $periodStart, + 'period_end' => $periodEnd, + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $period = (object) [ + 'id' => $periodId, + 'period_start' => $periodStart, + 'period_end' => $periodEnd, + 'admin_site_id' => $siteId, + ]; + + foreach ([$branch, $otherBranch] as $node) { + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:pipe-'.$node->code, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'pipe_'.$node->code, + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $node->id, + ]); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -50, + 'reason' => 'bet_hold', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $operator = AdminUser::query()->create([ + 'username' => 'pipe_scope_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantPipelineAgentOperator($operator, $branch); + + $pipeline = app(AgentSettlementPeriodPipelineService::class); + $scoped = $pipeline->countsForPeriods(collect([$period]), $operator); + $all = $pipeline->countsForPeriods(collect([$period]), null); + + expect($scoped[$period->id]['credit_ledger_count'])->toBe(1) + ->and($all[$period->id]['credit_ledger_count'])->toBe(2); +}); + +test('pipeline game win loss total uses raw platform pnl or agent share profit for viewer', 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' => 'pipe_profit_super', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'pipe-profit-branch', + 'name' => 'Profit Branch', + ]); + $otherBranch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'pipe-profit-other', + 'name' => 'Profit Other', + ]); + + $periodStart = now()->subDay()->toDateString(); + $periodEnd = now()->addDay()->toDateString(); + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => $periodStart, + 'period_end' => $periodEnd, + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $period = (object) [ + 'id' => $periodId, + 'period_start' => $periodStart, + 'period_end' => $periodEnd, + 'admin_site_id' => $siteId, + ]; + $settledAt = now()->toDateTimeString(); + + foreach ([ + [$branch, 300, 700, 1_000, 0], + [$otherBranch, 900, 100, -200, 0], + ] as [$node, $agentProfit, $platformProfit, $gameWinLoss, $basicRebate]) { + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:profit-'.$node->code, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'profit_'.$node->code, + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $node->id, + ]); + + $ticketItemId = createPipelineProfitTicketItem($player, 'T-'.$node->code); + + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $ticketItemId, + 'player_id' => $player->id, + 'agent_node_id' => $node->id, + 'agent_path' => json_encode([$node->id]), + 'share_snapshot' => json_encode([ + 'total_shares' => [(string) $node->code => 30.0], + 'chain_codes' => [(string) $node->code], + ]), + 'game_win_loss' => $gameWinLoss, + 'basic_rebate' => $basicRebate, + 'shared_net_win_loss' => $gameWinLoss - $basicRebate, + 'allocations_json' => json_encode([ + (string) $node->code => $agentProfit, + 'platform' => $platformProfit, + ]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + } + + $operator = AdminUser::query()->create([ + 'username' => 'pipe_profit_ops', + 'name' => 'Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantPipelineAgentOperator($operator, $branch); + + $pipeline = app(AgentSettlementPeriodPipelineService::class); + $platformView = $pipeline->countsForPeriods(collect([$period]), $super); + $agentView = $pipeline->countsForPeriods(collect([$period]), $operator); + + expect($platformView[$period->id]['win_loss_scope'])->toBe('platform') + ->and($platformView[$period->id]['game_win_loss_total'])->toBe(800) + ->and($agentView[$period->id]['win_loss_scope'])->toBe('agent') + ->and($agentView[$period->id]['game_win_loss_total'])->toBe(300); +}); + +function createPipelineProfitTicketItem(Player $player, string $ticketNo): int +{ + $draw = \App\Models\Draw::query()->create([ + 'draw_no' => 'DRAW-'.$ticketNo, + '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-'.$ticketNo, + '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(), + ]); + + return (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => $ticketNo, + '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, + ]); +} + +function grantPipelineAgentOperator(AdminUser $admin, \App\Models\AgentNode $agent): void +{ + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'pipe_ops_'.$admin->id, + 'code' => 'pipe_ops_'.$admin->id, + 'name' => 'Pipeline Ops', + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $actionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', ['agent.node.view']) + ->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_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => (int) $agent->admin_site_id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'is_primary' => true, + 'granted_at' => $now, + ]); + + DB::table('admin_user_agent_roles')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); +} diff --git a/tests/Feature/PlayerNativeAuthTest.php b/tests/Feature/PlayerNativeAuthTest.php index 02ee53d..0ec5e8a 100644 --- a/tests/Feature/PlayerNativeAuthTest.php +++ b/tests/Feature/PlayerNativeAuthTest.php @@ -21,6 +21,32 @@ beforeEach(function (): void { $this->seed(LotterySettingsSeeder::class); }); +test('native player can login without site code using default site', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native:test-no-site', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'agentplayer0', + 'password_hash' => Hash::make('secret-pass'), + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $login = $this->postJson('/api/v1/player/auth/login', [ + 'username' => 'agentplayer0', + 'password' => 'secret-pass', + ]); + + $login->assertOk() + ->assertJsonPath('data.player.id', $player->id); +}); + test('native player can login and access me', function (): void { $site = DB::table('admin_sites')->where('is_default', true)->first(); $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); diff --git a/tests/Feature/SettlementPaymentDirectionTest.php b/tests/Feature/SettlementPaymentDirectionTest.php new file mode 100644 index 0000000..40374d5 --- /dev/null +++ b/tests/Feature/SettlementPaymentDirectionTest.php @@ -0,0 +1,100 @@ +where('is_default', true)->value('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(), + ]); + + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => 1, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'net_amount' => -500, + 'unpaid_amount' => 500, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'pay_dir_super', + 'name' => 'PayDir', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + app(SettlementPaymentService::class)->recordPayment($billId, 500, (int) $admin->id, [ + 'method' => 'cash', + ]); + + $record = DB::table('payment_records')->where('settlement_bill_id', $billId)->first(); + expect($record)->not->toBeNull(); + expect((string) $record->payer_type)->toBe('agent'); + expect((int) $record->payer_id)->toBe(1); + expect((string) $record->payee_type)->toBe('player'); + expect((int) $record->payee_id)->toBe(1); +}); + +test('payment record uses owner as payer when player loses', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('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(), + ]); + + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => 1, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'net_amount' => 800, + 'unpaid_amount' => 800, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'pay_dir_super2', + 'name' => 'PayDir2', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + app(SettlementPaymentService::class)->recordPayment($billId, 800, (int) $admin->id); + + $record = DB::table('payment_records')->where('settlement_bill_id', $billId)->first(); + expect((string) $record->payer_type)->toBe('player'); + expect((int) $record->payer_id)->toBe(1); + expect((string) $record->payee_type)->toBe('agent'); + expect((int) $record->payee_id)->toBe(1); +}); diff --git a/tests/Feature/SevereOverdueFreezeLineTest.php b/tests/Feature/SevereOverdueFreezeLineTest.php new file mode 100644 index 0000000..9c04a17 --- /dev/null +++ b/tests/Feature/SevereOverdueFreezeLineTest.php @@ -0,0 +1,281 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('severe overdue agent line (7+ days) freezes betting for all players in line', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $extra = json_decode((string) ($site->extra_json ?? '{}'), true); + if (! is_array($extra)) { + $extra = []; + } + $extra['credit_line_mode'] = true; + DB::table('admin_sites')->where('id', $site->id)->update([ + 'extra_json' => json_encode($extra), + 'updated_at' => now(), + ]); + + $siteId = (int) $site->id; + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $super = AdminUser::query()->create([ + 'username' => 'severe_super', + 'name' => 'Severe', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + // 创建三级代理链: root -> A -> B + $agentA = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'SEV_A', + 'name' => 'Agent A', + 'username' => 'sev_agent_a', + 'total_share_rate' => 60, + 'credit_limit' => 100000, + 'can_create_player' => true, + ])); + + $agentB = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $agentA->id, + 'code' => 'SEV_B', + 'name' => 'Agent B', + 'username' => 'sev_agent_b', + 'total_share_rate' => 40, + 'credit_limit' => 50000, + 'can_create_player' => true, + ])); + + // 创建玩家归属 B + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $agentB->id, + 'site_player_id' => 'sev-p1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'sev_player', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 10000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // 创建 A 的严重逾期账单(8天前) + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeeks(2), + 'period_end' => now()->subWeeks(1), + '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' => $agentA->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $rootId, + 'gross_win_loss' => 0, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 1000, + 'paid_amount' => 0, + 'unpaid_amount' => 1000, + 'status' => 'overdue', + 'updated_at' => now()->subDays(8), // 8天前逾期 + 'created_at' => now()->subDays(8), + ]); + + // 验证严重逾期检查 + expect(AgentOverdueGuard::agentHasSevereOverdueBills($agentA->id, 7))->toBeTrue(); + expect(AgentOverdueGuard::agentLineHasSevereOverdueBills($agentB->id, 7))->toBeTrue(); + + // 玩家下注应该被拒绝 + expect(fn () => app(PlayerCreditService::class)->assertMayPlaceBet($player, 100)) + ->toThrow(\Illuminate\Validation\ValidationException::class); +}); + +test('normal overdue (less than 7 days) does not freeze line betting', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $extra = json_decode((string) ($site->extra_json ?? '{}'), true); + if (! is_array($extra)) { + $extra = []; + } + $extra['credit_line_mode'] = true; + DB::table('admin_sites')->where('id', $site->id)->update([ + 'extra_json' => json_encode($extra), + 'updated_at' => now(), + ]); + + $siteId = (int) $site->id; + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $super = AdminUser::query()->create([ + 'username' => 'normal_super', + 'name' => 'Normal', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $agentA = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'NORM_A', + 'name' => 'Agent A', + 'username' => 'norm_agent_a', + 'total_share_rate' => 60, + 'credit_limit' => 100000, + 'can_create_player' => true, + ])); + + $agentB = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $agentA->id, + 'code' => 'NORM_B', + 'name' => 'Agent B', + 'username' => 'norm_agent_b', + 'total_share_rate' => 40, + 'credit_limit' => 50000, + 'can_create_player' => true, + ])); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $agentB->id, + 'site_player_id' => 'norm-p1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'norm_player', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 10000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // 创建 A 的普通逾期账单(3天前) + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeeks(2), + 'period_end' => now()->subWeeks(1), + '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' => $agentA->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $rootId, + 'gross_win_loss' => 0, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 1000, + 'paid_amount' => 0, + 'unpaid_amount' => 1000, + 'status' => 'overdue', + 'updated_at' => now()->subDays(3), // 3天前逾期 + 'created_at' => now()->subDays(3), + ]); + + // 验证普通逾期检查 + expect(AgentOverdueGuard::agentHasSevereOverdueBills($agentA->id, 7))->toBeFalse(); + expect(AgentOverdueGuard::agentLineHasSevereOverdueBills($agentB->id, 7))->toBeFalse(); + + // 玩家下注应该成功(普通逾期不冻结整条线) + expect(fn () => app(PlayerCreditService::class)->assertMayPlaceBet($player, 100)) + ->not->toThrow(\Illuminate\Validation\ValidationException::class); +}); + +test('severe overdue check respects configurable days threshold', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $siteId = (int) $site->id; + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $super = AdminUser::query()->create([ + 'username' => 'config_super', + 'name' => 'Config', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $agentA = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'CFG_A', + 'name' => 'Agent A', + 'username' => 'cfg_agent_a', + 'total_share_rate' => 60, + 'credit_limit' => 100000, + ])); + + // 创建 5 天前的逾期账单 + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeeks(2), + 'period_end' => now()->subWeeks(1), + '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' => $agentA->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $rootId, + 'gross_win_loss' => 0, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 1000, + 'paid_amount' => 0, + 'unpaid_amount' => 1000, + 'status' => 'overdue', + 'updated_at' => now()->subDays(5), + 'created_at' => now()->subDays(5), + ]); + + // 7天阈值:5天不算严重逾期 + expect(AgentOverdueGuard::agentHasSevereOverdueBills($agentA->id, 7))->toBeFalse(); + + // 3天阈值:5天算严重逾期 + expect(AgentOverdueGuard::agentHasSevereOverdueBills($agentA->id, 3))->toBeTrue(); +});