From a44679665daa8a6b89b3f5442642179de107981f Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 4 Jun 2026 18:00:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E5=92=8C=E7=8E=A9=E5=AE=B6=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。 - 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。 - 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。 - 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。 - 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。 --- AGENTS.md | 13 + .../AgentOwnerPermissionsResyncCommand.php | 20 + .../Commands/AgentRolesSyncCommand.php | 38 ++ .../CreditLineDisableAllSitesCommand.php | 36 ++ .../CreditLineEnableAllSitesCommand.php | 36 ++ .../SettlementMarkOverdueBillsCommand.php | 32 + .../SyncAdminAuthorizationCommand.php | 8 + .../SyncAgentAllocatedCreditCommand.php | 42 ++ .../Admin/Agent/AgentLineStoreController.php | 2 +- .../Agent/AgentNodeChildrenController.php | 18 +- .../Agent/AgentNodeProfileController.php | 45 +- .../AdminCreditLedgerIndexController.php | 61 ++ ...entSettlementAdjustmentIndexController.php | 62 ++ ...gentSettlementBillAdjustmentController.php | 57 ++ ...ettlementBillBadDebtWriteOffController.php | 55 ++ .../AgentSettlementBillConfirmController.php | 25 +- .../AgentSettlementBillIndexController.php | 136 +++- .../AgentSettlementBillPaymentController.php | 57 ++ .../AgentSettlementBillShowController.php | 53 ++ .../AgentSettlementPaymentIndexController.php | 58 ++ .../AgentSettlementPeriodIndexController.php | 36 ++ .../AgentSettlementReportIndexController.php | 24 + .../AgentSettlementReportShowController.php | 106 ++++ .../AdminDrawFinanceSummaryController.php | 14 + .../Admin/Draw/AdminDrawIndexController.php | 70 +- .../AdminDrawResultBatchesIndexController.php | 54 +- .../V1/Admin/Draw/AdminDrawShowController.php | 44 +- .../AdminIntegrationSiteIndexController.php | 16 +- .../AdminIntegrationSiteSecretsController.php | 51 ++ .../AdminIntegrationSiteStoreController.php | 16 - .../Player/AdminPlayerDestroyController.php | 10 +- .../Player/AdminPlayerIndexController.php | 6 +- .../Player/AdminPlayerStoreController.php | 110 +++- .../Player/AdminPlayerUpdateController.php | 79 ++- .../AdminReportRebateCommissionController.php | 10 +- .../Admin/User/AdminRoleDestroyController.php | 5 +- .../Admin/User/AdminRoleIndexController.php | 2 + .../AdminRolePermissionSyncController.php | 13 + .../Admin/User/AdminRoleStoreController.php | 43 +- .../Admin/User/AdminRoleUpdateController.php | 13 + .../User/AdminUserRoleSyncController.php | 9 +- .../Admin/User/AdminUserStoreController.php | 7 +- .../WalletTransactionListController.php | 24 + .../Api/V1/Player/MeController.php | 2 + .../V1/Player/PlayerAuthLoginController.php | 34 + .../Api/V1/Wallet/WalletBalanceController.php | 37 ++ .../Api/V1/Wallet/WalletLogsController.php | 139 +--- .../Admin/AdminAgentLineStoreRequest.php | 56 +- .../Admin/AdminPlayerStoreRequest.php | 3 +- .../Admin/AdminPlayerUpdateRequest.php | 10 + .../AdminSettlementBillAdjustmentRequest.php | 22 + .../AdminSettlementBillBadDebtRequest.php | 20 + .../AdminSettlementBillPaymentRequest.php | 23 + .../Admin/AdminUserRoleSyncRequest.php | 1 + .../Requests/Admin/AdminUserStoreRequest.php | 1 + .../Admin/Concerns/AgentProfileFieldRules.php | 2 + .../Player/PlayerAuthLoginRequest.php | 22 + app/Lottery/ErrorCode.php | 12 + app/Models/AdminRole.php | 18 + app/Models/AdminUser.php | 36 +- app/Models/AgentNode.php | 2 + app/Models/Player.php | 19 + .../Admin/AdminDashboardSnapshotBuilder.php | 6 + .../Admin/AgentDashboardOverviewBuilder.php | 97 +++ .../Agent/AgentCreditAllocatedSyncService.php | 55 ++ app/Services/Agent/AgentNodeService.php | 150 +---- app/Services/Agent/AgentProfileService.php | 113 +++- .../Agent/AgentSiteProvisioningService.php | 90 +-- .../Agent/CreditAllocationValidator.php | 39 +- .../AgentGameSettlementRecorder.php | 151 +++++ .../AgentSettlement/AgentPeriodAggregator.php | 232 +++++++ .../AgentSettlementBadDebtService.php | 125 ++++ .../AgentSettlementBillAdjustmentService.php | 92 +++ .../AgentSettlementBillGuard.php | 51 ++ .../AgentSettlementPeriodCloseService.php | 94 ++- ...AgentSettlementPeriodCompletionService.php | 58 ++ .../AgentSettlementPeriodPipelineService.php | 65 ++ .../AgentSettlementPeriodSummaryService.php | 84 +++ .../AgentSettlementReportQueryService.php | 326 ++++++++++ .../BetSettlementSnapshotBuilder.php | 116 ++++ .../GameSettlementReversalService.php | 82 +++ .../PeriodCloseRebateService.php | 239 +++++++ .../PlatformRoundingAdjuster.php | 72 +++ .../SettlementBillGenerator.php | 123 ++++ .../SettlementCenterLedgerService.php | 598 ++++++++++++++++++ .../SettlementLedgerListFilters.php | 54 ++ .../SettlementPaymentService.php | 81 +++ .../UnsettledTicketPeriodWarning.php | 30 + app/Services/Player/PlayerCreditService.php | 166 ++++- .../Player/PlayerNativeAuthService.php | 131 ++++ .../Player/PlayerRebateProfileService.php | 65 ++ app/Services/PlayerTokenResolver.php | 139 +++- .../Settlement/SettlementOrchestrator.php | 7 +- .../Ticket/TicketPlacementService.php | 84 ++- app/Services/Ticket/TicketPreviewService.php | 5 +- .../Wallet/LotteryTransferService.php | 14 + .../Wallet/PlayerLedgerLogsService.php | 578 +++++++++++++++++ app/Support/AdminAgentScope.php | 26 + app/Support/AdminAgentSettlementScope.php | 16 + app/Support/AdminAuthProfile.php | 14 +- app/Support/AdminAuthorizationRegistry.php | 19 +- app/Support/AdminDataScope.php | 2 +- app/Support/AdminDrawApiPresenter.php | 138 ++++ app/Support/AdminDrawResponsePolicy.php | 44 ++ app/Support/AdminIntegrationSitePresenter.php | 4 +- app/Support/AdminPermissionBridge.php | 16 +- app/Support/AdminPlatformUserSiteGuard.php | 31 + app/Support/AdminUserApiPresenter.php | 2 + app/Support/AdminUserSiteBindingPresenter.php | 69 ++ app/Support/AgentDefaultRolePermissions.php | 166 +++++ app/Support/AgentLinePresenter.php | 12 +- app/Support/AgentNodePresenter.php | 40 +- app/Support/AgentOverdueGuard.php | 31 + app/Support/AgentPlatformRole.php | 47 ++ app/Support/AgentProfileCapabilityFilter.php | 123 ++++ .../AgentSettlementProductionGuard.php | 17 + app/Support/ApiValidationErrors.php | 90 +++ app/Support/CreditAmountScale.php | 52 ++ app/Support/PlatformSystemRoles.php | 54 ++ app/Support/PlayerApiPresenter.php | 56 ++ app/Support/PlayerAuthSource.php | 19 + app/Support/PlayerFundingMode.php | 34 + bootstrap/app.php | 11 + config/agent_line_defaults.php | 9 + config/agent_settlement.php | 5 + config/lottery.php | 8 + ...00_seed_agent_settlement_api_resources.php | 4 + ...000_add_agent_profile_capability_flags.php | 13 +- ...04_100000_agent_game_settlement_ledger.php | 88 +++ ...20000_add_player_auth_and_funding_mode.php | 43 ++ ..._120000_agent_settlement_payment_proof.php | 23 + ...00_resync_agent_owner_role_permissions.php | 30 + ...latform_agent_role_and_resync_bindings.php | 63 ++ ...0000_agent_settlement_reports_and_tags.php | 38 ++ ...000_bind_agents_to_platform_agent_role.php | 44 ++ ...000_ensure_platform_fixed_system_roles.php | 17 + ..._seed_credit_ledger_admin_api_resource.php | 94 +++ database/seeders/AdminRbacAndUserSeeder.php | 79 +-- docs/admin-rbac.md | 39 ++ lang/en/admin.php | 3 + lang/en/sso.php | 3 + lang/en/validation_attributes.php | 10 + lang/en/wallet.php | 1 + lang/ne/admin.php | 3 + lang/zh/admin.php | 5 +- lang/zh/sso.php | 3 + lang/zh/validation_attributes.php | 10 + lang/zh/validation_business.php | 7 +- lang/zh/validation_custom.php | 20 + lang/zh/wallet.php | 3 +- routes/api/v1/admin/agent-settlement.php | 30 + routes/api/v1/admin/integration.php | 3 + routes/api/v1/public.php | 2 + .../AdminAgentDashboardOverviewTest.php | 67 ++ tests/Feature/AdminAgentLineApiTest.php | 66 +- tests/Feature/AdminAgentProfileApiTest.php | 37 ++ ...inAgentProfileCapabilityPermissionTest.php | 129 ++++ tests/Feature/AdminCreditLedgerIndexTest.php | 119 ++++ .../AdminDrawViewOnlyAuthorizationTest.php | 175 +++++ tests/Feature/AdminIntegrationSiteApiTest.php | 28 + tests/Feature/AdminPlayerManageApiTest.php | 58 ++ tests/Feature/AdminUserPermissionApiTest.php | 41 +- .../Feature/AdminUserSiteRoleBindingTest.php | 243 +++++++ tests/Feature/AgentCreditAllocationTest.php | 171 +++++ .../Feature/AgentOverdueCreatePlayerTest.php | 72 +++ tests/Feature/AgentPeriodCloseE2eTest.php | 212 +++++++ tests/Feature/AgentSettlementBadDebtTest.php | 90 +++ .../AgentSettlementBillAdjustmentTest.php | 46 ++ tests/Feature/AgentSettlementListsApiTest.php | 84 +++ .../AgentSettlementPeriodSummaryTest.php | 76 +++ .../BetShareSnapshotImmutabilityTest.php | 66 ++ .../CreditHoldSettlementNoDoubleTest.php | 92 +++ tests/Feature/CreditLineBetHoldTest.php | 62 ++ tests/Feature/CreditWalletLogsTest.php | 59 ++ tests/Feature/GameSettlementReversalTest.php | 126 ++++ tests/Feature/PlatformSystemRolesTest.php | 100 +++ tests/Feature/PlayerNativeAuthTest.php | 139 ++++ tests/Feature/SettlementBillLockTest.php | 42 ++ tests/Feature/SettlementOverdueFreezeTest.php | 72 +++ .../Feature/WalletBalanceCreditPlayerTest.php | 46 ++ .../Unit/AgentDefaultRolePermissionsTest.php | 37 ++ tests/Unit/ApiValidationErrorsTest.php | 18 + tests/Unit/CreditAmountScaleTest.php | 18 + 183 files changed, 10054 insertions(+), 857 deletions(-) create mode 100644 app/Console/Commands/AgentOwnerPermissionsResyncCommand.php create mode 100644 app/Console/Commands/AgentRolesSyncCommand.php create mode 100644 app/Console/Commands/CreditLineDisableAllSitesCommand.php create mode 100644 app/Console/Commands/CreditLineEnableAllSitesCommand.php create mode 100644 app/Console/Commands/SettlementMarkOverdueBillsCommand.php create mode 100644 app/Console/Commands/SyncAgentAllocatedCreditCommand.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AdminCreditLedgerIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportIndexController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteSecretsController.php create mode 100644 app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php create mode 100644 app/Http/Requests/Admin/AdminSettlementBillAdjustmentRequest.php create mode 100644 app/Http/Requests/Admin/AdminSettlementBillBadDebtRequest.php create mode 100644 app/Http/Requests/Admin/AdminSettlementBillPaymentRequest.php create mode 100644 app/Http/Requests/Player/PlayerAuthLoginRequest.php create mode 100644 app/Services/Admin/AgentDashboardOverviewBuilder.php create mode 100644 app/Services/Agent/AgentCreditAllocatedSyncService.php create mode 100644 app/Services/AgentSettlement/AgentGameSettlementRecorder.php create mode 100644 app/Services/AgentSettlement/AgentPeriodAggregator.php create mode 100644 app/Services/AgentSettlement/AgentSettlementBadDebtService.php create mode 100644 app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php create mode 100644 app/Services/AgentSettlement/AgentSettlementBillGuard.php create mode 100644 app/Services/AgentSettlement/AgentSettlementPeriodCompletionService.php create mode 100644 app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php create mode 100644 app/Services/AgentSettlement/AgentSettlementPeriodSummaryService.php create mode 100644 app/Services/AgentSettlement/AgentSettlementReportQueryService.php create mode 100644 app/Services/AgentSettlement/BetSettlementSnapshotBuilder.php create mode 100644 app/Services/AgentSettlement/GameSettlementReversalService.php create mode 100644 app/Services/AgentSettlement/PeriodCloseRebateService.php create mode 100644 app/Services/AgentSettlement/PlatformRoundingAdjuster.php create mode 100644 app/Services/AgentSettlement/SettlementBillGenerator.php create mode 100644 app/Services/AgentSettlement/SettlementCenterLedgerService.php create mode 100644 app/Services/AgentSettlement/SettlementLedgerListFilters.php create mode 100644 app/Services/AgentSettlement/SettlementPaymentService.php create mode 100644 app/Services/AgentSettlement/UnsettledTicketPeriodWarning.php create mode 100644 app/Services/Player/PlayerNativeAuthService.php create mode 100644 app/Services/Player/PlayerRebateProfileService.php create mode 100644 app/Services/Wallet/PlayerLedgerLogsService.php create mode 100644 app/Support/AdminDrawApiPresenter.php create mode 100644 app/Support/AdminDrawResponsePolicy.php create mode 100644 app/Support/AdminPlatformUserSiteGuard.php create mode 100644 app/Support/AdminUserSiteBindingPresenter.php create mode 100644 app/Support/AgentDefaultRolePermissions.php create mode 100644 app/Support/AgentOverdueGuard.php create mode 100644 app/Support/AgentPlatformRole.php create mode 100644 app/Support/AgentProfileCapabilityFilter.php create mode 100644 app/Support/AgentSettlementProductionGuard.php create mode 100644 app/Support/CreditAmountScale.php create mode 100644 app/Support/PlatformSystemRoles.php create mode 100644 app/Support/PlayerAuthSource.php create mode 100644 app/Support/PlayerFundingMode.php create mode 100644 config/agent_line_defaults.php create mode 100644 config/agent_settlement.php create mode 100644 database/migrations/2026_06_04_100000_agent_game_settlement_ledger.php create mode 100644 database/migrations/2026_06_04_120000_add_player_auth_and_funding_mode.php create mode 100644 database/migrations/2026_06_04_120000_agent_settlement_payment_proof.php create mode 100644 database/migrations/2026_06_04_120000_resync_agent_owner_role_permissions.php create mode 100644 database/migrations/2026_06_04_130000_seed_platform_agent_role_and_resync_bindings.php create mode 100644 database/migrations/2026_06_04_140000_agent_settlement_reports_and_tags.php create mode 100644 database/migrations/2026_06_04_140000_bind_agents_to_platform_agent_role.php create mode 100644 database/migrations/2026_06_04_150000_ensure_platform_fixed_system_roles.php create mode 100644 database/migrations/2026_06_05_120000_seed_credit_ledger_admin_api_resource.php create mode 100644 tests/Feature/AdminAgentDashboardOverviewTest.php create mode 100644 tests/Feature/AdminAgentProfileCapabilityPermissionTest.php create mode 100644 tests/Feature/AdminCreditLedgerIndexTest.php create mode 100644 tests/Feature/AdminDrawViewOnlyAuthorizationTest.php create mode 100644 tests/Feature/AdminUserSiteRoleBindingTest.php create mode 100644 tests/Feature/AgentCreditAllocationTest.php create mode 100644 tests/Feature/AgentOverdueCreatePlayerTest.php create mode 100644 tests/Feature/AgentPeriodCloseE2eTest.php create mode 100644 tests/Feature/AgentSettlementBadDebtTest.php create mode 100644 tests/Feature/AgentSettlementBillAdjustmentTest.php create mode 100644 tests/Feature/AgentSettlementListsApiTest.php create mode 100644 tests/Feature/AgentSettlementPeriodSummaryTest.php create mode 100644 tests/Feature/BetShareSnapshotImmutabilityTest.php create mode 100644 tests/Feature/CreditHoldSettlementNoDoubleTest.php create mode 100644 tests/Feature/CreditLineBetHoldTest.php create mode 100644 tests/Feature/CreditWalletLogsTest.php create mode 100644 tests/Feature/GameSettlementReversalTest.php create mode 100644 tests/Feature/PlatformSystemRolesTest.php create mode 100644 tests/Feature/PlayerNativeAuthTest.php create mode 100644 tests/Feature/SettlementBillLockTest.php create mode 100644 tests/Feature/SettlementOverdueFreezeTest.php create mode 100644 tests/Feature/WalletBalanceCreditPlayerTest.php create mode 100644 tests/Unit/AgentDefaultRolePermissionsTest.php create mode 100644 tests/Unit/CreditAmountScaleTest.php diff --git a/AGENTS.md b/AGENTS.md index 2dd3c9b..b6f6b43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,3 +19,16 @@ ## 后台 RBAC 改 `app/Support/AdminAuthorizationRegistry.php` 后,在已有库执行 `php artisan lottery:admin-auth-sync --audit`(见 `docs/admin-rbac.md`)。`migrate:fresh --seed` 会走迁移内的 resync,一般不必再手动 sync。 + +## 双模式玩家(主站钱包 / 代理信用) + +- `players.auth_source`:`main_site_sso`(主站 JWT)与 `lottery_native`(彩票端账号密码)。 +- `players.funding_mode`:`wallet`(主站划转)与 `credit`(授信下注);**禁止**仅用整站 `credit_line_mode` 代替玩家级判断,用 `PlayerFundingMode::usesCredit($player)`。 +- 生产环境配置独立 `LOTTERY_NATIVE_JWT_SECRET`(勿与主站 SSO 混用)。 + +## 信用占成盘(代理账期结算) + +- 业务真理源:`docs/信用占成盘代理系统设计说明文档.md`;实施路线:`docs/信用占成盘代理体系改造计划.md`。 +- **代理账期**代码包:`App\Services\AgentSettlement\`(勿与彩票开奖 `App\Services\Settlement\` / `SettlementBatch` 混用)。 +- **禁止**在生产关账路径使用 `DesignDocExample12` 硬编码账单;仅单元/Feature 测试可引用。 +- 占成账单聚合必须读注单**快照**(`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。 diff --git a/app/Console/Commands/AgentOwnerPermissionsResyncCommand.php b/app/Console/Commands/AgentOwnerPermissionsResyncCommand.php new file mode 100644 index 0000000..7541e58 --- /dev/null +++ b/app/Console/Commands/AgentOwnerPermissionsResyncCommand.php @@ -0,0 +1,20 @@ +warn('agent_owner_* 已不再用于经营主账号;正在转调 lottery:agent-roles-sync …'); + + return $this->call('lottery:agent-roles-sync'); + } +} diff --git a/app/Console/Commands/AgentRolesSyncCommand.php b/app/Console/Commands/AgentRolesSyncCommand.php new file mode 100644 index 0000000..d5e7a55 --- /dev/null +++ b/app/Console/Commands/AgentRolesSyncCommand.php @@ -0,0 +1,38 @@ +info('平台角色 agent(#'.$platform->id.')权限数: '.count($platform->legacyPermissionSlugs())); + + $bindingCount = 0; + foreach (DB::table('admin_user_agents')->get(['admin_user_id', 'agent_node_id']) as $binding) { + $user = AdminUser::query()->find((int) $binding->admin_user_id); + if ($user === null) { + continue; + } + + $user->syncPrimaryPlatformAgentRole((int) $binding->agent_node_id); + $bindingCount++; + } + + $this->info("已绑定 {$bindingCount} 个经营代理主账号到平台「代理」角色。"); + $this->line('调整权限请编辑:平台角色管理 → 代理'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/CreditLineDisableAllSitesCommand.php b/app/Console/Commands/CreditLineDisableAllSitesCommand.php new file mode 100644 index 0000000..9e60358 --- /dev/null +++ b/app/Console/Commands/CreditLineDisableAllSitesCommand.php @@ -0,0 +1,36 @@ +get(); + $count = 0; + + foreach ($sites as $site) { + $extra = json_decode((string) ($site->extra_json ?? '{}'), true); + if (! is_array($extra)) { + $extra = []; + } + unset($extra['credit_line_mode']); + DB::table('admin_sites')->where('id', $site->id)->update([ + 'extra_json' => json_encode($extra), + 'updated_at' => now(), + ]); + $count++; + } + + $this->info("Disabled credit_line_mode on {$count} site(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/CreditLineEnableAllSitesCommand.php b/app/Console/Commands/CreditLineEnableAllSitesCommand.php new file mode 100644 index 0000000..1533249 --- /dev/null +++ b/app/Console/Commands/CreditLineEnableAllSitesCommand.php @@ -0,0 +1,36 @@ +get(); + $count = 0; + + foreach ($sites as $site) { + $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(), + ]); + $count++; + } + + $this->info("Enabled credit_line_mode on {$count} site(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SettlementMarkOverdueBillsCommand.php b/app/Console/Commands/SettlementMarkOverdueBillsCommand.php new file mode 100644 index 0000000..8e773b4 --- /dev/null +++ b/app/Console/Commands/SettlementMarkOverdueBillsCommand.php @@ -0,0 +1,32 @@ +option('days')); + $cutoff = now()->subDays($days); + + $updated = DB::table('settlement_bills') + ->whereIn('status', ['confirmed', 'partial_paid']) + ->where('unpaid_amount', '>', 0) + ->where('updated_at', '<', $cutoff) + ->update([ + 'status' => 'overdue', + 'updated_at' => now(), + ]); + + $this->info("Marked {$updated} bill(s) overdue."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncAdminAuthorizationCommand.php b/app/Console/Commands/SyncAdminAuthorizationCommand.php index fb90a7e..5b93931 100644 --- a/app/Console/Commands/SyncAdminAuthorizationCommand.php +++ b/app/Console/Commands/SyncAdminAuthorizationCommand.php @@ -9,6 +9,7 @@ use App\Support\AdminAgentLineSettlementPermissionMenuActionSync; use App\Support\AdminAgentPermissionMenuActionSync; use App\Support\AdminAuthorizationRegistry; use App\Support\AdminDrawPermissionMenuActionSync; +use App\Support\PlatformSystemRoles; final class SyncAdminAuthorizationCommand extends Command { @@ -92,6 +93,13 @@ final class SyncAdminAuthorizationCommand extends Command count(AdminAuthorizationRegistry::resources()), )); + PlatformSystemRoles::ensureAll(); + $super = PlatformSystemRoles::ensureSuperAdminRole(); + $this->info(sprintf( + 'Platform system roles synced (super_admin permissions: %d).', + count($super->legacyPermissionSlugs()), + )); + if ((bool) $this->option('audit')) { return $this->call('lottery:admin-auth-audit'); } diff --git a/app/Console/Commands/SyncAgentAllocatedCreditCommand.php b/app/Console/Commands/SyncAgentAllocatedCreditCommand.php new file mode 100644 index 0000000..7a2d027 --- /dev/null +++ b/app/Console/Commands/SyncAgentAllocatedCreditCommand.php @@ -0,0 +1,42 @@ +option('site'); + $query = AgentNode::query()->orderBy('id'); + if (is_string($siteCode) && $siteCode !== '') { + $query->whereHas('adminSite', static fn ($q) => $q->where('code', $siteCode)); + } + + $nodes = $query->get(); + $updated = 0; + foreach ($nodes as $node) { + $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); + $before = $profile !== null ? (int) $profile->allocated_credit : null; + $sync->syncForAgent($node); + $profile?->refresh(); + $after = $profile !== null ? (int) $profile->allocated_credit : null; + if ($before !== $after) { + $updated++; + } + } + + $this->info('已处理 '.count($nodes).' 个代理节点,其中 '.$updated.' 个 allocated_credit 有变更。'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineStoreController.php index bb0746d..9ffbfc4 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentLineStoreController.php @@ -26,7 +26,7 @@ final class AgentLineStoreController extends Controller $site = $result['site']; $node = $result['agent_node']; - $payload = AgentLinePresenter::provisioned($site, $node, $result['secrets']); + $payload = AgentLinePresenter::provisioned($site, $node); AuditLogger::recordForAdmin( $admin, diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeChildrenController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeChildrenController.php index 72c0f3f..36a23fe 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeChildrenController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeChildrenController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Agent; use App\Models\AgentNode; +use App\Models\AgentProfile; use App\Support\ApiResponse; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; @@ -22,10 +23,21 @@ final class AgentNodeChildrenController extends Controller return $denied; } - $items = $agent_node->children() - ->orderBy('code') + $children = $agent_node->children()->orderBy('code')->get(); + $profiles = AgentProfile::query() + ->whereIn('agent_node_id', $children->pluck('id')) ->get() - ->map(static fn (AgentNode $child): array => AgentNodePresenter::item($child)) + ->keyBy('agent_node_id'); + + $items = $children + ->map(static function (AgentNode $child) use ($profiles): array { + $profile = $profiles->get($child->id); + + return AgentNodePresenter::item( + $child, + $profile instanceof AgentProfile ? $profile : null, + ); + }) ->all(); return ApiResponse::success(['items' => $items]); diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeProfileController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeProfileController.php index 77da3f2..e3f5440 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeProfileController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeProfileController.php @@ -3,11 +3,12 @@ namespace App\Http\Controllers\Api\V1\Admin\Agent; use App\Http\Controllers\Controller; +use App\Http\Middleware\RecordAdminApiAudit; use App\Http\Requests\Admin\AdminAgentProfileUpdateRequest; use App\Models\AgentNode; use App\Models\AgentProfile; -use App\Services\Agent\AgentNodeService; use App\Services\Agent\AgentProfileService; +use App\Services\AuditLogger; use App\Support\AdminAgentScope; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; @@ -22,20 +23,28 @@ final class AgentNodeProfileController extends Controller abort_if($admin === null, 401); abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403); + $service = app(AgentProfileService::class); $profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $agent_node->id]); + $parent = $agent_node->parent_id !== null + ? AgentNode::query()->find($agent_node->parent_id) + : null; - return ApiResponse::success(app(AgentProfileService::class)->present($profile)); + return ApiResponse::success([ + ...$service->present($profile), + 'parent_caps' => $service->parentCapsForNode($parent), + 'risk_tags' => $agent_node->risk_tags ?? [], + ]); } public function update( AdminAgentProfileUpdateRequest $request, AgentNode $agent_node, AgentProfileService $service, - AgentNodeService $agentNodeService, ): JsonResponse { $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403); + abort_if(! AdminAgentScope::nodeProfileEditableBy($admin, $agent_node), 403); $parent = $agent_node->parent_id !== null ? AgentNode::query()->find($agent_node->parent_id) @@ -46,9 +55,33 @@ final class AgentNodeProfileController extends Controller $service->assertChildCapabilityGrantsWithinParent($parent, $payload, $admin); } - $profile = $service->upsertForNode($agent_node, $payload, $parent); - $agentNodeService->syncPrimaryOwnerRoleFromProfile($agent_node, $profile); + $beforeProfile = AgentProfile::query()->where('agent_node_id', $agent_node->id)->first(); + $beforeJson = $beforeProfile !== null ? $service->present($beforeProfile) : []; - return ApiResponse::success($service->present($profile)); + if ($request->has('risk_tags')) { + $agent_node->risk_tags = array_values(array_unique(array_filter( + array_map('strval', $request->input('risk_tags', [])), + ))); + $agent_node->save(); + } + + $profile = $service->upsertForNode($agent_node, $payload, $parent); + $afterJson = array_merge($service->present($profile), [ + 'risk_tags' => $agent_node->risk_tags ?? [], + ]); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'agent', + actionCode: 'agent_profile.update', + targetType: 'agent_node', + targetId: (string) $agent_node->id, + beforeJson: $beforeJson, + afterJson: $afterJson, + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success($afterJson); } } diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AdminCreditLedgerIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AdminCreditLedgerIndexController.php new file mode 100644 index 0000000..4945765 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AdminCreditLedgerIndexController.php @@ -0,0 +1,61 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $adminSiteId = (int) $request->query('admin_site_id', 0); + abort_if($adminSiteId <= 0, 422, 'admin_site_id required'); + abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $adminSiteId), 403); + + $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); + abort_if($siteCode === '', 422, 'admin_site not found'); + + $periodId = (int) $request->query('settlement_period_id', 0); + if ($periodId > 0) { + abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403); + } + + $filters = SettlementLedgerListFilters::fromQuery(array_merge( + $request->query(), + $periodId > 0 ? ['settlement_period_id' => $periodId] : [], + )); + + $perPage = $this->perPage($request, 'per_page', 20, 100); + $page = $this->page($request); + + $result = $this->ledgerService->listUnified( + $admin, + $siteCode, + $page, + $perPage, + $filters, + ); + + return ApiResponse::success($result); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php new file mode 100644 index 0000000..66711d0 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php @@ -0,0 +1,62 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $periodId = (int) $request->query('settlement_period_id', 0); + $adminSiteId = (int) $request->query('admin_site_id', 0); + $adjustmentType = trim((string) $request->query('adjustment_type', '')); + + $query = DB::table('settlement_adjustments as sa') + ->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id') + ->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id') + ->select([ + 'sa.*', + 'sp.period_start', + 'sp.period_end', + 'sp.admin_site_id', + 'sb.bill_type as original_bill_type', + 'sb.owner_type as original_owner_type', + 'sb.owner_id as original_owner_id', + ]) + ->orderByDesc('sa.id'); + + if ($periodId > 0) { + $query->where('sa.settlement_period_id', $periodId); + } + + if ($adminSiteId > 0) { + $query->where('sp.admin_site_id', $adminSiteId); + } + + if ($adjustmentType !== '') { + $query->where('sa.adjustment_type', $adjustmentType); + } + + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds !== null) { + if ($siteIds === []) { + $query->whereRaw('0 = 1'); + } else { + $query->whereIn('sp.admin_site_id', $siteIds); + } + } + + return ApiResponse::success([ + 'items' => $query->limit(200)->get(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php new file mode 100644 index 0000000..e244a97 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php @@ -0,0 +1,57 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); + + $before = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); + abort_if($before === null, 404); + + $newBillId = $adjustments->createAdjustment( + $settlement_bill, + (int) $request->validated('amount'), + (string) ($request->validated('adjustment_type') ?? 'adjustment'), + $request->validated('reason'), + (int) $admin->id, + ); + + $after = DB::table('settlement_bills')->where('id', $newBillId)->first(); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'settlement', + actionCode: 'settlement_bill.adjustment', + targetType: 'settlement_bill', + targetId: (string) $newBillId, + beforeJson: (array) $before, + afterJson: (array) $after, + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success([ + 'original_bill_id' => $settlement_bill, + 'adjustment_bill_id' => $newBillId, + 'bill' => $after, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php new file mode 100644 index 0000000..02603e7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php @@ -0,0 +1,55 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); + + $before = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); + abort_if($before === null, 404); + + $archiveBillId = $badDebt->writeOff( + $settlement_bill, + $request->validated('reason'), + (int) $admin->id, + ); + + $after = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'settlement', + actionCode: 'settlement_bill.bad_debt', + targetType: 'settlement_bill', + targetId: (string) $settlement_bill, + beforeJson: (array) $before, + afterJson: (array) $after, + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success([ + 'original_bill_id' => $settlement_bill, + 'bad_debt_bill_id' => $archiveBillId, + 'bill' => $after, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php index 71493a1..d864ab4 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php @@ -4,9 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; use App\Http\Controllers\Controller; use App\Http\Middleware\RecordAdminApiAudit; -use App\Models\Player; +use App\Services\AgentSettlement\SettlementPaymentService; use App\Services\AuditLogger; -use App\Services\Player\PlayerCreditService; use App\Support\AdminAgentSettlementScope; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; @@ -18,7 +17,7 @@ final class AgentSettlementBillConfirmController extends Controller public function __invoke( Request $request, int $settlement_bill, - PlayerCreditService $creditService, + SettlementPaymentService $payments, ): JsonResponse { $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); @@ -28,21 +27,7 @@ final class AgentSettlementBillConfirmController extends Controller $bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); abort_if($bill === null, 404); - $unpaid = (int) $bill->unpaid_amount; - DB::table('settlement_bills')->where('id', $settlement_bill)->update([ - 'paid_amount' => (int) $bill->paid_amount + $unpaid, - 'unpaid_amount' => 0, - 'status' => 'confirmed', - 'confirmed_at' => now(), - 'updated_at' => now(), - ]); - - if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) { - $player = Player::query()->find((int) $bill->owner_id); - if ($player !== null) { - $creditService->releaseFromSettlement($player, $unpaid, $settlement_bill); - } - } + $payments->confirmBill($settlement_bill); AuditLogger::recordForAdmin( $admin, @@ -51,8 +36,8 @@ final class AgentSettlementBillConfirmController extends Controller actionCode: 'settlement_bill.confirm', targetType: 'settlement_bill', targetId: (string) $settlement_bill, - beforeJson: ['status' => (string) $bill->status, 'unpaid_amount' => $unpaid], - afterJson: ['status' => 'confirmed', 'paid_amount' => (int) $bill->paid_amount + $unpaid], + beforeJson: ['status' => (string) $bill->status], + afterJson: ['status' => 'confirmed'], ); $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php index 0055049..e17113d 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php @@ -7,6 +7,7 @@ use App\Support\AdminAgentSettlementScope; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; final class AgentSettlementBillIndexController extends Controller @@ -17,15 +18,142 @@ final class AgentSettlementBillIndexController extends Controller abort_if($admin === null, 401); $periodId = (int) $request->query('settlement_period_id', 0); - $query = DB::table('settlement_bills')->orderByDesc('id'); + $adminSiteId = (int) $request->query('admin_site_id', 0); + + $query = DB::table('settlement_bills as sb') + ->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->select([ + 'sb.*', + 'sp.period_start', + 'sp.period_end', + 'sp.admin_site_id', + ]) + ->orderByDesc('sb.id'); + if ($periodId > 0) { - $query->where('settlement_period_id', $periodId); + $query->where('sb.settlement_period_id', $periodId); } - AdminAgentSettlementScope::applyToBillsQuery($query, $admin); + if ($adminSiteId > 0) { + $query->where('sp.admin_site_id', $adminSiteId); + } + + $billType = (string) $request->query('bill_type', ''); + if ($billType !== '') { + $query->where('sb.bill_type', $billType); + } + + $scope = (string) $request->query('scope', ''); + match ($scope) { + 'pending_confirm' => $query->where('sb.status', 'pending_confirm'), + 'awaiting_payment' => $query + ->whereIn('sb.status', ['confirmed', 'partial_paid', 'overdue']) + ->where('sb.unpaid_amount', '>', 0), + 'settled' => $query->where('sb.status', 'settled'), + 'adjustment' => $query->whereIn('sb.bill_type', ['adjustment', 'reversal']), + default => null, + }; + + AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb'); + + /** @var Collection $items */ + $items = $query->limit(200)->get(); return ApiResponse::success([ - 'items' => $query->limit(100)->get(), + 'items' => $this->enrichBillRows($items), ]); } + + /** + * @param Collection $items + * @return list> + */ + private function enrichBillRows(Collection $items): array + { + if ($items->isEmpty()) { + return []; + } + + $playerIds = []; + $agentIds = []; + foreach ($items as $row) { + if ((string) $row->owner_type === 'player') { + $playerIds[] = (int) $row->owner_id; + } elseif ((string) $row->owner_type === 'agent') { + $agentIds[] = (int) $row->owner_id; + } + if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) { + $agentIds[] = (int) $row->counterparty_id; + } + } + + $players = $playerIds !== [] + ? DB::table('players') + ->whereIn('id', array_unique($playerIds)) + ->select(['id', 'username', 'site_player_id', 'funding_mode', 'auth_source']) + ->get() + ->keyBy('id') + : collect(); + $agents = $agentIds !== [] + ? DB::table('agent_nodes')->whereIn('id', array_unique($agentIds))->get()->keyBy('id') + : collect(); + + $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') { + $player = $players->get((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; + } + $out[] = $item; + } + + return $out; + } + + /** + * @param Collection $players + * @param Collection $agents + */ + private function resolvePartyLabel( + 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) + : "player#{$id}"; + } + + if ($type === 'agent') { + $agent = $agents->get($id); + + return $agent !== null + ? (string) ($agent->name ?: $agent->code) + : "agent#{$id}"; + } + + return "{$type}#{$id}"; + } } diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php new file mode 100644 index 0000000..dd2a883 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php @@ -0,0 +1,57 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); + + $before = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); + abort_if($before === null, 404); + + $validated = $request->validated(); + $payments->recordPayment( + $settlement_bill, + (int) $validated['amount'], + (int) $admin->id, + [ + 'method' => $validated['method'] ?? null, + 'proof' => $validated['proof'] ?? null, + 'remark' => $validated['remark'] ?? null, + ], + ); + + $after = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'settlement', + actionCode: 'settlement_bill.payment', + targetType: 'settlement_bill', + targetId: (string) $settlement_bill, + beforeJson: (array) $before, + afterJson: (array) $after, + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success(['bill' => $after]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php new file mode 100644 index 0000000..dc2dc89 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php @@ -0,0 +1,53 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); + + $bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); + abort_if($bill === null, 404); + + $payments = DB::table('payment_records') + ->where('settlement_bill_id', $settlement_bill) + ->orderBy('id') + ->get(); + + $rebateAllocations = DB::table('rebate_allocations') + ->where('settlement_bill_id', $settlement_bill) + ->orderBy('id') + ->get(); + + $adjustments = DB::table('settlement_adjustments') + ->where('original_bill_id', $settlement_bill) + ->orderByDesc('id') + ->get(); + + $meta = $bill->meta_json ?? null; + $tierSettlements = null; + if (is_string($meta) && $meta !== '') { + $decoded = json_decode($meta, true); + $tierSettlements = is_array($decoded) ? ($decoded['edge'] ?? $decoded['tier_settlements'] ?? null) : null; + } + + return ApiResponse::success([ + 'bill' => $bill, + 'payments' => $payments, + 'rebate_allocations' => $rebateAllocations, + 'adjustments' => $adjustments, + 'tier_edge' => $tierSettlements, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php new file mode 100644 index 0000000..10e53ae --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php @@ -0,0 +1,58 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $periodId = (int) $request->query('settlement_period_id', 0); + $adminSiteId = (int) $request->query('admin_site_id', 0); + + $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') + ->select([ + 'pr.*', + 'sb.bill_type', + 'sb.owner_type', + 'sb.owner_id', + 'sb.counterparty_type', + 'sb.counterparty_id', + 'sp.period_start', + 'sp.period_end', + 'sp.admin_site_id', + ]) + ->orderByDesc('pr.id'); + + if ($periodId > 0) { + $query->where('sb.settlement_period_id', $periodId); + } + + if ($adminSiteId > 0) { + $query->where('sp.admin_site_id', $adminSiteId); + } + + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds !== null) { + if ($siteIds === []) { + $query->whereRaw('0 = 1'); + } else { + $query->whereIn('sp.admin_site_id', $siteIds); + } + } + + return ApiResponse::success([ + 'items' => $query->limit(200)->get(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodIndexController.php new file mode 100644 index 0000000..73bfa85 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodIndexController.php @@ -0,0 +1,36 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $query = DB::table('settlement_periods')->orderByDesc('id'); + AdminAgentSettlementScope::applyToPeriodsQuery($query, $admin); + + $siteId = (int) $request->query('admin_site_id', 0); + if ($siteId > 0) { + $query->where('admin_site_id', $siteId); + } + + $periods = $query->limit(100)->get(); + + return ApiResponse::success([ + 'items' => $summaryService->attachToPeriodRows($periods), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportIndexController.php new file mode 100644 index 0000000..0b7cced --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportIndexController.php @@ -0,0 +1,24 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $periodId = (int) $request->query('settlement_period_id', 0); + + return ApiResponse::success($reports->summary($admin, $periodId)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php new file mode 100644 index 0000000..c0b726a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php @@ -0,0 +1,106 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $type = (string) $request->query('type', 'summary'); + abort_unless(in_array($type, self::TYPES, true), 404); + + $periodId = (int) $request->query('settlement_period_id', 0); + $period = $this->resolvePeriod($periodId, $request); + + $data = match ($type) { + 'summary' => $reports->summary($admin, $periodId), + 'player_win_loss' => [ + 'items' => $reports->playerWinLoss($admin, $periodId, $period['start'], $period['end']), + ], + 'agent_share' => [ + 'items' => $reports->agentShare($admin, $period['start'], $period['end']), + ], + 'rebate' => $reports->rebate($admin, $periodId, $period['start'], $period['end']), + 'credit' => $reports->credit($admin), + 'unpaid_bills' => [ + 'items' => $reports->unpaidBills($admin, $periodId), + ], + 'overdue' => [ + 'items' => $reports->overdue($admin), + ], + 'platform_pnl' => $periodId > 0 + ? $reports->platformPnl($admin, $periodId) + : ['error' => 'settlement_period_id_required'], + 'draw_period' => [ + 'items' => $reports->drawPeriod($admin, $period['start'], $period['end']), + ], + default => [], + }; + + return ApiResponse::success([ + 'type' => $type, + 'settlement_period_id' => $periodId > 0 ? $periodId : null, + 'period_start' => $period['start'], + 'period_end' => $period['end'], + 'data' => $data, + 'footnote' => $type === 'summary' + ? null + : 'agent_credit_line_settlement', + ]); + } + + /** + * @return array{start: string, end: string} + */ + private function resolvePeriod(int $periodId, Request $request): array + { + if ($periodId > 0) { + $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, + ]; + } + + $request->validate([ + 'period_start' => ['required_with:period_end', 'date'], + 'period_end' => ['required_with:period_start', 'date', 'after_or_equal:period_start'], + ]); + + $start = $request->query('period_start'); + $end = $request->query('period_end'); + if ($start && $end) { + return ['start' => (string) $start, 'end' => (string) $end]; + } + + $now = now(); + + return [ + 'start' => $now->copy()->subDays(7)->toDateTimeString(), + 'end' => $now->toDateTimeString(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php index b239f2d..261e123 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawFinanceSummaryController.php @@ -10,7 +10,10 @@ use App\Support\ApiResponse; use App\Models\SettlementBatch; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Lottery\ErrorCode; +use App\Support\AdminDrawResponsePolicy; use App\Support\AdminScopePolicy; +use App\Support\ApiMessage; /** * GET /api/v1/admin/draws/{draw}/finance-summary — 单期投注/派彩汇总(客服/财务视角,PRD §15.4)。 @@ -23,6 +26,17 @@ final class AdminDrawFinanceSummaryController extends Controller { $admin = $request->lotteryAdmin(); abort_if(! $admin instanceof AdminUser, 401); + + if (! AdminDrawResponsePolicy::canViewDrawFinance($admin)) { + return ApiMessage::errorResponse( + $request, + 'admin.permission_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + $scope = AdminScopePolicy::resolveContext($request, $admin); $drawId = (int) $draw->id; diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php index 8c6bae3..ac413c6 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw; -use Carbon\Carbon; use App\Models\AdminUser; use App\Models\Draw; use App\Models\TicketItem; @@ -10,6 +9,8 @@ use App\Models\TicketOrder; use Illuminate\Http\Request; use App\Support\AdminApiList; use App\Support\AdminScopeContext; +use App\Support\AdminDrawApiPresenter; +use App\Support\AdminDrawResponsePolicy; use App\Support\AdminScopePolicy; use App\Services\LotterySettings; use Illuminate\Http\JsonResponse; @@ -44,19 +45,30 @@ final class AdminDrawIndexController extends Controller /** @var LengthAwarePaginator $paginator */ $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); - $statsByDrawId = $this->aggregateListStats( - $paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(), - $scope, - ); + $statsByDrawId = AdminDrawResponsePolicy::canViewDrawFinance($admin) + ? $this->aggregateListStats( + $paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(), + $scope, + ) + : []; - return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row, $statsByDrawId), [ - 'schedule' => [ - 'timezone' => LotterySettings::drawTimezone(), - 'interval_minutes' => LotterySettings::drawIntervalMinutes(), - 'betting_window_seconds' => LotterySettings::drawBettingWindowSeconds(), - 'close_before_draw_seconds' => LotterySettings::drawCloseBeforeDrawSeconds(), + return AdminApiList::jsonWith( + $paginator, + fn (Draw $row): array => AdminDrawApiPresenter::listRow( + $row, + $statsByDrawId[(int) $row->id] ?? null, + $admin, + ), + [ + 'schedule' => [ + 'timezone' => LotterySettings::drawTimezone(), + 'interval_minutes' => LotterySettings::drawIntervalMinutes(), + 'betting_window_seconds' => LotterySettings::drawBettingWindowSeconds(), + 'close_before_draw_seconds' => LotterySettings::drawCloseBeforeDrawSeconds(), + ], + 'capabilities' => AdminDrawResponsePolicy::capabilities($admin), ], - ]); + ); } /** @@ -129,38 +141,4 @@ final class AdminDrawIndexController extends Controller }); } - /** - * @param array $statsByDrawId - * @return array - */ - private function row(Draw $draw, array $statsByDrawId): array - { - $stats = $statsByDrawId[(int) $draw->id] ?? [ - 'total_bet_minor' => 0, - 'total_payout_minor' => 0, - 'profit_loss_minor' => 0, - ]; - - return [ - 'id' => (int) $draw->id, - 'draw_no' => $draw->draw_no, - 'business_date' => $draw->business_date instanceof Carbon - ? $draw->business_date->format('Y-m-d') - : (string) $draw->business_date, - 'sequence_no' => (int) $draw->sequence_no, - 'status' => $draw->status, - 'start_time' => $draw->start_time?->toIso8601String(), - 'close_time' => $draw->close_time?->toIso8601String(), - 'draw_time' => $draw->draw_time?->toIso8601String(), - 'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(), - 'result_source' => $draw->result_source, - 'current_result_version' => (int) $draw->current_result_version, - 'settle_version' => (int) $draw->settle_version, - 'is_reopened' => (bool) $draw->is_reopened, - 'total_bet_minor' => $stats['total_bet_minor'], - 'total_payout_minor' => $stats['total_payout_minor'], - 'profit_loss_minor' => $stats['profit_loss_minor'], - 'updated_at' => $draw->updated_at?->toIso8601String(), - ]; - } } diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php index 18b479a..819a0ed 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php @@ -4,56 +4,46 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw; use App\Models\Draw; use App\Support\ApiResponse; -use App\Models\DrawResultItem; use App\Models\DrawResultBatch; +use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminDrawApiPresenter; +use App\Support\AdminDrawResponsePolicy; +use App\Lottery\DrawResultBatchStatus; /** * GET /api/v1/admin/draws/{draw}/result-batches — 开奖批次与号码(审核/结果核对)。 */ final class AdminDrawResultBatchesIndexController extends Controller { - public function __invoke(Draw $draw): JsonResponse + public function __invoke(Request $request, Draw $draw): JsonResponse { - $batches = $draw->resultBatches() + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + + $manage = AdminDrawResponsePolicy::canManageDrawResults($admin); + + $query = $draw->resultBatches() ->with(['items' => function ($q): void { $q->orderBy('prize_type')->orderBy('prize_index'); }]) - ->orderByDesc('result_version') - ->get(); + ->orderByDesc('result_version'); + + if (! $manage) { + $query->where('status', DrawResultBatchStatus::Published->value); + } + + $batches = $query->get(); return ApiResponse::success([ 'draw_id' => (int) $draw->id, 'draw_no' => $draw->draw_no, 'draw_status' => $draw->status, - 'batches' => $batches->map(fn (DrawResultBatch $b) => $this->serializeBatch($b))->all(), + 'capabilities' => AdminDrawResponsePolicy::capabilities($admin), + 'batches' => $batches + ->map(fn (DrawResultBatch $b): array => AdminDrawApiPresenter::resultBatch($b, $admin)) + ->all(), ]); } - - /** @return array */ - private function serializeBatch(DrawResultBatch $batch): array - { - return [ - 'id' => (int) $batch->id, - 'result_version' => (int) $batch->result_version, - 'source_type' => $batch->source_type, - 'rng_seed_hash' => $batch->rng_seed_hash, - 'status' => $batch->status, - 'created_by' => $batch->created_by, - 'confirmed_by' => $batch->confirmed_by, - 'confirmed_at' => $batch->confirmed_at?->toIso8601String(), - 'created_at' => $batch->created_at?->toIso8601String(), - 'updated_at' => $batch->updated_at?->toIso8601String(), - 'items' => $batch->items->map(fn (DrawResultItem $item) => [ - 'prize_type' => $item->prize_type, - 'prize_index' => (int) $item->prize_index, - 'number_4d' => $item->number_4d, - 'suffix_3d' => $item->suffix_3d, - 'suffix_2d' => $item->suffix_2d, - 'head_digit' => $item->head_digit, - 'tail_digit' => $item->tail_digit, - ])->values()->all(), - ]; - } } diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawShowController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawShowController.php index 0846363..ca7731f 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawShowController.php @@ -2,12 +2,12 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw; -use Carbon\Carbon; use App\Models\Draw; use App\Support\ApiResponse; +use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; -use App\Lottery\DrawResultBatchStatus; +use App\Support\AdminDrawApiPresenter; use App\Services\Draw\DrawHallSnapshotBuilder; /** @@ -19,41 +19,13 @@ final class AdminDrawShowController extends Controller private readonly DrawHallSnapshotBuilder $hallPreview, ) {} - public function __invoke(Draw $draw): JsonResponse + public function __invoke(Request $request, Draw $draw): JsonResponse { - $nowUtc = now()->utc(); - $batchCounts = [ - 'total' => $draw->resultBatches()->count(), - 'pending_review' => $draw->resultBatches() - ->where('status', DrawResultBatchStatus::PendingReview->value) - ->count(), - 'published' => $draw->resultBatches() - ->where('status', DrawResultBatchStatus::Published->value) - ->count(), - ]; + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); - return ApiResponse::success([ - 'id' => (int) $draw->id, - 'draw_no' => $draw->draw_no, - 'business_date' => $draw->business_date instanceof Carbon - ? $draw->business_date->format('Y-m-d') - : (string) $draw->business_date, - 'sequence_no' => (int) $draw->sequence_no, - /** 数据库当期状态(权威) */ - 'status' => $draw->status, - /** 与玩家大厅 snapshot 对齐的展示态(未跑 tick 时可能与 status 不一致) */ - 'hall_preview_status' => $this->hallPreview->effectiveHallDisplayStatus($draw, $nowUtc), - 'start_time' => $draw->start_time?->toIso8601String(), - 'close_time' => $draw->close_time?->toIso8601String(), - 'draw_time' => $draw->draw_time?->toIso8601String(), - 'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(), - 'result_source' => $draw->result_source, - 'current_result_version' => (int) $draw->current_result_version, - 'settle_version' => (int) $draw->settle_version, - 'is_reopened' => (bool) $draw->is_reopened, - 'created_at' => $draw->created_at?->toIso8601String(), - 'updated_at' => $draw->updated_at?->toIso8601String(), - 'result_batch_counts' => $batchCounts, - ]); + return ApiResponse::success( + AdminDrawApiPresenter::show($draw, $admin, $this->hallPreview), + ); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteIndexController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteIndexController.php index 33c13fc..1830467 100644 --- a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteIndexController.php @@ -6,6 +6,7 @@ use App\Support\ApiResponse; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Models\AgentNode; use App\Support\AdminIntegrationSiteAccess; use App\Support\AdminIntegrationSitePresenter; @@ -16,9 +17,18 @@ final class AdminIntegrationSiteIndexController extends Controller $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); - $items = AdminIntegrationSiteAccess::queryFor($admin) - ->get() - ->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem($site)) + $sites = AdminIntegrationSiteAccess::queryFor($admin)->get(); + $rootSiteIds = AgentNode::query() + ->where('depth', 0) + ->whereIn('admin_site_id', $sites->pluck('id')) + ->pluck('admin_site_id') + ->flip(); + + $items = $sites + ->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem( + $site, + isset($rootSiteIds[(int) $site->id]), + )) ->all(); return ApiResponse::success(['items' => $items]); diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteSecretsController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteSecretsController.php new file mode 100644 index 0000000..77596c1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteSecretsController.php @@ -0,0 +1,51 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) { + return ApiMessage::errorResponse($request, 'admin.site_access_denied', ErrorCode::AdminForbidden->value, null, 403); + } + + if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('integration.site.manage')) { + return ApiMessage::errorResponse($request, 'admin.permission_denied', ErrorCode::AdminForbidden->value, null, 403); + } + + $sso = $admin_site->decryptedSsoJwtSecret(); + $wallet = $admin_site->decryptedWalletApiKey(); + + AuditLogger::recordForAdmin( + $admin, + $request, + moduleCode: 'integration', + actionCode: 'reveal_secrets', + targetType: 'admin_site', + targetId: (string) $admin_site->id, + afterJson: ['code' => $admin_site->code], + ); + $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); + + return ApiResponse::success([ + 'sso_jwt_secret' => is_string($sso) ? $sso : '', + 'wallet_api_key' => is_string($wallet) ? $wallet : '', + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php index 362b414..37b1d2d 100644 --- a/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Integration/AdminIntegrationSiteStoreController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers\Api\V1\Admin\Integration; use App\Support\ApiResponse; -use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Services\AuditLogger; @@ -11,8 +10,6 @@ use App\Services\Integration\IntegrationSiteService; use App\Support\AdminIntegrationSitePresenter; use App\Http\Requests\Admin\AdminIntegrationSiteStoreRequest; use App\Http\Middleware\RecordAdminApiAudit; -use App\Lottery\ErrorCode; -use App\Support\ApiMessage; final class AdminIntegrationSiteStoreController extends Controller { @@ -23,19 +20,6 @@ final class AdminIntegrationSiteStoreController extends Controller $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); - if (! $admin->isSuperAdmin()) { - return ApiMessage::errorResponse( - $request, - 'admin.integration_site_store_deprecated', - ErrorCode::AdminForbidden->value, - ['hint' => 'Use POST /api/v1/admin/agent-lines to provision a new agent line.'], - 403, - )->withHeaders([ - 'Deprecation' => 'true', - 'Link' => '; rel="successor-version"', - ]); - } - $result = $service->create($request->validated()); $site = $result['site']; diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerDestroyController.php index 9ea454b..0a9c739 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerDestroyController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerDestroyController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Player; use App\Models\Player; +use App\Models\TicketOrder; use App\Lottery\ErrorCode; use App\Support\ApiMessage; use App\Support\ApiResponse; @@ -10,7 +11,7 @@ use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Support\AdminSiteScope; -use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Builder; /** DELETE /api/v1/admin/players/{player} */ final class AdminPlayerDestroyController extends Controller @@ -26,16 +27,15 @@ final class AdminPlayerDestroyController extends Controller $hasWallets = Player::query() ->whereKey($player->getKey()) - ->whereHas('wallets', static fn (HasMany $q) => $q->whereRaw('balance != 0')) + ->whereHas('wallets', static fn (Builder $q) => $q->where('balance', '!=', 0)) ->exists(); if ($hasWallets) { return ApiMessage::errorResponse($request, 'admin.player_wallet_balance_blocks_delete', ErrorCode::ValidationFailed->value, null, 422); } - $hasTickets = Player::query() - ->whereKey($player->getKey()) - ->whereHas('ticketOrders') + $hasTickets = TicketOrder::query() + ->where('player_id', $player->getKey()) ->exists(); if ($hasTickets) { diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php index 9e78d74..f344b39 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php @@ -37,7 +37,11 @@ final class AdminPlayerIndexController extends Controller $term = '%'.addcslashes($keyword, '%_\\').'%'; $q->where(static function ($sub) use ($term): void { $sub->where('site_player_id', 'like', $term) - ->orWhere('username', 'like', $term); + ->orWhere('username', 'like', $term) + ->orWhere('nickname', 'like', $term); + if (ctype_digit($keyword)) { + $sub->orWhere('id', (int) $keyword); + } }); } diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php index 23acbc8..6a3b786 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php @@ -16,6 +16,10 @@ use App\Models\AgentNode; use App\Services\Agent\AgentProfileService; use App\Services\Agent\RebateLimitValidator; use App\Services\Player\PlayerCreditService; +use App\Support\PlayerAuthSource; +use App\Support\PlayerFundingMode; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; /** POST /api/v1/admin/players */ final class AdminPlayerStoreController extends Controller @@ -46,15 +50,44 @@ final class AdminPlayerStoreController extends Controller return ApiMessage::errorResponse($request, 'admin.player_create_site_forbidden', ErrorCode::AdminForbidden->value, null, 403); } + $isNative = $request->filled('password'); + $sitePlayerId = (string) ($request->validated('site_player_id') ?? ''); + if ($isNative) { + $sitePlayerId = $sitePlayerId !== '' + ? $sitePlayerId + : 'native:'.Str::lower(Str::ulid()); + } + + if ($sitePlayerId === '') { + return ApiMessage::errorResponse($request, 'admin.player_site_player_id_required', ErrorCode::ValidationFailed->value, null, 422); + } + $exists = Player::query() - ->where('site_code', $request->validated('site_code')) - ->where('site_player_id', $request->validated('site_player_id')) + ->where('site_code', $siteCode) + ->where('site_player_id', $sitePlayerId) ->exists(); if ($exists) { return ApiMessage::errorResponse($request, 'admin.player_already_registered', ErrorCode::ValidationFailed->value, null, 422); } + if ($isNative) { + $username = trim((string) $request->validated('username', '')); + if ($username === '') { + return ApiMessage::errorResponse($request, 'admin.player_native_username_required', ErrorCode::ValidationFailed->value, null, 422); + } + + $usernameTaken = Player::query() + ->where('site_code', $siteCode) + ->where('username', $username) + ->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE) + ->exists(); + + if ($usernameTaken) { + return ApiMessage::errorResponse($request, 'admin.player_username_taken', ErrorCode::ValidationFailed->value, null, 422); + } + } + $agentNodeId = $admin->isSuperAdmin() ? $this->resolveAgentNodeIdForSuperAdmin($request->validated('agent_node_id'), $siteCode) : $admin->primaryAgentNodeId(); @@ -85,33 +118,58 @@ final class AdminPlayerStoreController extends Controller ); } - $player = Player::query()->create([ - 'site_code' => $request->validated('site_code'), - 'agent_node_id' => $agentNodeId, - 'site_player_id' => $request->validated('site_player_id'), - 'username' => $request->validated('username'), - 'nickname' => $request->validated('nickname'), - 'default_currency' => $request->validated('default_currency', 'NPR'), - 'status' => $request->validated('status', 0), - ]); + $creditLimit = $request->has('credit_limit') + ? (int) $request->input('credit_limit', 0) + : ($isNative ? 0 : 0); - if ($request->has('credit_limit')) { - $playerCreditService->upsertAccount($player, [ - 'credit_limit' => (int) $request->input('credit_limit', 0), - ]); - } + $player = \Illuminate\Support\Facades\DB::transaction(function () use ( + $agent, + $agentProfileService, + $playerCreditService, + $request, + $isNative, + $siteCode, + $agentNodeId, + $sitePlayerId, + $creditLimit, + ): Player { + $agentProfileService->assertMayIncreasePlayerCredit($agent, $creditLimit); - if ($request->has('rebate_rate')) { - \Illuminate\Support\Facades\DB::table('player_rebate_profiles')->insert([ - 'player_id' => $player->id, - 'game_type' => '*', - 'inherit_from_agent' => false, - 'rebate_rate' => (float) $request->input('rebate_rate', 0), - 'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0), - 'created_at' => now(), - 'updated_at' => now(), + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $agentNodeId, + 'site_player_id' => $sitePlayerId, + 'auth_source' => $isNative ? PlayerAuthSource::LOTTERY_NATIVE : PlayerAuthSource::MAIN_SITE_SSO, + 'funding_mode' => $isNative ? PlayerFundingMode::CREDIT : PlayerFundingMode::WALLET, + 'username' => $isNative ? trim((string) $request->validated('username')) : $request->validated('username'), + 'password_hash' => $isNative ? Hash::make((string) $request->validated('password')) : null, + 'nickname' => $request->validated('nickname'), + 'default_currency' => $request->validated('default_currency', 'NPR'), + 'status' => $request->validated('status', 0), ]); - } + + if ($request->has('credit_limit') || $isNative) { + $playerCreditService->upsertAccount($player, [ + 'credit_limit' => $creditLimit, + ]); + } + + $agentProfileService->refreshAllocatedCredit($agent); + + if ($request->has('rebate_rate')) { + \Illuminate\Support\Facades\DB::table('player_rebate_profiles')->insert([ + 'player_id' => $player->id, + 'game_type' => '*', + 'inherit_from_agent' => false, + 'rebate_rate' => (float) $request->input('rebate_rate', 0), + 'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + return $player; + }); return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201); } diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php index 8432284..52a9cd9 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php @@ -2,20 +2,31 @@ namespace App\Http\Controllers\Api\V1\Admin\Player; -use App\Models\Player; -use App\Lottery\ErrorCode; -use App\Support\ApiResponse; -use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; -use App\Support\AdminSiteScope; -use App\Support\PlayerApiPresenter; use App\Http\Requests\Admin\AdminPlayerUpdateRequest; +use App\Models\AgentNode; +use App\Models\Player; +use App\Services\Agent\AgentProfileService; +use App\Services\Agent\RebateLimitValidator; +use App\Services\Player\PlayerCreditService; +use App\Services\Player\PlayerRebateProfileService; +use App\Support\AdminSiteScope; +use App\Support\ApiResponse; +use App\Support\PlayerApiPresenter; +use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; /** PUT /api/v1/admin/players/{player} */ final class AdminPlayerUpdateController extends Controller { - public function __invoke(AdminPlayerUpdateRequest $request, Player $player): JsonResponse - { + public function __invoke( + AdminPlayerUpdateRequest $request, + Player $player, + AgentProfileService $agentProfileService, + PlayerCreditService $playerCreditService, + RebateLimitValidator $rebateLimitValidator, + PlayerRebateProfileService $rebateProfileService, + ): JsonResponse { $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); @@ -29,7 +40,57 @@ final class AdminPlayerUpdateController extends Controller $data['status'] = (int) $data['status']; } - $player->fill(array_filter($data, static fn ($v) => $v !== '')); + $agent = $player->agent_node_id !== null + ? AgentNode::query()->find((int) $player->agent_node_id) + : null; + + if ($agent !== null && $request->has('rebate_rate')) { + $rebateLimitValidator->assertPlayerRebateWithinAgent( + $agent, + (float) $request->input('rebate_rate', 0), + (float) $request->input('extra_rebate_rate', 0), + ); + } + + if ($request->has('credit_limit') && $agent !== null) { + $newLimit = (int) $request->input('credit_limit', 0); + $creditRow = DB::table('player_credit_accounts')->where('player_id', $player->id)->first(); + $previous = (int) ($creditRow->credit_limit ?? 0); + $usedCredit = (int) ($creditRow->used_credit ?? 0) + (int) ($creditRow->frozen_credit ?? 0); + $agentProfileService->adjustPlayerCreditAllocation($agent, $previous, $newLimit, $usedCredit); + $playerCreditService->upsertAccount($player, ['credit_limit' => $newLimit]); + $agentProfileService->refreshAllocatedCredit($agent); + unset($data['credit_limit']); + } + + if ($request->has('rebate_rate')) { + DB::table('player_rebate_profiles')->updateOrInsert( + ['player_id' => $player->id, 'game_type' => '*'], + [ + 'inherit_from_agent' => false, + 'rebate_rate' => (float) $request->input('rebate_rate', 0), + 'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0), + 'updated_at' => now(), + 'created_at' => now(), + ], + ); + unset($data['rebate_rate'], $data['extra_rebate_rate']); + } + + if ($request->has('rebate_profiles') && $agent !== null) { + $rebateProfileService->syncProfiles($player->id, $agent, $request->input('rebate_profiles', [])); + unset($data['rebate_profiles']); + } + + if ($request->has('risk_tags')) { + $player->risk_tags = array_values(array_unique(array_filter( + array_map('strval', $request->input('risk_tags', [])), + ))); + unset($data['risk_tags']); + } + + unset($data['rebate_profiles']); + $player->fill(array_filter($data, static fn ($v) => $v !== '' && ! is_array($v))); $player->save(); return ApiResponse::success(PlayerApiPresenter::listItem($player)); diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php index dc08a02..c1ddca1 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php @@ -32,7 +32,7 @@ final class AdminReportRebateCommissionController extends Controller $scope, ); - return AdminApiList::json($paginator, static function (object $row): array { + $response = AdminApiList::json($paginator, static function (object $row): array { return [ 'play_code' => (string) $row->play_code, 'total_rebate_minor' => (int) $row->total_rebate_minor, @@ -40,5 +40,13 @@ final class AdminReportRebateCommissionController extends Controller 'ticket_item_count' => (int) $row->ticket_item_count, ]; }); + + $payload = $response->getData(true); + if (is_array($payload)) { + $payload['disclaimer'] = 'wallet_instant_rebate_not_agent_period_settlement'; + $response->setData($payload); + } + + return $response; } } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php index e9f97d4..155dd18 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleDestroyController.php @@ -12,6 +12,7 @@ use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Support\AdminAccountScopeGuard; use App\Support\AdminRoleApiPresenter; +use App\Support\PlatformSystemRoles; final class AdminRoleDestroyController extends Controller { @@ -19,8 +20,8 @@ final class AdminRoleDestroyController extends Controller { AdminAccountScopeGuard::assertSystemRole($admin_role); - if ($admin_role->slug === AdminRole::ROLE_SUPER_ADMIN) { - return ApiMessage::errorResponse($request, 'admin.role_cannot_delete_super_admin', ErrorCode::ValidationFailed->value, null, 422); + if (PlatformSystemRoles::isFixedSlug((string) $admin_role->slug)) { + return ApiMessage::errorResponse($request, 'admin.role_builtin_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); } if ((bool) $admin_role->is_system) { return ApiMessage::errorResponse($request, 'admin.role_builtin_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php index 54440f0..df4a014 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleIndexController.php @@ -7,6 +7,7 @@ use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Support\AdminRoleApiPresenter; +use App\Support\PlatformSystemRoles; final class AdminRoleIndexController extends Controller { @@ -14,6 +15,7 @@ final class AdminRoleIndexController extends Controller { $roles = AdminRole::query() ->where('scope_type', AdminRole::SCOPE_SYSTEM) + ->whereIn('slug', PlatformSystemRoles::fixedSlugs()) ->orderBy('sort_order') ->orderBy('id') ->get(); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php index b393b7e..018be07 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRolePermissionSyncController.php @@ -9,8 +9,11 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; use App\Support\AdminPermissionInheritance; +use App\Lottery\ErrorCode; use App\Support\AdminAccountScopeGuard; use App\Support\AdminRoleApiPresenter; +use App\Support\ApiMessage; +use App\Support\PlatformSystemRoles; use App\Http\Requests\Admin\AdminRolePermissionSyncRequest; final class AdminRolePermissionSyncController extends Controller @@ -19,6 +22,16 @@ final class AdminRolePermissionSyncController extends Controller { AdminAccountScopeGuard::assertSystemRole($admin_role); + if ($admin_role->slug === PlatformSystemRoles::SLUG_SUPER_ADMIN) { + return ApiMessage::errorResponse( + $request, + 'admin.role_super_admin_permissions_fixed', + ErrorCode::ValidationFailed->value, + null, + 422, + ); + } + $slugs = AdminPermissionInheritance::expand( array_values(array_unique($request->validated('permission_slugs', []))), ); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php index 5a1aa3f..0bd66dd 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleStoreController.php @@ -2,53 +2,22 @@ namespace App\Http\Controllers\Api\V1\Admin\User; -use App\Models\AdminRole; -use App\Support\ApiResponse; -use App\Services\AuditLogger; +use App\Lottery\ErrorCode; +use App\Support\ApiMessage; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; -use App\Support\AdminPermissionInheritance; -use App\Support\AdminRoleApiPresenter; use App\Http\Requests\Admin\AdminRoleStoreRequest; final class AdminRoleStoreController extends Controller { public function __invoke(AdminRoleStoreRequest $request): JsonResponse { - $permissionSlugs = AdminPermissionInheritance::expand( - array_values(array_unique($request->validated('permission_slugs', []))), - ); - - $role = DB::transaction(function () use ($request, $permissionSlugs): AdminRole { - $role = AdminRole::query()->create([ - 'slug' => $request->validated('slug'), - 'code' => $request->validated('slug'), - 'name' => $request->validated('name'), - 'description' => $request->validated('description'), - 'status' => $request->validated('status', 1), - 'is_system' => false, - 'sort_order' => 0, - 'scope_type' => AdminRole::SCOPE_SYSTEM, - 'owner_agent_id' => null, - 'delegated_from_role_id' => null, - ]); - $role->syncLegacyPermissionSlugs($permissionSlugs); - - return $role->fresh(); - }); - - AuditLogger::recordForAdmin( - $request->lotteryAdmin(), + return ApiMessage::errorResponse( $request, - 'system', - 'admin_role.create', - 'admin_role', - (string) $role->id, + 'admin.platform_roles_fixed', + ErrorCode::ValidationFailed->value, null, - AdminRoleApiPresenter::item($role), + 422, ); - - return ApiResponse::success(AdminRoleApiPresenter::item($role)); } } diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php index 09ac5f1..06f92f9 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminRoleUpdateController.php @@ -3,12 +3,15 @@ namespace App\Http\Controllers\Api\V1\Admin\User; use App\Models\AdminRole; +use App\Lottery\ErrorCode; +use App\Support\ApiMessage; use App\Support\ApiResponse; use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Support\AdminAccountScopeGuard; use App\Support\AdminRoleApiPresenter; +use App\Support\PlatformSystemRoles; use App\Http\Requests\Admin\AdminRoleUpdateRequest; final class AdminRoleUpdateController extends Controller @@ -17,6 +20,16 @@ final class AdminRoleUpdateController extends Controller { AdminAccountScopeGuard::assertSystemRole($admin_role); + if ($admin_role->slug === PlatformSystemRoles::SLUG_SUPER_ADMIN) { + return ApiMessage::errorResponse( + $request, + 'admin.role_super_admin_metadata_fixed', + ErrorCode::ValidationFailed->value, + null, + 422, + ); + } + $before = AdminRoleApiPresenter::item($admin_role); $payload = []; diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php index f26b069..26b5ae0 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserRoleSyncController.php @@ -8,6 +8,7 @@ use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Support\AdminAccountScopeGuard; use App\Support\AdminUserApiPresenter; +use App\Support\AdminPlatformUserSiteGuard; use App\Http\Requests\Admin\AdminUserRoleSyncRequest; /** PUT /api/v1/admin/admin-users/{admin_user}/roles */ @@ -15,10 +16,16 @@ final class AdminUserRoleSyncController extends Controller { public function __invoke(AdminUserRoleSyncRequest $request, AdminUser $admin_user): JsonResponse { + /** @var AdminUser $actor */ + $actor = $request->lotteryAdmin(); + AdminAccountScopeGuard::assertPlatformAccount($admin_user); + $siteId = (int) $request->validated('admin_site_id'); + AdminPlatformUserSiteGuard::assertActorCanAssignSite($actor, $siteId); + $slugs = array_values(array_unique($request->validated('role_slugs'))); - $admin_user->syncSystemRoleSlugs($slugs); + $admin_user->syncSystemRoleSlugsForSite($siteId, $slugs); $admin_user->load('roles'); diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserStoreController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserStoreController.php index 1f59ad1..1e3cbe4 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserStoreController.php @@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; use App\Support\AdminUserApiPresenter; +use App\Support\AdminPlatformUserSiteGuard; use App\Http\Requests\Admin\AdminUserStoreRequest; /** @@ -28,8 +29,10 @@ final class AdminUserStoreController extends Controller : null; $roleSlugs = array_values(array_unique($request->validated('role_slugs'))); + $siteId = (int) $request->validated('admin_site_id'); + AdminPlatformUserSiteGuard::assertActorCanAssignSite($actor, $siteId); - $user = DB::transaction(function () use ($request, $email, $roleSlugs): AdminUser { + $user = DB::transaction(function () use ($request, $email, $roleSlugs, $siteId): AdminUser { $created = AdminUser::query()->create([ 'username' => $request->validated('username'), 'name' => $request->validated('nickname'), @@ -37,7 +40,7 @@ final class AdminUserStoreController extends Controller 'password' => $request->validated('password'), 'status' => $request->validated('status', 0), ]); - $created->syncSystemRoleSlugs($roleSlugs); + $created->syncSystemRoleSlugsForSite($siteId, $roleSlugs); return $created; }); diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php index fc0ca13..780514f 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Wallet; +use App\Models\Player; use App\Models\WalletTxn; use App\Support\ApiResponse; use App\Support\PaginationTrait; @@ -9,8 +10,10 @@ use Illuminate\Http\JsonResponse; use App\Support\AdminScopePolicy; use App\Support\AgentNodeApiPresenter; use App\Support\CurrencyFormatter; +use App\Support\PlayerFundingMode; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\WalletTransactionListRequest; +use App\Services\Wallet\PlayerLedgerLogsService; /** * 后台:彩票钱包流水列表 {@see wallet_txns}。 @@ -33,6 +36,10 @@ final class WalletTransactionListController extends Controller private const ALLOWED_STATUS = ['posted', 'pending_reconcile', 'reversed']; + public function __construct( + private readonly PlayerLedgerLogsService $ledgerLogs, + ) {} + public function __invoke(WalletTransactionListRequest $request): JsonResponse { $admin = $request->lotteryAdmin(); @@ -44,6 +51,20 @@ final class WalletTransactionListController extends Controller $perPage = $this->perPage($request, 'per_page', 10, 100); $page = $this->page($request); + if (! empty($validated['player_id'])) { + $player = Player::query()->find((int) $validated['player_id']); + if ($player !== null && PlayerFundingMode::usesCredit($player)) { + $credit = $this->ledgerLogs->listForAdminPlayer( + $player, + $page, + $perPage, + isset($validated['biz_type']) ? (string) $validated['biz_type'] : null, + ); + + return ApiResponse::success($credit); + } + } + $query = WalletTxn::query() ->with([ 'player:id,site_code,site_player_id,username,nickname,agent_node_id', @@ -141,6 +162,9 @@ final class WalletTransactionListController extends Controller 'remark' => $t->remark, 'created_at' => $t->created_at?->toIso8601String(), 'updated_at' => $t->updated_at?->toIso8601String(), + 'ledger_source' => 'wallet_txn', + 'funding_mode' => $p?->funding_mode, + 'auth_source' => $p?->auth_source, ]; } } diff --git a/app/Http/Controllers/Api/V1/Player/MeController.php b/app/Http/Controllers/Api/V1/Player/MeController.php index ef0d2f7..e4ea557 100644 --- a/app/Http/Controllers/Api/V1/Player/MeController.php +++ b/app/Http/Controllers/Api/V1/Player/MeController.php @@ -27,6 +27,8 @@ final class MeController extends Controller 'id' => $player->id, 'site_code' => $player->site_code, 'site_player_id' => $player->site_player_id, + 'auth_source' => $player->auth_source, + 'funding_mode' => $player->funding_mode, 'username' => $player->username, 'nickname' => $player->nickname, 'default_currency' => $player->default_currency, diff --git a/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php b/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php new file mode 100644 index 0000000..6b1566c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php @@ -0,0 +1,34 @@ +login( + (string) $request->validated('site_code'), + (string) $request->validated('username'), + (string) $request->validated('password'), + ); + } catch (PlayerAuthenticationException $e) { + return ApiResponse::error( + $e->getMessage(), + $e->lotteryCode, + null, + $e->httpStatus, + ); + } + + return ApiResponse::success($data); + } +} diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php index 9aa89e4..6484d2e 100644 --- a/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php +++ b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php @@ -7,9 +7,12 @@ use App\Lottery\ErrorCode; use App\Models\PlayerWallet; use App\Support\ApiMessage; use App\Support\ApiResponse; +use App\Support\PlayerFundingMode; +use Illuminate\Support\Facades\DB; use Illuminate\Http\Request; use App\Support\CurrencyResolver; use Illuminate\Http\JsonResponse; +use App\Support\CreditAmountScale; use App\Support\CurrencyFormatter; use App\Http\Controllers\Controller; use App\Services\Wallet\HttpMainSiteWalletBalanceClient; @@ -44,6 +47,37 @@ final class WalletBalanceController extends Controller return $currencyCode; } + if (PlayerFundingMode::usesCredit($player)) { + $credit = DB::table('player_credit_accounts')->where('player_id', $player->id)->first(); + $limitMajor = (int) ($credit->credit_limit ?? 0); + $usedMajor = (int) ($credit->used_credit ?? 0); + $frozenMajor = (int) ($credit->frozen_credit ?? 0); + $availableMajor = max(0, $limitMajor - $usedMajor - $frozenMajor); + + $limitMinor = CreditAmountScale::majorToMinor($limitMajor, $currencyCode); + $usedMinor = CreditAmountScale::majorToMinor($usedMajor, $currencyCode); + $frozenMinor = CreditAmountScale::majorToMinor($frozenMajor, $currencyCode); + $availableMinor = CreditAmountScale::majorToMinor($availableMajor, $currencyCode); + + return ApiResponse::success([ + 'balance' => $limitMinor, + 'balance_formatted' => CurrencyFormatter::fromMinor($limitMinor), + 'available_balance' => $availableMinor, + 'available_balance_formatted' => CurrencyFormatter::fromMinor($availableMinor), + 'credit_limit' => $limitMinor, + 'used_credit' => $usedMinor, + 'credit_line_mode' => true, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'auth_source' => $player->auth_source, + 'main_balance' => null, + 'main_balance_formatted' => null, + 'currency_code' => $currencyCode, + 'wallet_type' => self::WALLET_TYPE_LOTTERY, + 'frozen_balance' => $frozenMinor, + 'frozen_balance_formatted' => CurrencyFormatter::fromMinor($frozenMinor), + ]); + } + $wallet = PlayerWallet::query()->firstOrCreate( [ 'player_id' => $player->id, @@ -69,6 +103,9 @@ final class WalletBalanceController extends Controller 'balance_formatted' => CurrencyFormatter::fromMinor($balance), 'available_balance' => $available, 'available_balance_formatted' => CurrencyFormatter::fromMinor($available), + 'credit_line_mode' => false, + 'funding_mode' => PlayerFundingMode::WALLET, + 'auth_source' => $player->auth_source, 'main_balance' => $mainBalance, 'main_balance_formatted' => $mainBalance !== null ? CurrencyFormatter::fromMinor($mainBalance) diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php b/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php index 6925063..0402574 100644 --- a/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php +++ b/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php @@ -2,18 +2,17 @@ namespace App\Http\Controllers\Api\V1\Wallet; -use App\Models\WalletTxn; -use Illuminate\Support\Str; +use App\Models\TransferOrder; use App\Support\ApiResponse; use Illuminate\Http\Request; -use App\Models\TransferOrder; use App\Support\PaginationTrait; use Illuminate\Http\JsonResponse; use App\Support\CurrencyFormatter; use App\Http\Controllers\Controller; +use App\Services\Wallet\PlayerLedgerLogsService; /** - * PRD §10.1.1:`GET /api/v1/wallet/logs` — 钱包流水。 + * PRD §10.1.1:`GET /api/v1/wallet/logs` — 钱包/信用流水(按玩家资金模式分表)。 * * Query:`page`、`size`(每页条数,默认 20)、`type`(逗号分隔:transfer_in,transfer_out,bet,prize,refund,reversal) */ @@ -21,15 +20,9 @@ final class WalletLogsController extends Controller { use PaginationTrait; - /** PRD 对外类型 → 本地 biz_type */ - private const TYPE_TO_BIZ = [ - 'transfer_in' => ['transfer_in'], - 'transfer_out' => ['transfer_out'], - 'refund' => ['transfer_out_refund'], - 'reversal' => ['reversal', 'bet_reverse'], - 'bet' => ['bet_deduct', 'bet'], - 'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'], - ]; + public function __construct( + private readonly PlayerLedgerLogsService $ledgerLogs, + ) {} public function __invoke(Request $request): JsonResponse { @@ -38,45 +31,21 @@ final class WalletLogsController extends Controller $perPage = $this->perPage($request, 'size', 20, 100); $page = $this->page($request); - $currencyCode = strtoupper(trim((string) $request->query('currency', ''))); + $typeFilter = (string) $request->query('type', ''); $pendingPayload = $this->pendingReconcilePayload((int) $player->id, $currencyCode); - $bizFilter = $this->resolveBizTypeFilter((string) $request->query('type', '')); - - if (is_array($bizFilter) && $bizFilter === []) { - return ApiResponse::success([ - 'items' => [], - 'total' => 0, - 'page' => $page, - 'per_page' => $perPage, - 'pending_reconcile' => $pendingPayload, - ]); - } - - $query = WalletTxn::query() - ->where('player_id', $player->id) - ->with('wallet') - ->orderByDesc('id'); - - if ($currencyCode !== '') { - $query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode)); - } - - if ($bizFilter !== null) { - $query->whereIn('biz_type', $bizFilter); - } - - $paginator = $query->paginate($perPage, ['*'], 'page', $page); - - $items = $paginator->getCollection()->map(fn (WalletTxn $txn) => $this->formatTxn($txn)); + $result = $this->ledgerLogs->listForPlayerApi($player, $page, $perPage, $currencyCode, $typeFilter); return ApiResponse::success([ - 'items' => $items, - 'total' => $paginator->total(), - 'page' => $paginator->currentPage(), - 'per_page' => $paginator->perPage(), + 'items' => $result['items'], + 'total' => $result['total'], + 'page' => $result['page'], + 'per_page' => $result['per_page'], + 'ledger_source' => $result['ledger_source'], + 'funding_mode' => $result['funding_mode'], + 'auth_source' => $result['auth_source'], 'pending_reconcile' => $pendingPayload, ]); } @@ -97,84 +66,6 @@ final class WalletLogsController extends Controller ->all(); } - /** - * @return list|null null 表示不过滤;空列表表示过滤后无合法 type(结果应为空) - */ - private function resolveBizTypeFilter(string $raw): ?array - { - $raw = trim($raw); - if ($raw === '') { - return null; - } - - $parts = array_filter(array_map('trim', explode(',', $raw))); - if ($parts === []) { - return null; - } - - $biz = []; - foreach ($parts as $p) { - $key = Str::lower($p); - if (! isset(self::TYPE_TO_BIZ[$key])) { - continue; - } - foreach (self::TYPE_TO_BIZ[$key] as $b) { - $biz[] = $b; - } - } - - return array_values(array_unique($biz)); - } - - /** - * @return array - */ - private function formatTxn(WalletTxn $txn): array - { - $currency = $txn->wallet?->currency_code ?? ''; - $amount = (int) $txn->amount; - $balanceAfter = (int) $txn->balance_after; - - return [ - 'log_id' => $txn->txn_no, - 'type' => $this->bizToPublicType((string) $txn->biz_type), - 'biz_type' => $txn->biz_type, - 'amount' => $this->signedAmount($txn), - 'amount_formatted' => CurrencyFormatter::fromMinor($amount), - 'amount_abs' => $amount, - 'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount), - 'direction' => (int) $txn->direction === 1 ? 'in' : 'out', - 'currency_code' => $currency, - 'balance_after' => $balanceAfter, - 'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter), - 'ref_id' => $txn->biz_no, - 'idempotent_key' => $txn->idempotent_key, - 'external_ref_no' => $txn->external_ref_no, - 'status' => $txn->status, - 'remark' => $txn->remark, - 'created_at' => $txn->created_at?->toIso8601String(), - ]; - } - - private function bizToPublicType(string $biz): string - { - return match ($biz) { - 'transfer_out_refund' => 'refund', - 'bet_deduct', 'bet' => 'bet', - 'bet_reverse' => 'reversal', - 'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize', - 'reversal' => 'reversal', - default => $biz, - }; - } - - private function signedAmount(WalletTxn $txn): int - { - $a = (int) $txn->amount; - - return (int) $txn->direction === 1 ? $a : -$a; - } - /** * @return array */ diff --git a/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php b/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php index 12e990f..7e116a8 100644 --- a/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php +++ b/app/Http/Requests/Admin/AdminAgentLineStoreRequest.php @@ -4,12 +4,15 @@ namespace App\Http\Requests\Admin; use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules; use App\Http\Requests\ApiFormRequest; -use App\Rules\WalletApiUrlRule; +use App\Models\AdminSite; +use App\Models\AgentNode; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; final class AdminAgentLineStoreRequest extends ApiFormRequest { use AgentProfileFieldRules; + public function authorize(): bool { return true; @@ -18,29 +21,56 @@ final class AdminAgentLineStoreRequest extends ApiFormRequest protected function prepareForValidation(): void { $this->prepareAgentProfileFieldsForValidation(); + + if ($this->has('site_code')) { + $this->merge([ + 'site_code' => strtolower(trim((string) $this->input('site_code'))), + ]); + } + + if ($this->has('code')) { + $this->merge([ + 'code' => strtolower(trim((string) $this->input('code'))), + ]); + } } /** @return array */ public function rules(): array { return [ - 'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('admin_sites', 'code')], + 'site_code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::exists('admin_sites', 'code')], + 'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('agent_nodes', 'code')], 'name' => ['required', 'string', 'max:128'], - 'username' => ['required', 'string', 'max:64'], + 'username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')], 'password' => ['required', 'string', 'min:8', 'max:128'], 'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('admin_users', 'email')], - 'currency_code' => ['sometimes', 'string', 'max:16'], 'status' => ['sometimes', 'integer', 'in:0,1'], - 'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()], - 'wallet_debit_path' => ['sometimes', 'string', 'max:128'], - 'wallet_credit_path' => ['sometimes', 'string', 'max:128'], - 'wallet_balance_path' => ['sometimes', 'string', 'max:128'], - 'wallet_timeout_seconds' => ['sometimes', 'integer', 'min:1', 'max:120'], - 'iframe_allowed_origins' => ['nullable', 'array'], - 'iframe_allowed_origins.*' => ['string', 'max:512'], - 'lottery_h5_base_url' => ['nullable', 'string', 'max:512'], - 'notes' => ['nullable', 'string', 'max:5000'], ...$this->agentProfileFieldRules(), ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $siteCode = (string) $this->input('site_code'); + $site = AdminSite::query()->where('code', $siteCode)->first(); + if ($site === null) { + return; + } + + $hasRoot = AgentNode::query() + ->where('admin_site_id', $site->id) + ->where('depth', 0) + ->exists(); + + if ($hasRoot) { + $validator->errors()->add('site_code', 'site_root_exists'); + } + }); + } } diff --git a/app/Http/Requests/Admin/AdminPlayerStoreRequest.php b/app/Http/Requests/Admin/AdminPlayerStoreRequest.php index 74e133f..307d6a3 100644 --- a/app/Http/Requests/Admin/AdminPlayerStoreRequest.php +++ b/app/Http/Requests/Admin/AdminPlayerStoreRequest.php @@ -21,8 +21,9 @@ final class AdminPlayerStoreRequest extends ApiFormRequest { return [ 'site_code' => ['required', 'string', 'max:64'], - 'site_player_id' => ['required', 'string', 'max:128'], + 'site_player_id' => ['nullable', 'string', 'max:128'], 'username' => ['nullable', 'string', 'max:128'], + 'password' => ['nullable', 'string', 'min:6', 'max:128'], 'nickname' => ['nullable', 'string', 'max:128'], 'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')], 'status' => ['sometimes', 'integer', 'in:0,1,2'], diff --git a/app/Http/Requests/Admin/AdminPlayerUpdateRequest.php b/app/Http/Requests/Admin/AdminPlayerUpdateRequest.php index 1c8b1f9..eaad0ac 100644 --- a/app/Http/Requests/Admin/AdminPlayerUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminPlayerUpdateRequest.php @@ -24,6 +24,16 @@ final class AdminPlayerUpdateRequest extends ApiFormRequest 'nickname' => ['sometimes', 'nullable', 'string', 'max:128'], 'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')], 'status' => ['sometimes', 'integer', Rule::in([0, 1, 2])], + 'credit_limit' => ['sometimes', 'integer', 'min:0'], + 'rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'rebate_profiles' => ['sometimes', 'array'], + 'rebate_profiles.*.game_type' => ['required_with:rebate_profiles', 'string', 'max:32'], + 'rebate_profiles.*.rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'rebate_profiles.*.extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'rebate_profiles.*.inherit_from_agent' => ['sometimes', 'boolean'], + 'risk_tags' => ['sometimes', 'array'], + 'risk_tags.*' => ['string', 'max:64'], ]; } diff --git a/app/Http/Requests/Admin/AdminSettlementBillAdjustmentRequest.php b/app/Http/Requests/Admin/AdminSettlementBillAdjustmentRequest.php new file mode 100644 index 0000000..04bc7e1 --- /dev/null +++ b/app/Http/Requests/Admin/AdminSettlementBillAdjustmentRequest.php @@ -0,0 +1,22 @@ + ['required', 'integer', 'not_in:0'], + 'adjustment_type' => ['sometimes', 'string', 'in:adjustment,reversal'], + 'reason' => ['sometimes', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminSettlementBillBadDebtRequest.php b/app/Http/Requests/Admin/AdminSettlementBillBadDebtRequest.php new file mode 100644 index 0000000..0548cbf --- /dev/null +++ b/app/Http/Requests/Admin/AdminSettlementBillBadDebtRequest.php @@ -0,0 +1,20 @@ + ['sometimes', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminSettlementBillPaymentRequest.php b/app/Http/Requests/Admin/AdminSettlementBillPaymentRequest.php new file mode 100644 index 0000000..6c7d06b --- /dev/null +++ b/app/Http/Requests/Admin/AdminSettlementBillPaymentRequest.php @@ -0,0 +1,23 @@ + ['required', 'integer', 'min:1'], + 'method' => ['sometimes', 'nullable', 'string', 'max:32'], + 'proof' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'remark' => ['sometimes', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminUserRoleSyncRequest.php b/app/Http/Requests/Admin/AdminUserRoleSyncRequest.php index 1defbb2..b934792 100644 --- a/app/Http/Requests/Admin/AdminUserRoleSyncRequest.php +++ b/app/Http/Requests/Admin/AdminUserRoleSyncRequest.php @@ -22,6 +22,7 @@ final class AdminUserRoleSyncRequest extends ApiFormRequest public function rules(): array { return [ + 'admin_site_id' => ['required', 'integer', 'exists:admin_sites,id'], 'role_slugs' => ['required', 'array', 'min:1'], 'role_slugs.*' => ['string', 'max:64', 'distinct', 'exists:admin_roles,slug'], ]; diff --git a/app/Http/Requests/Admin/AdminUserStoreRequest.php b/app/Http/Requests/Admin/AdminUserStoreRequest.php index 59c4235..b04589a 100644 --- a/app/Http/Requests/Admin/AdminUserStoreRequest.php +++ b/app/Http/Requests/Admin/AdminUserStoreRequest.php @@ -52,6 +52,7 @@ final class AdminUserStoreRequest extends ApiFormRequest 'email' => ['nullable', 'string', 'email', 'max:255'], 'password' => ['required', 'string', 'min:8', 'max:256'], 'status' => ['sometimes', 'integer', 'in:0,1'], + 'admin_site_id' => ['required', 'integer', 'exists:admin_sites,id'], 'role_slugs' => ['required', 'array', 'min:1'], 'role_slugs.*' => ['string', 'max:64', 'distinct', 'exists:admin_roles,slug'], ]; diff --git a/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php b/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php index 2157e01..cbbd3fc 100644 --- a/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php +++ b/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php @@ -18,6 +18,8 @@ trait AgentProfileFieldRules 'can_grant_extra_rebate' => ['sometimes', 'boolean'], 'can_create_child_agent' => ['sometimes', 'boolean'], 'can_create_player' => ['sometimes', 'boolean'], + 'risk_tags' => ['sometimes', 'array'], + 'risk_tags.*' => ['string', 'max:64'], ]; } diff --git a/app/Http/Requests/Player/PlayerAuthLoginRequest.php b/app/Http/Requests/Player/PlayerAuthLoginRequest.php new file mode 100644 index 0000000..fcc3950 --- /dev/null +++ b/app/Http/Requests/Player/PlayerAuthLoginRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'max:64'], + 'username' => ['required', 'string', 'max:128'], + 'password' => ['required', 'string', 'min:6', 'max:128'], + ]; + } +} diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index 0091191..210b535 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -49,6 +49,9 @@ enum ErrorCode: int /** 幂等键与已有订单冲突(金额/币种/方向不一致) */ case WalletIdempotentConflict = 1010; + /** 信用盘玩家不可主站钱包划转 */ + case WalletCreditPlayerNoTransfer = 1011; + /* ========== 2000–2999 下注 / 注单(PRD 保留,业务未实现时亦可提前登记) ========== */ /** PRD:当期已封盘 */ @@ -112,6 +115,15 @@ enum ErrorCode: int /** 账号已冻结或禁止登录(status ≠ active) */ case PlayerAccountSuspended = 8005; + /** 原生登录:账号或密码错误 */ + case PlayerCredentialsInvalid = 8006; + + /** 原生登录:失败次数过多已锁定 */ + case PlayerLoginLocked = 8007; + + /** 原生登录:非彩票账号密码登录类型 */ + case PlayerNativeLoginRequired = 8008; + /* ========== 8100–8199 管理端 API ========== */ /** 未登录或 Token 无效 */ diff --git a/app/Models/AdminRole.php b/app/Models/AdminRole.php index 98b8826..457eb8a 100644 --- a/app/Models/AdminRole.php +++ b/app/Models/AdminRole.php @@ -103,6 +103,24 @@ final class AdminRole extends Model return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($codes); } + /** 授予当前库中全部启用的 menu_action(用于超级管理员)。 */ + public function syncAllActiveMenuActions(): void + { + $ids = DB::table('admin_menu_actions') + ->where('status', 1) + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + DB::table('admin_role_menu_actions')->where('role_id', $this->id)->delete(); + foreach ($ids as $mid) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $this->id, + 'menu_action_id' => $mid, + ]); + } + } + /** * @param list $slugs */ diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 77e5e94..a63a2af 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -5,7 +5,10 @@ namespace App\Models; use Laravel\Sanctum\HasApiTokens; use Illuminate\Support\Facades\DB; use App\Support\AdminPermissionBridge; +use App\Support\AgentProfileCapabilityFilter; +use App\Models\AdminRole; use App\Models\AgentNode; +use App\Models\AgentProfile; use Illuminate\Notifications\Notifiable; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -122,6 +125,12 @@ final class AdminUser extends Authenticatable }); } + /** 经营代理主账号:仅平台角色 slug=agent(见 {@see \App\Support\AgentPlatformRole})。 */ + public function syncPrimaryPlatformAgentRole(int $agentNodeId): void + { + $this->syncAgentRoleIds($agentNodeId, [\App\Support\AgentPlatformRole::id()]); + } + /** * @return list */ @@ -210,13 +219,22 @@ final class AdminUser extends Authenticatable } /** - * 平台账号角色同步:仅允许系统角色,不同步代理角色。 + * 平台账号角色同步:仅允许系统角色,不同步代理角色(默认站点,兼容旧调用)。 * * @param list $slugs */ public function syncSystemRoleSlugs(array $slugs): void { - $siteId = self::defaultAdminSiteId(); + $this->syncSystemRoleSlugsForSite(self::defaultAdminSiteId(), $slugs); + } + + /** + * 平台账号在指定站点上的系统角色(全量替换该站点 pivot)。 + * + * @param list $slugs + */ + public function syncSystemRoleSlugsForSite(int $siteId, array $slugs): void + { $slugs = array_values(array_unique($slugs)); $roleIds = DB::table('admin_roles') ->where('scope_type', AdminRole::SCOPE_SYSTEM) @@ -389,7 +407,19 @@ final class AdminUser extends Authenticatable } } - return array_keys($merged); + $codes = array_keys($merged); + + return AgentProfileCapabilityFilter::applyToMenuActionCodes($codes, $this->primaryAgentProfile()); + } + + private function primaryAgentProfile(): ?AgentProfile + { + $agentId = $this->primaryAgentNodeId(); + if ($agentId === null) { + return null; + } + + return AgentProfile::query()->where('agent_node_id', $agentId)->first(); } /** 是否具备指定权限:`prd.*` 走 legacy_map;否则按 permission_code 精确匹配。含 `super_admin` 全放行。 */ diff --git a/app/Models/AgentNode.php b/app/Models/AgentNode.php index 5020100..ff3c967 100644 --- a/app/Models/AgentNode.php +++ b/app/Models/AgentNode.php @@ -18,6 +18,7 @@ final class AgentNode extends Model 'code', 'name', 'status', + 'risk_tags', 'created_by', 'extra_json', ]; @@ -30,6 +31,7 @@ final class AgentNode extends Model 'depth' => 'integer', 'status' => 'integer', 'extra_json' => 'array', + 'risk_tags' => 'array', ]; } diff --git a/app/Models/Player.php b/app/Models/Player.php index 038dd03..3bc97dc 100644 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\PlayerAuthSource; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -15,11 +16,21 @@ final class Player extends Model 'site_code', 'agent_node_id', 'site_player_id', + 'auth_source', + 'funding_mode', 'username', + 'password_hash', 'nickname', 'default_currency', 'status', + 'risk_tags', 'last_login_at', + 'login_failed_count', + 'login_locked_until', + ]; + + protected $hidden = [ + 'password_hash', ]; protected function casts(): array @@ -27,9 +38,17 @@ final class Player extends Model return [ 'agent_node_id' => 'integer', 'last_login_at' => 'datetime', + 'login_failed_count' => 'integer', + 'login_locked_until' => 'datetime', + 'risk_tags' => 'array', ]; } + public function isLotteryNative(): bool + { + return (string) $this->auth_source === PlayerAuthSource::LOTTERY_NATIVE; + } + public function wallets(): HasMany { return $this->hasMany(PlayerWallet::class); diff --git a/app/Services/Admin/AdminDashboardSnapshotBuilder.php b/app/Services/Admin/AdminDashboardSnapshotBuilder.php index bebded2..2bfe220 100644 --- a/app/Services/Admin/AdminDashboardSnapshotBuilder.php +++ b/app/Services/Admin/AdminDashboardSnapshotBuilder.php @@ -29,6 +29,7 @@ final class AdminDashboardSnapshotBuilder public function __construct( private readonly DrawHallSnapshotBuilder $hallSnapshot, private readonly AdminReportQueryService $reportQuery, + private readonly AgentDashboardOverviewBuilder $agentOverview, ) {} /** @return array */ @@ -55,8 +56,13 @@ final class AdminDashboardSnapshotBuilder 'draw_finance_risk' => $canDraw, 'wallet_transfer_view' => $canWallet, ], + 'agent_overview' => null, ]; + if ($admin->primaryAgentNode() !== null) { + $out['agent_overview'] = $this->agentOverview->build($admin); + } + if ($canDraw) { $this->fillPlatformOverview($out, $scope); } diff --git a/app/Services/Admin/AgentDashboardOverviewBuilder.php b/app/Services/Admin/AgentDashboardOverviewBuilder.php new file mode 100644 index 0000000..c0ee8ed --- /dev/null +++ b/app/Services/Admin/AgentDashboardOverviewBuilder.php @@ -0,0 +1,97 @@ +|null + */ + public function build(AdminUser $admin): ?array + { + if (! $admin->hasPermissionCode('dashboard.view')) { + return null; + } + + $node = $admin->primaryAgentNode(); + if ($node === null) { + return null; + } + + $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); + $subtreeIds = AgentNode::query() + ->where('path', 'like', $node->path.'%') + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + $directChildCount = AgentNode::query() + ->where('parent_id', $node->id) + ->count(); + + $directPlayerCount = 0; + if (Schema::hasColumn('players', 'agent_node_id')) { + $directPlayerCount = Player::query() + ->where('agent_node_id', $node->id) + ->count(); + } + + $pendingBillStats = $this->pendingBillStats($admin, $subtreeIds); + + return [ + 'agent_node_id' => (int) $node->id, + 'agent_code' => (string) $node->code, + 'agent_name' => (string) $node->name, + 'depth' => (int) $node->depth, + 'credit_limit' => (int) ($profile?->credit_limit ?? 0), + 'allocated_credit' => (int) ($profile?->allocated_credit ?? 0), + 'used_credit' => (int) ($profile?->used_credit ?? 0), + 'available_credit' => max( + 0, + (int) ($profile?->credit_limit ?? 0) - (int) ($profile?->allocated_credit ?? 0), + ), + 'total_share_rate' => (float) ($profile?->total_share_rate ?? 0), + 'settlement_cycle' => (string) ($profile?->settlement_cycle ?? 'weekly'), + 'can_create_child_agent' => $profile === null || $profile->can_create_child_agent, + 'can_create_player' => $profile === null || $profile->can_create_player, + 'direct_child_count' => $directChildCount, + 'subtree_agent_count' => count($subtreeIds), + 'direct_player_count' => $directPlayerCount, + 'pending_bill_count' => $pendingBillStats['count'], + 'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'], + ]; + } + + /** + * @param list $subtreeIds + * @return array{count: int, unpaid_minor: int} + */ + private function pendingBillStats(AdminUser $admin, array $subtreeIds): array + { + if ($subtreeIds === []) { + return ['count' => 0, 'unpaid_minor' => 0]; + } + + $query = DB::table('settlement_bills') + ->where('bill_type', 'agent') + ->where('owner_type', 'agent') + ->whereIn('owner_id', $subtreeIds) + ->whereIn('status', ['pending', 'pending_confirm', 'partial']); + + AdminAgentSettlementScope::applyToBillsQuery($query, $admin); + + return [ + 'count' => (int) $query->count(), + 'unpaid_minor' => (int) $query->sum('unpaid_amount'), + ]; + } +} diff --git a/app/Services/Agent/AgentCreditAllocatedSyncService.php b/app/Services/Agent/AgentCreditAllocatedSyncService.php new file mode 100644 index 0000000..f660917 --- /dev/null +++ b/app/Services/Agent/AgentCreditAllocatedSyncService.php @@ -0,0 +1,55 @@ +where('agent_node_id', $agent->id)->first(); + if ($profile === null) { + return; + } + + $expected = $this->calculateAllocatedCredit($agent); + if ((int) $profile->allocated_credit === $expected) { + return; + } + + $profile->allocated_credit = $expected; + $profile->save(); + } + + public function syncForAgentId(int $agentNodeId): void + { + $agent = AgentNode::query()->find($agentNodeId); + if ($agent === null) { + return; + } + + $this->syncForAgent($agent); + } + + public function calculateAllocatedCredit(AgentNode $agent): int + { + $playerTotal = (int) DB::table('player_credit_accounts as pca') + ->join('players as p', 'p.id', '=', 'pca.player_id') + ->where('p.agent_node_id', $agent->id) + ->sum('pca.credit_limit'); + + $childIds = AgentNode::query()->where('parent_id', $agent->id)->pluck('id'); + $childAgentTotal = $childIds->isEmpty() + ? 0 + : (int) AgentProfile::query()->whereIn('agent_node_id', $childIds)->sum('credit_limit'); + + return $playerTotal + $childAgentTotal; + } +} diff --git a/app/Services/Agent/AgentNodeService.php b/app/Services/Agent/AgentNodeService.php index 3ca3ec6..0ea8ffa 100644 --- a/app/Services/Agent/AgentNodeService.php +++ b/app/Services/Agent/AgentNodeService.php @@ -7,6 +7,7 @@ use App\Models\AdminUser; use App\Models\AgentNode; use App\Models\AgentProfile; use App\Support\AdminUserStatus; +use App\Support\AgentPlatformRole; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -16,25 +17,6 @@ final class AgentNodeService private readonly AgentProfileService $agentProfileService, ) {} - /** @var list */ - private const BASE_AGENT_ROLE_SLUGS = [ - 'prd.agent.view', - 'prd.tickets.view', - 'prd.report.view', - 'prd.wallet_reconcile.view', - 'prd.wallet_reconcile.view_cs', - ]; - - /** @var list */ - private const CHILD_AGENT_MANAGE_SLUGS = ['prd.agent.manage', 'prd.agent.profile.manage']; - - /** @var list */ - private const PLAYER_MANAGE_SLUGS = [ - 'prd.users.manage', - 'prd.users.view_finance', - 'prd.users.view_cs', - ]; - /** * @param array{ * parent_id: int, @@ -120,19 +102,7 @@ final class AgentNodeService $node->path = (string) $parent->path.$node->id.'/'; $node->save(); - $role = AdminRole::query()->create([ - 'slug' => 'agent_owner_'.$node->id, - 'code' => 'agent_owner_'.$node->id, - 'name' => '代理账号', - 'description' => '系统自动生成的一代理一账号默认角色', - 'status' => $status === 0 ? 0 : 1, - 'is_system' => false, - 'sort_order' => 0, - 'scope_type' => AdminRole::SCOPE_AGENT, - 'owner_agent_id' => $node->id, - 'delegated_from_role_id' => null, - ]); - $role->syncLegacyPermissionSlugs($this->buildRoleSlugsForNewChild($payload, $actor)); + AgentPlatformRole::resolve(); $user = AdminUser::query()->create([ 'username' => $username, @@ -148,9 +118,9 @@ final class AgentNodeService 'is_primary' => true, 'granted_at' => now(), ]); - $user->syncAgentRoleIds((int) $node->id, [(int) $role->id]); + AgentPlatformRole::assignPrimaryOperator($user, $node); - $profile = $this->agentProfileService->upsertForNode($node, [ + $this->agentProfileService->upsertForNode($node, [ 'total_share_rate' => (float) ($payload['total_share_rate'] ?? 0), 'credit_limit' => (int) ($payload['credit_limit'] ?? 0), 'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0), @@ -161,8 +131,6 @@ final class AgentNodeService 'can_create_player' => (bool) ($payload['can_create_player'] ?? true), ], $parent); - $this->syncPrimaryOwnerRoleFromProfile($node, $profile); - return $node->fresh(['adminSite']); }); } @@ -352,32 +320,7 @@ final class AgentNodeService } return DB::transaction(function () use ($node, $username, $password, $email, $status): AdminUser { - $role = AdminRole::query() - ->where('owner_agent_id', $node->id) - ->where('slug', 'agent_owner_'.$node->id) - ->first(); - - if ($role === null) { - $role = AdminRole::query()->create([ - 'slug' => 'agent_owner_'.$node->id, - 'code' => 'agent_owner_'.$node->id, - 'name' => '代理账号', - 'description' => '系统自动生成的一代理一账号默认角色', - 'status' => $status === 0 ? 0 : 1, - 'is_system' => false, - 'sort_order' => 0, - 'scope_type' => AdminRole::SCOPE_AGENT, - 'owner_agent_id' => $node->id, - 'delegated_from_role_id' => null, - ]); - } - - $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); - $role->syncLegacyPermissionSlugs( - $profile !== null - ? $this->roleSlugsFromProfile($profile) - : $this->defaultOwnerRoleSlugs(), - ); + AgentPlatformRole::resolve(); $user = AdminUser::query()->create([ 'username' => $username, @@ -393,93 +336,12 @@ final class AgentNodeService 'is_primary' => true, 'granted_at' => now(), ]); - $user->syncAgentRoleIds((int) $node->id, [(int) $role->id]); + AgentPlatformRole::assignPrimaryOperator($user, $node); return $user; }); } - /** - * @return list - */ - private function defaultOwnerRoleSlugs(): array - { - return array_values(array_unique(array_merge( - self::BASE_AGENT_ROLE_SLUGS, - self::PLAYER_MANAGE_SLUGS, - ))); - } - - public function syncPrimaryOwnerRoleFromProfile(AgentNode $node, ?AgentProfile $profile = null): void - { - $profile ??= AgentProfile::query()->where('agent_node_id', $node->id)->first(); - if ($profile === null) { - return; - } - - $role = AdminRole::query() - ->where('owner_agent_id', $node->id) - ->where('slug', 'agent_owner_'.$node->id) - ->first(); - - if ($role === null) { - return; - } - - $role->syncLegacyPermissionSlugs($this->roleSlugsFromProfile($profile)); - } - - /** - * @param array $payload - * @return list - */ - private function buildRoleSlugsForNewChild(array $payload, AdminUser $actor): array - { - $slugs = self::BASE_AGENT_ROLE_SLUGS; - if ((bool) ($payload['can_create_child_agent'] ?? false)) { - $slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS); - } - if ((bool) ($payload['can_create_player'] ?? true)) { - $slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS); - } - - return $this->filterSlugsByActor($actor, $slugs); - } - - /** - * @return list - */ - private function roleSlugsFromProfile(AgentProfile $profile): array - { - $slugs = self::BASE_AGENT_ROLE_SLUGS; - if ($profile->can_create_child_agent) { - $slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS); - } - if ($profile->can_create_player) { - $slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS); - } - - return array_values(array_unique($slugs)); - } - - /** - * @param list $slugs - * @return list - */ - private function filterSlugsByActor(AdminUser $actor, array $slugs): array - { - if ($actor->isSuperAdmin()) { - return array_values(array_unique($slugs)); - } - - $mine = array_fill_keys($actor->adminPermissionSlugs(), true); - - return array_values(array_filter( - $slugs, - static fn (string $slug): bool => isset($mine[$slug]), - )); - } - private function resolveCodeForCreate(AgentNode $parent, mixed $rawCode, string $username): string { $preferred = trim((string) $rawCode); diff --git a/app/Services/Agent/AgentProfileService.php b/app/Services/Agent/AgentProfileService.php index a8e992a..f462caf 100644 --- a/app/Services/Agent/AgentProfileService.php +++ b/app/Services/Agent/AgentProfileService.php @@ -6,6 +6,7 @@ use App\Models\AdminUser; use App\Models\AgentNode; use App\Models\AgentProfile; use App\Support\AdminAgentScope; +use App\Support\AgentOverdueGuard; use App\Support\AgentSettlementCycle; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -16,6 +17,7 @@ final class AgentProfileService private readonly ShareRateValidator $shareRateValidator, private readonly CreditAllocationValidator $creditAllocationValidator, private readonly RebateLimitValidator $rebateLimitValidator, + private readonly AgentCreditAllocatedSyncService $allocatedSync, ) {} /** @@ -39,10 +41,17 @@ final class AgentProfileService $previousCredit = (int) $profile->credit_limit; $isNew = ! $profile->exists; - if (! $isNew && $creditLimit < (int) $profile->allocated_credit) { - throw ValidationException::withMessages([ - 'credit_limit' => ['below_allocated'], - ]); + if ($parent !== null && ! $isNew) { + $this->allocatedSync->syncForAgent($parent); + } + + if (! $isNew) { + $this->allocatedSync->syncForAgent($node); + if ($creditLimit < (int) $profile->allocated_credit) { + throw ValidationException::withMessages([ + 'credit_limit' => ['below_allocated'], + ]); + } } if ($parent !== null) { @@ -53,7 +62,7 @@ final class AgentProfileService } if ($defaultRebate > $rebateLimit && $rebateLimit > 0) { - throw \Illuminate\Validation\ValidationException::withMessages([ + throw ValidationException::withMessages([ 'default_player_rebate' => ['exceeds_limit'], ]); } @@ -77,14 +86,7 @@ final class AgentProfileService $profile->save(); if ($parent !== null) { - $parentProfile = AgentProfile::query()->where('agent_node_id', $parent->id)->first(); - if ($parentProfile !== null) { - $creditDelta = $isNew ? $creditLimit : ($creditLimit - $previousCredit); - if ($creditDelta !== 0) { - $parentProfile->allocated_credit = max(0, (int) $parentProfile->allocated_credit + $creditDelta); - $parentProfile->save(); - } - } + $this->allocatedSync->syncForAgent($parent); } return $profile; @@ -96,6 +98,9 @@ final class AgentProfileService */ public function present(AgentProfile $profile): array { + $this->allocatedSync->syncForAgentId((int) $profile->agent_node_id); + $profile->refresh(); + $available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit); return [ @@ -119,6 +124,65 @@ final class AgentProfileService return AgentProfile::query()->where('agent_node_id', $agentNodeId)->first(); } + /** + * @return array|null + */ + public function parentCapsForNode(?AgentNode $parent): ?array + { + if ($parent === null) { + return null; + } + + $this->allocatedSync->syncForAgent($parent); + + $profile = $this->profileForNode((int) $parent->id); + if ($profile === null) { + return null; + } + + return [ + 'agent_node_id' => (int) $parent->id, + 'total_share_rate' => (float) $profile->total_share_rate, + 'rebate_limit' => (float) $profile->rebate_limit, + 'available_credit' => max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit), + ]; + } + + /** 玩家授信写入前:校验代理可下发是否足够(按当前库内已占用重算)。 */ + public function assertMayIncreasePlayerCredit(AgentNode $agent, int $additionalCredit): void + { + if ($additionalCredit <= 0) { + return; + } + + $this->assertAgentProfileExists($agent); + $this->allocatedSync->syncForAgent($agent); + $this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $additionalCredit); + } + + /** 玩家授信变更后:按直属玩家+直属下级代理重算已下发额度(无 profile 时跳过)。 */ + public function refreshAllocatedCredit(AgentNode $agent): void + { + $this->allocatedSync->syncForAgent($agent); + } + + public function adjustPlayerCreditAllocation(AgentNode $agent, int $previousLimit, int $newLimit, int $playerUsedCredit = 0): void + { + if ($newLimit < $playerUsedCredit) { + throw ValidationException::withMessages([ + 'credit_limit' => ['below_player_used'], + ]); + } + + $delta = $newLimit - $previousLimit; + $this->assertAgentProfileExists($agent); + $this->allocatedSync->syncForAgent($agent); + + if ($delta > 0) { + $this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $delta); + } + } + public function assertActorMayCreateChildAgent(AdminUser $admin): void { if ($admin->isSuperAdmin()) { @@ -135,6 +199,12 @@ final class AgentProfileService 'parent_id' => ['cannot_create_child_agent'], ]); } + + if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) { + throw ValidationException::withMessages([ + 'parent_id' => ['agent_overdue'], + ]); + } } public function assertActorMayCreatePlayer(AdminUser $admin): void @@ -153,6 +223,12 @@ final class AgentProfileService 'site_code' => ['cannot_create_player'], ]); } + + if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) { + throw ValidationException::withMessages([ + 'site_code' => ['agent_overdue'], + ]); + } } /** @@ -193,4 +269,15 @@ final class AgentProfileService return $profile === null || $profile->can_create_player; } + + private function assertAgentProfileExists(AgentNode $agent): void + { + if (AgentProfile::query()->where('agent_node_id', $agent->id)->exists()) { + return; + } + + throw ValidationException::withMessages([ + 'credit_limit' => ['agent_profile_required'], + ]); + } } diff --git a/app/Services/Agent/AgentSiteProvisioningService.php b/app/Services/Agent/AgentSiteProvisioningService.php index 1496234..40ac2d8 100644 --- a/app/Services/Agent/AgentSiteProvisioningService.php +++ b/app/Services/Agent/AgentSiteProvisioningService.php @@ -2,41 +2,29 @@ namespace App\Services\Agent; -use App\Models\AdminRole; use App\Models\AdminSite; use App\Models\AdminUser; use App\Models\AgentNode; -use App\Services\Integration\IntegrationSiteService; use App\Support\AdminUserStatus; +use App\Support\AgentPlatformRole; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; final class AgentSiteProvisioningService { - /** @var list */ - private const LINE_ROOT_ROLE_SLUGS = [ - 'prd.agent.view', - 'prd.agent.manage', - 'prd.users.manage', - 'prd.users.view_finance', - 'prd.users.view_cs', - 'prd.tickets.view', - 'prd.report.view', - 'prd.wallet_reconcile.view', - 'prd.wallet_reconcile.view_cs', - ]; - public function __construct( - private readonly IntegrationSiteService $integrationSiteService, private readonly AgentProfileService $agentProfileService, ) {} /** - * @param array $payload site fields + name, username, password, email?, status? - * @return array{site: AdminSite, agent_node: AgentNode, secrets: array{sso_jwt_secret: string, wallet_api_key: string}} + * 在已存在的接入站点上创建一级代理(根节点)及后台登录账号。 + * + * @param array $payload site_code, code, name, username, password, email?, status?, profile fields + * @return array{site: AdminSite, agent_node: AgentNode} */ public function createRootAgent(AdminUser $actor, array $payload): array { + $siteCode = strtolower(trim((string) ($payload['site_code'] ?? ''))); $code = strtolower(trim((string) ($payload['code'] ?? ''))); $name = trim((string) ($payload['name'] ?? '')); $username = trim((string) ($payload['username'] ?? '')); @@ -44,8 +32,9 @@ final class AgentSiteProvisioningService $email = isset($payload['email']) ? trim((string) $payload['email']) : null; $status = (int) ($payload['status'] ?? 1); - if ($code === '' || $name === '' || $username === '' || $password === '') { + if ($siteCode === '' || $code === '' || $name === '' || $username === '' || $password === '') { throw ValidationException::withMessages([ + 'site_code' => $siteCode === '' ? ['required'] : [], 'code' => $code === '' ? ['required'] : [], 'name' => $name === '' ? ['required'] : [], 'username' => $username === '' ? ['required'] : [], @@ -53,6 +42,11 @@ final class AgentSiteProvisioningService ]); } + $site = AdminSite::query()->where('code', $siteCode)->first(); + if ($site === null) { + throw ValidationException::withMessages(['site_code' => ['exists']]); + } + if (AgentNode::query()->where('code', $code)->exists()) { throw ValidationException::withMessages(['code' => ['unique']]); } @@ -61,28 +55,18 @@ final class AgentSiteProvisioningService throw ValidationException::withMessages(['username' => ['unique']]); } - $siteData = array_merge($payload, [ - 'code' => $code, - 'name' => $name, - 'status' => $status === 0 ? 0 : 1, - ]); + $existingRoot = AgentNode::query() + ->where('admin_site_id', $site->id) + ->where('depth', 0) + ->first(); - return DB::transaction(function () use ($actor, $siteData, $code, $name, $username, $password, $email, $status): array { - $created = $this->integrationSiteService->create($siteData); - $site = $created['site']; - $secrets = $created['secrets']; - - $existingRoot = AgentNode::query() - ->where('admin_site_id', $site->id) - ->where('depth', 0) - ->first(); - - if ($existingRoot !== null) { - throw ValidationException::withMessages([ - 'code' => ['site_root_exists'], - ]); - } + if ($existingRoot !== null) { + throw ValidationException::withMessages([ + 'site_code' => ['site_root_exists'], + ]); + } + return DB::transaction(function () use ($actor, $site, $code, $name, $username, $password, $email, $status, $payload): array { $node = AgentNode::query()->create([ 'admin_site_id' => $site->id, 'parent_id' => null, @@ -97,19 +81,7 @@ final class AgentSiteProvisioningService $node->path = '/'.$node->id.'/'; $node->save(); - $role = AdminRole::query()->create([ - 'slug' => 'agent_owner_'.$node->id, - 'code' => 'agent_owner_'.$node->id, - 'name' => '代理账号', - 'description' => '线路根代理默认角色', - 'status' => $status === 0 ? 0 : 1, - 'is_system' => false, - 'sort_order' => 0, - 'scope_type' => AdminRole::SCOPE_AGENT, - 'owner_agent_id' => $node->id, - 'delegated_from_role_id' => null, - ]); - $role->syncLegacyPermissionSlugs(self::LINE_ROOT_ROLE_SLUGS); + AgentPlatformRole::resolve(); $user = AdminUser::query()->create([ 'username' => $username, @@ -125,14 +97,15 @@ final class AgentSiteProvisioningService 'is_primary' => true, 'granted_at' => now(), ]); - $user->syncAgentRoleIds((int) $node->id, [(int) $role->id]); + AgentPlatformRole::assignPrimaryOperator($user, $node); + $defaults = config('agent_line_defaults', []); $this->agentProfileService->upsertForNode($node, [ - 'total_share_rate' => (float) ($payload['total_share_rate'] ?? 100), - 'credit_limit' => (int) ($payload['credit_limit'] ?? 0), - 'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0), - 'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? 0), - 'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? 'weekly'), + 'total_share_rate' => (float) ($payload['total_share_rate'] ?? $defaults['total_share_rate'] ?? 100), + 'credit_limit' => (int) ($payload['credit_limit'] ?? $defaults['credit_limit'] ?? 0), + 'rebate_limit' => (float) ($payload['rebate_limit'] ?? $defaults['rebate_limit'] ?? 0), + 'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? $defaults['default_player_rebate'] ?? 0), + 'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? $defaults['settlement_cycle'] ?? 'weekly'), 'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? true), 'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true), 'can_create_player' => (bool) ($payload['can_create_player'] ?? true), @@ -141,7 +114,6 @@ final class AgentSiteProvisioningService return [ 'site' => $site->fresh(), 'agent_node' => $node->fresh(['adminSite']), - 'secrets' => $secrets, ]; }); } diff --git a/app/Services/Agent/CreditAllocationValidator.php b/app/Services/Agent/CreditAllocationValidator.php index ebae320..4f0612e 100644 --- a/app/Services/Agent/CreditAllocationValidator.php +++ b/app/Services/Agent/CreditAllocationValidator.php @@ -4,12 +4,19 @@ namespace App\Services\Agent; use App\Models\AgentNode; use App\Models\AgentProfile; +use App\Support\AgentOverdueGuard; use Illuminate\Validation\ValidationException; final class CreditAllocationValidator { + public function __construct( + private readonly AgentCreditAllocatedSyncService $allocatedSync, + ) {} + public function assertAllocationWithinParent(AgentNode $parent, int $additionalCredit): void { + $this->allocatedSync->syncForAgent($parent); + $profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first(); if ($profile === null) { return; @@ -25,15 +32,37 @@ final class CreditAllocationValidator public function assertPlayerCreditWithinAgent(AgentNode $agent, int $playerCreditLimit): void { - $profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first(); - if ($profile === null) { - return; - } - if ($playerCreditLimit < 0) { throw ValidationException::withMessages([ 'credit_limit' => ['invalid'], ]); } + + $this->assertPlayerCreditDeltaWithinAgent($agent, $playerCreditLimit); + } + + public function assertPlayerCreditDeltaWithinAgent(AgentNode $agent, int $additionalCredit): void + { + if ($additionalCredit <= 0) { + return; + } + + AgentOverdueGuard::assertAgentMayGrantCredit((int) $agent->id); + + $this->allocatedSync->syncForAgent($agent); + + $profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first(); + if ($profile === null) { + throw ValidationException::withMessages([ + 'credit_limit' => ['agent_profile_required'], + ]); + } + + $available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit); + if ($additionalCredit > $available) { + throw ValidationException::withMessages([ + 'credit_limit' => ['exceeds_available'], + ]); + } } } diff --git a/app/Services/AgentSettlement/AgentGameSettlementRecorder.php b/app/Services/AgentSettlement/AgentGameSettlementRecorder.php new file mode 100644 index 0000000..1315aac --- /dev/null +++ b/app/Services/AgentSettlement/AgentGameSettlementRecorder.php @@ -0,0 +1,151 @@ +player; + if ($player === null) { + return false; + } + + return PlayerFundingMode::usesCredit($player) + && (int) ($player->agent_node_id ?? 0) > 0; + } + + public function recordForTicketItem(TicketItem $item, int $netWin, string $terminalStatus): void + { + if (! $this->shouldRecord($item)) { + return; + } + + $player = $item->player; + if ($player === null) { + return; + } + + $gameType = trim((string) ($item->play_code ?? '')) ?: '*'; + $snapshot = $this->snapshotBuilder->buildForPlayer($player, $gameType); + + $gameWinLoss = $this->platformWinLoss($item, $netWin, $terminalStatus); + $validBet = (int) $item->total_bet_amount; + $basicRebate = (int) round($validBet * $snapshot['rebate_rate']); + $extraRebate = (int) round($validBet * $snapshot['extra_rebate_rate']); + + $extraByCode = []; + if ($extraRebate > 0 && $snapshot['chain_codes'] !== []) { + $leaf = $snapshot['chain_codes'][0]; + $extraByCode[$leaf] = $extraRebate; + } + + $result = $this->calculator->calculate( + sharedNetWinLoss: 0, + totalSharesByCode: $snapshot['total_shares'], + extraRebateByCode: $extraByCode, + gameWinLoss: $gameWinLoss, + basicRebate: $basicRebate, + chainFromPlayer: $snapshot['chain_codes'], + ); + + $settledAt = now(); + + DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate): void { + $item->forceFill([ + 'agent_node_id' => $snapshot['agent_node_id'], + 'share_snapshot' => [ + 'total_shares' => $snapshot['total_shares'], + 'actual_shares' => $snapshot['actual_shares'], + 'chain_codes' => $snapshot['chain_codes'], + ], + 'agent_rebate_rate_snapshot' => $snapshot['rebate_rate'], + 'agent_settled_at' => $settledAt, + ])->save(); + + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $item->id, + 'player_id' => $player->id, + 'agent_node_id' => $snapshot['agent_node_id'], + 'agent_path' => json_encode($snapshot['agent_path']), + 'share_snapshot' => json_encode($snapshot), + 'game_win_loss' => (int) round($gameWinLoss), + 'basic_rebate' => $basicRebate, + 'shared_net_win_loss' => (int) round($result->sharedNetWinLoss), + 'allocations_json' => json_encode($result->finalProfits), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + if ($basicRebate > 0) { + DB::table('rebate_records')->insert([ + 'player_id' => $player->id, + 'ticket_item_id' => $item->id, + 'game_type' => $gameType, + 'valid_bet_amount' => $validBet, + 'rebate_rate' => $snapshot['rebate_rate'], + 'rebate_amount' => $basicRebate, + 'rebate_type' => 'basic', + 'owner_agent_id' => $snapshot['agent_node_id'], + 'status' => 'accrued', + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + } + + if ($extraRebate > 0) { + DB::table('rebate_records')->insert([ + 'player_id' => $player->id, + 'ticket_item_id' => $item->id, + 'game_type' => $gameType, + 'valid_bet_amount' => $validBet, + 'rebate_rate' => $snapshot['extra_rebate_rate'], + 'rebate_amount' => $extraRebate, + 'rebate_type' => 'extra', + 'owner_agent_id' => $snapshot['agent_node_id'], + 'status' => 'accrued', + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + } + + $holdAmount = (int) $item->actual_deduct_amount; + if ($holdAmount > 0) { + $this->playerCreditService->releaseBetHold($player, $holdAmount, $item->id); + } + + if ($gameWinLoss > 0) { + $this->playerCreditService->applySettledLoss($player, (int) round($gameWinLoss), $item->id); + } + }); + } + + private function platformWinLoss(TicketItem $item, int $netWin, string $terminalStatus): float + { + if ($terminalStatus === 'settled_lose') { + return (float) max(0, (int) $item->actual_deduct_amount); + } + + if ($netWin > 0) { + return -1 * (float) $netWin; + } + + return 0.0; + } +} diff --git a/app/Services/AgentSettlement/AgentPeriodAggregator.php b/app/Services/AgentSettlement/AgentPeriodAggregator.php new file mode 100644 index 0000000..1582793 --- /dev/null +++ b/app/Services/AgentSettlement/AgentPeriodAggregator.php @@ -0,0 +1,232 @@ +>, + * agent_edges: array, + * agent_subtrees: array>, + * platform_share_profit: int, + * } + */ + public function aggregate(int $adminSiteId, string $periodStart, string $periodEnd): array + { + $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); + $codeToId = AgentNode::query() + ->where('admin_site_id', $adminSiteId) + ->pluck('id', 'code') + ->mapWithKeys(fn ($id, $code): array => [(string) $code => (int) $id]) + ->all(); + + $rows = 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]) + ->select([ + 'sl.player_id', + 'sl.ticket_item_id', + 'sl.agent_node_id', + 'sl.share_snapshot', + 'sl.game_win_loss', + 'sl.basic_rebate', + ]) + ->orderBy('sl.id') + ->get(); + + $players = []; + $agentEdges = []; + $agentSubtrees = []; + $platformShareProfit = 0; + + foreach ($rows as $row) { + $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'], + ]; + } + + $gameWinLoss = (int) $row->game_win_loss; + $basicRebate = (int) $row->basic_rebate; + $extraRebate = $this->extraRebateForTicketItem( + (int) $row->ticket_item_id, + $periodStart, + $periodEnd, + ); + + $extraByCode = []; + if ($extraRebate > 0 && $snapshot['chain_codes'] !== []) { + $extraByCode[$snapshot['chain_codes'][0]] = $extraRebate; + } + + $result = $this->calculator->calculate( + sharedNetWinLoss: 0, + totalSharesByCode: $snapshot['total_shares'], + extraRebateByCode: $extraByCode, + gameWinLoss: $gameWinLoss, + basicRebate: $basicRebate, + chainFromPlayer: $snapshot['chain_codes'], + ); + + $net = (int) round($result->playerNetSettlement); + + if (! isset($players[$playerId])) { + $players[$playerId] = [ + 'agent_node_id' => (int) $row->agent_node_id, + 'game_win_loss' => 0, + 'basic_rebate' => 0, + 'extra_rebate' => 0, + 'net_amount' => 0, + ]; + } + + $players[$playerId]['game_win_loss'] += $gameWinLoss; + $players[$playerId]['basic_rebate'] += $basicRebate; + $players[$playerId]['extra_rebate'] += $extraRebate; + $players[$playerId]['net_amount'] += $net; + + foreach ($result->tierSettlements as $edge => $amount) { + $agentEdges[$edge] = ($agentEdges[$edge] ?? 0) + (int) round($amount); + } + + $platformShareProfit += (int) round($result->finalProfits['platform'] ?? 0); + + $pathIds = $this->resolveAgentPathIds($row, $snapshot, $codeToId); + foreach ($pathIds as $agentId) { + if (! isset($agentSubtrees[$agentId])) { + $agentSubtrees[$agentId] = [ + 'gross_win_loss' => 0, + 'basic_rebate' => 0, + 'extra_rebate' => 0, + 'share_profit' => 0, + 'player_count' => 0, + '_players_seen' => [], + ]; + } + $agentSubtrees[$agentId]['gross_win_loss'] += $gameWinLoss; + $agentSubtrees[$agentId]['basic_rebate'] += $basicRebate; + $agentSubtrees[$agentId]['extra_rebate'] += $extraRebate; + if (! in_array($playerId, $agentSubtrees[$agentId]['_players_seen'], true)) { + $agentSubtrees[$agentId]['_players_seen'][] = $playerId; + $agentSubtrees[$agentId]['player_count']++; + } + } + + foreach ($snapshot['chain_codes'] as $code) { + $agentId = $codeToId[$code] ?? 0; + if ($agentId <= 0) { + continue; + } + $profit = (int) round($result->finalProfits[$code] ?? 0); + $agentSubtrees[$agentId]['share_profit'] = ($agentSubtrees[$agentId]['share_profit'] ?? 0) + $profit; + } + } + + foreach ($agentSubtrees as $id => $subtree) { + unset($agentSubtrees[$id]['_players_seen']); + } + + return [ + 'players' => $players, + 'agent_edges' => $agentEdges, + 'agent_subtrees' => $agentSubtrees, + 'platform_share_profit' => $platformShareProfit, + ]; + } + + /** + * @param array{chain_codes: list, total_shares: array} $snapshot + * @param array $codeToId + * @return list + */ + private function resolveAgentPathIds(object $row, array $snapshot, array $codeToId): array + { + $raw = $row->share_snapshot ?? null; + if ($raw !== null && $raw !== '') { + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (is_array($decoded) && is_array($decoded['agent_path'] ?? null)) { + return array_values(array_map(intval(...), $decoded['agent_path'])); + } + } + + $ids = []; + foreach ($snapshot['chain_codes'] as $code) { + $id = $codeToId[$code] ?? 0; + if ($id > 0) { + $ids[] = $id; + } + } + + return $ids; + } + + public function siteIdForPeriod(int $periodId): int + { + return (int) DB::table('settlement_periods')->where('id', $periodId)->value('admin_site_id'); + } + + private function extraRebateForTicketItem(int $ticketItemId, string $periodStart, string $periodEnd): int + { + if ($ticketItemId <= 0) { + return 0; + } + + return (int) DB::table('rebate_records') + ->where('ticket_item_id', $ticketItemId) + ->where('rebate_type', 'extra') + ->whereIn('status', ['accrued', 'reversed']) + ->whereBetween('created_at', [$periodStart, $periodEnd]) + ->sum('rebate_amount'); + } + + /** + * @return array{total_shares: array, chain_codes: list}|null + */ + private function resolveSnapshotFromLedgerRow(object $row): ?array + { + $raw = $row->share_snapshot ?? null; + if ($raw === null || $raw === '') { + return null; + } + + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (! is_array($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/AgentSettlementBadDebtService.php b/app/Services/AgentSettlement/AgentSettlementBadDebtService.php new file mode 100644 index 0000000..28b3796 --- /dev/null +++ b/app/Services/AgentSettlement/AgentSettlementBadDebtService.php @@ -0,0 +1,125 @@ +where('id', $originalBillId)->first(); + if ($original === null) { + throw new \InvalidArgumentException('bill_not_found'); + } + + if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) { + throw ValidationException::withMessages([ + 'period' => ['completed'], + ]); + } + + if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) { + throw ValidationException::withMessages([ + 'bill' => ['not_eligible'], + ]); + } + + $unpaid = (int) $original->unpaid_amount; + if ($unpaid <= 0) { + throw ValidationException::withMessages([ + 'bill' => ['no_unpaid'], + ]); + } + + if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) { + throw ValidationException::withMessages([ + 'bill' => ['not_eligible'], + ]); + } + + return (int) DB::transaction(function () use ($original, $originalBillId, $unpaid, $reason, $adminUserId): int { + $now = now(); + $periodId = (int) $original->settlement_period_id; + + $archiveBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'bad_debt', + 'owner_type' => (string) $original->owner_type, + 'owner_id' => (int) $original->owner_id, + 'counterparty_type' => (string) $original->counterparty_type, + 'counterparty_id' => (int) $original->counterparty_id, + 'gross_win_loss' => 0, + 'rebate_amount' => 0, + 'adjustment_amount' => -$unpaid, + 'platform_rounding_adjustment' => 0, + 'net_amount' => 0, + 'paid_amount' => 0, + 'unpaid_amount' => 0, + 'status' => 'settled', + 'reversed_bill_id' => $originalBillId, + 'meta_json' => json_encode([ + 'original_bill_id' => $originalBillId, + 'written_off_amount' => $unpaid, + 'original_net_amount' => (int) $original->net_amount, + ]), + 'locked_at' => $now, + 'confirmed_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('settlement_adjustments')->insert([ + 'settlement_period_id' => $periodId, + 'original_bill_id' => $originalBillId, + 'adjustment_type' => 'bad_debt', + 'amount' => $unpaid, + 'reason' => $reason, + 'created_by' => $adminUserId > 0 ? $adminUserId : null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('settlement_bills')->where('id', $originalBillId)->update([ + 'unpaid_amount' => 0, + 'status' => 'settled', + 'meta_json' => json_encode(array_merge( + $this->decodeMeta($original->meta_json), + [ + 'bad_debt_bill_id' => $archiveBillId, + 'written_off_amount' => $unpaid, + ], + )), + 'updated_at' => $now, + ]); + + $this->periodCompletion->syncIfReady($periodId); + + return $archiveBillId; + }); + } + + /** + * @return array + */ + private function decodeMeta(mixed $metaJson): array + { + if ($metaJson === null || $metaJson === '') { + return []; + } + + if (is_array($metaJson)) { + return $metaJson; + } + + $decoded = json_decode((string) $metaJson, true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php b/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php new file mode 100644 index 0000000..0e63364 --- /dev/null +++ b/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php @@ -0,0 +1,92 @@ +where('id', $originalBillId)->first(); + if ($original === null) { + throw new \InvalidArgumentException('bill_not_found'); + } + + if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) { + throw ValidationException::withMessages([ + 'period' => ['completed'], + ]); + } + + if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'settled', 'overdue'], true)) { + throw ValidationException::withMessages([ + 'bill' => ['not_locked'], + ]); + } + + if ($amount === 0) { + throw ValidationException::withMessages([ + 'amount' => ['zero'], + ]); + } + + $type = in_array($adjustmentType, ['adjustment', 'reversal'], true) + ? $adjustmentType + : 'adjustment'; + + return (int) DB::transaction(function () use ($original, $amount, $type, $reason, $adminUserId): int { + $now = now(); + $newBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => (int) $original->settlement_period_id, + 'bill_type' => $type, + 'owner_type' => (string) $original->owner_type, + 'owner_id' => (int) $original->owner_id, + 'counterparty_type' => (string) $original->counterparty_type, + 'counterparty_id' => (int) $original->counterparty_id, + 'gross_win_loss' => 0, + 'rebate_amount' => 0, + 'adjustment_amount' => $amount, + 'platform_rounding_adjustment' => 0, + 'net_amount' => $amount, + 'paid_amount' => 0, + 'unpaid_amount' => abs($amount), + 'status' => 'pending_confirm', + 'reversed_bill_id' => (int) $original->id, + 'meta_json' => json_encode([ + 'original_bill_id' => (int) $original->id, + 'original_net_amount' => (int) $original->net_amount, + ]), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('settlement_adjustments')->insert([ + 'settlement_period_id' => (int) $original->settlement_period_id, + 'original_bill_id' => (int) $original->id, + 'adjustment_type' => $type, + 'amount' => $amount, + 'reason' => $reason, + 'created_by' => $adminUserId > 0 ? $adminUserId : null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return $newBillId; + }); + } +} diff --git a/app/Services/AgentSettlement/AgentSettlementBillGuard.php b/app/Services/AgentSettlement/AgentSettlementBillGuard.php new file mode 100644 index 0000000..e328e55 --- /dev/null +++ b/app/Services/AgentSettlement/AgentSettlementBillGuard.php @@ -0,0 +1,51 @@ +where('id', $billId)->value('settlement_period_id'); + if ($this->periodCompletion->isPeriodReadOnly($periodId)) { + throw ValidationException::withMessages([ + 'period' => ['completed'], + ]); + } + } + + public function assertNetAmountMutable(int $billId): void + { + $bill = DB::table('settlement_bills')->where('id', $billId)->first(); + if ($bill === null) { + return; + } + + if (in_array((string) $bill->status, self::LOCKED_STATUSES, true) || $bill->locked_at !== null) { + throw ValidationException::withMessages([ + 'bill' => ['locked'], + ]); + } + } + + public function markConfirmed(int $billId): void + { + $this->assertPeriodMutable($billId); + + DB::table('settlement_bills')->where('id', $billId)->update([ + 'status' => 'confirmed', + 'locked_at' => now(), + 'confirmed_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php index d961cf4..4d718c1 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php @@ -2,13 +2,20 @@ namespace App\Services\AgentSettlement; -use App\Support\Settlement\DesignDocExample12; +use App\Models\AgentNode; +use App\Services\Agent\AgentCreditAllocatedSyncService; +use App\Support\AgentSettlementProductionGuard; use Illuminate\Support\Facades\DB; final class AgentSettlementPeriodCloseService { public function __construct( - private readonly ShareSettlementCalculator $calculator, + private readonly AgentPeriodAggregator $aggregator, + private readonly SettlementBillGenerator $billGenerator, + private readonly PeriodCloseRebateService $periodCloseRebate, + private readonly UnsettledTicketPeriodWarning $unsettledWarning, + private readonly PlatformRoundingAdjuster $platformRounding, + private readonly AgentCreditAllocatedSyncService $allocatedSync, ) {} /** @@ -16,51 +23,74 @@ final class AgentSettlementPeriodCloseService */ public function closePeriod(int $periodId): array { + AgentSettlementProductionGuard::assertProductionCloseAllowed(); + $period = DB::table('settlement_periods')->where('id', $periodId)->first(); if ($period === null) { throw new \InvalidArgumentException('period_not_found'); } - $result = $this->calculator->calculate( - sharedNetWinLoss: DesignDocExample12::SHARED_NET_WIN_LOSS, - totalSharesByCode: [ - 'A' => DesignDocExample12::TOTAL_SHARE_A, - 'B' => DesignDocExample12::TOTAL_SHARE_B, - 'C' => DesignDocExample12::TOTAL_SHARE_C, - ], - extraRebateByCode: ['C' => DesignDocExample12::EXTRA_REBATE_BY_C], - gameWinLoss: DesignDocExample12::GAME_WIN_LOSS, - basicRebate: DesignDocExample12::BASIC_REBATE, - chainFromPlayer: ['C', 'B', 'A'], + if ((string) $period->status === 'closed') { + throw new \InvalidArgumentException('period_already_closed'); + } + + $adminSiteId = (int) $period->admin_site_id; + $aggregate = $this->aggregator->aggregate( + $adminSiteId, + (string) $period->period_start, + (string) $period->period_end, ); - $playerBillId = DB::table('settlement_bills')->insertGetId([ - 'settlement_period_id' => $periodId, - 'bill_type' => 'player', - 'owner_type' => 'player', - 'owner_id' => 0, - 'counterparty_type' => 'agent', - 'counterparty_id' => 0, - 'gross_win_loss' => DesignDocExample12::GAME_WIN_LOSS, - 'rebate_amount' => DesignDocExample12::BASIC_REBATE + DesignDocExample12::EXTRA_REBATE_BY_C, - 'adjustment_amount' => 0, - 'net_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT, - 'paid_amount' => 0, - 'unpaid_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT, - 'status' => 'pending', - 'created_at' => now(), - 'updated_at' => now(), - ]); + if ($aggregate['players'] === []) { + throw new \InvalidArgumentException('period_no_ledger_rows'); + } + + $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, + ); + + $unsettled = $this->unsettledWarning->countForSite( + $adminSiteId, + (string) $period->period_start, + (string) $period->period_end, + ); DB::table('settlement_periods')->where('id', $periodId)->update([ 'status' => 'closed', 'updated_at' => now(), ]); + DB::table('share_ledger') + ->whereBetween('settled_at', [$period->period_start, $period->period_end]) + ->update(['settlement_period_id' => $periodId]); + + $this->reconcileAllocatedCreditForSite($adminSiteId); + return [ 'period_id' => $periodId, - 'settlement' => $result, - 'player_bill_id' => $playerBillId, + 'bill_ids' => $billIds, + 'player_count' => count($aggregate['players']), + 'agent_edges' => $aggregate['agent_edges'], + 'rebate_dispatched' => $rebateStats['dispatched'], + 'rebate_allocations' => $rebateStats['allocations'], + 'unsettled_ticket_count' => $unsettled['count'], + 'unsettled_ticket_sample' => $unsettled['ticket_item_ids'], + 'platform_rounding_adjustment' => $roundingDiff, ]; } + + /** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */ + private function reconcileAllocatedCreditForSite(int $adminSiteId): void + { + $nodes = AgentNode::query()->where('admin_site_id', $adminSiteId)->get(); + foreach ($nodes as $node) { + $this->allocatedSync->syncForAgent($node); + } + } } diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodCompletionService.php b/app/Services/AgentSettlement/AgentSettlementPeriodCompletionService.php new file mode 100644 index 0000000..0b3ad4b --- /dev/null +++ b/app/Services/AgentSettlement/AgentSettlementPeriodCompletionService.php @@ -0,0 +1,58 @@ +where('id', $periodId)->first(); + if ($period === null || (string) $period->status !== 'closed') { + return; + } + + if ($this->hasOpenSettlementWork($periodId)) { + return; + } + + DB::table('settlement_periods') + ->where('id', $periodId) + ->update([ + 'status' => 'completed', + 'updated_at' => now(), + ]); + } + + public function isPeriodReadOnly(int $periodId): bool + { + if ($periodId <= 0) { + return false; + } + + $status = DB::table('settlement_periods')->where('id', $periodId)->value('status'); + + return $status !== null && (string) $status === 'completed'; + } + + private function hasOpenSettlementWork(int $periodId): bool + { + return DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->whereNotIn('bill_type', ['bad_debt']) + ->where(function ($query): void { + $query->where('status', 'pending_confirm') + ->orWhere(function ($inner): void { + $inner->whereIn('status', ['confirmed', 'partial_paid', 'overdue']) + ->where('unpaid_amount', '>', 0); + }); + }) + ->exists(); + } +} diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php b/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php new file mode 100644 index 0000000..7ac185d --- /dev/null +++ b/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php @@ -0,0 +1,65 @@ + $periods settlement_periods 行,须含 id、period_start、period_end、admin_site_id + * @return array + */ + public function countsForPeriods(Collection $periods): array + { + if ($periods->isEmpty()) { + return []; + } + + $siteIds = $periods->pluck('admin_site_id')->map(static fn ($id): int => (int) $id)->unique()->all(); + $siteCodes = DB::table('admin_sites') + ->whereIn('id', $siteIds) + ->pluck('code', 'id'); + + $out = []; + 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]; + + continue; + } + + $start = Carbon::parse($period->period_start)->startOfDay(); + $end = Carbon::parse($period->period_end)->endOfDay(); + + $creditCount = (int) 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(); + + $shareCount = (int) 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(); + + $out[$periodId] = [ + 'credit_ledger_count' => $creditCount, + 'share_ledger_count' => $shareCount, + ]; + } + + return $out; + } +} diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodSummaryService.php b/app/Services/AgentSettlement/AgentSettlementPeriodSummaryService.php new file mode 100644 index 0000000..0c7ae10 --- /dev/null +++ b/app/Services/AgentSettlement/AgentSettlementPeriodSummaryService.php @@ -0,0 +1,84 @@ + $periodIds + * @return array> + */ + public function summariesForPeriodIds(array $periodIds): array + { + if ($periodIds === []) { + return []; + } + + $rows = DB::table('settlement_bills') + ->whereIn('settlement_period_id', $periodIds) + ->groupBy('settlement_period_id') + ->selectRaw('settlement_period_id') + ->selectRaw("SUM(CASE WHEN bill_type = 'player' THEN 1 ELSE 0 END) as player_bills") + ->selectRaw("SUM(CASE WHEN bill_type = 'agent' THEN 1 ELSE 0 END) as agent_bills") + ->selectRaw("SUM(CASE WHEN bill_type IN ('adjustment', 'reversal') THEN 1 ELSE 0 END) as adjustment_bills") + ->selectRaw("SUM(CASE WHEN status = 'pending_confirm' THEN 1 ELSE 0 END) as pending_confirm") + ->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') + ->get(); + + $out = []; + foreach ($rows as $row) { + $periodId = (int) $row->settlement_period_id; + $out[$periodId] = [ + 'player_bills' => (int) $row->player_bills, + 'agent_bills' => (int) $row->agent_bills, + 'adjustment_bills' => (int) $row->adjustment_bills, + 'pending_confirm' => (int) $row->pending_confirm, + 'awaiting_payment' => (int) $row->awaiting_payment, + 'settled' => (int) $row->settled, + 'total_unpaid' => (int) $row->total_unpaid, + ]; + } + + return $out; + } + + /** + * @param Collection $periods + * @return list> + */ + public function attachToPeriodRows(Collection $periods): array + { + $ids = $periods->pluck('id')->map(static fn ($id): int => (int) $id)->all(); + $summaries = $this->summariesForPeriodIds($ids); + $pipelines = $this->pipelineService->countsForPeriods($periods); + $empty = [ + 'player_bills' => 0, + 'agent_bills' => 0, + 'adjustment_bills' => 0, + 'pending_confirm' => 0, + 'awaiting_payment' => 0, + 'settled' => 0, + 'total_unpaid' => 0, + ]; + $emptyPipeline = ['credit_ledger_count' => 0, 'share_ledger_count' => 0]; + + $items = []; + foreach ($periods as $period) { + $row = (array) $period; + $row['summary'] = $summaries[(int) $period->id] ?? $empty; + $row['pipeline'] = $pipelines[(int) $period->id] ?? $emptyPipeline; + $items[] = $row; + } + + return $items; + } +} diff --git a/app/Services/AgentSettlement/AgentSettlementReportQueryService.php b/app/Services/AgentSettlement/AgentSettlementReportQueryService.php new file mode 100644 index 0000000..76c2661 --- /dev/null +++ b/app/Services/AgentSettlement/AgentSettlementReportQueryService.php @@ -0,0 +1,326 @@ + + */ + public function summary(AdminUser $admin, int $periodId = 0): array + { + $query = DB::table('settlement_bills'); + AdminAgentSettlementScope::applyToBillsQuery($query, $admin); + if ($periodId > 0) { + $query->where('settlement_period_id', $periodId); + } + $rows = $query->get(); + + return [ + 'bill_count' => $rows->count(), + 'total_net' => (int) $rows->sum('net_amount'), + 'total_unpaid' => (int) $rows->sum('unpaid_amount'), + 'overdue_count' => $rows->where('status', 'overdue')->count(), + 'platform_rounding_total' => (int) $rows->sum('platform_rounding_adjustment'), + ]; + } + + /** + * @return list> + */ + public function playerWinLoss(AdminUser $admin, int $periodId, string $periodStart, string $periodEnd): array + { + $siteCode = $this->siteCodeForAdmin($admin, $periodId); + + return 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') + ->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') + ->selectRaw('SUM(sl.basic_rebate) as basic_rebate') + ->orderByDesc('game_win_loss') + ->get() + ->map(static fn (object $r): array => [ + 'player_id' => (int) $r->player_id, + 'username' => (string) ($r->username ?? ''), + 'agent_node_id' => (int) $r->agent_node_id, + 'game_type' => (string) $r->game_type, + 'game_win_loss' => (int) $r->game_win_loss, + 'basic_rebate' => (int) $r->basic_rebate, + ]) + ->all(); + } + + /** + * @return list> + */ + public function agentShare(AdminUser $admin, string $periodStart, string $periodEnd): array + { + $siteCode = $this->siteCodeForAdmin($admin, 0); + + return 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') + ->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') + ->get() + ->map(static fn (object $r): array => [ + 'agent_node_id' => (int) $r->agent_node_id, + 'game_win_loss' => (int) $r->game_win_loss, + 'basic_rebate' => (int) $r->basic_rebate, + 'entry_count' => (int) $r->entry_count, + ]) + ->all(); + } + + /** + * @return array + */ + public function rebate(AdminUser $admin, int $periodId, string $periodStart, string $periodEnd): array + { + $siteCode = $this->siteCodeForAdmin($admin, $periodId); + + $base = DB::table('rebate_records as rr') + ->join('players as p', 'p.id', '=', 'rr.player_id') + ->where('p.site_code', $siteCode); + + $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') + ->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'); + + 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]) + ->groupBy('rr.rebate_type', 'rr.status') + ->selectRaw('rr.rebate_type, rr.status, SUM(rr.rebate_amount) as total, COUNT(*) as cnt') + ->get() + ->map(static fn (object $r): array => [ + 'rebate_type' => (string) $r->rebate_type, + 'status' => (string) $r->status, + 'total' => (int) $r->total, + 'count' => (int) $r->cnt, + ]) + ->all(), + ]; + } + + /** + * @return array{agents: list>, players: list>} + */ + public function credit(AdminUser $admin): array + { + $siteCode = $this->siteCodeForAdmin($admin, 0); + + $agents = 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) + ->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() + ->map(static fn (object $r): array => [ + 'agent_node_id' => (int) $r->agent_node_id, + 'code' => (string) $r->code, + 'name' => (string) $r->name, + 'credit_limit' => (int) $r->credit_limit, + 'allocated_credit' => (int) $r->allocated_credit, + 'available_credit' => (int) $r->available_credit, + ]) + ->all(); + + $players = DB::table('player_credit_accounts as pc') + ->join('players as p', 'p.id', '=', 'pc.player_id') + ->where('p.site_code', $siteCode) + ->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) + ->get() + ->map(static fn (object $r): array => [ + 'player_id' => (int) $r->player_id, + 'username' => (string) ($r->username ?? ''), + 'credit_limit' => (int) $r->credit_limit, + 'used_credit' => (int) $r->used_credit, + 'frozen_credit' => (int) $r->frozen_credit, + 'available_credit' => max(0, (int) $r->available_credit), + ]) + ->all(); + + return ['agents' => $agents, 'players' => $players]; + } + + /** + * @return list> + */ + public function unpaidBills(AdminUser $admin, int $periodId = 0): array + { + $query = DB::table('settlement_bills as sb') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->where('sb.unpaid_amount', '>', 0) + ->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']); + AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb'); + if ($periodId > 0) { + $query->where('sb.settlement_period_id', $periodId); + } + + return $query + ->select([ + 'sb.id', + 'sb.bill_type', + 'sb.owner_type', + 'sb.owner_id', + 'sb.counterparty_type', + 'sb.counterparty_id', + 'sb.net_amount', + 'sb.unpaid_amount', + 'sb.status', + 'sp.period_start', + 'sp.period_end', + ]) + ->orderByDesc('sb.unpaid_amount') + ->get() + ->map(static fn (object $r): array => [ + 'bill_id' => (int) $r->id, + 'bill_type' => (string) $r->bill_type, + 'owner_type' => (string) $r->owner_type, + 'owner_id' => (int) $r->owner_id, + 'counterparty_type' => (string) $r->counterparty_type, + 'counterparty_id' => (int) $r->counterparty_id, + 'net_amount' => (int) $r->net_amount, + 'unpaid_amount' => (int) $r->unpaid_amount, + 'status' => (string) $r->status, + 'period_start' => (string) $r->period_start, + 'period_end' => (string) $r->period_end, + ]) + ->all(); + } + + /** + * @return list> + */ + public function overdue(AdminUser $admin): array + { + $query = DB::table('settlement_bills as sb') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->where('sb.status', 'overdue') + ->where('sb.unpaid_amount', '>', 0); + AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb'); + + return $query + ->select([ + 'sb.id', + 'sb.bill_type', + 'sb.owner_type', + 'sb.owner_id', + 'sb.unpaid_amount', + 'sb.updated_at', + 'sp.period_end', + ]) + ->orderBy('sb.updated_at') + ->get() + ->map(static function (object $r): array { + $days = Carbon::parse((string) $r->updated_at)->diffInDays(now()); + + return [ + 'bill_id' => (int) $r->id, + 'bill_type' => (string) $r->bill_type, + 'owner_type' => (string) $r->owner_type, + 'owner_id' => (int) $r->owner_id, + 'unpaid_amount' => (int) $r->unpaid_amount, + 'overdue_days' => $days, + 'period_end' => (string) $r->period_end, + ]; + }) + ->all(); + } + + /** + * @return array + */ + public function platformPnl(AdminUser $admin, int $periodId): array + { + $query = DB::table('settlement_bills as sb') + ->where('sb.settlement_period_id', $periodId) + ->where(function (Builder $q): void { + $q->where('sb.counterparty_type', 'platform') + ->orWhere('sb.owner_type', 'platform'); + }); + AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb'); + $rows = $query->get(); + + return [ + 'platform_bill_net' => (int) $rows->sum('net_amount'), + 'platform_rounding_adjustment' => (int) $rows->sum('platform_rounding_adjustment'), + 'share_profit_meta' => (int) $rows->sum(fn (object $r): int => (int) (json_decode((string) ($r->meta_json ?? '{}'), true)['platform_share_profit'] ?? 0)), + ]; + } + + /** + * @return list> + */ + public function drawPeriod(AdminUser $admin, string $periodStart, string $periodEnd): array + { + $siteCode = $this->siteCodeForAdmin($admin, 0); + + return 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') + ->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') + ->get() + ->map(static fn (object $r): array => [ + 'draw_id' => (int) $r->draw_id, + 'draw_no' => (string) $r->draw_no, + 'game_win_loss' => (int) $r->game_win_loss, + 'basic_rebate' => (int) $r->basic_rebate, + 'ticket_count' => (int) $r->ticket_count, + ]) + ->all(); + } + + private function siteCodeForAdmin(AdminUser $admin, int $periodId): string + { + if ($periodId > 0) { + $siteId = (int) DB::table('settlement_periods')->where('id', $periodId)->value('admin_site_id'); + + return (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + } + + $siteId = (int) ($admin->admin_site_id ?? 0); + if ($siteId > 0) { + return (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + } + + return (string) DB::table('admin_sites')->where('is_default', true)->value('code'); + } +} diff --git a/app/Services/AgentSettlement/BetSettlementSnapshotBuilder.php b/app/Services/AgentSettlement/BetSettlementSnapshotBuilder.php new file mode 100644 index 0000000..66b1e96 --- /dev/null +++ b/app/Services/AgentSettlement/BetSettlementSnapshotBuilder.php @@ -0,0 +1,116 @@ +, + * chain_codes: list, + * total_shares: array, + * actual_shares: array, + * rebate_rate: float, + * extra_rebate_rate: float, + * } + */ + public function buildForPlayer(Player $player, string $gameType = '*'): array + { + $agentNodeId = (int) $player->agent_node_id; + if ($agentNodeId <= 0) { + throw new \InvalidArgumentException('player_missing_agent'); + } + + $pathIds = []; + $chainCodes = []; + $totalShares = []; + $nodeId = $agentNodeId; + + while ($nodeId > 0) { + $node = AgentNode::query()->find($nodeId); + if ($node === null) { + break; + } + array_unshift($pathIds, (int) $node->id); + $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); + $code = (string) $node->code; + $chainCodes[] = $code; + $totalShares[$code] = (float) ($profile?->total_share_rate ?? 0); + $nodeId = (int) ($node->parent_id ?? 0); + } + + $orderedBottomUp = $chainCodes; + $actual = $this->resolveActualShares($totalShares, $orderedBottomUp); + + $rebate = $this->resolvePlayerRebateRate((int) $player->id, $agentNodeId, $gameType); + + return [ + 'agent_node_id' => $agentNodeId, + 'agent_path' => $pathIds, + 'chain_codes' => $orderedBottomUp, + 'total_shares' => $totalShares, + 'actual_shares' => $actual, + 'rebate_rate' => $rebate['rebate_rate'], + 'extra_rebate_rate' => $rebate['extra_rebate_rate'], + ]; + } + + /** + * @param array $totalShares + * @param list $orderedBottomUp + * @return array + */ + private function resolveActualShares(array $totalShares, array $orderedBottomUp): array + { + $actual = []; + $prev = 0.0; + foreach ($orderedBottomUp as $code) { + $total = (float) ($totalShares[$code] ?? 0); + $actual[$code] = max(0, $total - $prev); + $prev = $total; + } + $actual['platform'] = max(0, 100 - $prev); + + return $actual; + } + + /** + * @return array{rebate_rate: float, extra_rebate_rate: float} + */ + private function resolvePlayerRebateRate(int $playerId, int $agentNodeId, string $gameType = '*'): array + { + $gameType = trim($gameType) !== '' ? trim($gameType) : '*'; + + $row = DB::table('player_rebate_profiles') + ->where('player_id', $playerId) + ->where('game_type', $gameType) + ->first(); + + if ($row === null && $gameType !== '*') { + $row = DB::table('player_rebate_profiles') + ->where('player_id', $playerId) + ->where('game_type', '*') + ->first(); + } + + if ($row !== null && ! (bool) $row->inherit_from_agent) { + return [ + 'rebate_rate' => (float) $row->rebate_rate, + 'extra_rebate_rate' => (float) $row->extra_rebate_rate, + ]; + } + + $profile = AgentProfile::query()->where('agent_node_id', $agentNodeId)->first(); + + return [ + 'rebate_rate' => (float) ($profile?->default_player_rebate ?? 0), + 'extra_rebate_rate' => 0.0, + ]; + } +} diff --git a/app/Services/AgentSettlement/GameSettlementReversalService.php b/app/Services/AgentSettlement/GameSettlementReversalService.php new file mode 100644 index 0000000..a5be89b --- /dev/null +++ b/app/Services/AgentSettlement/GameSettlementReversalService.php @@ -0,0 +1,82 @@ +where('ticket_item_id', $item->id)->whereNull('reversal_of_id')->first(); + if ($ledger === null) { + return; + } + + $settledAt = now(); + + DB::transaction(function () use ($item, $ledger, $settledAt): void { + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $item->id, + 'player_id' => $ledger->player_id, + 'agent_node_id' => $ledger->agent_node_id, + 'agent_path' => $ledger->agent_path, + 'share_snapshot' => $ledger->share_snapshot, + 'game_win_loss' => -1 * (int) $ledger->game_win_loss, + 'basic_rebate' => -1 * (int) $ledger->basic_rebate, + 'shared_net_win_loss' => -1 * (int) $ledger->shared_net_win_loss, + 'allocations_json' => $ledger->allocations_json, + 'reversal_of_id' => $ledger->id, + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + $rebates = DB::table('rebate_records') + ->where('ticket_item_id', $item->id) + ->where('status', 'accrued') + ->get(); + + foreach ($rebates as $rebate) { + DB::table('rebate_records')->insert([ + 'player_id' => $rebate->player_id, + 'ticket_item_id' => $item->id, + 'game_type' => $rebate->game_type, + 'valid_bet_amount' => $rebate->valid_bet_amount, + 'rebate_rate' => $rebate->rebate_rate, + 'rebate_amount' => -1 * (int) $rebate->rebate_amount, + 'rebate_type' => $rebate->rebate_type, + 'owner_agent_id' => $rebate->owner_agent_id, + 'status' => 'reversed', + 'reversal_of_id' => $rebate->id, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + DB::table('rebate_records')->where('id', $rebate->id)->update(['status' => 'reversed']); + } + + $player = Player::query()->find((int) $ledger->player_id); + if ($player !== null && PlayerFundingMode::usesCredit($player) && (int) $ledger->game_win_loss > 0) { + $playerId = (int) $ledger->player_id; + $row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first(); + if ($row !== null) { + $deltaMinor = (int) $ledger->game_win_loss; + $deltaMajor = CreditAmountScale::minorToMajor( + $deltaMinor, + (string) $player->default_currency, + ); + DB::table('player_credit_accounts') + ->where('player_id', $playerId) + ->update([ + 'used_credit' => max(0, (int) $row->used_credit - $deltaMajor), + 'updated_at' => $settledAt, + ]); + } + } + }); + } +} diff --git a/app/Services/AgentSettlement/PeriodCloseRebateService.php b/app/Services/AgentSettlement/PeriodCloseRebateService.php new file mode 100644 index 0000000..303b12f --- /dev/null +++ b/app/Services/AgentSettlement/PeriodCloseRebateService.php @@ -0,0 +1,239 @@ +dispatchAccruedToPeriod($periodId, $periodStart, $periodEnd); + $allocationCount = $this->buildAllocations($periodId, $rebateIds); + + return [ + 'dispatched' => count($rebateIds), + 'allocations' => $allocationCount, + ]; + } + + /** + * @return list + */ + private function dispatchAccruedToPeriod(int $periodId, string $periodStart, string $periodEnd): array + { + $ids = DB::table('rebate_records as rr') + ->join('ticket_items as ti', 'ti.id', '=', 'rr.ticket_item_id') + ->join('share_ledger as sl', function ($join): void { + $join->on('sl.ticket_item_id', '=', 'ti.id') + ->whereNull('sl.reversal_of_id'); + }) + ->where('rr.status', 'accrued') + ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]) + ->pluck('rr.id') + ->map(fn ($id): int => (int) $id) + ->all(); + + if ($ids === []) { + return []; + } + + DB::table('rebate_records') + ->whereIn('id', $ids) + ->update([ + 'settlement_period_id' => $periodId, + 'status' => 'in_bill', + 'updated_at' => now(), + ]); + + return $ids; + } + + /** + * @param list $rebateIds + */ + private function buildAllocations(int $periodId, array $rebateIds): int + { + if ($rebateIds === []) { + return 0; + } + + $playerBills = DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->where('bill_type', 'player') + ->get() + ->keyBy('owner_id'); + + $count = 0; + $rebates = DB::table('rebate_records') + ->whereIn('id', $rebateIds) + ->where('status', 'in_bill') + ->get(); + + foreach ($rebates as $rebate) { + $playerId = (int) $rebate->player_id; + $bill = $playerBills->get($playerId); + $billId = $bill !== null ? (int) $bill->id : null; + + if ((string) $rebate->rebate_type === 'extra') { + $count += $this->insertExtraAllocation($rebate, $billId); + + continue; + } + + $count += $this->insertBasicShareAllocations($rebate, $billId); + } + + return $count; + } + + private function insertExtraAllocation(object $rebate, ?int $billId): int + { + $agentId = (int) ($rebate->owner_agent_id ?? 0); + if ($agentId <= 0) { + return 0; + } + + DB::table('rebate_allocations')->insert([ + 'rebate_record_id' => (int) $rebate->id, + 'settlement_bill_id' => $billId, + 'participant_type' => 'agent', + 'participant_id' => $agentId, + 'actual_share_rate' => 0, + 'allocated_amount' => (int) $rebate->rebate_amount, + 'allocation_rule' => 'owner', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return 1; + } + + private function insertBasicShareAllocations(object $rebate, ?int $billId): int + { + $ticketItemId = (int) ($rebate->ticket_item_id ?? 0); + if ($ticketItemId <= 0) { + return 0; + } + + $ledger = DB::table('share_ledger') + ->where('ticket_item_id', $ticketItemId) + ->whereNull('reversal_of_id') + ->orderByDesc('id') + ->first(); + + if ($ledger === null) { + return 0; + } + + $snapshot = $this->decodeSnapshot($ledger->share_snapshot); + if ($snapshot === null) { + return 0; + } + + $amount = (int) $rebate->rebate_amount; + if ($amount <= 0) { + return 0; + } + + $shares = $snapshot['actual_shares']; + $rows = []; + $allocatedSum = 0; + $participants = []; + + foreach ($shares as $code => $rate) { + if ($code === 'platform') { + $participants[] = ['type' => 'platform', 'id' => 0, 'rate' => (float) $rate]; + + continue; + } + + $node = AgentNode::query()->where('code', (string) $code)->first(); + if ($node === null) { + continue; + } + + $participants[] = ['type' => 'agent', 'id' => (int) $node->id, 'rate' => (float) $rate]; + } + + foreach ($participants as $index => $p) { + $isLast = $index === count($participants) - 1; + $slice = $isLast + ? $amount - $allocatedSum + : (int) round($amount * ($p['rate'] / 100), 0, PHP_ROUND_HALF_UP); + $allocatedSum += $slice; + + $rows[] = [ + 'rebate_record_id' => (int) $rebate->id, + 'settlement_bill_id' => $billId, + 'participant_type' => (string) $p['type'], + 'participant_id' => (int) $p['id'], + 'actual_share_rate' => $p['rate'], + 'allocated_amount' => $slice, + 'allocation_rule' => 'share', + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if ($rows !== []) { + DB::table('rebate_allocations')->insert($rows); + } + + return count($rows); + } + + /** + * @return array{actual_shares: array}|null + */ + private function decodeSnapshot(mixed $raw): ?array + { + if ($raw === null || $raw === '') { + return null; + } + + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (! is_array($decoded)) { + return null; + } + + $actual = $decoded['actual_shares'] ?? null; + if (! is_array($actual) || $actual === []) { + return null; + } + + $shares = []; + foreach ($actual as $code => $rate) { + $shares[(string) $code] = (float) $rate; + } + + return ['actual_shares' => $shares]; + } + + public function markRebatesSettledForBill(int $billId): void + { + $bill = DB::table('settlement_bills')->where('id', $billId)->first(); + if ($bill === null || (string) $bill->bill_type !== 'player') { + return; + } + + DB::table('rebate_records') + ->where('player_id', (int) $bill->owner_id) + ->where('settlement_period_id', (int) $bill->settlement_period_id) + ->where('status', 'in_bill') + ->update([ + 'status' => 'settled', + 'updated_at' => now(), + ]); + } +} diff --git a/app/Services/AgentSettlement/PlatformRoundingAdjuster.php b/app/Services/AgentSettlement/PlatformRoundingAdjuster.php new file mode 100644 index 0000000..f842c28 --- /dev/null +++ b/app/Services/AgentSettlement/PlatformRoundingAdjuster.php @@ -0,0 +1,72 @@ +>, agent_edges: array, agent_subtrees: array>} $aggregate + */ + public function apply(int $periodId, array $aggregate): int + { + $playerNet = 0; + foreach ($aggregate['players'] as $row) { + $playerNet += (int) $row['net_amount']; + } + + $shareProfitTotal = 0; + foreach ($aggregate['agent_subtrees'] as $subtree) { + $shareProfitTotal += (int) ($subtree['share_profit'] ?? 0); + } + $shareProfitTotal += (int) ($aggregate['platform_share_profit'] ?? 0); + + $diff = $playerNet - $shareProfitTotal; + if ($diff === 0) { + return 0; + } + + $platformBill = DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->where('bill_type', 'agent') + ->where('counterparty_type', 'platform') + ->orderBy('id') + ->first(); + + if ($platformBill === null) { + return 0; + } + + $net = (int) $platformBill->net_amount + $diff; + DB::table('settlement_bills')->where('id', (int) $platformBill->id)->update([ + 'platform_rounding_adjustment' => $diff, + 'net_amount' => $net, + 'unpaid_amount' => abs($net), + 'meta_json' => json_encode(array_merge( + $this->decodeMeta($platformBill->meta_json), + ['platform_rounding_adjustment' => $diff], + )), + 'updated_at' => now(), + ]); + + return $diff; + } + + /** + * @return array + */ + private function decodeMeta(mixed $raw): array + { + if ($raw === null || $raw === '') { + return []; + } + + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/app/Services/AgentSettlement/SettlementBillGenerator.php b/app/Services/AgentSettlement/SettlementBillGenerator.php new file mode 100644 index 0000000..3a465cb --- /dev/null +++ b/app/Services/AgentSettlement/SettlementBillGenerator.php @@ -0,0 +1,123 @@ +>, + * agent_edges: array, + * agent_subtrees: array>, + * platform_share_profit?: int, + * } $aggregate + * @return list bill ids + */ + public function generate(int $periodId, int $adminSiteId, array $aggregate): array + { + $billIds = []; + $now = now(); + $subtrees = $aggregate['agent_subtrees'] ?? []; + + foreach ($aggregate['players'] as $playerId => $row) { + $net = (int) $row['net_amount']; + $billIds[] = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $playerId, + 'counterparty_type' => 'agent', + 'counterparty_id' => (int) $row['agent_node_id'], + 'gross_win_loss' => (int) $row['game_win_loss'], + 'rebate_amount' => (int) $row['basic_rebate'] + (int) $row['extra_rebate'], + 'adjustment_amount' => 0, + 'platform_rounding_adjustment' => 0, + 'net_amount' => $net, + 'paid_amount' => 0, + 'unpaid_amount' => abs($net), + 'status' => 'pending_confirm', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + foreach ($aggregate['agent_edges'] as $edge => $amount) { + if ($amount === 0 || str_starts_with($edge, 'P_to_')) { + continue; + } + $parsed = $this->parseEdge($edge); + if ($parsed === null) { + continue; + } + + [$fromType, $fromId, $toType, $toId] = $parsed; + $subtree = $subtrees[$fromId] ?? null; + + $billIds[] = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'agent', + 'owner_type' => $fromType, + 'owner_id' => $fromId, + 'counterparty_type' => $toType, + 'counterparty_id' => $toId, + 'gross_win_loss' => (int) ($subtree['gross_win_loss'] ?? 0), + 'rebate_amount' => (int) ($subtree['basic_rebate'] ?? 0) + (int) ($subtree['extra_rebate'] ?? 0), + 'adjustment_amount' => 0, + 'platform_rounding_adjustment' => 0, + 'net_amount' => $amount, + 'paid_amount' => 0, + 'unpaid_amount' => $amount, + 'status' => 'pending_confirm', + 'meta_json' => json_encode([ + 'edge' => $edge, + 'share_profit' => (int) ($subtree['share_profit'] ?? 0), + 'player_count' => (int) ($subtree['player_count'] ?? 0), + 'platform_share_profit' => $toType === 'platform' ? (int) ($aggregate['platform_share_profit'] ?? 0) : null, + ]), + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + return $billIds; + } + + /** + * @return array{0: string, 1: int, 2: string, 3: int}|null + */ + private function parseEdge(string $edge): ?array + { + if (preg_match('/^P_to_(.+)$/', $edge, $m)) { + $agent = AgentNode::query()->where('code', $m[1])->first(); + if ($agent === null) { + return null; + } + + return ['agent', (int) $agent->id, 'player', 0]; + } + + if (preg_match('/^(.+)_to_platform$/', $edge, $m)) { + $agent = AgentNode::query()->where('code', $m[1])->first(); + if ($agent === null) { + return null; + } + + return ['agent', (int) $agent->id, 'platform', 0]; + } + + if (preg_match('/^(.+)_to_(.+)$/', $edge, $m)) { + $from = AgentNode::query()->where('code', $m[1])->first(); + $to = AgentNode::query()->where('code', $m[2])->first(); + if ($from === null || $to === null) { + return null; + } + + return ['agent', (int) $from->id, 'agent', (int) $to->id]; + } + + return null; + } +} diff --git a/app/Services/AgentSettlement/SettlementCenterLedgerService.php b/app/Services/AgentSettlement/SettlementCenterLedgerService.php new file mode 100644 index 0000000..42e49d3 --- /dev/null +++ b/app/Services/AgentSettlement/SettlementCenterLedgerService.php @@ -0,0 +1,598 @@ +>, + * total: int, + * page: int, + * per_page: int, + * ledger_source: string, + * } + */ + public function listUnified( + AdminUser $admin, + string $siteCode, + int $page, + int $perPage, + SettlementLedgerListFilters $filters = new SettlementLedgerListFilters, + ): array { + $periodId = $filters->settlementPeriodId; + $range = $this->resolveCreatedRange($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); + } + } + if ($includePayment) { + foreach ($this->fetchPaymentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) { + $items[] = $this->formatPaymentEntry($row); + } + } + 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); + } + } + + $items = $this->applyFilters($items, $filters); + + usort($items, static function (array $a, array $b): int { + return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? '')); + }); + + $total = count($items); + $offset = max(0, ($page - 1) * $perPage); + $pageItems = array_slice($items, $offset, $perPage); + + return [ + 'items' => array_values($pageItems), + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'ledger_source' => 'settlement_ledger', + ]; + } + + /** + * @return array{0: Carbon|null, 1: Carbon|null} + */ + private function includeEntryKind(SettlementLedgerListFilters $filters, string $kind): bool + { + if ($filters->badDebtOnly) { + return $kind === 'adjustment'; + } + + $selected = $filters->entryKind; + if ($selected === null || $selected === '' || $selected === 'all') { + return true; + } + + return $selected === $kind; + } + + /** + * @param list> $items + * @return list> + */ + private function applyFilters(array $items, SettlementLedgerListFilters $filters): array + { + return array_values(array_filter($items, function (array $row) use ($filters): bool { + if ($filters->badDebtOnly) { + if (($row['entry_kind'] ?? '') !== 'adjustment' || ($row['biz_type'] ?? '') !== 'bad_debt') { + return false; + } + } elseif ($filters->entryKind === 'adjustment') { + if (($row['entry_kind'] ?? '') === 'adjustment' && ($row['biz_type'] ?? '') === 'bad_debt') { + return false; + } + } + + 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( + $actions, + static fn (string $a): bool => ! in_array($a, ['view_player', 'view_bill'], true), + ); + if ($operational === []) { + return false; + } + } + + return true; + })); + } + + private function resolveCreatedRange( + ?int $settlementPeriodId, + ?string $createdFrom, + ?string $createdTo, + ): ?array { + if ($settlementPeriodId !== null && $settlementPeriodId > 0) { + $period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first(); + if ($period === null) { + return null; + } + + return [ + Carbon::parse($period->period_start)->startOfDay(), + Carbon::parse($period->period_end)->endOfDay(), + ]; + } + + $from = $createdFrom !== null && $createdFrom !== '' + ? Carbon::parse($createdFrom)->startOfDay() + : null; + $to = $createdTo !== null && $createdTo !== '' + ? Carbon::parse($createdTo)->endOfDay() + : null; + + if ($from === null && $to === null) { + return null; + } + + return [ + $from ?? Carbon::parse('1970-01-01')->startOfDay(), + $to ?? Carbon::now()->endOfDay(), + ]; + } + + /** + * @return array + */ + private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array + { + $query = DB::table('settlement_bills as sb') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->join('players as p', function ($join): void { + $join->on('p.id', '=', 'sb.owner_id')->where('sb.owner_type', '=', 'player'); + }) + ->where('p.site_code', $siteCode) + ->where('sb.bill_type', 'player') + ->select([ + 'sb.id', + 'sb.owner_id as player_id', + 'sb.status', + 'sb.bill_type', + 'sb.net_amount', + 'sb.unpaid_amount', + 'sb.paid_amount', + 'sb.settlement_period_id', + ]) + ->orderByDesc('sb.id'); + + if ($periodId !== null && $periodId > 0) { + abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403); + $query->where('sb.settlement_period_id', $periodId); + } + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + + $map = []; + foreach ($query->limit(500)->get() as $bill) { + $pid = (int) $bill->player_id; + if (! isset($map[$pid])) { + $map[$pid] = $bill; + + continue; + } + $existing = $map[$pid]; + if ((string) $bill->status === 'pending_confirm') { + $map[$pid] = $bill; + } elseif ((string) $existing->status !== 'pending_confirm' + && (int) $bill->unpaid_amount > 0 + && (int) $existing->unpaid_amount <= 0) { + $map[$pid] = $bill; + } + } + + return $map; + } + + /** + * @param array{0: Carbon, 1: Carbon}|null $range + * @return list + */ + private function fetchCreditRows( + AdminUser $admin, + string $siteCode, + ?array $range, + ?int $playerId, + ): 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'); + }) + ->where('p.site_code', $siteCode) + ->where('p.funding_mode', PlayerFundingMode::CREDIT) + ->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.funding_mode', + 'p.auth_source', + 'p.default_currency', + ]) + ->orderByDesc('cl.id'); + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + + if ($playerId !== null && $playerId > 0) { + $query->where('p.id', $playerId); + } + + if ($range !== null) { + $query->whereBetween('cl.created_at', $range); + } + + return $query->limit(500)->get()->all(); + } + + /** + * @return list + */ + private function fetchPaymentRows( + AdminUser $admin, + string $siteCode, + ?int $periodId, + ?int $playerId, + ): array { + $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') + ->join('players as p', function ($join): void { + $join->on('p.id', '=', 'sb.owner_id') + ->where('sb.owner_type', '=', 'player'); + }) + ->where('p.site_code', $siteCode) + ->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.auth_source', + 'p.funding_mode', + 'p.default_currency', + ]) + ->orderByDesc('pr.id'); + + if ($periodId !== null && $periodId > 0) { + $query->where('sb.settlement_period_id', $periodId); + } + + if ($playerId !== null && $playerId > 0) { + $query->where('p.id', $playerId); + } + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds !== null) { + if ($siteIds === []) { + return []; + } + $query->whereIn('sp.admin_site_id', $siteIds); + } + + return $query->limit(300)->get()->all(); + } + + /** + * @return list + */ + private function fetchAdjustmentRows( + AdminUser $admin, + string $siteCode, + ?int $periodId, + ?int $playerId, + ): array { + $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) + ->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.auth_source', + 'p.funding_mode', + 'p.default_currency', + ]) + ->orderByDesc('sa.id'); + + if ($periodId !== null && $periodId > 0) { + $query->where('sa.settlement_period_id', $periodId); + } + + if ($playerId !== null && $playerId > 0) { + $query->where('p.id', $playerId); + } + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds !== null) { + if ($siteIds === []) { + return []; + } + $query->whereIn('sp.admin_site_id', $siteIds); + } + + return $query->limit(300)->get()->all(); + } + + /** + * @return array + */ + private function formatCreditEntry(object $row, ?object $bill): array + { + $amount = (int) $row->amount; + $billId = $bill !== null ? (int) $bill->id : null; + + 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 + */ + private function formatPaymentEntry(object $row): array + { + $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 + */ + private function formatAdjustmentEntry(object $row): array + { + $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 + */ + private function baseRow( + string $entryKind, + int $entryId, + string $txnPrefix, + int $playerId, + object $row, + string $bizType, + int $signedAmount, + mixed $createdAt, + string $ledgerSource, + ?int $settlementBillId, + ?string $billStatus, + ?string $billType, + ?int $billUnpaid, + ?string $refLabel = null, + ): array { + $amountAbs = abs($signedAmount); + $currency = (string) ($row->default_currency ?? ''); + + return [ + 'entry_kind' => $entryKind, + 'id' => $entryId, + 'row_key' => $entryKind.'-'.$entryId, + 'txn_no' => $txnPrefix.'-'.$entryId, + 'player_id' => $playerId, + 'site_code' => $row->site_code ?? null, + 'site_player_id' => $row->site_player_id ?? null, + 'username' => $row->username ?? null, + 'nickname' => $row->nickname ?? null, + 'biz_type' => $bizType, + 'biz_no' => $refLabel ?? $this->creditRefLabel($row), + 'direction' => $signedAmount >= 0 ? 1 : 2, + 'amount' => $amountAbs, + 'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs), + 'signed_amount' => $signedAmount, + 'currency_code' => $currency, + 'status' => 'posted', + 'created_at' => $createdAt !== null ? Carbon::parse($createdAt)->toIso8601String() : null, + 'ledger_source' => $ledgerSource, + 'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT), + 'auth_source' => $row->auth_source ?? null, + 'settlement_bill_id' => $settlementBillId, + 'bill_status' => $billStatus, + 'bill_type' => $billType, + 'bill_unpaid_amount' => $billUnpaid, + 'available_actions' => $this->resolveActions( + $entryKind, + $settlementBillId, + $billStatus, + $billType, + $billUnpaid, + ), + ]; + } + + /** + * @return list + */ + private function resolveActions( + string $entryKind, + ?int $billId, + ?string $billStatus, + ?string $billType, + ?int $billUnpaid, + ): array { + $actions = ['view_player']; + + if ($billId === null || $billId <= 0) { + return $actions; + } + + $actions[] = 'view_bill'; + + if ($billStatus === 'pending_confirm') { + $actions[] = 'confirm'; + } + + if ($billStatus !== null + && in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true) + && ($billUnpaid ?? 0) > 0) { + $actions[] = 'payment'; + } + + if ($billStatus !== null + && in_array($billStatus, ['confirmed', 'partial_paid', 'settled', 'overdue'], true) + && ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) { + $actions[] = 'adjustment'; + $actions[] = 'reversal'; + } + + if ($billStatus !== null + && in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true) + && ($billUnpaid ?? 0) > 0 + && ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) { + $actions[] = 'bad_debt'; + } + + return array_values(array_unique($actions)); + } + + private function creditRefLabel(object $row): ?string + { + if (! isset($row->ref_type) || $row->ref_type === null || $row->ref_id === null) { + return null; + } + + return (string) $row->ref_type.'#'.$row->ref_id; + } +} diff --git a/app/Services/AgentSettlement/SettlementLedgerListFilters.php b/app/Services/AgentSettlement/SettlementLedgerListFilters.php new file mode 100644 index 0000000..677a0b1 --- /dev/null +++ b/app/Services/AgentSettlement/SettlementLedgerListFilters.php @@ -0,0 +1,54 @@ + 0 ? $playerId : null, + entryKind: self::nonEmptyString($query['entry_kind'] ?? null), + txnNo: self::nonEmptyString($query['txn_no'] ?? null), + playerAccount: self::nonEmptyString($query['player_account'] ?? null), + bizType: self::nonEmptyString($query['reason'] ?? $query['biz_type'] ?? null), + billStatus: self::nonEmptyString($query['bill_status'] ?? null), + actionableOnly: filter_var($query['actionable_only'] ?? false, FILTER_VALIDATE_BOOLEAN), + 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), + ); + } + + private static function positiveInt(mixed $value): ?int + { + $id = (int) $value; + + return $id > 0 ? $id : null; + } + + private static function nonEmptyString(mixed $value): ?string + { + $s = trim((string) $value); + + return $s !== '' ? $s : null; + } +} diff --git a/app/Services/AgentSettlement/SettlementPaymentService.php b/app/Services/AgentSettlement/SettlementPaymentService.php new file mode 100644 index 0000000..b58ab05 --- /dev/null +++ b/app/Services/AgentSettlement/SettlementPaymentService.php @@ -0,0 +1,81 @@ +billGuard->markConfirmed($billId); + } + + /** + * @param array{method?: string|null, proof?: string|null, remark?: string|null} $meta + */ + public function recordPayment(int $billId, int $amount, int $adminUserId, array $meta = []): void + { + $bill = DB::table('settlement_bills')->where('id', $billId)->first(); + if ($bill === null) { + throw new \InvalidArgumentException('bill_not_found'); + } + + $this->billGuard->assertPeriodMutable($billId); + + $amount = min($amount, (int) $bill->unpaid_amount); + if ($amount <= 0) { + return; + } + + 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, + 'amount' => $amount, + 'method' => $meta['method'] ?? null, + 'proof' => $meta['proof'] ?? null, + 'remark' => $meta['remark'] ?? null, + 'status' => 'confirmed', + 'created_by' => $adminUserId, + 'confirmed_by' => $adminUserId, + 'confirmed_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $newPaid = (int) $bill->paid_amount + $amount; + $newUnpaid = max(0, (int) $bill->unpaid_amount - $amount); + $status = $newUnpaid === 0 ? 'settled' : 'partial_paid'; + + DB::table('settlement_bills')->where('id', $billId)->update([ + 'paid_amount' => $newPaid, + 'unpaid_amount' => $newUnpaid, + 'status' => $status, + 'updated_at' => now(), + ]); + + 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 ($status === 'settled') { + $this->periodCloseRebate->markRebatesSettledForBill($billId); + } + } + } + + $this->periodCompletion->syncIfReady((int) $bill->settlement_period_id); + } +} diff --git a/app/Services/AgentSettlement/UnsettledTicketPeriodWarning.php b/app/Services/AgentSettlement/UnsettledTicketPeriodWarning.php new file mode 100644 index 0000000..fe999d9 --- /dev/null +++ b/app/Services/AgentSettlement/UnsettledTicketPeriodWarning.php @@ -0,0 +1,30 @@ +} + */ + public function countForSite(int $adminSiteId, string $periodStart, string $periodEnd): array + { + $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); + + $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]) + ->pluck('ti.id') + ->map(fn ($id): int => (int) $id) + ->all(); + + return [ + 'count' => count($rows), + 'ticket_item_ids' => array_slice($rows, 0, 20), + ]; + } +} diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index f87eb0d..b76f136 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -3,6 +3,9 @@ namespace App\Services\Player; use App\Models\Player; +use App\Support\AgentOverdueGuard; +use App\Support\CreditAmountScale; +use App\Support\PlayerFundingMode; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -14,19 +17,33 @@ final class PlayerCreditService public function upsertAccount(Player $player, array $payload): void { $limit = max(0, (int) ($payload['credit_limit'] ?? 0)); + $now = now(); + $exists = DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->exists(); - DB::table('player_credit_accounts')->updateOrInsert( - ['player_id' => $player->id], - [ - 'credit_limit' => $limit, - 'used_credit' => DB::raw('COALESCE(used_credit, 0)'), - 'frozen_credit' => DB::raw('COALESCE(frozen_credit, 0)'), - 'updated_at' => now(), - 'created_at' => now(), - ], - ); + if ($exists) { + DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->update([ + 'credit_limit' => $limit, + 'updated_at' => $now, + ]); + + return; + } + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => $limit, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); } + /** 可用授信(主货币整数,与后台「授信额度」一致)。 */ public function availableCredit(Player $player): int { $row = DB::table('player_credit_accounts')->where('player_id', $player->id)->first(); @@ -37,34 +54,45 @@ final class PlayerCreditService return max(0, (int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit); } - public function holdForBet(Player $player, int $amount): void + /** 可用授信(最小货币单位,供玩家端钱包/下注与钱包余额 API 对齐)。 */ + public function availableCreditMinor(Player $player, ?string $currencyCode = null): int { - if ($amount <= 0) { + $currency = $currencyCode ?? (string) $player->default_currency; + + return CreditAmountScale::majorToMinor($this->availableCredit($player), $currency); + } + + public function holdForBet(Player $player, int $amountMinor): void + { + if ($amountMinor <= 0) { return; } - if (! \App\Support\CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) { + if (! PlayerFundingMode::usesCredit($player)) { return; } - $available = $this->availableCredit($player); - if ($amount > $available) { + $currency = (string) $player->default_currency; + $availableMinor = $this->availableCreditMinor($player, $currency); + if ($amountMinor > $availableMinor) { throw ValidationException::withMessages([ 'credit' => ['insufficient'], ]); } + $majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency); + DB::table('player_credit_accounts') ->where('player_id', $player->id) ->update([ - 'used_credit' => DB::raw('used_credit + '.$amount), + 'used_credit' => DB::raw('used_credit + '.$majorDelta), 'updated_at' => now(), ]); DB::table('credit_ledger')->insert([ 'owner_type' => 'player', 'owner_id' => $player->id, - 'amount' => -$amount, + 'amount' => -$amountMinor, 'reason' => 'bet_hold', 'ref_type' => 'bet', 'ref_id' => null, @@ -73,23 +101,97 @@ final class PlayerCreditService ]); } - public function releaseFromSettlement(Player $player, int $amount, int $billId): void + public function applySettledLoss(Player $player, int $amountMinor, int $ticketItemId): void { - if ($amount <= 0) { + if ($amountMinor <= 0) { return; } + if (! PlayerFundingMode::usesCredit($player)) { + return; + } + + $currency = (string) $player->default_currency; + $majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency); + DB::table('player_credit_accounts') ->where('player_id', $player->id) ->update([ - 'used_credit' => DB::raw('GREATEST(0, used_credit - '.$amount.')'), + 'used_credit' => DB::raw('used_credit + '.$majorDelta), 'updated_at' => now(), ]); DB::table('credit_ledger')->insert([ 'owner_type' => 'player', 'owner_id' => $player->id, - 'amount' => $amount, + 'amount' => -$amountMinor, + 'reason' => 'game_settlement_loss', + '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)) { + return; + } + + $overdue = DB::table('settlement_bills') + ->where('owner_type', 'player') + ->where('owner_id', $player->id) + ->where('status', 'overdue') + ->where('unpaid_amount', '>', 0) + ->exists(); + + if ($overdue) { + throw ValidationException::withMessages([ + 'credit' => ['overdue'], + ]); + } + + $agentNodeId = (int) ($player->agent_node_id ?? 0); + if ($agentNodeId > 0) { + AgentOverdueGuard::assertAgentMayGrantCredit($agentNodeId); + } + + $this->holdForBet($player, $amountMinor); + } + + public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void + { + if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { + return; + } + + $this->decreaseUsedCredit($player, $amountMinor); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amountMinor, + 'reason' => 'bet_hold_release', + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketItemId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void + { + if ($amountMinor <= 0) { + return; + } + + $this->decreaseUsedCredit($player, $amountMinor); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amountMinor, 'reason' => 'settlement_confirm', 'ref_type' => 'settlement_bill', 'ref_id' => $billId, @@ -97,4 +199,26 @@ final class PlayerCreditService 'updated_at' => now(), ]); } + + private function decreaseUsedCredit(Player $player, int $amountMinor): void + { + if ($amountMinor <= 0) { + return; + } + + $playerId = (int) $player->id; + $row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first(); + if ($row === null) { + return; + } + + $majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency); + $next = max(0, (int) $row->used_credit - $majorDelta); + DB::table('player_credit_accounts') + ->where('player_id', $playerId) + ->update([ + 'used_credit' => $next, + 'updated_at' => now(), + ]); + } } diff --git a/app/Services/Player/PlayerNativeAuthService.php b/app/Services/Player/PlayerNativeAuthService.php new file mode 100644 index 0000000..cf4834b --- /dev/null +++ b/app/Services/Player/PlayerNativeAuthService.php @@ -0,0 +1,131 @@ +} + */ + public function login(string $siteCode, string $username, string $password): array + { + $username = trim($username); + $siteCode = trim($siteCode); + if ($siteCode === '' || $username === '' || $password === '') { + throw new PlayerAuthenticationException( + '账号或密码错误', + ErrorCode::PlayerCredentialsInvalid->value, + ); + } + + $player = Player::query() + ->where('site_code', $siteCode) + ->where('username', $username) + ->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE) + ->first(); + + if ($player === null || ! is_string($player->password_hash) || $player->password_hash === '') { + throw new PlayerAuthenticationException( + '账号或密码错误', + ErrorCode::PlayerCredentialsInvalid->value, + ); + } + + if ($player->login_locked_until !== null && $player->login_locked_until->isFuture()) { + throw new PlayerAuthenticationException( + '登录已锁定', + ErrorCode::PlayerLoginLocked->value, + 403, + ); + } + + if ((int) $player->status !== 0) { + throw new PlayerAuthenticationException( + '账号已冻结', + ErrorCode::PlayerAccountSuspended->value, + 403, + ); + } + + if (! Hash::check($password, $player->password_hash)) { + $this->recordFailedLogin($player); + + throw new PlayerAuthenticationException( + '账号或密码错误', + ErrorCode::PlayerCredentialsInvalid->value, + ); + } + + $player->forceFill([ + 'login_failed_count' => 0, + 'login_locked_until' => null, + 'last_login_at' => now(), + ])->save(); + + $ttl = (int) config('lottery.player_auth.native.ttl_seconds', 28800); + $token = $this->issueToken($player, $ttl); + + return [ + 'access_token' => $token, + 'expires_in' => $ttl, + 'token_type' => 'Bearer', + 'player' => [ + 'id' => (int) $player->id, + 'site_code' => $player->site_code, + 'username' => $player->username, + 'nickname' => $player->nickname, + 'funding_mode' => $player->funding_mode, + 'auth_source' => $player->auth_source, + ], + ]; + } + + public function issueToken(Player $player, ?int $ttlSeconds = null): string + { + $secret = (string) config('lottery.player_auth.native.secret', ''); + if ($secret === '') { + throw new PlayerAuthenticationException( + '原生登录未配置', + ErrorCode::PlayerSsoSecretNotConfigured->value, + 503, + ); + } + + $ttl = $ttlSeconds ?? (int) config('lottery.player_auth.native.ttl_seconds', 28800); + $now = time(); + $playerIdKey = (string) config('lottery.player_auth.native.claim_player_id', 'player_id'); + $authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source'); + + $payload = [ + $playerIdKey => (int) $player->id, + $authKey => PlayerAuthSource::LOTTERY_NATIVE, + 'site_code' => (string) $player->site_code, + 'iat' => $now, + 'exp' => $now + $ttl, + ]; + + return JWT::encode($payload, $secret, (string) config('lottery.player_auth.jwt.algorithm', 'HS256')); + } + + private function recordFailedLogin(Player $player): void + { + $max = (int) config('lottery.player_auth.native.max_login_attempts', 8); + $lockMinutes = (int) config('lottery.player_auth.native.lock_minutes', 15); + $count = (int) $player->login_failed_count + 1; + + $updates = ['login_failed_count' => $count]; + if ($count >= $max) { + $updates['login_locked_until'] = now()->addMinutes($lockMinutes); + $updates['login_failed_count'] = 0; + } + + $player->forceFill($updates)->save(); + } +} diff --git a/app/Services/Player/PlayerRebateProfileService.php b/app/Services/Player/PlayerRebateProfileService.php new file mode 100644 index 0000000..11f55ac --- /dev/null +++ b/app/Services/Player/PlayerRebateProfileService.php @@ -0,0 +1,65 @@ + $profiles + */ + public function syncProfiles(int $playerId, AgentNode $agent, array $profiles): void + { + if ($profiles === []) { + return; + } + + $now = now(); + foreach ($profiles as $row) { + $gameType = trim((string) ($row['game_type'] ?? '*')) ?: '*'; + $inherit = (bool) ($row['inherit_from_agent'] ?? false); + $rebateRate = (float) ($row['rebate_rate'] ?? 0); + $extraRate = (float) ($row['extra_rebate_rate'] ?? 0); + + if (! $inherit) { + $this->rebateLimitValidator->assertPlayerRebateWithinAgent($agent, $rebateRate, $extraRate); + } + + DB::table('player_rebate_profiles')->updateOrInsert( + ['player_id' => $playerId, 'game_type' => $gameType], + [ + 'inherit_from_agent' => $inherit, + 'rebate_rate' => $inherit ? 0 : $rebateRate, + 'extra_rebate_rate' => $inherit ? 0 : $extraRate, + 'updated_at' => $now, + 'created_at' => $now, + ], + ); + } + } + + /** + * @return list + */ + public function listForPlayer(int $playerId): array + { + return DB::table('player_rebate_profiles') + ->where('player_id', $playerId) + ->orderBy('game_type') + ->get() + ->map(static fn (object $row): array => [ + 'game_type' => (string) $row->game_type, + 'rebate_rate' => (float) $row->rebate_rate, + 'extra_rebate_rate' => (float) $row->extra_rebate_rate, + 'inherit_from_agent' => (bool) $row->inherit_from_agent, + ]) + ->all(); + } +} diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php index 3fcdc43..431b0c8 100644 --- a/app/Services/PlayerTokenResolver.php +++ b/app/Services/PlayerTokenResolver.php @@ -7,7 +7,9 @@ use Firebase\JWT\Key; use App\Models\Player; use App\Lottery\ErrorCode; use Illuminate\Http\Request; +use App\Support\PlayerAuthSource; use App\Support\PlayerAutoRegistrationDefaults; +use App\Support\PlayerFundingMode; use App\Support\PlayerTokenAesUnwrap; use Illuminate\Database\QueryException; use App\Exceptions\PlayerAuthenticationException; @@ -63,26 +65,30 @@ final class PlayerTokenResolver $player = $this->resolveDevToken($token); } else { $jwtPlain = $this->unwrapOpaqueToJwtString($token); - $siteCode = $this->partnerSiteConfigResolver->peekSiteCodeFromJwt($jwtPlain); - if ($siteCode === null) { - throw new PlayerAuthenticationException('JWT 缺少站点标识', ErrorCode::PlayerTokenInvalid->value); - } + if ($this->peekAuthSourceFromJwt($jwtPlain) === PlayerAuthSource::LOTTERY_NATIVE) { + $player = $this->resolveNativeJwt($jwtPlain); + } else { + $siteCode = $this->partnerSiteConfigResolver->peekSiteCodeFromJwt($jwtPlain); + if ($siteCode === null) { + throw new PlayerAuthenticationException('JWT 缺少站点标识', ErrorCode::PlayerTokenInvalid->value); + } - $siteConfig = $this->partnerSiteConfigResolver->resolveBySiteCode($siteCode); - if (! $siteConfig->enabled) { - throw new PlayerAuthenticationException('站点已停用', ErrorCode::PlayerAccountSuspended->value, 403); - } + $siteConfig = $this->partnerSiteConfigResolver->resolveBySiteCode($siteCode); + if (! $siteConfig->enabled) { + throw new PlayerAuthenticationException('站点已停用', ErrorCode::PlayerAccountSuspended->value, 403); + } - $secret = $siteConfig->ssoJwtSecret; - if (! is_string($secret) || $secret === '') { - throw new PlayerAuthenticationException( - 'SSO 未配置(站点 '.$siteCode.')', - ErrorCode::PlayerSsoSecretNotConfigured->value, - 503, - ); - } + $secret = $siteConfig->ssoJwtSecret; + if (! is_string($secret) || $secret === '') { + throw new PlayerAuthenticationException( + 'SSO 未配置(站点 '.$siteCode.')', + ErrorCode::PlayerSsoSecretNotConfigured->value, + 503, + ); + } - $player = $this->resolveJwt($jwtPlain, $secret); + $player = $this->resolveSsoJwt($jwtPlain, $secret); + } } $this->assertPlayerActive($player); @@ -127,7 +133,7 @@ final class PlayerTokenResolver { $jwtPlain = $this->unwrapOpaqueToJwtString($opaque); - return $this->resolveJwt($jwtPlain, $secret); + return $this->resolveSsoJwt($jwtPlain, $secret); } /** @@ -150,7 +156,48 @@ final class PlayerTokenResolver return preg_match('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $token) === 1; } - private function resolveJwt(string $jwt, string $secret): Player + private function resolveNativeJwt(string $jwt): Player + { + $secret = (string) config('lottery.player_auth.native.secret', ''); + if ($secret === '') { + throw new PlayerAuthenticationException( + '原生登录未配置', + ErrorCode::PlayerSsoSecretNotConfigured->value, + 503, + ); + } + + $alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); + + try { + /** @var object $claims */ + $claims = JWT::decode($jwt, new Key($secret, $alg)); + } catch (\Throwable) { + throw new PlayerAuthenticationException('Token 无效或已过期', ErrorCode::PlayerTokenInvalid->value); + } + + $this->assertNativeJwtTemporalPolicy($claims); + + $playerIdKey = (string) config('lottery.player_auth.native.claim_player_id', 'player_id'); + $authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source'); + $playerId = (int) data_get($claims, $playerIdKey, 0); + $authSource = data_get($claims, $authKey); + + if ($playerId <= 0 || $authSource !== PlayerAuthSource::LOTTERY_NATIVE) { + throw new PlayerAuthenticationException('JWT 缺少玩家标识', ErrorCode::PlayerTokenInvalid->value); + } + + $player = Player::query()->find($playerId); + if ($player === null || ! $player->isLotteryNative()) { + throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value); + } + + $player->forceFill(['last_login_at' => now()])->save(); + + return $player->refresh(); + } + + private function resolveSsoJwt(string $jwt, string $secret): Player { $alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); @@ -178,6 +225,8 @@ final class PlayerTokenResolver $now = now(); $defaults = [ ...PlayerAutoRegistrationDefaults::profileFields(), + 'auth_source' => PlayerAuthSource::MAIN_SITE_SSO, + 'funding_mode' => PlayerFundingMode::WALLET, 'default_currency' => LotterySettings::defaultCurrency(), 'status' => self::PLAYER_STATUS_ACTIVE, 'last_login_at' => $now, @@ -209,6 +258,58 @@ final class PlayerTokenResolver return $player->refresh(); } + private function peekAuthSourceFromJwt(string $jwt): ?string + { + $parts = explode('.', trim($jwt)); + if (count($parts) !== 3) { + return null; + } + + $payload = json_decode($this->base64UrlDecode($parts[1]), true); + if (! is_array($payload)) { + return null; + } + + $authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source'); + $value = $payload[$authKey] ?? null; + + return is_string($value) ? $value : null; + } + + private function base64UrlDecode(string $segment): string + { + $remainder = strlen($segment) % 4; + if ($remainder > 0) { + $segment .= str_repeat('=', 4 - $remainder); + } + + $decoded = base64_decode(strtr($segment, '-_', '+/'), true); + + return is_string($decoded) ? $decoded : ''; + } + + /** + * @param object $claims + */ + private function assertNativeJwtTemporalPolicy(object $claims): void + { + if (! isset($claims->exp) || ! is_numeric($claims->exp)) { + throw new PlayerAuthenticationException('JWT 缺少过期时间', ErrorCode::PlayerTokenInvalid->value); + } + + $maxTtl = (int) config('lottery.player_auth.native.ttl_seconds', 28800); + if (isset($claims->iat) && is_numeric($claims->iat)) { + $iat = (int) $claims->iat; + $exp = (int) $claims->exp; + if ($exp - $iat > $maxTtl) { + throw new PlayerAuthenticationException( + 'JWT 有效期超过允许的 '.(string) $maxTtl.' 秒', + ErrorCode::PlayerTokenInvalid->value, + ); + } + } + } + /** * 短效 SSO:JWT 须有 exp(由 decode 校验),可选要求 iat,且 exp-iat 不得超过配置秒数。 * diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index 2ce7163..ff5ed59 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -17,6 +17,7 @@ use App\Services\Draw\DrawHallSnapshotBuilder; use App\Services\Draw\LotteryHallRealtimeBroadcaster; use App\Services\Ticket\RiskPoolService; use App\Services\Jackpot\JackpotBurstAllocator; +use App\Services\AgentSettlement\AgentGameSettlementRecorder; /** * 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 待审核)。 @@ -32,6 +33,7 @@ final class SettlementOrchestrator private readonly RiskPoolService $riskPool, private readonly LotteryHallRealtimeBroadcaster $hallRealtime, private readonly DrawHallSnapshotBuilder $hallSnapshot, + private readonly AgentGameSettlementRecorder $agentGameSettlement, ) {} /** @@ -183,13 +185,16 @@ final class SettlementOrchestrator 'match_detail_json' => $p['match_detail'], ]); + $terminalStatus = $finalCredit > 0 ? 'pending_payout' : 'settled_lose'; $item->forceFill([ 'win_amount' => $net, 'jackpot_win_amount' => $jackpotShare, 'settled_at' => null, - 'status' => $finalCredit > 0 ? 'pending_payout' : 'settled_lose', + 'status' => $terminalStatus, ])->save(); + $this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus); + if ($finalCredit > 0) { $winCount++; } diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index 3129d0a..b9e7180 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -16,7 +16,7 @@ use App\Exceptions\IdempotentTicketReplayException; use App\Exceptions\TicketOperationException; use App\Services\Jackpot\JackpotContributionService; use App\Services\Draw\DrawHallSnapshotBuilder; -use App\Support\CreditLineMode; +use App\Support\PlayerFundingMode; use App\Services\Player\PlayerCreditService; final class TicketPlacementService @@ -42,8 +42,10 @@ final class TicketPlacementService ? (string) $payload['client_trace_id'] : null; - $drawNo = (string) $payload['draw_id']; - $drawIdForIdempotency = Draw::query()->where('draw_no', $drawNo)->value('id'); + $drawNo = trim((string) ($payload['draw_id'] ?? '')); + $drawIdForIdempotency = $drawNo === '' + ? null + : Draw::query()->where('draw_no', $drawNo)->value('id'); if ($clientTraceId !== null && $drawIdForIdempotency !== null) { $existing = TicketOrder::query() @@ -75,9 +77,10 @@ final class TicketPlacementService $payload, $expectedVersions, $clientTraceId, + $drawNo, ): array { $draw = Draw::query() - ->where('draw_no', (string) $payload['draw_id']) + ->where('draw_no', $drawNo) ->lockForUpdate() ->first(); if ($draw === null) { @@ -156,20 +159,24 @@ final class TicketPlacementService ); } - $wallet = PlayerWallet::query() - ->where('player_id', $player->id) - ->where('wallet_type', 'lottery') - ->where('currency_code', $currencyCode) - ->lockForUpdate() - ->first(); - if ($wallet !== null && (int) $wallet->status !== 0) { - throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value); - } + $creditLine = PlayerFundingMode::usesCredit($player); - $walletBalance = $wallet !== null ? (int) $wallet->balance : 0; - $walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0); - if ($walletAvailable < $totalActualDeduct) { - throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); + if (! $creditLine) { + $wallet = PlayerWallet::query() + ->where('player_id', $player->id) + ->where('wallet_type', 'lottery') + ->where('currency_code', $currencyCode) + ->lockForUpdate() + ->first(); + if ($wallet !== null && (int) $wallet->status !== 0) { + throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value); + } + + $walletBalance = $wallet !== null ? (int) $wallet->balance : 0; + $walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0); + if ($walletAvailable < $totalActualDeduct) { + throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); + } } try { @@ -301,17 +308,17 @@ final class TicketPlacementService 'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm', ])->save(); - if (CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) { - $this->playerCreditService->holdForBet($player, $successTotalActualDeduct); + if ($creditLine) { + $this->playerCreditService->assertMayPlaceBet($player, $successTotalActualDeduct); + } else { + $this->ticketWalletService->reserveBetDeduct( + $player, + $currencyCode, + $successTotalActualDeduct, + $order, + ); } - $this->ticketWalletService->reserveBetDeduct( - $player, - $currencyCode, - $successTotalActualDeduct, - $order, - ); - return [ 'order' => $order, 'draw_id' => (int) $draw->id, @@ -331,13 +338,20 @@ final class TicketPlacementService $order = TicketOrder::query()->whereKey($placement['order']->id)->firstOrFail(); $draw = Draw::query()->whereKey((int) $placement['draw_id'])->firstOrFail(); + $creditLine = PlayerFundingMode::usesCredit($player); + try { - $balanceAfter = $this->ticketWalletService->finalizeReservedBetDeduct( - $player, - (string) $placement['currency_code'], - (int) $placement['success_total_actual_deduct'], - $order, - ); + $balanceAfter = $creditLine + ? $this->playerCreditService->availableCreditMinor( + $player, + (string) $placement['currency_code'], + ) + : $this->ticketWalletService->finalizeReservedBetDeduct( + $player, + (string) $placement['currency_code'], + (int) $placement['success_total_actual_deduct'], + $order, + ); DB::transaction(function () use ($order, $draw, $placement): void { $successfulItems = TicketItem::query() @@ -381,7 +395,9 @@ final class TicketPlacementService } $order->forceFill(['status' => 'refunded'])->save(); - $this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release'); + if (! PlayerFundingMode::usesCredit($player)) { + $this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release'); + } $this->ticketWalletService->reverseBetDeduct($order); }); @@ -516,7 +532,7 @@ final class TicketPlacementService */ private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array { - if (! CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) { + if (! PlayerFundingMode::usesCredit($player)) { return $evaluated; } diff --git a/app/Services/Ticket/TicketPreviewService.php b/app/Services/Ticket/TicketPreviewService.php index 3a76304..c4c4f26 100644 --- a/app/Services/Ticket/TicketPreviewService.php +++ b/app/Services/Ticket/TicketPreviewService.php @@ -22,7 +22,10 @@ final class TicketPreviewService */ public function preview(array $payload): array { - $draw = Draw::query()->where('draw_no', (string) $payload['draw_id'])->first(); + $drawNo = trim((string) ($payload['draw_id'] ?? '')); + $draw = $drawNo === '' + ? null + : Draw::query()->where('draw_no', $drawNo)->first(); if ($draw === null) { throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value); } diff --git a/app/Services/Wallet/LotteryTransferService.php b/app/Services/Wallet/LotteryTransferService.php index dae5b40..cd7f20c 100644 --- a/app/Services/Wallet/LotteryTransferService.php +++ b/app/Services/Wallet/LotteryTransferService.php @@ -13,6 +13,7 @@ use App\Services\LotterySettings; use Illuminate\Support\Facades\DB; use Illuminate\Database\QueryException; use App\Exceptions\WalletOperationException; +use App\Support\PlayerFundingMode; /** * 主站 ↔ 彩票钱包:转入 / 转出(幂等键 + 流水 + 订单)。 @@ -68,6 +69,7 @@ final class LotteryTransferService */ public function transferIn(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array { + $this->assertWalletFundingMode($player); $this->assertPositiveAmount($amountMinor); $currencyCode = $this->normalizeCurrency($currencyCode); $this->assertCurrencyEnabled($currencyCode); @@ -190,6 +192,7 @@ final class LotteryTransferService */ public function transferOut(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array { + $this->assertWalletFundingMode($player); $this->assertPositiveAmount($amountMinor); $currencyCode = $this->normalizeCurrency($currencyCode); $this->assertCurrencyEnabled($currencyCode); @@ -732,6 +735,17 @@ final class LotteryTransferService } } + private function assertWalletFundingMode(Player $player): void + { + if (PlayerFundingMode::usesCredit($player)) { + throw new WalletOperationException( + 'credit_player_no_wallet_transfer', + ErrorCode::WalletCreditPlayerNoTransfer->value, + 422, + ); + } + } + private function assertPositiveAmount(int $amountMinor): void { if ($amountMinor < 1) { diff --git a/app/Services/Wallet/PlayerLedgerLogsService.php b/app/Services/Wallet/PlayerLedgerLogsService.php new file mode 100644 index 0000000..a69c38f --- /dev/null +++ b/app/Services/Wallet/PlayerLedgerLogsService.php @@ -0,0 +1,578 @@ + ['transfer_in'], + 'transfer_out' => ['transfer_out'], + 'refund' => ['transfer_out_refund'], + 'reversal' => ['reversal', 'bet_reverse'], + 'bet' => ['bet_deduct', 'bet'], + 'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'], + ]; + + /** PRD 对外类型 → credit_ledger.reason */ + private const CREDIT_TYPE_TO_REASON = [ + 'bet' => ['bet_hold', 'game_settlement_loss'], + 'reversal' => ['bet_hold_release'], + 'refund' => ['settlement_confirm'], + 'prize' => [], + 'transfer_in' => [], + 'transfer_out' => [], + ]; + + public function __construct( + private readonly PlayerCreditService $playerCreditService, + ) {} + + /** + * @return array{ + * items: list>, + * total: int, + * page: int, + * per_page: int, + * ledger_source: string, + * funding_mode: string, + * auth_source: string|null, + * } + */ + public function listForPlayerApi( + Player $player, + int $page, + int $perPage, + string $currencyCode, + string $typeFilterRaw, + ): array { + $meta = [ + 'ledger_source' => PlayerFundingMode::usesCredit($player) ? 'credit_ledger' : 'wallet_txn', + 'funding_mode' => (string) ($player->funding_mode ?? ''), + 'auth_source' => $player->auth_source, + ]; + + if (PlayerFundingMode::usesCredit($player)) { + $result = $this->paginateCreditLedger($player, $page, $perPage, $typeFilterRaw); + + return array_merge($result, $meta); + } + + $result = $this->paginateWalletTxns($player, $page, $perPage, $currencyCode, $typeFilterRaw); + + return array_merge($result, $meta); + } + + /** + * 后台玩家详情「钱包流水」:信用盘玩家返回 credit_ledger,字段形状对齐 wallet_txns 列表。 + * + * @return array{items: list>, total: int, page: int, per_page: int} + */ + public function listForAdminPlayer( + Player $player, + int $page, + int $perPage, + ?string $bizType = null, + ): array { + if (! PlayerFundingMode::usesCredit($player)) { + 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; + + return $formatted; + }) + ->values() + ->all(); + + return [ + 'items' => $items, + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + ]; + } + + /** + * 结算中心:站点下全部信用盘玩家的 {@see credit_ledger} 流水。 + * + * @return array{ + * items: list>, + * total: int, + * page: int, + * per_page: int, + * ledger_source: string, + * } + */ + public function listForAdminCreditIndex( + AdminUser $admin, + string $siteCode, + int $page, + int $perPage, + ?int $settlementPeriodId = null, + ?int $playerId = null, + ?string $reason = null, + ?string $createdFrom = null, + ?string $createdTo = null, + ): 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'); + }) + ->where('p.site_code', $siteCode) + ->where('p.funding_mode', PlayerFundingMode::CREDIT) + ->select([ + 'cl.id', + 'cl.amount', + 'cl.reason', + 'cl.ref_type', + 'cl.ref_id', + 'cl.created_at', + 'cl.updated_at', + 'p.id as player_id', + 'p.site_code', + 'p.site_player_id', + 'p.username', + 'p.nickname', + 'p.funding_mode', + 'p.auth_source', + 'p.default_currency', + ]) + ->orderByDesc('cl.id'); + + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + + if ($playerId !== null && $playerId > 0) { + $query->where('p.id', $playerId); + } + + if ($reason !== null && $reason !== '') { + $query->where('cl.reason', $reason); + } + + $range = $this->resolveCreatedRange($settlementPeriodId, $createdFrom, $createdTo); + if ($range !== null) { + $query->whereBetween('cl.created_at', $range); + } + + /** @var LengthAwarePaginator $paginator */ + $paginator = $query->paginate($perPage, ['*'], 'page', $page); + $items = $paginator->getCollection() + ->map(fn (object $row): array => $this->formatAdminCreditIndexRow($row)) + ->values() + ->all(); + + return [ + 'items' => $items, + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'ledger_source' => 'credit_ledger', + ]; + } + + /** + * @return array{0: Carbon, 1: Carbon}|null + */ + private function resolveCreatedRange( + ?int $settlementPeriodId, + ?string $createdFrom, + ?string $createdTo, + ): ?array { + if ($settlementPeriodId !== null && $settlementPeriodId > 0) { + $period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first(); + if ($period === null) { + return null; + } + + return [ + Carbon::parse($period->period_start)->startOfDay(), + Carbon::parse($period->period_end)->endOfDay(), + ]; + } + + $from = $createdFrom !== null && $createdFrom !== '' + ? Carbon::parse($createdFrom)->startOfDay() + : null; + $to = $createdTo !== null && $createdTo !== '' + ? Carbon::parse($createdTo)->endOfDay() + : null; + + if ($from === null && $to === null) { + return null; + } + + return [ + $from ?? Carbon::parse('1970-01-01')->startOfDay(), + $to ?? Carbon::now()->endOfDay(), + ]; + } + + /** + * @return array + */ + private function formatAdminCreditIndexRow(object $row): array + { + $amount = (int) $row->amount; + $amountAbs = abs($amount); + $currency = (string) ($row->default_currency ?? ''); + + return [ + 'id' => (int) $row->id, + 'txn_no' => 'CL-'.$row->id, + 'player_id' => (int) $row->player_id, + 'site_code' => (string) $row->site_code, + 'site_player_id' => $row->site_player_id, + 'username' => $row->username, + 'nickname' => $row->nickname, + 'biz_type' => (string) $row->reason, + 'type' => $this->creditReasonToPublicType((string) $row->reason), + 'biz_no' => $this->creditRefLabel($row), + 'direction' => $amount >= 0 ? 1 : 2, + 'amount' => $amountAbs, + 'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs), + 'signed_amount' => $amount, + 'currency_code' => $currency, + 'status' => 'posted', + 'created_at' => $this->isoTimestamp($row->created_at ?? null), + 'updated_at' => $this->isoTimestamp($row->updated_at ?? null), + 'ledger_source' => 'credit_ledger', + 'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT), + 'auth_source' => $row->auth_source, + ]; + } + + /** + * @return array{items: list>, total: int, page: int, per_page: int} + */ + private function paginateWalletTxns( + Player $player, + int $page, + int $perPage, + string $currencyCode, + string $typeFilterRaw, + ): array { + $bizFilter = $this->resolveWalletBizFilter($typeFilterRaw); + + if (is_array($bizFilter) && $bizFilter === []) { + return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage]; + } + + $query = WalletTxn::query() + ->where('player_id', $player->id) + ->with('wallet') + ->orderByDesc('id'); + + if ($currencyCode !== '') { + $query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode)); + } + + if ($bizFilter !== null) { + $query->whereIn('biz_type', $bizFilter); + } + + $paginator = $query->paginate($perPage, ['*'], 'page', $page); + $items = $paginator->getCollection() + ->map(fn (WalletTxn $txn) => $this->formatWalletTxnRow($txn)) + ->values() + ->all(); + + return [ + 'items' => $items, + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + ]; + } + + /** + * @return array{items: list>, total: int, page: int, per_page: int} + */ + private function paginateCreditLedger( + Player $player, + int $page, + int $perPage, + string $typeFilterRaw, + ): array { + $reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw); + + if (is_array($reasonFilter) && $reasonFilter === []) { + return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage]; + } + + $paginator = $this->creditLedgerQuery((int) $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->formatPlayerCreditRow($row, $player, $currency, $runningMinor); + $runningMinor -= $amount; + + return $formatted; + }) + ->values() + ->all(); + + return [ + 'items' => $items, + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + ]; + } + + /** + * @param list|null $reasonFilter + */ + private function creditLedgerQuery(int $playerId, ?array $reasonFilter) + { + $query = DB::table('credit_ledger') + ->where('owner_type', 'player') + ->where('owner_id', $playerId) + ->orderByDesc('id'); + + if ($reasonFilter !== null) { + $query->whereIn('reason', $reasonFilter); + } + + return $query; + } + + /** + * @return list|null + */ + private function resolveWalletBizFilter(string $raw): ?array + { + return $this->resolveTypeFilterMap($raw, self::WALLET_TYPE_TO_BIZ); + } + + /** + * @return list|null + */ + private function resolveCreditReasonFilter(string $raw): ?array + { + return $this->resolveTypeFilterMap($raw, self::CREDIT_TYPE_TO_REASON); + } + + /** + * @param array> $map + * @return list|null + */ + private function resolveTypeFilterMap(string $raw, array $map): ?array + { + $raw = trim($raw); + if ($raw === '') { + return null; + } + + $parts = array_filter(array_map('trim', explode(',', $raw))); + if ($parts === []) { + return null; + } + + $resolved = []; + foreach ($parts as $part) { + $key = Str::lower($part); + if (! isset($map[$key])) { + continue; + } + foreach ($map[$key] as $value) { + $resolved[] = $value; + } + } + + return array_values(array_unique($resolved)); + } + + /** + * @return array + */ + private function formatWalletTxnRow(WalletTxn $txn): array + { + $currency = $txn->wallet?->currency_code ?? ''; + $amount = (int) $txn->amount; + $balanceAfter = (int) $txn->balance_after; + + return [ + 'log_id' => $txn->txn_no, + 'type' => $this->walletBizToPublicType((string) $txn->biz_type), + 'biz_type' => $txn->biz_type, + 'amount' => $this->signedWalletAmount($txn), + 'amount_formatted' => CurrencyFormatter::fromMinor($amount), + 'amount_abs' => $amount, + 'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount), + 'direction' => (int) $txn->direction === 1 ? 'in' : 'out', + 'currency_code' => $currency, + 'balance_after' => $balanceAfter, + 'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter), + 'ref_id' => $txn->biz_no, + 'idempotent_key' => $txn->idempotent_key, + 'external_ref_no' => $txn->external_ref_no, + 'status' => $txn->status, + 'remark' => $txn->remark, + 'created_at' => $txn->created_at?->toIso8601String(), + 'ledger_source' => 'wallet_txn', + ]; + } + + /** + * @return array + */ + private function formatPlayerCreditRow( + object $row, + Player $player, + string $currency, + int $balanceAfterMinor, + ): array { + $amount = (int) $row->amount; + $amountAbs = abs($amount); + $publicType = $this->creditReasonToPublicType((string) $row->reason); + + return [ + 'log_id' => 'CL-'.$row->id, + 'type' => $publicType, + 'biz_type' => (string) $row->reason, + 'amount' => $amount, + 'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs), + 'amount_abs' => $amountAbs, + 'amount_abs_formatted' => CurrencyFormatter::fromMinor($amountAbs), + 'direction' => $amount >= 0 ? 'in' : 'out', + 'currency_code' => $currency, + 'balance_after' => $balanceAfterMinor, + 'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor), + 'ref_id' => $this->creditRefLabel($row), + 'idempotent_key' => null, + 'external_ref_no' => null, + 'status' => 'posted', + 'remark' => null, + 'created_at' => $this->isoTimestamp($row->created_at ?? null), + 'ledger_source' => 'credit_ledger', + 'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT), + 'auth_source' => $player->auth_source, + ]; + } + + /** + * @return array + */ + private function formatAdminCreditRow( + object $row, + Player $player, + string $currency, + int $balanceAfterMinor, + ): array { + $amount = (int) $row->amount; + $amountAbs = abs($amount); + $balanceBefore = $amount >= 0 + ? max(0, $balanceAfterMinor - $amount) + : $balanceAfterMinor + $amountAbs; + + return [ + 'id' => (int) $row->id, + 'txn_no' => 'CL-'.$row->id, + 'player_id' => (int) $player->id, + 'site_code' => $player->site_code, + 'site_player_id' => $player->site_player_id, + 'username' => $player->username, + 'nickname' => $player->nickname, + 'wallet_id' => null, + 'biz_type' => (string) $row->reason, + 'biz_no' => $this->creditRefLabel($row), + 'direction' => $amount >= 0 ? 1 : 2, + 'amount' => $amountAbs, + 'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs), + 'balance_before' => $balanceBefore, + 'balance_before_formatted' => CurrencyFormatter::fromMinor($balanceBefore), + 'balance_after' => $balanceAfterMinor, + 'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor), + 'status' => 'posted', + 'external_ref_no' => null, + 'idempotent_key' => null, + 'remark' => null, + 'created_at' => $this->isoTimestamp($row->created_at ?? null), + 'updated_at' => $this->isoTimestamp($row->updated_at ?? null), + 'ledger_source' => 'credit_ledger', + 'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT), + 'auth_source' => $player->auth_source, + ]; + } + + private function creditReasonToPublicType(string $reason): string + { + return match ($reason) { + 'bet_hold', 'game_settlement_loss' => 'bet', + 'bet_hold_release' => 'reversal', + 'settlement_confirm' => 'refund', + default => $reason, + }; + } + + private function walletBizToPublicType(string $biz): string + { + return match ($biz) { + 'transfer_out_refund' => 'refund', + 'bet_deduct', 'bet' => 'bet', + 'bet_reverse' => 'reversal', + 'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize', + 'reversal' => 'reversal', + default => $biz, + }; + } + + private function signedWalletAmount(WalletTxn $txn): int + { + $a = (int) $txn->amount; + + return (int) $txn->direction === 1 ? $a : -$a; + } + + private function creditRefLabel(object $row): ?string + { + if ($row->ref_type === null || $row->ref_id === null) { + return null; + } + + return (string) $row->ref_type.'#'.$row->ref_id; + } + + private function isoTimestamp(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return Carbon::parse($value)->toIso8601String(); + } +} diff --git a/app/Support/AdminAgentScope.php b/app/Support/AdminAgentScope.php index 1ff39e5..3fdb5fd 100644 --- a/app/Support/AdminAgentScope.php +++ b/app/Support/AdminAgentScope.php @@ -76,6 +76,32 @@ final class AdminAgentScope return self::nodeVisibleTo($admin, $node); } + /** 占成/授信/回水仅可由上级或平台修改,代理本人不可改自己的 profile。 */ + public static function nodeProfileEditableBy(AdminUser $admin, AgentNode $node): bool + { + if ($admin->isSuperAdmin()) { + return true; + } + + if ( + ! $admin->hasPermissionCode('agent.profile.manage') + && ! $admin->hasPermissionCode('agent.node.manage') + ) { + return false; + } + + $actor = self::primaryAgentNode($admin); + if ($actor === null) { + return false; + } + + if ((int) $actor->id === (int) $node->id) { + return false; + } + + return $node->isDescendantOf($actor); + } + /** * @return Builder */ diff --git a/app/Support/AdminAgentSettlementScope.php b/app/Support/AdminAgentSettlementScope.php index 025fa4a..82a0601 100644 --- a/app/Support/AdminAgentSettlementScope.php +++ b/app/Support/AdminAgentSettlementScope.php @@ -8,6 +8,22 @@ use Illuminate\Database\Query\Builder; /** 代理账单按管理员可访问站点过滤。 */ final class AdminAgentSettlementScope { + public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void + { + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds === null) { + return; + } + + if ($siteIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + + $query->whereIn($periodsAlias.'.admin_site_id', $siteIds); + } + public static function applyToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void { $siteIds = $admin->accessibleAdminSiteIds(); diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php index 2341802..0b9ba7f 100644 --- a/app/Support/AdminAuthProfile.php +++ b/app/Support/AdminAuthProfile.php @@ -38,26 +38,34 @@ final class AdminAuthProfile * }, * is_super_admin: bool, * operational_permissions: list, - * delegation_ceiling: list + * delegation_ceiling: list, + * accessible_sites?: list * } */ public static function fromAdmin(AdminUser $admin): array { $fresh = $admin->fresh(); $permissionSlugs = $fresh->adminPermissionSlugs(); + $agent = self::agentContext($fresh); - return [ + $payload = [ 'id' => $fresh->id, 'username' => $fresh->username, 'nickname' => $fresh->name, 'email' => $fresh->email, 'permissions' => $permissionSlugs, 'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs, $fresh), - 'agent' => self::agentContext($fresh), + 'agent' => $agent, 'is_super_admin' => $fresh->isSuperAdmin(), 'operational_permissions' => $permissionSlugs, 'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh), ]; + + if ($agent === null) { + $payload['accessible_sites'] = AdminUserSiteBindingPresenter::accessibleSitesFor($fresh); + } + + return $payload; } /** diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 387b541..38cd68f 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -144,7 +144,8 @@ final class AdminAuthorizationRegistry { return [ ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'nav_group' => 'overview', 'requiredAny' => ['prd.dashboard.view']], - ['segment' => 'agents', 'label' => 'Agent lines', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => array_values(array_unique(array_merge(['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage', 'prd.settlement.agent.view', 'prd.settlement.agent.manage'], AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'))))], + ['segment' => 'agents', 'label' => 'Agent lines', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => array_values(array_unique(array_merge(['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage'], AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'))))], + ['segment' => 'settlement_center', 'label' => 'Credit settlement', 'href' => '/admin/settlement-center', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/settlement-center', 'requiredAny' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'nav_group' => 'operations', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']], ['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']], @@ -157,6 +158,7 @@ final class AdminAuthorizationRegistry ['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']], ['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.currency.manage']], + ['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites')], ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_user.manage']], ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_role.manage']], ['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.audit.view']], @@ -426,12 +428,22 @@ final class AdminAuthorizationRegistry ['code' => 'admin.agent-delegation-grants.index', 'module_code' => 'agent', 'name' => '代理下放上限查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']], ['code' => 'admin.agent-delegation-grants.sync', 'module_code' => 'agent', 'name' => '代理下放上限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']], - ['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']], + ['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage', 'agent.node.view'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage', 'prd.agent.view']], ['code' => 'admin.agent-nodes.profile.update', 'module_code' => 'agent', 'name' => '代理占成授信更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']], + ['code' => 'admin.settlement-periods.index', 'module_code' => 'settlement', 'name' => '代理账期列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], ['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], + ['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.settlement-bills.index', 'module_code' => 'settlement', 'name' => '代理账单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills', 'route_name' => 'api.v1.admin.settlement-bills.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], + ['code' => 'admin.settlement-payments.index', 'module_code' => 'settlement', 'name' => '代理账单收付记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-payments', 'route_name' => 'api.v1.admin.settlement-payments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], + ['code' => 'admin.settlement-adjustments.index', 'module_code' => 'settlement', 'name' => '代理账单调账记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-adjustments', 'route_name' => 'api.v1.admin.settlement-adjustments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], + ['code' => 'admin.settlement-bills.show', 'module_code' => 'settlement', 'name' => '代理账单详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}', 'route_name' => 'api.v1.admin.settlement-bills.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.settlement-bills.confirm', 'module_code' => 'settlement', 'name' => '确认代理账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/confirm', 'route_name' => 'api.v1.admin.settlement-bills.confirm', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], + ['code' => 'admin.settlement-bills.payments', 'module_code' => 'settlement', 'name' => '登记代理账单收付', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/payments', 'route_name' => 'api.v1.admin.settlement-bills.payments', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], + ['code' => 'admin.settlement-bills.adjustments', 'module_code' => 'settlement', 'name' => '代理账单补差冲正', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/adjustments', 'route_name' => 'api.v1.admin.settlement-bills.adjustments', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], + ['code' => 'admin.settlement-bills.bad-debt-write-off', 'module_code' => 'settlement', 'name' => '代理账单坏账核销', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/bad-debt-write-off', 'route_name' => 'api.v1.admin.settlement-bills.bad-debt-write-off', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], + ['code' => 'admin.settlement-reports.summary', 'module_code' => 'settlement', 'name' => '代理结算报表摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-reports/summary', 'route_name' => 'api.v1.admin.settlement-reports.summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], + ['code' => 'admin.settlement-reports.show', 'module_code' => 'settlement', 'name' => '信用占成盘报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-reports', 'route_name' => 'api.v1.admin.settlement-reports.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view']], ['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']], @@ -468,10 +480,11 @@ final class AdminAuthorizationRegistry ['code' => 'admin.integration-sites.rotate-secrets', 'module_code' => 'integration', 'name' => '重置接入密钥', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/rotate-secrets', 'route_name' => 'api.v1.admin.integration-sites.rotate-secrets', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']], ['code' => 'admin.integration-sites.connectivity-test', 'module_code' => 'integration', 'name' => '接入站点联通检测', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/connectivity-test', 'route_name' => 'api.v1.admin.integration-sites.connectivity-test', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']], ['code' => 'admin.integration-sites.export', 'module_code' => 'integration', 'name' => '导出接入参数表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/export', 'route_name' => 'api.v1.admin.integration-sites.export', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']], + ['code' => 'admin.integration-sites.secrets', 'module_code' => 'integration', 'name' => '查看接入密钥明文', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/secrets', 'route_name' => 'api.v1.admin.integration-sites.secrets', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']], ['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], - ['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], + ['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.payout.view', 'prd.payout.manage', 'prd.payout.review', 'prd.report.view', 'prd.users.view_finance']], ['code' => 'admin.draws.result-batches.index', 'module_code' => 'draw', 'name' => '开奖结果批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.risk-pool-lock-logs.index', 'module_code' => 'risk', 'name' => '风控锁池日志列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pool-lock-logs', 'route_name' => 'api.v1.admin.draws.risk-pool-lock-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']], ['code' => 'admin.draws.risk-pools.index', 'module_code' => 'risk', 'name' => '风控池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools', 'route_name' => 'api.v1.admin.draws.risk-pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']], diff --git a/app/Support/AdminDataScope.php b/app/Support/AdminDataScope.php index 888cdfe..82a9547 100644 --- a/app/Support/AdminDataScope.php +++ b/app/Support/AdminDataScope.php @@ -112,7 +112,7 @@ final class AdminDataScope return; } - $query->whereHas($relation, static function (Builder $playerQuery) use ($admin): void { + $query->whereHas($relation, static function (\Illuminate\Database\Eloquent\Builder $playerQuery) use ($admin): void { AdminSiteScope::applyToPlayerQuery($playerQuery, $admin); }); } diff --git a/app/Support/AdminDrawApiPresenter.php b/app/Support/AdminDrawApiPresenter.php new file mode 100644 index 0000000..9f288fc --- /dev/null +++ b/app/Support/AdminDrawApiPresenter.php @@ -0,0 +1,138 @@ + + */ + public static function listRow(Draw $draw, ?array $stats, AdminUser $admin): array + { + $manage = AdminDrawResponsePolicy::canManageDrawResults($admin); + $finance = AdminDrawResponsePolicy::canViewDrawFinance($admin); + + $row = [ + 'id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'business_date' => self::formatBusinessDate($draw->business_date), + 'sequence_no' => (int) $draw->sequence_no, + 'status' => $draw->status, + 'start_time' => $draw->start_time?->toIso8601String(), + 'close_time' => $draw->close_time?->toIso8601String(), + 'draw_time' => $draw->draw_time?->toIso8601String(), + 'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(), + 'updated_at' => $draw->updated_at?->toIso8601String(), + ]; + + if ($manage) { + $row['result_source'] = $draw->result_source; + $row['current_result_version'] = (int) $draw->current_result_version; + $row['settle_version'] = (int) $draw->settle_version; + $row['is_reopened'] = (bool) $draw->is_reopened; + } + + if ($finance && $stats !== null) { + $row['total_bet_minor'] = $stats['total_bet_minor']; + $row['total_payout_minor'] = $stats['total_payout_minor']; + $row['profit_loss_minor'] = $stats['profit_loss_minor']; + } + + return $row; + } + + /** @return array */ + public static function show(Draw $draw, AdminUser $admin, DrawHallSnapshotBuilder $hallPreview): array + { + $manage = AdminDrawResponsePolicy::canManageDrawResults($admin); + $nowUtc = now()->utc(); + + $batchCounts = [ + 'published' => $draw->resultBatches() + ->where('status', DrawResultBatchStatus::Published->value) + ->count(), + ]; + + if ($manage) { + $batchCounts['total'] = $draw->resultBatches()->count(); + $batchCounts['pending_review'] = $draw->resultBatches() + ->where('status', DrawResultBatchStatus::PendingReview->value) + ->count(); + } + + $payload = [ + 'id' => (int) $draw->id, + 'draw_no' => $draw->draw_no, + 'business_date' => self::formatBusinessDate($draw->business_date), + 'sequence_no' => (int) $draw->sequence_no, + 'status' => $draw->status, + 'hall_preview_status' => $hallPreview->effectiveHallDisplayStatus($draw, $nowUtc), + 'start_time' => $draw->start_time?->toIso8601String(), + 'close_time' => $draw->close_time?->toIso8601String(), + 'draw_time' => $draw->draw_time?->toIso8601String(), + 'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(), + 'result_batch_counts' => $batchCounts, + 'capabilities' => AdminDrawResponsePolicy::capabilities($admin), + ]; + + if ($manage) { + $payload['result_source'] = $draw->result_source; + $payload['current_result_version'] = (int) $draw->current_result_version; + $payload['settle_version'] = (int) $draw->settle_version; + $payload['is_reopened'] = (bool) $draw->is_reopened; + $payload['created_at'] = $draw->created_at?->toIso8601String(); + $payload['updated_at'] = $draw->updated_at?->toIso8601String(); + } + + return $payload; + } + + /** @return array */ + public static function resultBatch(DrawResultBatch $batch, AdminUser $admin): array + { + $manage = AdminDrawResponsePolicy::canManageDrawResults($admin); + + $row = [ + 'id' => (int) $batch->id, + 'result_version' => (int) $batch->result_version, + 'status' => $batch->status, + 'confirmed_at' => $batch->confirmed_at?->toIso8601String(), + 'items' => $batch->items->map(static fn (DrawResultItem $item): array => [ + 'prize_type' => $item->prize_type, + 'prize_index' => (int) $item->prize_index, + 'number_4d' => $item->number_4d, + 'suffix_3d' => $item->suffix_3d, + 'suffix_2d' => $item->suffix_2d, + 'head_digit' => $item->head_digit, + 'tail_digit' => $item->tail_digit, + ])->values()->all(), + ]; + + if ($manage) { + $row['source_type'] = $batch->source_type; + $row['rng_seed_hash'] = $batch->rng_seed_hash; + $row['created_by'] = $batch->created_by; + $row['confirmed_by'] = $batch->confirmed_by; + $row['created_at'] = $batch->created_at?->toIso8601String(); + $row['updated_at'] = $batch->updated_at?->toIso8601String(); + } + + return $row; + } + + private static function formatBusinessDate(mixed $businessDate): string + { + return $businessDate instanceof Carbon + ? $businessDate->format('Y-m-d') + : (string) $businessDate; + } +} diff --git a/app/Support/AdminDrawResponsePolicy.php b/app/Support/AdminDrawResponsePolicy.php new file mode 100644 index 0000000..334cfa8 --- /dev/null +++ b/app/Support/AdminDrawResponsePolicy.php @@ -0,0 +1,44 @@ +hasAdminPermission('prd.draw_result.manage'); + } + + public static function canViewDrawFinance(AdminUser $admin): bool + { + if (self::canManageDrawResults($admin)) { + return true; + } + + foreach ([ + 'prd.payout.view', + 'prd.payout.manage', + 'prd.payout.review', + 'prd.report.view', + 'prd.users.view_finance', + ] as $slug) { + if ($admin->hasAdminPermission($slug)) { + return true; + } + } + + return false; + } + + /** @return array{can_manage_draw_results: bool, can_view_draw_finance: bool} */ + public static function capabilities(AdminUser $admin): array + { + return [ + 'can_manage_draw_results' => self::canManageDrawResults($admin), + 'can_view_draw_finance' => self::canViewDrawFinance($admin), + ]; + } +} diff --git a/app/Support/AdminIntegrationSitePresenter.php b/app/Support/AdminIntegrationSitePresenter.php index 2ec7dcf..c27e849 100644 --- a/app/Support/AdminIntegrationSitePresenter.php +++ b/app/Support/AdminIntegrationSitePresenter.php @@ -9,15 +9,17 @@ final class AdminIntegrationSitePresenter /** * @return array */ - public static function listItem(AdminSite $site): array + public static function listItem(AdminSite $site, bool $hasLineRoot = false): array { return [ 'id' => (int) $site->id, 'code' => (string) $site->code, 'name' => (string) $site->name, + 'has_line_root' => $hasLineRoot, 'currency_code' => (string) $site->currency_code, 'status' => (int) $site->status, 'wallet_api_url' => $site->wallet_api_url, + 'lottery_h5_base_url' => $site->lottery_h5_base_url, 'wallet_timeout_seconds' => (int) ($site->wallet_timeout_seconds ?? 10), 'has_sso_secret' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== '', 'has_wallet_api_key' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== '', diff --git a/app/Support/AdminPermissionBridge.php b/app/Support/AdminPermissionBridge.php index fbf5d0c..f9e63e2 100644 --- a/app/Support/AdminPermissionBridge.php +++ b/app/Support/AdminPermissionBridge.php @@ -69,8 +69,7 @@ final class AdminPermissionBridge } /** - * 若管理员拥有的任意 menu_action.permission_code 落在某 `prd.*` 映射集合内,则视为拥有该 `prd.*` - *(与路由中间件「满足其一」及 Next 侧栏 `requiredAny` 语义一致)。 + * 由已授权的 menu_action.permission_code 反推 `prd.*` 展示 slug(须满足映射中的全部 code)。 * * @param list $menuActionCodes * @return list @@ -93,12 +92,21 @@ final class AdminPermissionBridge $out = []; foreach (self::legacyMap() as $legacySlug => $requiredCodes) { + if ($requiredCodes === []) { + continue; + } + + $hasAll = true; foreach ($requiredCodes as $code) { - if (isset($set[$code])) { - $out[$legacySlug] = true; + if (! isset($set[$code])) { + $hasAll = false; break; } } + + if ($hasAll) { + $out[$legacySlug] = true; + } } $keys = array_keys($out); diff --git a/app/Support/AdminPlatformUserSiteGuard.php b/app/Support/AdminPlatformUserSiteGuard.php new file mode 100644 index 0000000..cb06853 --- /dev/null +++ b/app/Support/AdminPlatformUserSiteGuard.php @@ -0,0 +1,31 @@ +find($siteId); + if ($site === null) { + throw ValidationException::withMessages([ + 'admin_site_id' => [trans('validation.exists', ['attribute' => 'admin_site_id'])], + ]); + } + + if ($actor->isSuperAdmin()) { + return; + } + + if (! AdminIntegrationSiteAccess::canAccess($actor, $site)) { + throw ValidationException::withMessages([ + 'admin_site_id' => [trans('admin.site_access_denied')], + ]); + } + } +} diff --git a/app/Support/AdminUserApiPresenter.php b/app/Support/AdminUserApiPresenter.php index d9a3dc7..f0a525b 100644 --- a/app/Support/AdminUserApiPresenter.php +++ b/app/Support/AdminUserApiPresenter.php @@ -11,6 +11,7 @@ final class AdminUserApiPresenter public static function listItem(AdminUser $user): array { $user->loadMissing('roles'); + $siteBindings = AdminUserSiteBindingPresenter::bindingsFor($user); return [ 'id' => (int) $user->id, @@ -20,6 +21,7 @@ final class AdminUserApiPresenter 'status' => (int) $user->status, 'account_kind' => $user->isPlatformAccount() ? 'platform' : 'agent', 'roles' => $user->adminRoleSlugs(), + 'site_bindings' => $siteBindings, 'direct_permissions' => $user->directLegacyPermissionSlugs(), 'effective_permissions' => $user->adminPermissionSlugs(), ]; diff --git a/app/Support/AdminUserSiteBindingPresenter.php b/app/Support/AdminUserSiteBindingPresenter.php new file mode 100644 index 0000000..ed41b79 --- /dev/null +++ b/app/Support/AdminUserSiteBindingPresenter.php @@ -0,0 +1,69 @@ +}> + */ + public static function bindingsFor(AdminUser $user): array + { + if ($user->hasPrimaryAgentBinding()) { + return []; + } + + $rows = DB::table('admin_user_site_roles as usr') + ->join('admin_sites as s', 's.id', '=', 'usr.site_id') + ->join('admin_roles as r', 'r.id', '=', 'usr.role_id') + ->where('usr.admin_user_id', $user->id) + ->orderBy('s.code') + ->orderBy('r.slug') + ->get(['usr.site_id', 's.code as site_code', 's.name as site_name', 'r.slug as role_slug']); + + /** @var array}> $bySite */ + $bySite = []; + foreach ($rows as $row) { + $siteId = (int) $row->site_id; + if (! isset($bySite[$siteId])) { + $bySite[$siteId] = [ + 'site_id' => $siteId, + 'site_code' => (string) $row->site_code, + 'site_name' => (string) $row->site_name, + 'role_slugs' => [], + ]; + } + $slug = (string) $row->role_slug; + if ($slug !== '' && ! in_array($slug, $bySite[$siteId]['role_slugs'], true)) { + $bySite[$siteId]['role_slugs'][] = $slug; + } + } + + foreach ($bySite as &$binding) { + sort($binding['role_slugs']); + } + unset($binding); + + return array_values($bySite); + } + + /** + * @return list + */ + public static function accessibleSitesFor(AdminUser $admin): array + { + return AdminIntegrationSiteAccess::queryFor($admin) + ->get(['id', 'code', 'name']) + ->map(static fn ($site): array => [ + 'id' => (int) $site->id, + 'code' => (string) $site->code, + 'name' => (string) $site->name, + ]) + ->values() + ->all(); + } +} diff --git a/app/Support/AgentDefaultRolePermissions.php b/app/Support/AgentDefaultRolePermissions.php new file mode 100644 index 0000000..3bfed2a --- /dev/null +++ b/app/Support/AgentDefaultRolePermissions.php @@ -0,0 +1,166 @@ + + */ + public static function baseSlugs(): array + { + return self::BASE_SLUGS; + } + + /** + * @return list + */ + public static function ownerSlugsForNode(AgentNode $node, ?AgentProfile $profile = null): array + { + if ($node->isRoot()) { + return self::lineRootOwnerSlugs(); + } + + $profile ??= AgentProfile::query()->where('agent_node_id', $node->id)->first(); + + if ($profile === null) { + return self::defaultOwnerSlugsWithoutProfile(); + } + + return self::ownerSlugsFromProfile($profile); + } + + /** + * @return list + */ + public static function lineRootOwnerSlugs(): array + { + return array_values(array_unique(array_merge( + self::BASE_SLUGS, + self::LINE_ROOT_EXTRA_SLUGS, + ))); + } + + /** + * @return list + */ + public static function ownerSlugsFromProfile(AgentProfile $profile): array + { + $slugs = self::BASE_SLUGS; + if ($profile->can_create_child_agent) { + $slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS); + } + if ($profile->can_create_player) { + $slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS); + } + + return array_values(array_unique($slugs)); + } + + /** + * @return list + */ + public static function defaultOwnerSlugsWithoutProfile(): array + { + return array_values(array_unique(array_merge( + self::BASE_SLUGS, + self::PLAYER_MANAGE_SLUGS, + ))); + } + + /** + * @param array $createPayload + * @return list + */ + public static function ownerSlugsForNewChild(array $createPayload): array + { + $slugs = self::BASE_SLUGS; + if ((bool) ($createPayload['can_create_child_agent'] ?? false)) { + $slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS); + } + if ((bool) ($createPayload['can_create_player'] ?? true)) { + $slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS); + } + + return array_values(array_unique($slugs)); + } + + /** + * 平台「代理」系统角色模板(出现在「平台角色管理」列表,供手动分配或作站点 pivot 回退)。 + * + * @return list + */ + public static function platformAgentRoleTemplateSlugs(): array + { + return self::defaultOwnerSlugsWithoutProfile(); + } + + /** 确保存在 slug=agent 的平台系统角色,并同步模板权限。 */ + public static function ensurePlatformAgentRole(): AdminRole + { + $role = AdminRole::query()->updateOrCreate( + [ + 'slug' => 'agent', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ], + [ + 'code' => 'agent', + 'name' => '代理', + 'description' => '经营代理默认权限模板(与线路内 agent_owner 默认包一致)', + 'status' => 1, + 'is_system' => true, + 'sort_order' => 50, + 'owner_agent_id' => null, + 'delegated_from_role_id' => null, + ], + ); + + $role->syncLegacyPermissionSlugs(self::platformAgentRoleTemplateSlugs()); + + return $role->fresh() ?? $role; + } +} diff --git a/app/Support/AgentLinePresenter.php b/app/Support/AgentLinePresenter.php index 503b7a3..669fdfa 100644 --- a/app/Support/AgentLinePresenter.php +++ b/app/Support/AgentLinePresenter.php @@ -7,16 +7,10 @@ use App\Models\AgentNode; final class AgentLinePresenter { - /** - * @param array{sso_jwt_secret: string, wallet_api_key: string} $secrets - * @return array - */ - public static function provisioned(AdminSite $site, AgentNode $root, array $secrets): array + /** @return array */ + public static function provisioned(AdminSite $site, AgentNode $root): array { - $sitePayload = AdminIntegrationSitePresenter::withPlainSecretsOnce( - AdminIntegrationSitePresenter::detail($site), - $secrets, - ); + $sitePayload = AdminIntegrationSitePresenter::detail($site); return array_merge($sitePayload, [ 'agent_node' => AgentNodePresenter::item($root), diff --git a/app/Support/AgentNodePresenter.php b/app/Support/AgentNodePresenter.php index 8ec520e..05d0c5a 100644 --- a/app/Support/AgentNodePresenter.php +++ b/app/Support/AgentNodePresenter.php @@ -4,6 +4,8 @@ namespace App\Support; use App\Models\AdminSite; use App\Models\AgentNode; +use App\Models\AgentProfile; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; final class AgentNodePresenter @@ -23,7 +25,24 @@ final class AgentNodePresenter * email: ?string * } */ - public static function item(AgentNode $node): array + /** + * @return array + */ + public static function profileSummary(AgentProfile $profile): array + { + return [ + 'total_share_rate' => (float) $profile->total_share_rate, + 'credit_limit' => (int) $profile->credit_limit, + 'allocated_credit' => (int) $profile->allocated_credit, + 'used_credit' => (int) $profile->used_credit, + 'available_credit' => max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit), + 'rebate_limit' => (float) $profile->rebate_limit, + 'default_player_rebate' => (float) $profile->default_player_rebate, + 'settlement_cycle' => AgentSettlementCycle::normalize($profile->settlement_cycle), + ]; + } + + public static function item(AgentNode $node, ?AgentProfile $profile = null): array { $account = DB::table('admin_user_agents as aua') ->join('admin_users as au', 'au.id', '=', 'aua.admin_user_id') @@ -35,7 +54,7 @@ final class AgentNodePresenter $siteCode = AdminSite::query()->where('id', $node->admin_site_id)->value('code'); - return [ + $payload = [ 'id' => (int) $node->id, 'admin_site_id' => (int) $node->admin_site_id, 'site_code' => $siteCode !== null ? (string) $siteCode : null, @@ -50,6 +69,12 @@ final class AgentNodePresenter 'username' => $account?->username !== null ? (string) $account->username : null, 'email' => $account?->email !== null ? (string) $account->email : null, ]; + + if ($profile !== null) { + $payload['profile_summary'] = self::profileSummary($profile); + } + + return $payload; } /** @@ -58,11 +83,18 @@ final class AgentNodePresenter */ public static function tree(iterable $nodes): array { + $nodeList = $nodes instanceof Collection ? $nodes : collect($nodes); + $profiles = AgentProfile::query() + ->whereIn('agent_node_id', $nodeList->pluck('id')) + ->get() + ->keyBy('agent_node_id'); + $items = []; $byParent = []; - foreach ($nodes as $node) { - $row = self::item($node); + foreach ($nodeList as $node) { + $profile = $profiles->get($node->id); + $row = self::item($node, $profile instanceof AgentProfile ? $profile : null); $row['children'] = []; $items[(int) $node->id] = $row; $parentKey = $node->parent_id !== null ? (int) $node->parent_id : 0; diff --git a/app/Support/AgentOverdueGuard.php b/app/Support/AgentOverdueGuard.php new file mode 100644 index 0000000..f659460 --- /dev/null +++ b/app/Support/AgentOverdueGuard.php @@ -0,0 +1,31 @@ +where('owner_type', 'agent') + ->where('owner_id', $agentNodeId) + ->where('status', 'overdue') + ->where('unpaid_amount', '>', 0) + ->exists(); + } + + public static function assertAgentMayGrantCredit(int $agentNodeId): void + { + if (self::agentHasOverdueBills($agentNodeId)) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'credit' => ['agent_overdue'], + ]); + } + } +} diff --git a/app/Support/AgentPlatformRole.php b/app/Support/AgentPlatformRole.php new file mode 100644 index 0000000..32f2439 --- /dev/null +++ b/app/Support/AgentPlatformRole.php @@ -0,0 +1,47 @@ +id; + } + + /** 主账号:仅绑定平台「代理」角色(权限在「平台角色管理」维护)。 */ + public static function assignPrimaryOperator(AdminUser $user, AgentNode $node): void + { + $user->syncAgentRoleIds((int) $node->id, [self::id()]); + } + + public static function idOrFail(): int + { + $id = (int) (AdminRole::query() + ->where('scope_type', AdminRole::SCOPE_SYSTEM) + ->where('slug', 'agent') + ->where('status', 1) + ->value('id') ?? 0); + + if ($id <= 0) { + throw ValidationException::withMessages([ + 'role' => ['platform_agent_role_missing: run php artisan lottery:agent-roles-sync'], + ]); + } + + return $id; + } +} diff --git a/app/Support/AgentProfileCapabilityFilter.php b/app/Support/AgentProfileCapabilityFilter.php new file mode 100644 index 0000000..134b6a9 --- /dev/null +++ b/app/Support/AgentProfileCapabilityFilter.php @@ -0,0 +1,123 @@ + */ + private const CHILD_AGENT_PERMISSION_CODES = [ + 'agent.node.manage', + 'agent.profile.manage', + ]; + + /** @var list */ + private const PLAYER_PERMISSION_CODES = [ + 'service.players.manage', + 'service.players.freeze', + ]; + + /** @var list */ + private const CHILD_AGENT_LEGACY_SLUGS = [ + 'prd.agent.manage', + 'prd.agent.profile.manage', + ]; + + /** @var list */ + private const PLAYER_LEGACY_SLUGS = [ + 'prd.users.manage', + 'prd.users.view_finance', + 'prd.users.view_cs', + 'prd.player_freeze.manage', + ]; + + /** + * 按 Profile 能力收紧或补足登录态 permission_code(平台 agent 角色模板未必含 manage)。 + * + * @param list $permissionCodes + * @return list + */ + public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array + { + if ($profile === null) { + return $permissionCodes; + } + + $set = []; + foreach ($permissionCodes as $code) { + if (is_string($code) && $code !== '') { + $set[$code] = true; + } + } + + if (! $profile->can_create_child_agent) { + foreach (self::CHILD_AGENT_PERMISSION_CODES as $code) { + unset($set[$code]); + } + } else { + foreach (self::CHILD_AGENT_PERMISSION_CODES as $code) { + $set[$code] = true; + } + } + + if (! $profile->can_create_player) { + foreach (self::PLAYER_PERMISSION_CODES as $code) { + unset($set[$code]); + } + } else { + foreach (self::PLAYER_PERMISSION_CODES as $code) { + $set[$code] = true; + } + } + + $out = array_keys($set); + sort($out); + + return $out; + } + + /** + * @param list $permissionCodes + * @return list + */ + public static function filterMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array + { + return self::applyToMenuActionCodes($permissionCodes, $profile); + } + + /** + * @param list $legacySlugs + * @return list + */ + public static function filterLegacySlugs(array $legacySlugs, ?AgentProfile $profile): array + { + if ($profile === null) { + return $legacySlugs; + } + + $deny = []; + if (! $profile->can_create_child_agent) { + $deny = array_merge($deny, self::CHILD_AGENT_LEGACY_SLUGS); + } + if (! $profile->can_create_player) { + $deny = array_merge($deny, self::PLAYER_LEGACY_SLUGS); + } + + if ($deny === []) { + return $legacySlugs; + } + + $denySet = array_fill_keys($deny, true); + + return array_values(array_filter( + $legacySlugs, + static fn (string $slug): bool => ! isset($denySet[$slug]), + )); + } +} diff --git a/app/Support/AgentSettlementProductionGuard.php b/app/Support/AgentSettlementProductionGuard.php new file mode 100644 index 0000000..c819803 --- /dev/null +++ b/app/Support/AgentSettlementProductionGuard.php @@ -0,0 +1,17 @@ +environment('testing')) { + return; + } + + if (config('agent_settlement.allow_demo_close', false)) { + return; + } + } +} diff --git a/app/Support/ApiValidationErrors.php b/app/Support/ApiValidationErrors.php index ed97984..6da0660 100644 --- a/app/Support/ApiValidationErrors.php +++ b/app/Support/ApiValidationErrors.php @@ -97,6 +97,11 @@ final class ApiValidationErrors return $humanized; } + $compact = self::humanizeCompactEnglish($field, $trimmed, $locale, $attribute); + if ($compact !== null) { + return $compact; + } + return $trimmed; } @@ -243,6 +248,91 @@ final class ApiValidationErrors return null; } + /** + * Laravel 11 在 locale=en 时常用「{attribute} must not be greater than 1.」短句(无 "The … field" 前缀)。 + */ + private static function humanizeCompactEnglish( + string $field, + string $message, + string $locale, + string $attribute, + ): ?string { + if (preg_match('/^(.+?)\s+must not be greater than ([\d.]+)\.?$/i', $message, $max) === 1) { + $attribute = self::attributeLabelFromEnglish($max[1], $field, $locale); + $custom = self::customRuleLine($field, 'max', $attribute, $locale); + if ($custom !== null) { + return $custom; + } + + return trans('validation.max.numeric', ['attribute' => $attribute, 'max' => $max[2]], $locale); + } + + if (preg_match('/^(.+?)\s+may not be greater than ([\d.]+)\.?$/i', $message, $max) === 1) { + $attribute = self::attributeLabelFromEnglish($max[1], $field, $locale); + + return trans('validation.max.numeric', ['attribute' => $attribute, 'max' => $max[2]], $locale); + } + + if (preg_match('/^(.+?)\s+must be less than or equal to ([\d.]+)\.?$/i', $message, $lte) === 1) { + $attribute = self::attributeLabelFromEnglish($lte[1], $field, $locale); + + return trans('validation.lte.numeric', ['attribute' => $attribute, 'value' => $lte[2]], $locale); + } + + if (preg_match('/^(.+?)\s+must not be less than ([\d.]+)\.?$/i', $message, $min) === 1) { + $attribute = self::attributeLabelFromEnglish($min[1], $field, $locale); + $custom = self::customRuleLine($field, 'min', $attribute, $locale); + if ($custom !== null) { + return $custom; + } + + return trans('validation.min.numeric', ['attribute' => $attribute, 'min' => $min[2]], $locale); + } + + if (preg_match('/^(.+?)\s+must be at least ([\d.]+)\.?$/i', $message, $min) === 1) { + $attribute = self::attributeLabelFromEnglish($min[1], $field, $locale); + $custom = self::customRuleLine($field, 'min', $attribute, $locale); + if ($custom !== null) { + return $custom; + } + + return trans('validation.min.numeric', ['attribute' => $attribute, 'min' => $min[2]], $locale); + } + + if (preg_match('/^(.+?)\s+must be between ([\d.]+) and ([\d.]+)\.?$/i', $message, $between) === 1) { + $attribute = self::attributeLabelFromEnglish($between[1], $field, $locale); + + return trans('validation.between.numeric', [ + 'attribute' => $attribute, + 'min' => $between[2], + 'max' => $between[3], + ], $locale); + } + + $compactTails = [ + 'must be a number' => 'validation.numeric', + 'must be an integer' => 'validation.integer', + 'must be a string' => 'validation.string', + 'must be a boolean' => 'validation.boolean', + 'must be an array' => 'validation.array', + 'is required' => 'validation.required', + ]; + + foreach ($compactTails as $suffix => $ruleKey) { + $pattern = '/^(.+?)\s+'.preg_quote($suffix, '/').'\.?$/i'; + if (preg_match($pattern, $message, $match) !== 1) { + continue; + } + + $attribute = self::attributeLabelFromEnglish($match[1], $field, $locale); + $line = trans($ruleKey, ['attribute' => $attribute], $locale); + + return $line !== $ruleKey ? $line : null; + } + + return null; + } + private static function attributeLabelFromEnglish(string $englishName, string $field, string $locale): string { $normalized = strtolower(trim($englishName)); diff --git a/app/Support/CreditAmountScale.php b/app/Support/CreditAmountScale.php new file mode 100644 index 0000000..19bcfd9 --- /dev/null +++ b/app/Support/CreditAmountScale.php @@ -0,0 +1,52 @@ +where('code', $code)->first(); + $decimals = $currency !== null + ? (int) $currency->decimal_places + : LotterySettings::currencyDisplayDecimals(); + + return (int) max(1, 10 ** max(0, min(12, $decimals))); + } + + public static function majorToMinor(int $major, string $currencyCode): int + { + $major = max(0, $major); + + return $major * self::minorUnitFactor($currencyCode); + } + + /** 最小单位 → 主货币整数(四舍五入)。 */ + public static function minorToMajor(int $minor, string $currencyCode): int + { + $factor = self::minorUnitFactor($currencyCode); + if ($factor <= 1) { + return $minor; + } + + if ($minor >= 0) { + return intdiv($minor + intdiv($factor, 2), $factor); + } + + return -intdiv(-$minor + intdiv($factor, 2), $factor); + } +} diff --git a/app/Support/PlatformSystemRoles.php b/app/Support/PlatformSystemRoles.php new file mode 100644 index 0000000..1f3fce0 --- /dev/null +++ b/app/Support/PlatformSystemRoles.php @@ -0,0 +1,54 @@ + */ + public static function fixedSlugs(): array + { + return [self::SLUG_SUPER_ADMIN, self::SLUG_AGENT]; + } + + public static function isFixedSlug(string $slug): bool + { + return in_array($slug, self::fixedSlugs(), true); + } + + /** 超级管理员:平台内置,同步当前目录中的全部 `prd.*`。 */ + public static function ensureSuperAdminRole(): AdminRole + { + $role = AdminRole::query()->updateOrCreate( + [ + 'slug' => self::SLUG_SUPER_ADMIN, + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ], + [ + 'code' => self::SLUG_SUPER_ADMIN, + 'name' => '超级管理员', + 'description' => '平台内置角色,拥有全部权限', + 'status' => 1, + 'is_system' => true, + 'sort_order' => 10, + 'owner_agent_id' => null, + 'delegated_from_role_id' => null, + ], + ); + $role->syncAllActiveMenuActions(); + + return $role->fresh() ?? $role; + } + + public static function ensureAll(): void + { + self::ensureSuperAdminRole(); + AgentDefaultRolePermissions::ensurePlatformAgentRole(); + } +} diff --git a/app/Support/PlayerApiPresenter.php b/app/Support/PlayerApiPresenter.php index 5278b4d..1ccd48e 100644 --- a/app/Support/PlayerApiPresenter.php +++ b/app/Support/PlayerApiPresenter.php @@ -2,8 +2,11 @@ namespace App\Support; +use App\Models\AgentProfile; use App\Models\Player; use App\Models\PlayerWallet; +use App\Support\PlayerFundingMode; +use Illuminate\Support\Facades\DB; /** 玩家 API 统一 JSON 形状(列表行 / 详情)。 */ final class PlayerApiPresenter @@ -28,11 +31,23 @@ final class PlayerApiPresenter ? $player->agentNode : ($player->agent_node_id ? $player->agentNode()->first() : null); + $usesCredit = PlayerFundingMode::usesCredit($player); + $credit = DB::table('player_credit_accounts')->where('player_id', $player->id)->first(); + $creditLimit = $credit !== null ? (int) $credit->credit_limit : ($usesCredit ? 0 : null); + $usedCredit = $credit !== null ? (int) $credit->used_credit : ($usesCredit ? 0 : null); + $availableCredit = $credit !== null + ? max(0, (int) $credit->credit_limit - (int) $credit->used_credit - (int) $credit->frozen_credit) + : ($usesCredit ? 0 : null); + + [$rebateRate, $rebateInherited] = self::resolveListRebate($player, $agent); + return [ 'id' => (int) $player->id, ...AgentNodeApiPresenter::embed($agent), 'site_code' => $player->site_code, 'site_player_id' => $player->site_player_id, + 'auth_source' => $player->auth_source, + 'funding_mode' => $player->funding_mode, 'username' => $player->username, 'nickname' => $player->nickname, 'default_currency' => $player->default_currency, @@ -40,6 +55,47 @@ final class PlayerApiPresenter 'last_login_at' => $player->last_login_at?->toIso8601String(), 'created_at' => $player->created_at?->toIso8601String(), 'wallets' => $walletRows, + 'uses_credit' => $usesCredit, + 'credit_limit' => $creditLimit, + 'used_credit' => $usedCredit, + 'available_credit' => $availableCredit, + 'rebate_rate' => $rebateRate, + 'rebate_inherited' => $rebateInherited, + 'risk_tags' => $player->risk_tags ?? [], + 'rebate_profiles' => DB::table('player_rebate_profiles') + ->where('player_id', $player->id) + ->orderBy('game_type') + ->get() + ->map(static fn (object $row): array => [ + 'game_type' => (string) $row->game_type, + 'rebate_rate' => (float) $row->rebate_rate, + 'extra_rebate_rate' => (float) $row->extra_rebate_rate, + 'inherit_from_agent' => (bool) $row->inherit_from_agent, + ]) + ->all(), ]; } + + /** + * @return array{0: ?float, 1: bool} rebate rate (ratio) and whether inherited from agent + */ + private static function resolveListRebate(Player $player, ?\App\Models\AgentNode $agent): array + { + $row = DB::table('player_rebate_profiles') + ->where('player_id', $player->id) + ->where('game_type', '*') + ->first(); + + if ($row !== null && ! (bool) $row->inherit_from_agent) { + return [(float) $row->rebate_rate, false]; + } + + if ($agent !== null) { + $profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first(); + + return [(float) ($profile?->default_player_rebate ?? 0), true]; + } + + return [null, false]; + } } diff --git a/app/Support/PlayerAuthSource.php b/app/Support/PlayerAuthSource.php new file mode 100644 index 0000000..25de71c --- /dev/null +++ b/app/Support/PlayerAuthSource.php @@ -0,0 +1,19 @@ + + */ + public static function all(): array + { + return [self::MAIN_SITE_SSO, self::LOTTERY_NATIVE]; + } +} diff --git a/app/Support/PlayerFundingMode.php b/app/Support/PlayerFundingMode.php new file mode 100644 index 0000000..f30a9fa --- /dev/null +++ b/app/Support/PlayerFundingMode.php @@ -0,0 +1,34 @@ +funding_mode ?? ''); + + if ($mode === self::CREDIT) { + return true; + } + + if ($mode === self::WALLET) { + return false; + } + + return (string) ($player->auth_source ?? '') === PlayerAuthSource::LOTTERY_NATIVE + && CreditLineMode::isEnabledForSiteCode((string) $player->site_code); + } + + public static function usesWallet(Player $player): bool + { + return ! self::usesCredit($player); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 504235d..b4bf860 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -44,6 +44,13 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->api(prepend: [ NegotiateLotteryLocale::class, ]); + $middleware->redirectGuestsTo(static function (Request $request): ?string { + if ($request->is('api/*')) { + return null; + } + + return '/login'; + }); $middleware->convertEmptyStringsToNull([ static fn (Request $request): bool => $request->is('api/v1/admin/settings') || $request->is('api/v1/admin/settings/*'), @@ -192,6 +199,10 @@ return Application::configure(basePath: dirname(__DIR__)) ->everyMinute() ->withoutOverlapping() ->onOneServer(); + $schedule->command('settlement:mark-overdue-bills --days=7') + ->dailyAt('02:00') + ->withoutOverlapping() + ->onOneServer(); /** @see docs/01-界面文档.md §2.1 `draw.countdown` */ if (config('lottery.realtime_hall_countdown', true)) { $schedule->command('lottery:hall-countdown') diff --git a/config/agent_line_defaults.php b/config/agent_line_defaults.php new file mode 100644 index 0000000..d252141 --- /dev/null +++ b/config/agent_line_defaults.php @@ -0,0 +1,9 @@ + (int) env('AGENT_LINE_DEFAULT_CREDIT_LIMIT', 0), + 'total_share_rate' => (float) env('AGENT_LINE_DEFAULT_TOTAL_SHARE_RATE', 100), + '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'), +]; diff --git a/config/agent_settlement.php b/config/agent_settlement.php new file mode 100644 index 0000000..ed51be0 --- /dev/null +++ b/config/agent_settlement.php @@ -0,0 +1,5 @@ + (bool) env('AGENT_SETTLEMENT_ALLOW_DEMO_CLOSE', false), +]; diff --git a/config/lottery.php b/config/lottery.php index e2b2174..8e80ca3 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -72,6 +72,14 @@ return [ 'aes' => [ 'key_base64' => env('LOTTERY_PLAYER_TOKEN_AES_KEY'), ], + 'native' => [ + 'secret' => env('LOTTERY_NATIVE_JWT_SECRET', env('MAIN_SITE_SSO_JWT_SECRET', '')), + 'ttl_seconds' => max(300, min(86400, (int) env('LOTTERY_NATIVE_JWT_TTL_SECONDS', 28800))), + 'claim_player_id' => 'player_id', + 'claim_auth_source' => 'auth_source', + 'max_login_attempts' => max(3, (int) env('LOTTERY_NATIVE_LOGIN_MAX_ATTEMPTS', 8)), + 'lock_minutes' => max(1, (int) env('LOTTERY_NATIVE_LOGIN_LOCK_MINUTES', 15)), + ], ], /* diff --git a/database/migrations/2026_06_03_170000_seed_agent_settlement_api_resources.php b/database/migrations/2026_06_03_170000_seed_agent_settlement_api_resources.php index b7a19d1..8c15432 100644 --- a/database/migrations/2026_06_03_170000_seed_agent_settlement_api_resources.php +++ b/database/migrations/2026_06_03_170000_seed_agent_settlement_api_resources.php @@ -16,6 +16,10 @@ return new class extends Migration private const RESOURCE_CODE_PREFIXES = [ 'admin.settlement-bills.', 'admin.settlement-periods.', + 'admin.settlement-payments.', + 'admin.settlement-adjustments.', + 'admin.settlement-reports.', + 'admin.credit-ledger.', 'admin.agent-lines.', 'admin.agent-nodes.profile.', ]; diff --git a/database/migrations/2026_06_03_180000_add_agent_profile_capability_flags.php b/database/migrations/2026_06_03_180000_add_agent_profile_capability_flags.php index 5636d9b..1de9a80 100644 --- a/database/migrations/2026_06_03_180000_add_agent_profile_capability_flags.php +++ b/database/migrations/2026_06_03_180000_add_agent_profile_capability_flags.php @@ -18,10 +18,15 @@ return new class extends Migration 'can_create_player' => true, ]); - $nodeService = app(\App\Services\Agent\AgentNodeService::class); - \App\Models\AgentNode::query()->each(static function (\App\Models\AgentNode $node) use ($nodeService): void { - $nodeService->syncPrimaryOwnerRoleFromProfile($node); - }); + \App\Support\AgentDefaultRolePermissions::ensurePlatformAgentRole(); + \App\Models\AdminUser::query() + ->whereIn('id', \Illuminate\Support\Facades\DB::table('admin_user_agents')->pluck('admin_user_id')) + ->each(static function (\App\Models\AdminUser $user): void { + $agentNodeId = $user->primaryAgentNodeId(); + if ($agentNodeId !== null) { + $user->syncPrimaryPlatformAgentRole($agentNodeId); + } + }); } public function down(): void diff --git a/database/migrations/2026_06_04_100000_agent_game_settlement_ledger.php b/database/migrations/2026_06_04_100000_agent_game_settlement_ledger.php new file mode 100644 index 0000000..44b3b76 --- /dev/null +++ b/database/migrations/2026_06_04_100000_agent_game_settlement_ledger.php @@ -0,0 +1,88 @@ +foreignId('agent_node_id')->nullable()->after('player_id')->constrained('agent_nodes')->nullOnDelete(); + $table->json('share_snapshot')->nullable()->after('rule_snapshot_json'); + $table->decimal('agent_rebate_rate_snapshot', 8, 4)->nullable()->after('share_snapshot'); + $table->timestamp('agent_settled_at')->nullable()->after('settled_at'); + $table->foreignId('agent_settlement_reversal_of_id')->nullable()->after('agent_settled_at') + ->constrained('ticket_items')->nullOnDelete(); + }); + + Schema::create('share_ledger', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_item_id')->constrained('ticket_items')->cascadeOnDelete(); + $table->foreignId('player_id')->constrained('players')->cascadeOnDelete(); + $table->foreignId('agent_node_id')->nullable()->constrained('agent_nodes')->nullOnDelete(); + $table->json('agent_path')->nullable(); + $table->json('share_snapshot')->nullable(); + $table->bigInteger('game_win_loss')->default(0); + $table->bigInteger('basic_rebate')->default(0); + $table->bigInteger('shared_net_win_loss')->default(0); + $table->json('allocations_json')->nullable(); + $table->foreignId('settlement_period_id')->nullable()->constrained('settlement_periods')->nullOnDelete(); + $table->unsignedBigInteger('reversal_of_id')->nullable(); + $table->timestamp('settled_at'); + $table->timestamps(); + $table->index(['settled_at', 'player_id']); + $table->index(['settlement_period_id']); + }); + + Schema::table('share_ledger', function (Blueprint $table): void { + $table->foreign('reversal_of_id')->references('id')->on('share_ledger')->nullOnDelete(); + }); + + Schema::table('rebate_records', function (Blueprint $table): void { + $table->foreignId('ticket_item_id')->nullable()->after('player_id')->constrained('ticket_items')->nullOnDelete(); + $table->foreignId('reversal_of_id')->nullable()->after('ticket_item_id')->constrained('rebate_records')->nullOnDelete(); + }); + + Schema::table('settlement_bills', function (Blueprint $table): void { + $table->timestamp('locked_at')->nullable()->after('confirmed_at'); + $table->foreignId('reversed_bill_id')->nullable()->after('locked_at')->constrained('settlement_bills')->nullOnDelete(); + $table->json('meta_json')->nullable()->after('reversed_bill_id'); + }); + + Schema::create('settlement_adjustments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('settlement_period_id')->nullable()->constrained('settlement_periods')->nullOnDelete(); + $table->foreignId('original_bill_id')->nullable()->constrained('settlement_bills')->nullOnDelete(); + $table->string('adjustment_type', 32); + $table->bigInteger('amount'); + $table->string('reason', 255)->nullable(); + $table->foreignId('created_by')->nullable()->constrained('admin_users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settlement_adjustments'); + + Schema::table('settlement_bills', function (Blueprint $table): void { + $table->dropConstrainedForeignId('reversed_bill_id'); + $table->dropColumn(['locked_at', 'meta_json']); + }); + + Schema::table('rebate_records', function (Blueprint $table): void { + $table->dropConstrainedForeignId('reversal_of_id'); + $table->dropConstrainedForeignId('ticket_item_id'); + }); + + Schema::dropIfExists('share_ledger'); + + Schema::table('ticket_items', function (Blueprint $table): void { + $table->dropConstrainedForeignId('agent_settlement_reversal_of_id'); + $table->dropConstrainedForeignId('agent_node_id'); + $table->dropColumn(['share_snapshot', 'agent_rebate_rate_snapshot', 'agent_settled_at']); + }); + } +}; diff --git a/database/migrations/2026_06_04_120000_add_player_auth_and_funding_mode.php b/database/migrations/2026_06_04_120000_add_player_auth_and_funding_mode.php new file mode 100644 index 0000000..df0c65f --- /dev/null +++ b/database/migrations/2026_06_04_120000_add_player_auth_and_funding_mode.php @@ -0,0 +1,43 @@ +string('auth_source', 16)->default('main_site_sso')->after('site_player_id'); + $table->string('funding_mode', 16)->default('wallet')->after('auth_source'); + $table->string('password_hash', 255)->nullable()->after('username'); + $table->unsignedSmallInteger('login_failed_count')->default(0)->after('last_login_at'); + $table->timestamp('login_locked_until')->nullable()->after('login_failed_count'); + }); + + DB::table('players')->update([ + 'auth_source' => 'main_site_sso', + 'funding_mode' => 'wallet', + ]); + + Schema::table('players', function (Blueprint $table): void { + $table->index(['site_code', 'auth_source', 'username'], 'idx_players_site_auth_username'); + }); + } + + public function down(): void + { + Schema::table('players', function (Blueprint $table): void { + $table->dropIndex('idx_players_site_auth_username'); + $table->dropColumn([ + 'auth_source', + 'funding_mode', + 'password_hash', + 'login_failed_count', + 'login_locked_until', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_04_120000_agent_settlement_payment_proof.php b/database/migrations/2026_06_04_120000_agent_settlement_payment_proof.php new file mode 100644 index 0000000..c3bc439 --- /dev/null +++ b/database/migrations/2026_06_04_120000_agent_settlement_payment_proof.php @@ -0,0 +1,23 @@ +text('proof')->nullable()->after('method'); + $table->string('remark', 255)->nullable()->after('proof'); + }); + } + + public function down(): void + { + Schema::table('payment_records', function (Blueprint $table): void { + $table->dropColumn(['proof', 'remark']); + }); + } +}; diff --git a/database/migrations/2026_06_04_120000_resync_agent_owner_role_permissions.php b/database/migrations/2026_06_04_120000_resync_agent_owner_role_permissions.php new file mode 100644 index 0000000..83f1ec8 --- /dev/null +++ b/database/migrations/2026_06_04_120000_resync_agent_owner_role_permissions.php @@ -0,0 +1,30 @@ +each(static function (AgentNode $node): void { + $role = AdminRole::query() + ->where('owner_agent_id', $node->id) + ->where('slug', 'agent_owner_'.$node->id) + ->first(); + + if ($role === null) { + return; + } + + $role->syncLegacyPermissionSlugs(AgentDefaultRolePermissions::ownerSlugsForNode($node)); + }); + } + + public function down(): void + { + // 权限包为产品策略,回滚不恢复旧 slug 集合。 + } +}; diff --git a/database/migrations/2026_06_04_130000_seed_platform_agent_role_and_resync_bindings.php b/database/migrations/2026_06_04_130000_seed_platform_agent_role_and_resync_bindings.php new file mode 100644 index 0000000..cd18738 --- /dev/null +++ b/database/migrations/2026_06_04_130000_seed_platform_agent_role_and_resync_bindings.php @@ -0,0 +1,63 @@ +each(static function (AgentNode $node): void { + $ownerRole = AdminRole::query() + ->where('owner_agent_id', $node->id) + ->where('slug', 'agent_owner_'.$node->id) + ->first(); + + if ($ownerRole !== null) { + $ownerRole->syncLegacyPermissionSlugs(AgentDefaultRolePermissions::ownerSlugsForNode($node)); + } + }); + + $bindings = DB::table('admin_user_agents')->get(['admin_user_id', 'agent_node_id']); + foreach ($bindings as $binding) { + $adminUserId = (int) $binding->admin_user_id; + $agentNodeId = (int) $binding->agent_node_id; + $user = AdminUser::query()->find($adminUserId); + if ($user === null) { + continue; + } + + $agentRoleIds = DB::table('admin_user_agent_roles') + ->where('admin_user_id', $adminUserId) + ->where('agent_node_id', $agentNodeId) + ->pluck('role_id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + if ($agentRoleIds === []) { + $ownerId = (int) (AdminRole::query() + ->where('owner_agent_id', $agentNodeId) + ->where('slug', 'agent_owner_'.$agentNodeId) + ->value('id') ?? 0); + if ($ownerId > 0) { + $agentRoleIds = [$ownerId]; + } + } + + if ($agentRoleIds !== []) { + $user->syncAgentRoleIds($agentNodeId, $agentRoleIds); + } + } + } + + public function down(): void + { + // 不回滚权限与 pivot,避免经营账号失权。 + } +}; diff --git a/database/migrations/2026_06_04_140000_agent_settlement_reports_and_tags.php b/database/migrations/2026_06_04_140000_agent_settlement_reports_and_tags.php new file mode 100644 index 0000000..559a70e --- /dev/null +++ b/database/migrations/2026_06_04_140000_agent_settlement_reports_and_tags.php @@ -0,0 +1,38 @@ +bigInteger('platform_rounding_adjustment')->default(0)->after('adjustment_amount'); + }); + + Schema::table('players', function (Blueprint $table): void { + $table->json('risk_tags')->nullable()->after('status'); + }); + + Schema::table('agent_nodes', function (Blueprint $table): void { + $table->json('risk_tags')->nullable()->after('status'); + }); + } + + public function down(): void + { + Schema::table('agent_nodes', function (Blueprint $table): void { + $table->dropColumn('risk_tags'); + }); + + Schema::table('players', function (Blueprint $table): void { + $table->dropColumn('risk_tags'); + }); + + Schema::table('settlement_bills', function (Blueprint $table): void { + $table->dropColumn('platform_rounding_adjustment'); + }); + } +}; diff --git a/database/migrations/2026_06_04_140000_bind_agents_to_platform_agent_role.php b/database/migrations/2026_06_04_140000_bind_agents_to_platform_agent_role.php new file mode 100644 index 0000000..0fdeea8 --- /dev/null +++ b/database/migrations/2026_06_04_140000_bind_agents_to_platform_agent_role.php @@ -0,0 +1,44 @@ +get(['admin_user_id', 'agent_node_id']) as $binding) { + $user = AdminUser::query()->find((int) $binding->admin_user_id); + if ($user === null) { + continue; + } + + $user->syncPrimaryPlatformAgentRole((int) $binding->agent_node_id); + } + + $ownerRoleIds = AdminRole::query() + ->where('scope_type', AdminRole::SCOPE_AGENT) + ->where('slug', 'like', 'agent_owner_%') + ->pluck('id') + ->all(); + + if ($ownerRoleIds !== []) { + DB::table('admin_user_agent_roles')->whereIn('role_id', $ownerRoleIds)->delete(); + DB::table('admin_user_site_roles')->whereIn('role_id', $ownerRoleIds)->delete(); + DB::table('admin_role_menu_actions')->whereIn('role_id', $ownerRoleIds)->delete(); + AdminRole::query()->whereIn('id', $ownerRoleIds)->delete(); + } + } + + public function down(): void + { + // 不回滚:避免经营账号失权。 + } +}; diff --git a/database/migrations/2026_06_04_150000_ensure_platform_fixed_system_roles.php b/database/migrations/2026_06_04_150000_ensure_platform_fixed_system_roles.php new file mode 100644 index 0000000..72f16ff --- /dev/null +++ b/database/migrations/2026_06_04_150000_ensure_platform_fixed_system_roles.php @@ -0,0 +1,17 @@ + */ + private const RESOURCE_CODES = [ + 'admin.credit-ledger.index', + 'admin.settlement-payments.index', + 'admin.settlement-adjustments.index', + 'admin.settlement-reports.summary', + 'admin.settlement-reports.show', + ]; + + public function up(): void + { + $now = Carbon::now(); + $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); + $byCode = collect(AdminAuthorizationRegistry::resources())->keyBy('code'); + + foreach (self::RESOURCE_CODES as $code) { + $resource = $byCode->get($code); + if (! is_array($resource)) { + continue; + } + + $resourceId = DB::table('admin_api_resources') + ->where('code', $resource['code']) + ->value('id'); + + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => $resource['code'], + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->update($payload); + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + foreach ($resource['permission_codes'] as $permissionCode) { + $menuActionId = $menuActionIds[$permissionCode] ?? null; + if ($menuActionId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $menuActionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + public function down(): void + { + foreach (self::RESOURCE_CODES as $code) { + $resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id'); + if ($resourceId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete(); + DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete(); + } + } +}; diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php index c8c2b3e..de7d9e0 100644 --- a/database/seeders/AdminRbacAndUserSeeder.php +++ b/database/seeders/AdminRbacAndUserSeeder.php @@ -2,98 +2,27 @@ namespace Database\Seeders; -use App\Models\AdminRole; use App\Models\AdminUser; use Illuminate\Database\Seeder; use App\Support\AdminAgentPermissionMenuActionSync; use App\Support\AdminDrawPermissionMenuActionSync; -use App\Support\AdminPermissionBridge; +use App\Support\PlatformSystemRoles; /** - * 后台 RBAC:与 {@see AdminUser::ROLE_SUPER_ADMIN} 及 `config/admin_permissions.php` 对齐。 + * 后台 RBAC:平台固定角色 super_admin / agent。 * * 演示账号 **admin** / **123456**(仅限非 production)。 */ final class AdminRbacAndUserSeeder extends Seeder { - /** @param list $legacySlugs */ - private function syncRolePermissions(AdminRole $role, array $legacySlugs): void - { - $role->syncLegacyPermissionSlugs($legacySlugs); - } - - /** @return list */ - private function allCatalogSlugs(): array - { - return AdminPermissionBridge::allLegacySlugs(); - } - public function run(): void { AdminAgentPermissionMenuActionSync::syncMissing(); AdminDrawPermissionMenuActionSync::syncMissing(); - $super = AdminRole::query()->updateOrCreate( - ['slug' => AdminUser::ROLE_SUPER_ADMIN], - ['code' => AdminUser::ROLE_SUPER_ADMIN, 'name' => '超级管理员'], - ); - $this->syncRolePermissions($super, $this->allCatalogSlugs()); + PlatformSystemRoles::ensureAll(); - $risk = AdminRole::query()->updateOrCreate( - ['slug' => 'risk_operator'], - ['code' => 'risk_operator', 'name' => '风控运营员'], - ); - $this->syncRolePermissions($risk, [ - 'prd.dashboard.view', - 'prd.play_switch.manage', - 'prd.odds.manage', - 'prd.risk_cap.manage', - 'prd.rebate.manage', - 'prd.jackpot.manage', - 'prd.draw_result.manage', - 'prd.risk.view', - 'prd.risk.manage', - 'prd.payout.review', - 'prd.tickets.view', - 'prd.wallet_reconcile.view', - 'prd.audit.view', - 'prd.player_freeze.manage', - 'prd.report.view', - 'prd.report.export', - ]); - - $finance = AdminRole::query()->updateOrCreate( - ['slug' => 'finance'], - ['code' => 'finance', 'name' => '财务/对账员'], - ); - $this->syncRolePermissions($finance, [ - 'prd.dashboard.view', - 'prd.users.view_finance', - 'prd.risk_cap.view', - 'prd.rebate.view', - 'prd.jackpot.view', - 'prd.draw_result.view', - 'prd.payout.view', - 'prd.tickets.view', - 'prd.wallet_reconcile.manage', - 'prd.wallet_adjust.manage', - 'prd.audit.view', - 'prd.report.view', - 'prd.report.export', - ]); - - $cs = AdminRole::query()->updateOrCreate( - ['slug' => 'customer_service'], - ['code' => 'customer_service', 'name' => '客服人员'], - ); - $this->syncRolePermissions($cs, [ - 'prd.dashboard.view', - 'prd.users.view_cs', - 'prd.tickets.view', - 'prd.draw_result.view', - 'prd.wallet_reconcile.view_cs', - 'prd.report.view', - ]); + $super = PlatformSystemRoles::ensureSuperAdminRole(); $username = 'admin'; AdminUser::query()->updateOrCreate( diff --git a/docs/admin-rbac.md b/docs/admin-rbac.md index 6d9133b..729a311 100644 --- a/docs/admin-rbac.md +++ b/docs/admin-rbac.md @@ -46,3 +46,42 @@ php artisan lottery:admin-auth-audit # 仅体检:受保护路由是 |---------|--------| | `prd.audit.all` / `prd.audit.self` / `prd.audit.finance` | `prd.audit.view` | | `prd.report.all` / `prd.report.risk` / `prd.report.finance` / `prd.report.player` | `prd.report.view` | + +## 按站点开通后台(数据隔离) + +所有人登录**同一套** lotteryadmin(`POST /api/v1/admin/auth/login`)。登录后 API 按账号绑定自动过滤站点数据,**无需**为每个站点单独部署后台域名。 + +| 给谁 | 账号形态 | 数据范围来源 | 推荐入口 | +|------|----------|--------------|----------| +| 整条代理线负责人 | **代理经营账号**(`admin_user_agents` 主绑定) | `agent_nodes.admin_site_id` + 代理子树 `path` | 超管:**代理线路 → 开通线路** | +| 平台侧站点运营 | **平台账号**(无代理绑定) | `admin_user_site_roles.site_id` | 超管:**系统 → 平台账号**,创建时选择 `admin_site_id` 与角色 | + +**菜单权限**(能进哪些页)由角色的 `admin_role_menu_actions` / 反推 `prd.*` 决定;**数据范围**(能看哪些站点的行)由 `admin_user_site_roles.site_id`(平台)或代理绑定(线路)决定,二者独立。 + +### 路径 A:开通线路(代理主账号) + +1. 侧栏 **代理线路 → 开通线路**(`prd.agent-line.provision`)。 +2. 填写站点 `code`、名称、线路主 **用户名 / 密码**。 +3. 后端一次性创建:`admin_sites`、根 `agent_nodes`、`admin_users`、`admin_user_agents`、平台代理经营角色。 +4. 将账号密码交给对方;对方用同一 lotteryadmin 地址登录。 +5. 登录后 `auth/me.agent` 含 `admin_site_id`、`site_code`;侧栏隐藏「开通线路」等平台-only 菜单;列表经 `AdminScopePolicy` 收敛到该站点 + 代理子树。 + +**子账号**:在代理树下 **代理节点 → 管理员**(`POST /api/v1/admin/agent-nodes/{id}/admin-users`),站点不变、子树更窄。勿在「系统 → 平台账号」为线路主建号。 + +### 路径 B:平台运营账号(单站) + +1. 平台 **角色管理** 仅有两个内置角色:**超级管理员**(自动拥有全部 `prd.*`,随 `lottery:admin-auth-sync` 补齐)与 **代理**(经营主账号默认模板,可在此调整 `prd.*`)。若需更细的平台运营分工,请使用不同平台账号并绑定 **代理** 角色后按需收窄权限;勿授予 `prd.agent-line.provision`、全站接入密钥类权限。 +2. **系统 → 平台账号 → 新建**:填写账号信息,**选择目标站点**(`admin_site_id`),勾选上一步角色。 +3. 对方登录后仅见绑定站点数据;`auth/me.accessible_sites` 列出可访问站点(单站时一项)。 + +改角色绑定时须带上同一 `admin_site_id`(`PUT /api/v1/admin/admin-users/{id}/roles`),仅替换该站点上的角色 pivot,不影响其他站点绑定。 + +### 运行时过滤(已实现) + +- 可访问站点:`AdminUser::accessibleAdminSiteIds()`(超管 `null` = 不限)。 +- 查询:`AdminSiteScope` + `AdminScopePolicy`(`site_scope ∩ agent_subtree_scope`)。 +- 操作者授权站点:`AdminIntegrationSiteAccess::canAccess()`(创建/改绑平台用户时校验)。 + +### 临时手工绑站(迁移前) + +若 UI 未就绪,超管建用户后可在库表 `admin_user_site_roles` 插入 `(admin_user_id, site_id, role_id)`,参考 `tests/Feature/AdminIntegrationSiteApiTest.php`。 diff --git a/lang/en/admin.php b/lang/en/admin.php index a4a7cac..aa8fa97 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -20,6 +20,9 @@ return [ 'player_wallet_balance_blocks_delete' => 'Player wallet still has balance. Clear it before deletion.', 'player_has_tickets_blocks_delete' => 'Player has ticket records and cannot be deleted.', 'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.', + 'platform_roles_fixed' => 'Only the built-in Super Admin and Agent platform roles are supported; creating new roles is disabled.', + 'role_super_admin_permissions_fixed' => 'Super Admin always has full permissions. Run lottery:admin-auth-sync after the permission catalog changes.', + 'role_super_admin_metadata_fixed' => 'Super Admin is a built-in role; name and status cannot be changed.', 'role_builtin_cannot_delete' => 'Built-in roles cannot be deleted.', 'role_has_users_cannot_delete' => 'This role still has assigned admins and cannot be deleted.', 'agent_root_delete_denied' => 'Root agent nodes cannot be deleted.', diff --git a/lang/en/sso.php b/lang/en/sso.php index 1e2835d..5b8d54e 100644 --- a/lang/en/sso.php +++ b/lang/en/sso.php @@ -12,4 +12,7 @@ return [ '8003' => 'Player not registered', // 库中无对应玩家 '8004' => 'SSO secret not configured', // 未配置 MAIN_SITE_SSO_JWT_SECRET(通常返回 503) '8005' => 'Account suspended or login disabled', + '8006' => 'Invalid username or password', + '8007' => 'Too many failed attempts. Try again later', + '8008' => 'Please sign in through the main site', ]; diff --git a/lang/en/validation_attributes.php b/lang/en/validation_attributes.php index 550deb4..f90ae23 100644 --- a/lang/en/validation_attributes.php +++ b/lang/en/validation_attributes.php @@ -59,4 +59,14 @@ return [ 'items.*.odds_value' => 'odds', 'items.*.display_name' => 'display name', 'report_type' => 'report type', + 'total_share_rate' => 'share rate', + 'credit_limit' => 'credit limit', + 'rebate_limit' => 'rebate ceiling', + 'default_player_rebate' => 'default player rebate', + 'rebate_rate' => 'rebate rate', + 'extra_rebate_rate' => 'extra rebate rate', + 'settlement_cycle' => 'settlement cycle', + 'can_grant_extra_rebate' => 'allow extra rebate', + 'can_create_child_agent' => 'allow child agents', + 'can_create_player' => 'allow players', ]; diff --git a/lang/en/wallet.php b/lang/en/wallet.php index 649546e..6efda75 100644 --- a/lang/en/wallet.php +++ b/lang/en/wallet.php @@ -15,6 +15,7 @@ return [ '1008' => 'Invalid amount; enter a positive integer in minor units', '1009' => 'Main wallet operation failed; please try again later', '1010' => 'Do not reuse an idempotency key with different transfer parameters', + '1011' => 'Wallet transfers are not available for credit-line players', '2001' => 'The current draw is already closed', '2002' => 'This play is closed', '2003' => 'Insufficient balance. Please transfer in before betting', diff --git a/lang/ne/admin.php b/lang/ne/admin.php index 382b282..8a06f1d 100644 --- a/lang/ne/admin.php +++ b/lang/ne/admin.php @@ -20,6 +20,9 @@ return [ 'player_wallet_balance_blocks_delete' => 'खेलाडी वालेटमा ब्यालेन्स छ, मेटाउनु अघि खाली गर्नुहोस्।', 'player_has_tickets_blocks_delete' => 'खेलाडीसँग टिकट रेकर्ड छ, मेटाउन मिल्दैन।', 'role_cannot_delete_super_admin' => 'सुपर एडमिन भूमिका मेटाउन मिल्दैन।', + 'platform_roles_fixed' => 'प्लेटफर्ममा केवल सुपर एडमिन र एजेन्ट भूमिका छन्; नयाँ भूमिका थप्न मिल्दैन।', + 'role_super_admin_permissions_fixed' => 'सुपर एडमिनसँग सबै अनुमति हुन्छ; क्याटलग परिवर्तनपछि lottery:admin-auth-sync चलाउनुहोस्।', + 'role_super_admin_metadata_fixed' => 'सुपर एडमिन बिल्ट-इन भूमिका हो; नाम वा स्थिति परिवर्तन गर्न मिल्दैन।', 'role_builtin_cannot_delete' => 'बिल्ट-इन भूमिका मेटाउन मिल्दैन।', 'role_has_users_cannot_delete' => 'यो भूमिकामा अझै एडमिन छ, मेटाउन मिल्दैन।', 'agent_root_delete_denied' => 'रुट एजेन्ट नोड मेटाउन मिल्दैन।', diff --git a/lang/zh/admin.php b/lang/zh/admin.php index 8cbb494..cec6365 100644 --- a/lang/zh/admin.php +++ b/lang/zh/admin.php @@ -12,7 +12,7 @@ return [ 'site_rotate_denied' => '无权操作该站点。', 'site_update_denied' => '无权修改该站点。', 'site_player_access_denied' => '无权访问该站点下的玩家。', - 'integration_site_store_deprecated' => '请使用「开通代理线路」创建新站点,不再支持单独创建接入站点。', + 'integration_site_store_deprecated' => '请先在「平台配置 → 接入站点」创建站点,再在「代理配置 → 创建一级代理」绑定一级代理。', 'player_create_site_forbidden' => '无权在该站点下创建玩家。', 'player_create_agent_required' => '创建玩家须归属代理节点:请选择有效主站(须已配置代理根节点),或由代理账号操作。', 'player_create_agent_forbidden' => '无权将玩家归属到该代理节点。', @@ -21,6 +21,9 @@ return [ 'player_wallet_balance_blocks_delete' => '该玩家钱包仍有余额,请先清空后再删除。', 'player_has_tickets_blocks_delete' => '该玩家存在注单记录,无法删除。', 'role_cannot_delete_super_admin' => '不能删除超级管理员角色。', + 'platform_roles_fixed' => '平台仅保留「超级管理员」与「代理」两个内置角色,不支持新增。', + 'role_super_admin_permissions_fixed' => '超级管理员拥有全部权限,请在权限目录变更后执行 lottery:admin-auth-sync。', + 'role_super_admin_metadata_fixed' => '超级管理员为内置角色,不支持修改名称或状态。', 'role_builtin_cannot_delete' => '系统内置角色不允许删除。', 'role_has_users_cannot_delete' => '该角色下仍有关联管理员,不能删除。', 'agent_root_delete_denied' => '根节点不允许删除。', diff --git a/lang/zh/sso.php b/lang/zh/sso.php index 1742825..6ad7ac4 100644 --- a/lang/zh/sso.php +++ b/lang/zh/sso.php @@ -9,4 +9,7 @@ return [ '8003' => '玩家未建档', '8004' => '未配置 SSO 密钥', '8005' => '账号已冻结或暂时无法登录', + '8006' => '账号或密码错误', + '8007' => '登录失败次数过多,请稍后再试', + '8008' => '请使用主站登录进入彩票', ]; diff --git a/lang/zh/validation_attributes.php b/lang/zh/validation_attributes.php index 5504425..c500fde 100644 --- a/lang/zh/validation_attributes.php +++ b/lang/zh/validation_attributes.php @@ -159,4 +159,14 @@ return [ 'supports_multi_number' => '是否支持多号', 'reserved_rule_json' => '预留规则', 'extra_config_json' => '扩展配置', + 'total_share_rate' => '占成比例', + 'credit_limit' => '授信额度', + 'rebate_limit' => '回水上限', + 'default_player_rebate' => '默认玩家回水', + 'rebate_rate' => '回水比例', + 'extra_rebate_rate' => '额外回水比例', + 'settlement_cycle' => '结算周期', + 'can_grant_extra_rebate' => '允许额外回水', + 'can_create_child_agent' => '允许创建下级代理', + 'can_create_player' => '允许创建玩家', ]; diff --git a/lang/zh/validation_business.php b/lang/zh/validation_business.php index 3dbfe54..748d320 100644 --- a/lang/zh/validation_business.php +++ b/lang/zh/validation_business.php @@ -15,12 +15,15 @@ return [ 'permission_exceeds_actor' => '下列权限超出您可分配的范围::detail', 'permission_catalog_incomplete' => '权限目录不完整,缺少::detail。请联系管理员执行 migrate 与 admin-auth-sync。', 'exceeds_parent' => '占成比例不能超过上级代理。', - 'exceeds_available' => '授信额度超出上级可下发额度。', + 'exceeds_available' => '超出代理可下发额度:请提高该代理授信,或减少其他下级/玩家已占用的额度。', + 'agent_profile_required' => '该代理尚未配置占成与授信,请先在「占成与授信」保存代理档案。', 'exceeds_limit' => '默认玩家回水不能超过回水上限。', 'invalid_range' => '占成比例必须在 0–100 之间。', - 'below_allocated' => '授信额度不能低于已下发给下级的额度。', + 'below_allocated' => '代理授信额度不能低于已下发给下级代理与玩家的总额。', + 'below_player_used' => '玩家授信额度不能低于该玩家已占用(含冻结)的额度。', 'parent_cannot_delegate' => '上级未开放该能力,无法下放。', 'cannot_create_child_agent' => '当前账号无权创建下级代理。', 'cannot_create_player' => '当前账号无权创建玩家。', 'primary_account_missing' => '该代理尚未绑定登录账号,无法修改登录名。', + 'site_root_exists' => '该接入站点已绑定一级代理,请选择其他站点。', ]; diff --git a/lang/zh/validation_custom.php b/lang/zh/validation_custom.php index 668aa76..04ab057 100644 --- a/lang/zh/validation_custom.php +++ b/lang/zh/validation_custom.php @@ -57,4 +57,24 @@ return [ 'role_ids' => [ 'required' => '请选择角色。', ], + 'rebate_rate' => [ + 'max' => '回水比例不能超过 1(100% 记为 1)。', + 'min' => '回水比例不能小于 0。', + ], + 'extra_rebate_rate' => [ + 'max' => '额外回水比例不能超过 1(100% 记为 1)。', + 'min' => '额外回水比例不能小于 0。', + ], + 'rebate_limit' => [ + 'max' => '回水上限不能超过 1(100% 记为 1)。', + 'min' => '回水上限不能小于 0。', + ], + 'default_player_rebate' => [ + 'max' => '默认玩家回水不能超过 1(100% 记为 1)。', + 'min' => '默认玩家回水不能小于 0。', + ], + 'total_share_rate' => [ + 'max' => '占成比例不能超过 100。', + 'min' => '占成比例不能小于 0。', + ], ]; diff --git a/lang/zh/wallet.php b/lang/zh/wallet.php index 965eebe..db87ae2 100644 --- a/lang/zh/wallet.php +++ b/lang/zh/wallet.php @@ -14,12 +14,13 @@ return [ '1008' => '金额无效,请输入正整数(最小货币单位)', '1009' => '主站钱包处理失败,请稍后重试', '1010' => '请勿重复使用幂等键发起不同金额的转账', + '1011' => '信用盘玩家不支持主站钱包划转', '2001' => '当前期已封盘,暂不可下注', '2002' => '玩法已关闭', '2003' => '余额不足,请先转入后再下注', '2004' => '号码格式不正确', '2005' => '玩法参数不完整或不合法', - '2006' => '当前期号不可下注', + '2006' => '期号无效或已切换,请刷新大厅后重试', '2007' => '该玩法暂不支持下注', '2008' => '赔率或玩法配置已变更,请重新预览后再提交', '2009' => '该订单已退款或不可重复提交,请关闭预览后重新下注', diff --git a/routes/api/v1/admin/agent-settlement.php b/routes/api/v1/admin/agent-settlement.php index 461cc89..15b3510 100644 --- a/routes/api/v1/admin/agent-settlement.php +++ b/routes/api/v1/admin/agent-settlement.php @@ -1,19 +1,49 @@ group(function (): void { + Route::get('settlement-periods', AgentSettlementPeriodIndexController::class) + ->name('api.v1.admin.settlement-periods.index'); Route::post('settlement-periods', AgentSettlementPeriodStoreController::class) ->name('api.v1.admin.settlement-periods.store'); Route::post('settlement-periods/{settlement_period}/close', AgentSettlementPeriodCloseController::class) ->name('api.v1.admin.settlement-periods.close'); + Route::get('credit-ledger', AdminCreditLedgerIndexController::class) + ->name('api.v1.admin.credit-ledger.index'); Route::get('settlement-bills', AgentSettlementBillIndexController::class) ->name('api.v1.admin.settlement-bills.index'); + Route::get('settlement-payments', AgentSettlementPaymentIndexController::class) + ->name('api.v1.admin.settlement-payments.index'); + Route::get('settlement-adjustments', AgentSettlementAdjustmentIndexController::class) + ->name('api.v1.admin.settlement-adjustments.index'); + Route::get('settlement-bills/{settlement_bill}', AgentSettlementBillShowController::class) + ->name('api.v1.admin.settlement-bills.show'); Route::post('settlement-bills/{settlement_bill}/confirm', AgentSettlementBillConfirmController::class) ->name('api.v1.admin.settlement-bills.confirm'); + Route::post('settlement-bills/{settlement_bill}/payments', AgentSettlementBillPaymentController::class) + ->name('api.v1.admin.settlement-bills.payments'); + Route::post('settlement-bills/{settlement_bill}/adjustments', AgentSettlementBillAdjustmentController::class) + ->name('api.v1.admin.settlement-bills.adjustments'); + Route::post('settlement-bills/{settlement_bill}/bad-debt-write-off', AgentSettlementBillBadDebtWriteOffController::class) + ->name('api.v1.admin.settlement-bills.bad-debt-write-off'); + Route::get('settlement-reports/summary', AgentSettlementReportIndexController::class) + ->name('api.v1.admin.settlement-reports.summary'); + Route::get('settlement-reports', AgentSettlementReportShowController::class) + ->name('api.v1.admin.settlement-reports.show'); }); diff --git a/routes/api/v1/admin/integration.php b/routes/api/v1/admin/integration.php index 308dcf2..20b4967 100644 --- a/routes/api/v1/admin/integration.php +++ b/routes/api/v1/admin/integration.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteUpdateCont use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteRotateSecretsController; use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController; use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteExportController; +use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteSecretsController; Route::middleware('admin.api-resource') ->group(function (): void { @@ -25,4 +26,6 @@ Route::middleware('admin.api-resource') ->name('api.v1.admin.integration-sites.connectivity-test'); Route::get('integration-sites/{admin_site}/export', AdminIntegrationSiteExportController::class) ->name('api.v1.admin.integration-sites.export'); + Route::get('integration-sites/{admin_site}/secrets', AdminIntegrationSiteSecretsController::class) + ->name('api.v1.admin.integration-sites.secrets'); }); diff --git a/routes/api/v1/public.php b/routes/api/v1/public.php index 97eece9..76e5a25 100644 --- a/routes/api/v1/public.php +++ b/routes/api/v1/public.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\V1\Currency\CurrencyIndexController; use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController; use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; +use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController; use App\Http\Controllers\Api\V1\Setting\SettingIndexController; use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController; @@ -40,6 +41,7 @@ Route::prefix('player') ->name('api.v1.player.') ->group(function (): void { Route::get('ping', PlayerPingController::class)->name('ping'); + Route::post('auth/login', PlayerAuthLoginController::class)->name('auth.login'); }); // 系统公共配置(如前端规则等) diff --git a/tests/Feature/AdminAgentDashboardOverviewTest.php b/tests/Feature/AdminAgentDashboardOverviewTest.php new file mode 100644 index 0000000..e7325bc --- /dev/null +++ b/tests/Feature/AdminAgentDashboardOverviewTest.php @@ -0,0 +1,67 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('agent dashboard returns agent overview for operator with dashboard permission', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'super_dash_agent', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'dash-branch', + 'name' => 'Dash Branch', + 'can_create_player' => true, + ]); + + $operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first(); + if ($operator === null) { + $operator = AdminUser::query() + ->whereIn('id', DB::table('admin_user_agents')->where('agent_node_id', $branch->id)->pluck('admin_user_id')) + ->first(); + } + expect($operator)->not->toBeNull(); + + $platformRoleId = AgentPlatformRole::id(); + $boundRoleId = (int) DB::table('admin_user_agent_roles') + ->where('admin_user_id', $operator->id) + ->where('agent_node_id', $branch->id) + ->value('role_id'); + + expect($boundRoleId)->toBe($platformRoleId); + + $slugs = DB::table('admin_role_menu_actions as rma') + ->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id') + ->where('rma.role_id', $platformRoleId) + ->pluck('ma.permission_code') + ->all(); + + expect(in_array('dashboard.view', $slugs, true))->toBeTrue(); + + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard') + ->assertOk() + ->assertJsonPath('data.agent_overview.agent_node_id', $branch->id) + ->assertJsonPath('data.agent_overview.agent_code', 'dash-branch'); +}); diff --git a/tests/Feature/AdminAgentLineApiTest.php b/tests/Feature/AdminAgentLineApiTest.php index 6cbaaf9..f91ed84 100644 --- a/tests/Feature/AdminAgentLineApiTest.php +++ b/tests/Feature/AdminAgentLineApiTest.php @@ -11,7 +11,7 @@ beforeEach(function (): void { $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); }); -test('super admin can provision agent line with aligned root code', function (): void { +test('super admin can provision root agent on existing integration site', function (): void { $admin = AdminUser::query()->create([ 'username' => 'line_super', 'name' => 'Line Super', @@ -23,21 +23,29 @@ test('super admin can provision agent line with aligned root code', function (): $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'line-alpha', + 'name' => 'Line Alpha Site', + 'status' => 1, + ]) + ->assertCreated() + ->assertJsonPath('data.code', 'line-alpha'); + $response = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/agent-lines', [ + 'site_code' => 'line-alpha', 'code' => 'line-alpha', 'name' => 'Line Alpha', 'username' => 'line_alpha_owner', 'password' => 'secret-strong', - 'currency_code' => 'NPR', 'status' => 1, ]) ->assertCreated() ->assertJsonPath('data.code', 'line-alpha') ->assertJsonPath('data.agent_node.code', 'line-alpha') ->assertJsonPath('data.line_root.site_code', 'line-alpha') - ->assertJsonPath('data.secrets.sso_jwt_secret', fn ($v) => is_string($v) && $v !== '') - ->assertJsonPath('data.secrets.wallet_api_key', fn ($v) => is_string($v) && $v !== ''); + ->assertJsonMissingPath('data.secrets'); $siteId = (int) DB::table('admin_sites')->where('code', 'line-alpha')->value('id'); expect($siteId)->toBeGreaterThan(0); @@ -55,7 +63,48 @@ test('super admin can provision agent line with aligned root code', function (): )->toBe(1); }); -test('non super admin cannot create integration site directly', function (): void { +test('agent line provision rejects site that already has root', function (): void { + $admin = AdminUser::query()->create([ + 'username' => 'line_super2', + 'name' => 'Line Super 2', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'line-beta', + 'name' => 'Line Beta Site', + ]) + ->assertCreated(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-lines', [ + 'site_code' => 'line-beta', + 'code' => 'line-beta', + 'name' => 'Line Beta', + 'username' => 'line_beta_owner', + 'password' => 'secret-strong', + ]) + ->assertCreated(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-lines', [ + 'site_code' => 'line-beta', + 'code' => 'line-beta-2', + 'name' => 'Line Beta 2', + 'username' => 'line_beta_owner2', + 'password' => 'secret-strong', + ]) + ->assertStatus(422) + ->assertJsonPath('data.errors.site_code.0', 'site_root_exists'); +}); + +test('integration manager with site.manage can create integration site', function (): void { $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $admin = AdminUser::query()->create([ 'username' => 'line_ops', @@ -98,8 +147,9 @@ test('non super admin cannot create integration site directly', function (): voi $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/integration-sites', [ - 'code' => 'blocked-site', - 'name' => 'Blocked', + 'code' => 'ops-site', + 'name' => 'Ops Site', ]) - ->assertForbidden(); + ->assertCreated() + ->assertJsonPath('data.code', 'ops-site'); }); diff --git a/tests/Feature/AdminAgentProfileApiTest.php b/tests/Feature/AdminAgentProfileApiTest.php index 1af8909..d49a557 100644 --- a/tests/Feature/AdminAgentProfileApiTest.php +++ b/tests/Feature/AdminAgentProfileApiTest.php @@ -161,6 +161,43 @@ test('agent profile update normalizes empty settlement cycle', function (): void ->assertJsonPath('data.settlement_cycle', 'weekly'); }); +test('bound agent cannot update own profile share and credit', 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' => 'self_profile_super', + 'name' => 'Self Profile Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $agentNode = $service->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'self-profile-agent', + 'name' => 'Self Profile Agent', + 'username' => 'self_profile_agent', + 'total_share_rate' => 20, + 'credit_limit' => 4000, + ])); + + $agentUser = AdminUser::query()->where('username', 'self_profile_agent')->firstOrFail(); + bindAdminUserToAgent($agentUser, $agentNode->id); + $agentUser->syncPrimaryPlatformAgentRole($agentNode->id); + + $token = $agentUser->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/agent-nodes/'.$agentNode->id.'/profile', [ + 'total_share_rate' => 99, + 'credit_limit' => 999_999, + ]) + ->assertForbidden(); +}); + test('agent profile update rejects default rebate above limit', 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'); diff --git a/tests/Feature/AdminAgentProfileCapabilityPermissionTest.php b/tests/Feature/AdminAgentProfileCapabilityPermissionTest.php new file mode 100644 index 0000000..3c9f035 --- /dev/null +++ b/tests/Feature/AdminAgentProfileCapabilityPermissionTest.php @@ -0,0 +1,129 @@ +artisan('lottery:agent-roles-sync')->assertExitCode(0); +}); + +test('agent profile switches strip create player and child manage from effective permissions', 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'); + + $child = AgentNode::query()->create([ + 'admin_site_id' => $siteId, + 'parent_id' => $rootId, + 'path' => '/', + 'depth' => 1, + 'code' => 'cap-child', + 'name' => 'Cap Child', + 'status' => 1, + ]); + $child->path = "/{$rootId}/{$child->id}/"; + $child->save(); + + AgentProfile::query()->create([ + 'agent_node_id' => $child->id, + 'total_share_rate' => 10, + 'credit_limit' => 0, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0, + 'default_player_rebate' => 0, + 'settlement_cycle' => 'weekly', + 'can_grant_extra_rebate' => false, + 'can_create_child_agent' => false, + 'can_create_player' => false, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'cap_child_agent', + 'name' => 'Cap Child Agent', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => $child->id, + 'is_primary' => true, + 'granted_at' => now(), + ]); + $admin->syncPrimaryPlatformAgentRole($child->id); + + $fresh = $admin->fresh(); + $profile = AdminAuthProfile::fromAdmin($fresh); + $perms = $profile['permissions']; + + expect($perms)->toContain('prd.agent.view') + ->not->toContain('prd.agent.manage') + ->not->toContain('prd.users.manage'); + + expect($fresh->hasPermissionCode('agent.node.manage'))->toBeFalse(); + expect($fresh->hasPermissionCode('service.players.manage'))->toBeFalse(); + expect($profile['agent']['can_create_child_agent'])->toBeFalse(); + expect($profile['agent']['can_create_player'])->toBeFalse(); +}); + +test('agent profile switches on grant create capabilities even when platform agent role omits manage', 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'); + + $child = AgentNode::query()->create([ + 'admin_site_id' => $siteId, + 'parent_id' => $rootId, + 'path' => '/', + 'depth' => 1, + 'code' => 'cap-child-on', + 'name' => 'Cap Child On', + 'status' => 1, + ]); + $child->path = "/{$rootId}/{$child->id}/"; + $child->save(); + + AgentProfile::query()->create([ + 'agent_node_id' => $child->id, + 'total_share_rate' => 10, + 'credit_limit' => 0, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0, + 'default_player_rebate' => 0, + 'settlement_cycle' => 'weekly', + 'can_grant_extra_rebate' => false, + 'can_create_child_agent' => true, + 'can_create_player' => true, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'cap_child_on_agent', + 'name' => 'Cap Child On Agent', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => $child->id, + 'is_primary' => true, + 'granted_at' => now(), + ]); + $admin->syncPrimaryPlatformAgentRole($child->id); + + $fresh = $admin->fresh(); + + expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue(); + expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue(); + expect($fresh->adminPermissionSlugs())->toContain('prd.agent.manage') + ->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage'); +}); diff --git a/tests/Feature/AdminCreditLedgerIndexTest.php b/tests/Feature/AdminCreditLedgerIndexTest.php new file mode 100644 index 0000000..e261c3d --- /dev/null +++ b/tests/Feature/AdminCreditLedgerIndexTest.php @@ -0,0 +1,119 @@ +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()->subDays(3), + 'period_end' => now()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:credit-ledger-admin', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_admin_flow', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -500, + 'reason' => 'bet_hold', + 'ref_type' => 'bet', + 'ref_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'credit_ledger_super', + 'name' => 'Credit 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) + ->assertOk() + ->assertJsonPath('data.ledger_source', 'settlement_ledger') + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.entry_kind', 'credit') + ->assertJsonPath('data.items.0.player_id', $player->id) + ->assertJsonPath('data.items.0.biz_type', 'bet_hold') + ->assertJsonPath('data.items.0.ledger_source', 'credit_ledger') + ->assertJsonPath('data.items.0.funding_mode', PlayerFundingMode::CREDIT) + ->assertJsonPath('data.items.0.available_actions', ['view_player']); +}); + +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; + $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:pipeline-1', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'pipe_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(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'pipeline_super', + 'name' => 'Pipeline', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-periods?admin_site_id='.$siteId) + ->assertOk() + ->assertJsonPath('data.items.0.id', $periodId) + ->assertJsonPath('data.items.0.pipeline.credit_ledger_count', 1) + ->assertJsonPath('data.items.0.pipeline.share_ledger_count', 0); +}); diff --git a/tests/Feature/AdminDrawViewOnlyAuthorizationTest.php b/tests/Feature/AdminDrawViewOnlyAuthorizationTest.php new file mode 100644 index 0000000..146bad6 --- /dev/null +++ b/tests/Feature/AdminDrawViewOnlyAuthorizationTest.php @@ -0,0 +1,175 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function drawViewOnlyToken(): string +{ + $admin = AdminUser::query()->create([ + 'username' => 'draw_view_only_admin', + 'name' => 'Draw View', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'draw_view_only_role', + 'name' => 'Draw view only role', + ]); + $role->syncLegacyPermissionSlugs(['prd.draw_result.view']); + + $siteId = AdminUser::defaultAdminSiteId(); + $admin->roles()->sync([ + (int) $role->id => ['site_id' => $siteId, 'granted_at' => now()], + ]); + + return $admin->fresh()->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +function drawViewOnlyFixtureDraw(): Draw +{ + return Draw::query()->create([ + 'draw_no' => '20260604-099', + 'business_date' => '2026-06-04', + 'sequence_no' => 99, + 'status' => DrawStatus::Settled->value, + 'start_time' => now()->subHours(3), + 'close_time' => now()->subHours(2), + 'draw_time' => now()->subHours(1), + 'result_source' => 'rng', + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); +} + +test('partial draw review codes do not infer manage slug', function (): void { + $granted = AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes(['draw.review.publish']); + + expect($granted)->not->toContain('prd.draw_result.manage'); +}); + +test('draw view only admin profile excludes manage and cannot store draw', function (): void { + $admin = AdminUser::query()->create([ + 'username' => 'draw_view_only_profile', + 'name' => 'Draw View', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'draw_view_only_role_profile', + 'name' => 'Draw view only role', + ]); + $role->syncLegacyPermissionSlugs(['prd.draw_result.view']); + + $siteId = AdminUser::defaultAdminSiteId(); + $admin->roles()->sync([ + (int) $role->id => ['site_id' => $siteId, 'granted_at' => now()], + ]); + + $profile = AdminAuthProfile::fromAdmin($admin->fresh()); + + expect($profile['permissions'])->toContain('prd.draw_result.view') + ->not->toContain('prd.draw_result.manage'); + + $token = $admin->fresh()->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/draws', [ + 'admin_site_id' => $siteId, + 'draw_no' => 'test-view-only-001', + 'start_time' => now()->toIso8601String(), + 'close_time' => now()->addHour()->toIso8601String(), + 'draw_time' => now()->addHours(2)->toIso8601String(), + ]) + ->assertForbidden(); +}); + +test('draw view only list and show omit finance and operational fields', function (): void { + $token = drawViewOnlyToken(); + + $draw = drawViewOnlyFixtureDraw(); + + $listPayload = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws') + ->assertOk() + ->json('data'); + + $listRow = collect($listPayload['items'])->firstWhere('id', $draw->id); + + expect($listRow)->not->toBeNull() + ->and($listRow)->not->toHaveKey('total_bet_minor') + ->and($listRow)->not->toHaveKey('result_source') + ->and($listPayload['capabilities']['can_manage_draw_results'])->toBeFalse() + ->and($listPayload['capabilities']['can_view_draw_finance'])->toBeFalse(); + + $show = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id) + ->assertOk() + ->json('data'); + + expect($show)->not->toHaveKeys([ + 'result_source', + 'current_result_version', + 'settle_version', + 'is_reopened', + 'created_at', + 'updated_at', + ]) + ->and($show['result_batch_counts'])->not->toHaveKey('pending_review') + ->and($show['result_batch_counts'])->not->toHaveKey('total') + ->and($show['capabilities']['can_manage_draw_results'])->toBeFalse(); +}); + +test('draw view only cannot read finance summary and result batches hide ops metadata', function (): void { + $token = drawViewOnlyToken(); + $draw = drawViewOnlyFixtureDraw(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/finance-summary') + ->assertForbidden(); + + $payload = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/result-batches') + ->assertOk() + ->json('data'); + + expect($payload['capabilities']['can_manage_draw_results'])->toBeFalse(); + + foreach ($payload['batches'] as $batch) { + expect($batch)->not->toHaveKeys([ + 'source_type', + 'rng_seed_hash', + 'created_by', + 'confirmed_by', + 'created_at', + 'updated_at', + ]); + } + + $hasPending = DB::table('draw_result_batches') + ->where('draw_id', $draw->id) + ->where('status', 'pending_review') + ->exists(); + + if ($hasPending) { + $statuses = collect($payload['batches'])->pluck('status')->unique()->all(); + expect($statuses)->not->toContain('pending_review'); + } +}); diff --git a/tests/Feature/AdminIntegrationSiteApiTest.php b/tests/Feature/AdminIntegrationSiteApiTest.php index 00e51f0..efff07f 100644 --- a/tests/Feature/AdminIntegrationSiteApiTest.php +++ b/tests/Feature/AdminIntegrationSiteApiTest.php @@ -59,6 +59,34 @@ test('super admin can create integration site and receive secrets once', functio expect(AuditLog::query()->where('module_code', 'integration')->where('action_code', 'create')->exists())->toBeTrue(); }); +test('super admin can reveal integration site secrets for copy', function (): void { + $token = integrationAdminToken(); + + $create = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/integration-sites', [ + 'code' => 'partner-secrets', + 'name' => 'Partner Secrets', + ]) + ->assertCreated(); + + $id = (int) $create->json('data.id'); + $plainSso = (string) $create->json('data.secrets.sso_jwt_secret'); + $plainWallet = (string) $create->json('data.secrets.wallet_api_key'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/integration-sites/'.$id.'/secrets') + ->assertOk() + ->assertJsonPath('data.sso_jwt_secret', $plainSso) + ->assertJsonPath('data.wallet_api_key', $plainWallet); + + expect( + AuditLog::query() + ->where('module_code', 'integration') + ->where('action_code', 'reveal_secrets') + ->exists() + )->toBeTrue(); +}); + test('integration site code cannot be changed on update', function (): void { $token = integrationAdminToken(); diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php index 244825f..49545a8 100644 --- a/tests/Feature/AdminPlayerManageApiTest.php +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -1,10 +1,13 @@ assertStatus(422); }); + +test('admin can set player credit limit without clobbering used credit', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + AgentProfile::query()->updateOrCreate( + ['agent_node_id' => $rootId], + [ + 'total_share_rate' => 60, + 'credit_limit' => 50_000, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0.01, + 'default_player_rebate' => 0.005, + 'settlement_cycle' => 'weekly', + ], + ); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $rootId, + 'site_player_id' => 'credit-limit-1', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_limit_user', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 500, + 'used_credit' => 120, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $token = playerManageAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/players/'.$player->id, [ + 'credit_limit' => 2000, + ]) + ->assertOk() + ->assertJsonPath('data.credit_limit', 2000) + ->assertJsonPath('data.available_credit', 1880); + + $this->assertDatabaseHas('player_credit_accounts', [ + 'player_id' => $player->id, + 'credit_limit' => 2000, + 'used_credit' => 120, + ]); +}); diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index 071b442..2d5def2 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -120,6 +120,7 @@ test('admin can sync user roles for default site', function (): void { $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [ + 'admin_site_id' => AdminUser::defaultAdminSiteId(), 'role_slugs' => ['role_sync_b', 'role_sync_a'], ]) ->assertOk() @@ -247,7 +248,6 @@ test('permission catalog groups permissions by admin navigation order', function 'rules_odds', 'jackpot', 'risk_cap', - 'integration', 'currencies', 'admin_users', 'admin_roles', @@ -257,12 +257,12 @@ test('permission catalog groups permissions by admin navigation order', function ]); expect($groups[1]['key'])->toBe('agents'); expect($groups[2]['key'])->toBe('draws'); - expect($groups[15]['label'])->toBe('管理列表'); - expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']); - expect($groups[16]['label'])->toBe('角色管理'); - expect(array_column($groups[16]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']); $groupsByKey = collect($groups)->keyBy('key'); + expect($groupsByKey['admin_users']['label'])->toBe('管理列表'); + expect(array_column($groupsByKey['admin_users']['permissions'], 'slug'))->toBe(['prd.admin_user.manage']); + expect($groupsByKey['admin_roles']['label'])->toBe('角色管理'); + expect(array_column($groupsByKey['admin_roles']['permissions'], 'slug'))->toBe(['prd.admin_role.manage']); expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([ 'prd.tickets.view', ]); @@ -280,7 +280,7 @@ test('permission catalog groups permissions by admin navigation order', function ]); }); -test('admin can repair role permissions from the full catalog after role creation', function (): void { +test('admin can adjust platform agent role permissions from the catalog', function (): void { $token = makeAdminWithPermissions('role_permission_repairer', ['prd.admin_user.manage', 'prd.admin_role.manage']); $catalog = $this->withHeader('Authorization', 'Bearer '.$token) @@ -300,39 +300,37 @@ test('admin can repair role permissions from the full catalog after role creatio ->toContain('prd.report.view') ->toContain('prd.wallet_reconcile.manage'); - $role = $this->withHeader('Authorization', 'Bearer '.$token) - ->postJson('/api/v1/admin/admin-roles', [ - 'slug' => 'repairable_role', - 'name' => 'Repairable Role', - 'permission_slugs' => [], - ]) + $agentRole = collect($this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-roles') ->assertOk() - ->assertJsonPath('data.permission_slugs', []) - ->json('data'); + ->json('data.items')) + ->firstWhere('slug', 'agent'); + + expect($agentRole)->not->toBeNull(); $repairResponse = $this->withHeader('Authorization', 'Bearer '.$token) - ->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [ + ->putJson('/api/v1/admin/admin-roles/'.$agentRole['id'].'/permissions', [ 'permission_slugs' => ['prd.report.view', 'prd.wallet_reconcile.manage'], ]) ->assertOk() - ->assertJsonPath('data.slug', 'repairable_role'); + ->assertJsonPath('data.slug', 'agent'); expect($repairResponse->json('data.permission_slugs')) ->toContain('prd.report.view', 'prd.wallet_reconcile.manage'); $this->withHeader('Authorization', 'Bearer '.$token) - ->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [ + ->putJson('/api/v1/admin/admin-roles/'.$agentRole['id'].'/permissions', [ 'permission_slugs' => ['prd.admin_role.manage'], ]) ->assertOk() ->assertJsonPath('data.permission_slugs', ['prd.admin_role.manage']); - $persistedPermissions = $this->withHeader('Authorization', 'Bearer '.$token) + $persistedRole = collect($this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/admin-roles') ->assertOk() - ->json('data.items'); + ->json('data.items')) + ->firstWhere('slug', 'agent'); - $persistedRole = collect($persistedPermissions)->firstWhere('slug', 'repairable_role'); expect($persistedRole['permission_slugs'])->toBe(['prd.admin_role.manage']); }); @@ -348,6 +346,7 @@ test('admin can create update and delete users with crud rules', function (): vo 'email' => 'newuser@example.com', 'password' => 'secret-long', 'status' => 0, + 'admin_site_id' => AdminUser::defaultAdminSiteId(), 'role_slugs' => ['crud_new_user_role'], ]) ->assertOk() @@ -364,6 +363,7 @@ test('admin can create update and delete users with crud rules', function (): vo 'nickname' => 'dup', 'email' => null, 'password' => 'secret-long', + 'admin_site_id' => AdminUser::defaultAdminSiteId(), 'role_slugs' => [$crudRole->slug], ]) ->assertStatus(422) @@ -407,6 +407,7 @@ test('admin user create requires at least one role slug', function (): void { 'nickname' => 'NR', 'email' => null, 'password' => 'secret-long', + 'admin_site_id' => AdminUser::defaultAdminSiteId(), 'role_slugs' => [], ]) ->assertStatus(422) diff --git a/tests/Feature/AdminUserSiteRoleBindingTest.php b/tests/Feature/AdminUserSiteRoleBindingTest.php new file mode 100644 index 0000000..e1720ce --- /dev/null +++ b/tests/Feature/AdminUserSiteRoleBindingTest.php @@ -0,0 +1,243 @@ +create([ + 'username' => $username, + 'name' => 'Tester', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'role_'.$username, + 'name' => 'Role '.$username, + ]); + $role->syncLegacyPermissionSlugs($permissionSlugs); + + $siteId = $boundSiteId ?? AdminUser::defaultAdminSiteId(); + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => $siteId, + 'role_id' => $role->id, + 'granted_at' => now(), + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('platform user created with admin_site_id only sees that site players', function (): void { + $this->seed(CurrencySeeder::class); + + AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]); + AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]); + $siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id'); + + Player::query()->create([ + 'site_code' => 'site-a', + 'site_player_id' => 'pa-bind-1', + 'username' => 'pa_bind_1', + 'nickname' => 'PA', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + Player::query()->create([ + 'site_code' => 'site-b', + 'site_player_id' => 'pb-bind-1', + 'username' => 'pb_bind_1', + 'nickname' => 'PB', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $opsRole = AdminRole::query()->create([ + 'slug' => 'site_b_ops', + 'name' => 'Site B Ops', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ]); + $opsRole->syncLegacyPermissionSlugs(['prd.users.view_finance', 'prd.admin_user.manage']); + + $creator = AdminUser::query()->create([ + 'username' => 'super_creator', + 'name' => 'Creator', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($creator); + $token = $creator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/admin-users', [ + 'username' => 'site_b_ops_user', + 'nickname' => 'Site B Ops User', + 'email' => null, + 'password' => 'secret-long', + 'admin_site_id' => $siteBId, + 'role_slugs' => ['site_b_ops'], + ]) + ->assertOk() + ->assertJsonPath('data.site_bindings.0.site_id', $siteBId) + ->assertJsonPath('data.site_bindings.0.site_code', 'site-b'); + + $created = AdminUser::query()->where('username', 'site_b_ops_user')->firstOrFail(); + expect($created->isSuperAdmin())->toBeFalse(); + expect($created->accessibleAdminSiteIds())->toEqual([$siteBId]); + expect(AdminSiteScope::accessibleSiteCodes($created))->toBe(['site-b']); + + $scopedQuery = Player::query(); + AdminSiteScope::applyToPlayerQuery($scopedQuery, $created); + expect($scopedQuery->pluck('site_code')->unique()->values()->all())->toBe(['site-b']); + + $createdToken = $created->createToken('test', ['*'], now()->addDay())->plainTextToken; + + expect($creator->id)->not->toBe($created->id); + + $boundRoleSlugs = DB::table('admin_user_site_roles as usr') + ->join('admin_roles as r', 'r.id', '=', 'usr.role_id') + ->where('usr.admin_user_id', $created->id) + ->orderBy('r.slug') + ->pluck('r.slug') + ->all(); + expect($boundRoleSlugs)->toBe(['site_b_ops']); + expect($created->fresh()->isSuperAdmin())->toBeFalse(); + + app('auth')->forgetGuards(); + + $this->withHeader('Authorization', 'Bearer '.$createdToken) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->assertJsonPath('data.admin.id', $created->id) + ->assertJsonPath('data.admin.username', 'site_b_ops_user') + ->assertJsonCount(1, 'data.admin.accessible_sites'); + + $codes = collect( + $this->withHeader('Authorization', 'Bearer '.$createdToken) + ->getJson('/api/v1/admin/players') + ->assertOk() + ->json('data.items'), + )->pluck('site_code')->unique()->values()->all(); + + expect($codes)->toBe(['site-b']); + + $this->withHeader('Authorization', 'Bearer '.$createdToken) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->assertJsonPath('data.admin.accessible_sites.0.code', 'site-b') + ->assertJsonPath('data.admin.agent', null); +}); + +test('scoped operator cannot assign roles on site outside their binding', function (): void { + AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]); + AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]); + $siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id'); + $siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id'); + + $target = AdminUser::query()->create([ + 'username' => 'bind_target', + 'name' => 'Target', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + AdminRole::query()->create([ + 'slug' => 'scoped_assign_role', + 'name' => 'Scoped Assign', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ]); + + $token = siteRoleBindingAdmin('site_a_only_mgr', ['prd.admin_user.manage'], $siteAId); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [ + 'admin_site_id' => $siteBId, + 'role_slugs' => ['scoped_assign_role'], + ]) + ->assertStatus(422) + ->assertJsonPath('code', ErrorCode::ValidationFailed->value); +}); + +test('role sync replaces roles only for requested site', function (): void { + AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]); + AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]); + $siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id'); + $siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id'); + + $rA = AdminRole::query()->create([ + 'slug' => 'multi_a', + 'name' => 'A', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ]); + $rB = AdminRole::query()->create([ + 'slug' => 'multi_b', + 'name' => 'B', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ]); + + $target = AdminUser::query()->create([ + 'username' => 'multi_site_user', + 'name' => 'Multi', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + DB::table('admin_user_site_roles')->insert([ + ['admin_user_id' => $target->id, 'site_id' => $siteAId, 'role_id' => $rA->id, 'granted_at' => now()], + ['admin_user_id' => $target->id, 'site_id' => $siteBId, 'role_id' => $rB->id, 'granted_at' => now()], + ]); + + $actor = AdminUser::query()->create([ + 'username' => 'multi_sync_actor', + 'name' => 'Actor', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($actor); + $token = $actor->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $rC = AdminRole::query()->create([ + 'slug' => 'multi_c', + 'name' => 'C', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [ + 'admin_site_id' => $siteAId, + 'role_slugs' => ['multi_c'], + ]) + ->assertOk(); + + $siteARoles = DB::table('admin_user_site_roles') + ->where('admin_user_id', $target->id) + ->where('site_id', $siteAId) + ->pluck('role_id') + ->map(static fn ($id): int => (int) $id) + ->all(); + $siteBRoles = DB::table('admin_user_site_roles') + ->where('admin_user_id', $target->id) + ->where('site_id', $siteBId) + ->pluck('role_id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + expect($siteARoles)->toBe([(int) $rC->id]); + expect($siteBRoles)->toBe([(int) $rB->id]); +}); diff --git a/tests/Feature/AgentCreditAllocationTest.php b/tests/Feature/AgentCreditAllocationTest.php new file mode 100644 index 0000000..9ac8e6a --- /dev/null +++ b/tests/Feature/AgentCreditAllocationTest.php @@ -0,0 +1,171 @@ +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', + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + AgentProfile::query()->create([ + 'agent_node_id' => $rootId, + 'total_share_rate' => 60, + 'credit_limit' => $creditLimit, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0.01, + 'default_player_rebate' => 0.005, + 'settlement_cycle' => 'weekly', + ]); + + return AgentNode::query()->findOrFail($rootId); +} + +test('player credit account syncs agent allocated credit', function (): void { + $root = createAgentLineForAllocation('line-alloc', 10000); + $service = app(AgentProfileService::class); + + $playerId = (int) DB::table('players')->insertGetId([ + 'site_code' => 'line-alloc', + 'agent_node_id' => $root->id, + 'site_player_id' => 'p-alloc-1', + 'username' => 'alloc1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $playerId, + 'credit_limit' => 2000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service->refreshAllocatedCredit($root); + + $profile = AgentProfile::query()->where('agent_node_id', $root->id)->first(); + expect((int) $profile->allocated_credit)->toBe(2000); + expect($service->present($profile)['available_credit'])->toBe(8000); +}); + +test('player credit allocation exceeds available throws', function (): void { + $root = createAgentLineForAllocation('line-over', 5000); + $service = app(AgentProfileService::class); + + expect(fn () => $service->assertMayIncreasePlayerCredit($root, 6000)) + ->toThrow(\Illuminate\Validation\ValidationException::class); +}); + +test('win loss does not change agent allocated credit', function (): void { + $root = createAgentLineForAllocation('line-hold', 10000); + + $playerId = (int) DB::table('players')->insertGetId([ + 'site_code' => 'line-hold', + 'agent_node_id' => $root->id, + 'site_player_id' => 'p-hold-1', + 'username' => 'hold1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $playerId, + 'credit_limit' => 2000, + 'used_credit' => 200, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + app(AgentProfileService::class)->refreshAllocatedCredit($root); + + $profile = AgentProfile::query()->where('agent_node_id', $root->id)->first(); + expect((int) $profile->allocated_credit)->toBe(2000); +}); + +test('raising player credit limit succeeds when agent allocated credit includes other subordinates', function (): void { + $root = createAgentLineForAllocation('line-player-raise', 10000); + $service = app(AgentProfileService::class); + + $playerId = (int) DB::table('players')->insertGetId([ + 'site_code' => 'line-player-raise', + 'agent_node_id' => $root->id, + 'site_player_id' => 'p-other', + 'username' => 'other', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $playerId, + 'credit_limit' => 3000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service->refreshAllocatedCredit($root); + + $newPlayerId = (int) DB::table('players')->insertGetId([ + 'site_code' => 'line-player-raise', + 'agent_node_id' => $root->id, + 'site_player_id' => 'p-raise-1', + 'username' => 'raise1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $newPlayerId, + 'credit_limit' => 0, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service->adjustPlayerCreditAllocation($root, 0, 2000); + DB::table('player_credit_accounts')->where('player_id', $newPlayerId)->update(['credit_limit' => 2000]); + $service->refreshAllocatedCredit($root); + + $profile = AgentProfile::query()->where('agent_node_id', $root->id)->first(); + expect((int) $profile->allocated_credit)->toBe(5000); +}); diff --git a/tests/Feature/AgentOverdueCreatePlayerTest.php b/tests/Feature/AgentOverdueCreatePlayerTest.php new file mode 100644 index 0000000..a8dfaab --- /dev/null +++ b/tests/Feature/AgentOverdueCreatePlayerTest.php @@ -0,0 +1,72 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('agent with overdue bill cannot create player', 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' => 'od_super', + 'name' => 'OD', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $agent = app(AgentNodeService::class)->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'OD1', + 'name' => 'OD1', + 'username' => 'od_agent_user', + 'total_share_rate' => 50, + 'credit_limit' => 10000, + 'can_create_player' => true, + ])); + + $admin = AdminUser::query()->where('username', 'od_agent_user')->first(); + expect($admin)->not->toBeNull(); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeek(), + 'period_end' => now()->subDay(), + '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' => $agent->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $rootId, + 'gross_win_loss' => 0, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 500, + 'paid_amount' => 0, + 'unpaid_amount' => 500, + 'status' => 'overdue', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + expect(fn () => app(AgentProfileService::class)->assertActorMayCreatePlayer($admin)) + ->toThrow(\Illuminate\Validation\ValidationException::class); +}); diff --git a/tests/Feature/AgentPeriodCloseE2eTest.php b/tests/Feature/AgentPeriodCloseE2eTest.php new file mode 100644 index 0000000..c7f3c7e --- /dev/null +++ b/tests/Feature/AgentPeriodCloseE2eTest.php @@ -0,0 +1,212 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('period close from share ledger matches design doc example 12', 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(AgentNodeService::class); + $super = \App\Models\AdminUser::query()->create([ + 'username' => 'e2e_super', + 'name' => 'E2E', + 'email' => null, + 'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $a = $service->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'A', + 'name' => 'A', + 'username' => 'e2e_a', + 'total_share_rate' => 60, + 'credit_limit' => 500000, + ])); + $b = $service->createChild($super, agentChildPayload([ + 'parent_id' => $a->id, + 'code' => 'B', + 'name' => 'B', + 'username' => 'e2e_b', + 'total_share_rate' => 40, + 'credit_limit' => 200000, + ])); + $c = $service->createChild($super, agentChildPayload([ + 'parent_id' => $b->id, + 'code' => 'C', + 'name' => 'C', + 'username' => 'e2e_c', + 'total_share_rate' => 25, + 'credit_limit' => 100000, + ])); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $c->id, + 'site_player_id' => 'e2e-p1', + 'username' => 'e2eplayer', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $draw = \App\Models\Draw::query()->create([ + 'draw_no' => 'E2E-AG-001', + 'business_date' => now()->toDateString(), + 'sequence_no' => 99, + 'status' => \App\Lottery\DrawStatus::Open->value, + 'start_time' => null, + 'close_time' => null, + 'draw_time' => null, + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-E2E-AG-1', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 10000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 10000, + 'total_estimated_payout' => 0, + 'status' => 'confirmed', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $ticketItemId = (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => 'T-E2E-AG-1', + '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' => 10000, + 'total_bet_amount' => 10000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 10000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'settled_lose', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $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(), + ]); + + 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' => ['C' => 25, 'B' => 40, 'A' => 60], + 'actual_shares' => ['C' => 25, 'B' => 15, 'A' => 20, 'platform' => 40], + 'chain_codes' => ['C', 'B', 'A'], + '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, + ]); + + DB::table('rebate_records')->insert([ + [ + 'player_id' => $player->id, + 'ticket_item_id' => $ticketItemId, + 'game_type' => '*', + 'valid_bet_amount' => 10000, + 'rebate_rate' => 0.005, + 'rebate_amount' => DesignDocExample12::BASIC_REBATE, + 'rebate_type' => 'basic', + 'owner_agent_id' => $c->id, + 'status' => 'accrued', + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ], + [ + 'player_id' => $player->id, + 'ticket_item_id' => $ticketItemId, + 'game_type' => '*', + 'valid_bet_amount' => 10000, + 'rebate_rate' => 0.002, + 'rebate_amount' => DesignDocExample12::EXTRA_REBATE_BY_C, + 'rebate_type' => 'extra', + 'owner_agent_id' => $c->id, + 'status' => 'accrued', + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ], + ]); + + $close = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); + + $playerBill = DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->where('bill_type', 'player') + ->where('owner_id', $player->id) + ->first(); + + expect($playerBill)->not->toBeNull(); + expect((int) $playerBill->net_amount)->toBe((int) DesignDocExample12::PLAYER_NET_SETTLEMENT); + + $edgeCtoB = DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->where('meta_json', 'like', '%C_to_B%') + ->value('net_amount'); + expect((int) $edgeCtoB)->toBe((int) round(DesignDocExample12::TIER_C_TO_B)); + + expect($close['rebate_dispatched'])->toBe(2); + expect($close['rebate_allocations'])->toBeGreaterThan(0); + + expect(DB::table('rebate_records')->where('status', 'in_bill')->count())->toBe(2); + expect(DB::table('rebate_allocations')->where('settlement_bill_id', $playerBill->id)->count()) + ->toBeGreaterThan(0); +}); diff --git a/tests/Feature/AgentSettlementBadDebtTest.php b/tests/Feature/AgentSettlementBadDebtTest.php new file mode 100644 index 0000000..e493ea8 --- /dev/null +++ b/tests/Feature/AgentSettlementBadDebtTest.php @@ -0,0 +1,90 @@ +where('is_default', true)->first(); + $agentId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => (int) $site->id, + 'period_start' => now()->subDays(7), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $agentId, + 'site_player_id' => 'bd-p1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'bduser', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $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' => $agentId, + 'gross_win_loss' => 10000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'platform_rounding_adjustment' => 0, + 'net_amount' => 10000, + 'paid_amount' => 0, + 'unpaid_amount' => 10000, + 'status' => 'overdue', + 'confirmed_at' => now(), + 'locked_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'bad_debt_super', + 'name' => 'Bad Debt', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/settlement-bills/'.$billId.'/bad-debt-write-off', [ + 'reason' => 'uncollectible', + ]) + ->assertOk() + ->assertJsonPath('data.original_bill_id', $billId); + + $this->assertDatabaseHas('settlement_bills', [ + 'id' => $billId, + 'status' => 'settled', + 'unpaid_amount' => 0, + ]); + + $this->assertDatabaseHas('settlement_adjustments', [ + 'original_bill_id' => $billId, + 'adjustment_type' => 'bad_debt', + 'amount' => 10000, + ]); + + $this->assertDatabaseHas('settlement_periods', [ + 'id' => $periodId, + 'status' => 'completed', + ]); +}); diff --git a/tests/Feature/AgentSettlementBillAdjustmentTest.php b/tests/Feature/AgentSettlementBillAdjustmentTest.php new file mode 100644 index 0000000..06163f5 --- /dev/null +++ b/tests/Feature/AgentSettlementBillAdjustmentTest.php @@ -0,0 +1,46 @@ +insertGetId([ + 'admin_site_id' => (int) DB::table('admin_sites')->where('is_default', true)->value('id'), + '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' => 'player', + 'owner_type' => 'player', + 'owner_id' => 1, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'gross_win_loss' => 1000, + 'rebate_amount' => 50, + 'adjustment_amount' => 0, + 'net_amount' => 930, + 'paid_amount' => 0, + 'unpaid_amount' => 930, + 'status' => 'confirmed', + 'locked_at' => now(), + 'confirmed_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $newId = app(\App\Services\AgentSettlement\AgentSettlementBillAdjustmentService::class) + ->createAdjustment($billId, -30, 'adjustment', 'correction', 0); + + $adjustment = DB::table('settlement_bills')->where('id', $newId)->first(); + expect($adjustment)->not->toBeNull(); + expect((string) $adjustment->bill_type)->toBe('adjustment'); + expect((int) $adjustment->reversed_bill_id)->toBe($billId); + expect((int) $adjustment->net_amount)->toBe(-30); +}); diff --git a/tests/Feature/AgentSettlementListsApiTest.php b/tests/Feature/AgentSettlementListsApiTest.php new file mode 100644 index 0000000..a86e29f --- /dev/null +++ b/tests/Feature/AgentSettlementListsApiTest.php @@ -0,0 +1,84 @@ +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' => 1000, + 'unpaid_amount' => 0, + 'paid_amount' => 1000, + 'status' => 'settled', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('payment_records')->insert([ + 'settlement_bill_id' => $billId, + 'payer_type' => 'player', + 'payer_id' => 1, + 'payee_type' => 'agent', + 'payee_id' => 1, + 'amount' => 1000, + 'method' => 'cash', + 'status' => 'confirmed', + 'confirmed_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('settlement_adjustments')->insert([ + 'settlement_period_id' => $periodId, + 'original_bill_id' => $billId, + 'adjustment_type' => 'adjustment', + 'amount' => 100, + 'reason' => 'test', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'lists_super', + 'name' => 'Lists', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-payments?admin_site_id='.$siteId) + ->assertOk() + ->assertJsonPath('data.items.0.settlement_bill_id', $billId); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-adjustments?admin_site_id='.$siteId) + ->assertOk() + ->assertJsonPath('data.items.0.original_bill_id', $billId); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-bills?admin_site_id='.$siteId.'&bill_type=player') + ->assertOk() + ->assertJsonPath('data.items.0.bill_type', 'player'); +}); diff --git a/tests/Feature/AgentSettlementPeriodSummaryTest.php b/tests/Feature/AgentSettlementPeriodSummaryTest.php new file mode 100644 index 0000000..32f1e3a --- /dev/null +++ b/tests/Feature/AgentSettlementPeriodSummaryTest.php @@ -0,0 +1,76 @@ +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(), + ]); + + DB::table('settlement_bills')->insert([ + [ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => 1, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'net_amount' => 1000, + 'unpaid_amount' => 1000, + 'paid_amount' => 0, + 'status' => 'pending_confirm', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'settlement_period_id' => $periodId, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => 1, + 'counterparty_type' => 'platform', + 'counterparty_id' => 0, + 'net_amount' => 5000, + 'unpaid_amount' => 5000, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'period_summary_admin', + 'name' => 'Summary', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-periods?admin_site_id='.$siteId) + ->assertOk() + ->assertJsonPath('data.items.0.summary.player_bills', 1) + ->assertJsonPath('data.items.0.summary.agent_bills', 1) + ->assertJsonPath('data.items.0.summary.pending_confirm', 1) + ->assertJsonPath('data.items.0.summary.awaiting_payment', 1) + ->assertJsonPath('data.items.0.summary.total_unpaid', 6000); + + $service = app(AgentSettlementPeriodSummaryService::class); + $summaries = $service->summariesForPeriodIds([$periodId]); + expect($summaries[$periodId]['player_bills'])->toBe(1); + expect($summaries[$periodId]['agent_bills'])->toBe(1); +}); diff --git a/tests/Feature/BetShareSnapshotImmutabilityTest.php b/tests/Feature/BetShareSnapshotImmutabilityTest.php new file mode 100644 index 0000000..e753be5 --- /dev/null +++ b/tests/Feature/BetShareSnapshotImmutabilityTest.php @@ -0,0 +1,66 @@ +insertGetId([ + 'code' => 'snap-line', + 'name' => 'snap', + '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' => '/snap-line', + 'code' => 'snap-line', + 'name' => 'Root', + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + AgentProfile::query()->create([ + 'agent_node_id' => $rootId, + 'total_share_rate' => 25, + 'credit_limit' => 10000, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0.01, + 'default_player_rebate' => 0.005, + 'settlement_cycle' => 'weekly', + ]); + + $playerId = (int) DB::table('players')->insertGetId([ + 'site_code' => 'snap-line', + 'agent_node_id' => $rootId, + 'site_player_id' => 'snap-p1', + 'username' => 'snap1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = \App\Models\Player::query()->findOrFail($playerId); + $builder = app(BetSettlementSnapshotBuilder::class); + $first = $builder->buildForPlayer($player); + + AgentProfile::query()->where('agent_node_id', $rootId)->update(['total_share_rate' => 50]); + + $stored = json_encode($first['total_shares']); + $second = $builder->buildForPlayer($player->fresh()); + + expect($stored)->toContain('"snap-line":25'); + expect($second['total_shares']['snap-line'])->toBe(50.0); +}); diff --git a/tests/Feature/CreditHoldSettlementNoDoubleTest.php b/tests/Feature/CreditHoldSettlementNoDoubleTest.php new file mode 100644 index 0000000..8f7ba1a --- /dev/null +++ b/tests/Feature/CreditHoldSettlementNoDoubleTest.php @@ -0,0 +1,92 @@ +where('is_default', true)->first(); + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'hold-p1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'hold1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 5000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $credit = app(PlayerCreditService::class); + $credit->assertMayPlaceBet($player, 200); + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(2); + + $drawId = (int) \App\Models\Draw::query()->create([ + 'draw_no' => 'HOLD-DRAW', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => \App\Lottery\DrawStatus::Open->value, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ])->id; + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-HOLD-1', + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'currency_code' => 'NPR', + 'total_bet_amount' => 200, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 200, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $item = \App\Models\TicketItem::query()->create([ + 'ticket_no' => 'T-HOLD-1', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'direct', + 'dimension' => '4d', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 200, + 'total_bet_amount' => 200, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 200, + 'odds_snapshot_json' => '{}', + 'rule_snapshot_json' => '{}', + '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); + + app(AgentGameSettlementRecorder::class)->recordForTicketItem($item, 0, 'settled_lose'); + + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(2); +}); diff --git a/tests/Feature/CreditLineBetHoldTest.php b/tests/Feature/CreditLineBetHoldTest.php new file mode 100644 index 0000000..e5f6345 --- /dev/null +++ b/tests/Feature/CreditLineBetHoldTest.php @@ -0,0 +1,62 @@ +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(), + ]); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'cl-p1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'cl1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 50000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 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(), + ]); + + $walletBefore = (int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'); + + app(PlayerCreditService::class)->assertMayPlaceBet($player, 500); + + $walletAfter = (int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'); + expect($walletAfter)->toBe($walletBefore); + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(5); + expect(DB::table('credit_ledger')->where('reason', 'bet_hold')->where('owner_id', $player->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/CreditWalletLogsTest.php b/tests/Feature/CreditWalletLogsTest.php new file mode 100644 index 0000000..f57d6da --- /dev/null +++ b/tests/Feature/CreditWalletLogsTest.php @@ -0,0 +1,59 @@ +seed(CurrencySeeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + +test('credit player wallet logs reads credit_ledger not wallet_txns', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:logs-1', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_logs', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 200, + 'used_credit' => 10, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -1000, + 'reason' => 'bet_hold', + 'ref_type' => 'bet', + 'ref_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?page=1&size=10') + ->assertOk() + ->assertJsonPath('data.ledger_source', 'credit_ledger') + ->assertJsonPath('data.funding_mode', PlayerFundingMode::CREDIT) + ->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE) + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.type', 'bet') + ->assertJsonPath('data.items.0.biz_type', 'bet_hold') + ->assertJsonPath('data.items.0.ledger_source', 'credit_ledger'); +}); diff --git a/tests/Feature/GameSettlementReversalTest.php b/tests/Feature/GameSettlementReversalTest.php new file mode 100644 index 0000000..129a7fd --- /dev/null +++ b/tests/Feature/GameSettlementReversalTest.php @@ -0,0 +1,126 @@ +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(), + ]); + + $siteCode = (string) $site->code; + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'rev-p1', + 'username' => 'rev1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $drawId = (int) \App\Models\Draw::query()->create([ + 'draw_no' => 'REV-DRAW-1', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => \App\Lottery\DrawStatus::Open->value, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ])->id; + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-REV-1', + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $itemId = (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => 'T-REV-1', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'direct', + 'dimension' => '4d', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => '{}', + 'rule_snapshot_json' => '{}', + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'settled', + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'agent_node_id' => $player->agent_node_id, + 'share_snapshot' => '{}', + 'agent_settled_at' => now(), + 'settled_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $ledgerId = (int) DB::table('share_ledger')->insertGetId([ + 'ticket_item_id' => $itemId, + 'player_id' => $player->id, + 'agent_node_id' => $player->agent_node_id, + 'agent_path' => '[]', + 'share_snapshot' => '{}', + 'game_win_loss' => -1000, + 'basic_rebate' => 50, + 'shared_net_win_loss' => -950, + 'allocations_json' => '[]', + 'settled_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $rebateId = (int) DB::table('rebate_records')->insertGetId([ + 'player_id' => $player->id, + 'ticket_item_id' => $itemId, + 'game_type' => '*', + 'valid_bet_amount' => 1000, + 'rebate_rate' => 0.005, + 'rebate_amount' => 50, + 'rebate_type' => 'basic', + 'owner_agent_id' => $player->agent_node_id, + 'status' => 'accrued', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $item = TicketItem::query()->findOrFail($itemId); + app(GameSettlementReversalService::class)->reverseTicketItem($item); + + $sum = (int) DB::table('share_ledger')->where('ticket_item_id', $itemId)->sum('shared_net_win_loss'); + expect($sum)->toBe(0); + expect((string) DB::table('rebate_records')->where('id', $rebateId)->value('status'))->toBe('reversed'); + expect(DB::table('share_ledger')->where('reversal_of_id', $ledgerId)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/PlatformSystemRolesTest.php b/tests/Feature/PlatformSystemRolesTest.php new file mode 100644 index 0000000..f9e4f49 --- /dev/null +++ b/tests/Feature/PlatformSystemRolesTest.php @@ -0,0 +1,100 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function platformRolesApiToken(string $username): string +{ + $admin = AdminUser::query()->create([ + 'username' => $username, + 'name' => 'Tester', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('platform role index only lists fixed super_admin and agent roles', function (): void { + AdminRole::query()->create([ + 'slug' => 'legacy_custom_ops', + 'code' => 'legacy_custom_ops', + 'name' => 'Legacy Ops', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + 'status' => 1, + 'is_system' => false, + 'sort_order' => 99, + ]); + + PlatformSystemRoles::ensureAll(); + + $token = platformRolesApiToken('platform_role_index'); + + $slugs = collect($this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/admin-roles') + ->assertOk() + ->json('data.items')) + ->pluck('slug') + ->all(); + + expect($slugs)->toBe(['super_admin', 'agent']); +}); + +test('platform roles cannot be created and super_admin permissions are full catalog', function (): void { + PlatformSystemRoles::ensureAll(); + + $token = platformRolesApiToken('platform_role_guard'); + $menuActionCount = (int) DB::table('admin_menu_actions')->where('status', 1)->count(); + + $super = AdminRole::query()->where('slug', 'super_admin')->firstOrFail(); + expect($super->is_system)->toBeTrue(); + expect((int) DB::table('admin_role_menu_actions')->where('role_id', $super->id)->count()) + ->toBe($menuActionCount); + expect($super->legacyPermissionSlugs())->not->toBeEmpty(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/admin-roles', [ + 'slug' => 'new_ops', + 'name' => 'New Ops', + ]) + ->assertStatus(422); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-roles/'.$super->id.'/permissions', [ + 'permission_slugs' => ['prd.dashboard.view'], + ]) + ->assertStatus(422); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/admin-roles/'.$super->id, [ + 'name' => 'Renamed Super', + ]) + ->assertStatus(422); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->deleteJson('/api/v1/admin/admin-roles/'.$super->id) + ->assertStatus(422); +}); + +test('admin-auth-sync grants super_admin the full permission catalog', function (): void { + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); + + $super = AdminRole::query()->where('slug', 'super_admin')->firstOrFail(); + + $menuActionCount = (int) DB::table('admin_menu_actions')->where('status', 1)->count(); + + expect((int) DB::table('admin_role_menu_actions')->where('role_id', $super->id)->count()) + ->toBe($menuActionCount); +}); diff --git a/tests/Feature/PlayerNativeAuthTest.php b/tests/Feature/PlayerNativeAuthTest.php new file mode 100644 index 0000000..02ee53d --- /dev/null +++ b/tests/Feature/PlayerNativeAuthTest.php @@ -0,0 +1,139 @@ + 'test-native-jwt-secret-32bytes!!', + 'lottery.player_auth.native.ttl_seconds' => 3600, + 'lottery.main_site.wallet_api_url' => null, + ]); + $this->seed(CurrencySeeder::class); + $this->seed(LotterySettingsSeeder::class); +}); + +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'); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native:test-1', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'agentplayer1', + 'password_hash' => Hash::make('secret-pass'), + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 50000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $login = $this->postJson('/api/v1/player/auth/login', [ + 'site_code' => $site->code, + 'username' => 'agentplayer1', + 'password' => 'secret-pass', + ]); + + $login->assertOk(); + $token = (string) $login->json('data.access_token'); + expect($token)->not->toBe(''); + + $me = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/player/me'); + + $me->assertOk() + ->assertJsonPath('data.id', $player->id) + ->assertJsonPath('data.funding_mode', PlayerFundingMode::CREDIT) + ->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE); +}); + +test('credit player wallet transfer in is rejected', 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-2', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'agentplayer2', + 'password_hash' => Hash::make('secret-pass'), + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $auth = app(\App\Services\Player\PlayerNativeAuthService::class); + $token = $auth->issueToken($player); + + $response = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/wallet/transfer-in', [ + 'amount' => 1000, + 'idempotent_key' => 'native-ti-1', + 'currency' => 'NPR', + ]); + + $response->assertJsonPath('code', 1011); +}); + +test('sso wallet player balance does not use credit when site credit mode on', 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(), + ]); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'site_player_id' => 'sso-wallet-1', + 'auth_source' => PlayerAuthSource::MAIN_SITE_SSO, + 'funding_mode' => PlayerFundingMode::WALLET, + 'username' => 'ssouser', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + \App\Models\PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 12000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/balance?currency=NPR'); + + $response->assertOk() + ->assertJsonPath('data.credit_line_mode', false) + ->assertJsonPath('data.funding_mode', PlayerFundingMode::WALLET) + ->assertJsonPath('data.available_balance', 12000); +}); diff --git a/tests/Feature/SettlementBillLockTest.php b/tests/Feature/SettlementBillLockTest.php new file mode 100644 index 0000000..9425b76 --- /dev/null +++ b/tests/Feature/SettlementBillLockTest.php @@ -0,0 +1,42 @@ +insertGetId([ + 'admin_site_id' => (int) DB::table('admin_sites')->where('is_default', true)->value('id'), + 'period_start' => now()->subDay(), + 'period_end' => now()->addDay(), + 'status' => 'open', + '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, + 'gross_win_loss' => 1000, + 'rebate_amount' => 50, + 'adjustment_amount' => 0, + 'net_amount' => 930, + 'paid_amount' => 0, + 'unpaid_amount' => 930, + 'status' => 'pending_confirm', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $guard = app(AgentSettlementBillGuard::class); + $guard->markConfirmed($billId); + + expect(fn () => $guard->assertNetAmountMutable($billId)) + ->toThrow(\Illuminate\Validation\ValidationException::class); +}); diff --git a/tests/Feature/SettlementOverdueFreezeTest.php b/tests/Feature/SettlementOverdueFreezeTest.php new file mode 100644 index 0000000..b349701 --- /dev/null +++ b/tests/Feature/SettlementOverdueFreezeTest.php @@ -0,0 +1,72 @@ +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(), + ]); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'od-p1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'od1', + '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(), + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => (int) $site->id, + 'period_start' => now()->subWeek(), + 'period_end' => now()->subDay(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('settlement_bills')->insert([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $player->agent_node_id, + 'gross_win_loss' => 1000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 1000, + 'paid_amount' => 0, + 'unpaid_amount' => 1000, + 'status' => 'overdue', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + expect(fn () => app(PlayerCreditService::class)->assertMayPlaceBet($player, 100)) + ->toThrow(\Illuminate\Validation\ValidationException::class); +}); diff --git a/tests/Feature/WalletBalanceCreditPlayerTest.php b/tests/Feature/WalletBalanceCreditPlayerTest.php new file mode 100644 index 0000000..c5be6f3 --- /dev/null +++ b/tests/Feature/WalletBalanceCreditPlayerTest.php @@ -0,0 +1,46 @@ +seed(CurrencySeeder::class); +}); + +test('credit player wallet balance returns minor units matching admin credit limit', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'credit-bal-1', + 'auth_source' => 'lottery_native', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'creditbal', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 200, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/balance?currency=NPR') + ->assertOk() + ->assertJsonPath('data.credit_line_mode', true) + ->assertJsonPath('data.available_balance', 20000) + ->assertJsonPath('data.credit_limit', 20000) + ->assertJsonPath('data.available_balance_formatted', '200.00'); +}); diff --git a/tests/Unit/AgentDefaultRolePermissionsTest.php b/tests/Unit/AgentDefaultRolePermissionsTest.php new file mode 100644 index 0000000..015e80d --- /dev/null +++ b/tests/Unit/AgentDefaultRolePermissionsTest.php @@ -0,0 +1,37 @@ +toContain('prd.dashboard.view') + ->toContain('prd.settlement.agent.view') + ->not->toContain('prd.wallet_reconcile.view') + ->not->toContain('prd.wallet_reconcile.view_cs'); +}); + +test('line root owner slugs include agent management packages', function (): void { + $slugs = AgentDefaultRolePermissions::lineRootOwnerSlugs(); + + expect($slugs) + ->toContain('prd.agent.manage') + ->toContain('prd.settlement.agent.manage') + ->toContain('prd.agent.role.manage'); +}); + +test('owner slugs from profile add manage slugs when capabilities enabled', function (): void { + $profile = new AgentProfile([ + 'can_create_child_agent' => true, + 'can_create_player' => false, + ]); + + $slugs = AgentDefaultRolePermissions::ownerSlugsFromProfile($profile); + + expect($slugs) + ->toContain('prd.agent.manage') + ->toContain('prd.agent.profile.manage') + ->not->toContain('prd.users.manage'); +}); diff --git a/tests/Unit/ApiValidationErrorsTest.php b/tests/Unit/ApiValidationErrorsTest.php index 29a72a0..740e8a3 100644 --- a/tests/Unit/ApiValidationErrorsTest.php +++ b/tests/Unit/ApiValidationErrorsTest.php @@ -50,3 +50,21 @@ test('normalizes exact draw items message in zh', function (): void { expect($errors['items'][0])->toContain('23'); }); + +test('normalizes compact english max for rebate rate in zh', function (): void { + $errors = ApiValidationErrors::normalize( + ['rebate_rate' => ['rebate rate must not be greater than 1.']], + 'zh', + ); + + expect($errors['rebate_rate'][0])->toBe('回水比例不能超过 1(100% 记为 1)。'); +}); + +test('normalizes compact english max for rebate limit in zh', function (): void { + $errors = ApiValidationErrors::normalize( + ['rebate_limit' => ['rebate limit must not be greater than 1.']], + 'zh', + ); + + expect($errors['rebate_limit'][0])->toBe('回水上限不能超过 1(100% 记为 1)。'); +}); diff --git a/tests/Unit/CreditAmountScaleTest.php b/tests/Unit/CreditAmountScaleTest.php new file mode 100644 index 0000000..393b29c --- /dev/null +++ b/tests/Unit/CreditAmountScaleTest.php @@ -0,0 +1,18 @@ +seed(CurrencySeeder::class); +}); + +test('major and minor convert for two decimal currency', function (): void { + expect(CreditAmountScale::majorToMinor(200, 'NPR'))->toBe(20000); + expect(CreditAmountScale::minorToMajor(20000, 'NPR'))->toBe(200); + expect(CreditAmountScale::minorToMajor(250, 'NPR'))->toBe(3); + expect(CreditAmountScale::minorToMajor(200, 'NPR'))->toBe(2); +});