diff --git a/AGENTS.md b/AGENTS.md index 9295429..66b1930 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,3 +33,16 @@ - **禁止**在生产关账路径使用 `DesignDocExample12` 硬编码账单;仅单元/Feature 测试可引用。 - 非 `testing` 环境关账受 `AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE`(默认 `true`)控制;预发可设为 `false` 门禁。 - 占成账单聚合必须读注单**快照**(`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。 + +## Learned Workspace Facts + +- 期号 `close_time` / `draw_time` 以 UTC 存储与比较;后台展示转浏览器本地时区,创建/编辑表单提交前须转回 UTC。 +- 下注是否开放由 `DrawHallSnapshotBuilder::isBettingOpen()` / `effectiveHallDisplayStatus()` 实时判定,不只看 `draws.status`。 +- 后台期号列表展示数据库 `status`;详情 API 另提供 `hall_preview_status` 供与大厅预览态对比。 +- 绑定经营代理主账号统一绑平台角色 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对 **所有绑定代理主账号** 自动补足 `settlement.agent.manage`(`AgentProfileCapabilityFilter`),实际操作仍受直属边 + 收款方校验。 +- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage`(`canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。 +- 结算账单可见范围(绑定代理):**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点` 或 `counterparty=本节点`(不含下级玩家的账单、不含更深层代理链)。**账务流水/账期 pipeline** 的玩家维度同样仅直属玩家。站点财务/超管仍见全站。 +- 登记收付/确认:绑定代理仅可操作 **收款方**(玩家账单=直属 counterparty;代理账单=按 net_amount 方向的 payee)。上级不能代登下级玩家收付,下级也不能代登向上级的代理账单。 +- 收付/调账/坏账后端落库 `payment_records`、`settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。 +- 代理仪表盘/账期列表「输赢」用本级占成(`share_profit`),不可看 `platform_pnl` 全站报表。 +- 开/关账期仅未绑定代理的站点财务(`canManagePeriods = canOperateBills && boundAgent === null`)。 diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php index 1b05a9f..dcf1788 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php @@ -47,10 +47,6 @@ final class AgentNodeDestroyController extends Controller return ApiMessage::errorResponse($request, 'admin.agent_node_has_players_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); } - if (DB::table('admin_user_agents')->where('agent_node_id', $agent_node->id)->exists()) { - return ApiMessage::errorResponse($request, 'admin.agent_node_has_admin_users_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); - } - if ($service->hasBlockingCustomRoles($agent_node)) { return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); } diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php index 66711d0..c557a4c 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php @@ -7,6 +7,7 @@ use App\Support\AdminAgentSettlementScope; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; final class AgentSettlementAdjustmentIndexController extends Controller @@ -55,6 +56,19 @@ final class AgentSettlementAdjustmentIndexController extends Controller } } + $actorId = AdminAgentSettlementScope::boundAgentNodeId($admin); + if ($actorId !== null) { + $query->where(function (Builder $outer) use ($admin): void { + $outer->whereNull('sa.original_bill_id') + ->orWhereExists(function (Builder $exists) use ($admin): void { + $exists->selectRaw('1') + ->from('settlement_bills as sb') + ->whereColumn('sb.id', 'sa.original_bill_id'); + AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($exists, $admin, 'sb'); + }); + }); + } + return ApiResponse::success([ '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 index e244a97..a318a77 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php @@ -22,6 +22,7 @@ final class AgentSettlementBillAdjustmentController extends Controller $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); + AdminAgentSettlementScope::assertCanPerformFinanceAdjustments($admin); $before = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); abort_if($before === null, 404); diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php index 02603e7..dbefe5d 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php @@ -22,6 +22,7 @@ final class AgentSettlementBillBadDebtWriteOffController extends Controller $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); + AdminAgentSettlementScope::assertCanPerformFinanceAdjustments($admin); $before = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); abort_if($before === null, 404); diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php index d864ab4..9142ff9 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillConfirmController.php @@ -22,7 +22,7 @@ final class AgentSettlementBillConfirmController extends Controller $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); - abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); + AdminAgentSettlementScope::assertCanOperateBill($admin, $settlement_bill); $bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); abort_if($bill === null, 404); diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php index b68d0ed..bf01de6 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillIndexController.php @@ -81,7 +81,7 @@ final class AgentSettlementBillIndexController extends Controller $items = collect($paginator->items()); return ApiResponse::success([ - 'items' => $this->enrichBillRows($items), + 'items' => $this->partyEnrichment->enrichBillRows($items), 'total' => $paginator->total(), 'page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), @@ -138,112 +138,4 @@ final class AgentSettlementBillIndexController extends Controller }); }); } - - /** - * @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', 'agent_node_id', 'funding_mode', 'auth_source']) - ->get() - ->keyBy('id') - : collect(); - - foreach ($players as $player) { - $aid = (int) ($player->agent_node_id ?? 0); - if ($aid > 0) { - $agentIds[] = $aid; - } - } - - $agents = $this->partyEnrichment->loadAgents($agentIds); - - $out = []; - foreach ($items as $row) { - $item = (array) $row; - $ownerType = (string) $row->owner_type; - $counterType = (string) $row->counterparty_type; - $counterId = (int) $row->counterparty_id; - - $item['owner_label'] = $this->legacyOwnerLabel($ownerType, (int) $row->owner_id, $players, $agents); - $item['counterparty_label'] = $this->partyEnrichment->formatCounterpartyLabel($counterType, $counterId, $agents); - - $item['player_username'] = null; - $item['player_site_player_id'] = null; - $item['player_id_display'] = null; - $item['direct_agent_label'] = null; - $item['superior_agent_label'] = null; - $item['owner_party_label'] = null; - - if ($ownerType === 'player') { - $player = $players->get((int) $row->owner_id); - $item['player_username'] = $this->partyEnrichment->formatPlayerUsername($player); - $item['player_site_player_id'] = $this->partyEnrichment->formatPlayerSiteId($player); - $item['player_id_display'] = (int) $row->owner_id; - $item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null; - $item['owner_auth_source'] = $player !== null ? $player->auth_source : null; - - $directId = $counterType === 'agent' ? $counterId : (int) ($player->agent_node_id ?? 0); - $line = $this->partyEnrichment->agentLineLabels($directId > 0 ? $directId : null, $agents); - $item['direct_agent_label'] = $line['direct_agent_label']; - $item['superior_agent_label'] = $line['parent_agent_label']; - } elseif ($ownerType === 'agent') { - $ownerAgentId = (int) $row->owner_id; - $item['owner_party_label'] = $this->partyEnrichment->formatAgent($agents->get($ownerAgentId), $ownerAgentId); - $item['superior_agent_label'] = $counterType === 'platform' - ? 'platform' - : $this->partyEnrichment->formatCounterpartyLabel($counterType, $counterId, $agents); - } - - $out[] = $item; - } - - return $out; - } - - /** - * @param Collection $players - * @param Collection $agents - */ - private function legacyOwnerLabel( - string $type, - int $id, - Collection $players, - Collection $agents, - ): string { - if ($type === 'player') { - $player = $players->get($id); - - return $player !== null - ? (string) ($player->username ?: $player->site_player_id ?: "player#{$id}") - : "player#{$id}"; - } - - if ($type === 'agent') { - return $this->partyEnrichment->formatAgent($agents->get($id), $id); - } - - return "{$type}#{$id}"; - } } diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php index dd2a883..0842861 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php @@ -21,7 +21,7 @@ final class AgentSettlementBillPaymentController extends Controller ): JsonResponse { $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); - abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404); + AdminAgentSettlementScope::assertCanOperateBill($admin, $settlement_bill); $before = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); abort_if($before === null, 404); diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php index 2c0fbb1..6c03cfc 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillShowController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; use App\Http\Controllers\Controller; +use App\Services\AgentSettlement\SettlementBillDownlineShareBuilder; use App\Services\AgentSettlement\SettlementPartyEnrichment; use App\Support\AdminAgentSettlementScope; use App\Support\ApiResponse; @@ -14,6 +15,7 @@ final class AgentSettlementBillShowController extends Controller { public function __construct( private readonly SettlementPartyEnrichment $partyEnrichment, + private readonly SettlementBillDownlineShareBuilder $downlineShareBuilder, ) {} public function __invoke(Request $request, int $settlement_bill): JsonResponse @@ -77,11 +79,12 @@ final class AgentSettlementBillShowController extends Controller } return ApiResponse::success([ - 'bill' => $bill, + 'bill' => $this->partyEnrichment->enrichBillRow($bill), 'payments' => $payments, 'rebate_allocations' => $rebateAllocations, 'adjustments' => $adjustments, 'tier_edge' => $tierSettlements, + 'downline_shares' => $this->downlineShareBuilder->forBill($bill), ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php index 10e53ae..44e8c42 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; use App\Http\Controllers\Controller; +use App\Support\AdminAgentSettlementScope; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -51,6 +52,8 @@ final class AgentSettlementPaymentIndexController extends Controller } } + AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($query, $admin, 'sb'); + return ApiResponse::success([ 'items' => $query->limit(200)->get(), ]); diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodOpenHintsController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodOpenHintsController.php new file mode 100644 index 0000000..7ad3bfb --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPeriodOpenHintsController.php @@ -0,0 +1,28 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $siteId = (int) $request->query('admin_site_id', 0); + abort_if($siteId <= 0, 422); + abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $siteId), 404); + + return ApiResponse::success($hintsService->hints($siteId)); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php index a04146b..147e92a 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; use App\Http\Controllers\Controller; use App\Services\AgentSettlement\AgentSettlementReportQueryService; +use App\Support\AdminAgentScope; use App\Support\AgentSettlementPeriodWindow; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; @@ -31,6 +32,10 @@ final class AgentSettlementReportShowController extends Controller $type = (string) $request->query('type', 'summary'); abort_unless(in_array($type, self::TYPES, true), 404); + if ($type === 'platform_pnl' && AdminAgentScope::primaryAgentNode($admin) !== null) { + abort(403, 'agent_cannot_view_platform_pnl'); + } + $periodId = (int) $request->query('settlement_period_id', 0); $period = $this->resolvePeriod($periodId, $request); diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index d65f8a4..f13dff0 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -409,7 +409,11 @@ final class AdminUser extends Authenticatable $codes = array_keys($merged); - return AgentProfileCapabilityFilter::applyToMenuActionCodes($codes, $this->primaryAgentProfile()); + return AgentProfileCapabilityFilter::applyToMenuActionCodes( + $codes, + $this->primaryAgentProfile(), + $this->primaryAgentNode(), + ); } private function primaryAgentProfile(): ?AgentProfile diff --git a/app/Services/Admin/AdminDashboardAnalyticsBuilder.php b/app/Services/Admin/AdminDashboardAnalyticsBuilder.php index 09700a6..35dfcc8 100644 --- a/app/Services/Admin/AdminDashboardAnalyticsBuilder.php +++ b/app/Services/Admin/AdminDashboardAnalyticsBuilder.php @@ -3,6 +3,7 @@ namespace App\Services\Admin; use App\Models\AdminUser; +use App\Services\AgentSettlement\ShareLedgerScopedProfitAggregator; use App\Support\AdminScopeContextResolver; /** @@ -12,6 +13,7 @@ final class AdminDashboardAnalyticsBuilder { public function __construct( private readonly AdminReportQueryService $reportQuery, + private readonly ShareLedgerScopedProfitAggregator $shareProfitAggregator, ) {} /** @@ -53,6 +55,27 @@ final class AdminDashboardAnalyticsBuilder $dateTo = $range['date_to']; $trend = $this->reportQuery->dailyProfitSeriesFilled($dateFrom, $dateTo, scope: $scope); + $summary = $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $scope); + $dailySeries = $trend['series']; + $profitScope = 'house_gross'; + + if ($admin->primaryAgentNode() !== null) { + $profitScope = 'share_profit'; + $shareByDate = $this->shareProfitAggregator->shareProfitByBusinessDate($admin, $dateFrom, $dateTo); + $summary['approx_house_gross_minor'] = $this->shareProfitAggregator->sumShareProfitForAdmin( + $admin, + $dateFrom, + $dateTo, + ); + $dailySeries = array_map( + static function (array $row) use ($shareByDate): array { + $row['approx_house_gross_minor'] = $shareByDate[(string) $row['business_date']] ?? 0; + + return $row; + }, + $dailySeries, + ); + } return [ 'period' => $period, @@ -60,9 +83,10 @@ final class AdminDashboardAnalyticsBuilder 'play_code' => $playCode, 'date_from' => $dateFrom, 'date_to' => $dateTo, + 'profit_scope' => $profitScope, 'currency_code' => $this->reportQuery->resolvePeriodCurrencyCode($dateFrom, $dateTo, $scope), - 'summary' => $this->reportQuery->periodFinanceTotals($dateFrom, $dateTo, $scope), - 'daily_series' => $trend['series'], + 'summary' => $summary, + 'daily_series' => $dailySeries, 'chart_meta' => [ 'chart_date_from' => $trend['chart_date_from'], 'chart_date_to' => $trend['chart_date_to'], diff --git a/app/Services/Admin/AgentDashboardOverviewBuilder.php b/app/Services/Admin/AgentDashboardOverviewBuilder.php index 861ae51..f389658 100644 --- a/app/Services/Admin/AgentDashboardOverviewBuilder.php +++ b/app/Services/Admin/AgentDashboardOverviewBuilder.php @@ -8,6 +8,7 @@ use App\Models\AgentProfile; use App\Models\Player; use App\Support\AdminScopeContext; use App\Support\AdminScopeContextResolver; +use App\Services\AgentSettlement\ShareLedgerScopedProfitAggregator; use App\Support\AdminAgentSettlementScope; use Carbon\Carbon; use Illuminate\Support\Facades\DB; @@ -18,6 +19,7 @@ final class AgentDashboardOverviewBuilder { public function __construct( private readonly AdminReportQueryService $reportQuery, + private readonly ShareLedgerScopedProfitAggregator $shareProfitAggregator, ) {} /** @@ -58,6 +60,8 @@ final class AgentDashboardOverviewBuilder $sevenDayFrom = now()->subDays(6)->toDateString(); $todayTotals = $this->reportQuery->periodFinanceTotals($today, $today, $scope); $sevenDayTotals = $this->reportQuery->periodFinanceTotals($sevenDayFrom, $today, $scope); + $todayShareProfit = $this->shareProfitAggregator->sumShareProfitForAdmin($admin, $today, $today); + $sevenDayShareProfit = $this->shareProfitAggregator->sumShareProfitForAdmin($admin, $sevenDayFrom, $today); $currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scope) ?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scope); $teamPlayerStats = $this->teamPlayerStats($subtreeIds); @@ -87,10 +91,11 @@ final class AgentDashboardOverviewBuilder 'bet_order_count_today' => $todayActivityStats['order_count'], 'today_bet_minor' => $todayTotals['total_bet_minor'], 'today_payout_minor' => $todayTotals['total_payout_minor'], - 'today_profit_minor' => $todayTotals['approx_house_gross_minor'], + 'today_profit_minor' => $todayShareProfit, 'seven_day_bet_minor' => $sevenDayTotals['total_bet_minor'], 'seven_day_payout_minor' => $sevenDayTotals['total_payout_minor'], - 'seven_day_profit_minor' => $sevenDayTotals['approx_house_gross_minor'], + 'seven_day_profit_minor' => $sevenDayShareProfit, + 'profit_scope' => 'share_profit', 'currency_code' => $currencyCode, 'pending_bill_count' => $pendingBillStats['count'], 'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'], diff --git a/app/Services/AgentSettlement/AgentPeriodAggregator.php b/app/Services/AgentSettlement/AgentPeriodAggregator.php index c119c25..964a695 100644 --- a/app/Services/AgentSettlement/AgentPeriodAggregator.php +++ b/app/Services/AgentSettlement/AgentPeriodAggregator.php @@ -31,6 +31,7 @@ final class AgentPeriodAggregator $rows = DB::table('share_ledger as sl') ->join('players as p', 'p.id', '=', 'sl.player_id') ->where('p.site_code', $siteCode) + ->whereNull('sl.settlement_period_id') ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]) ->select([ 'sl.player_id', diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php index c659cad..d3188eb 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php @@ -79,6 +79,7 @@ final class AgentSettlementPeriodCloseService ->from('share_ledger as sl') ->join('players as p', 'p.id', '=', 'sl.player_id') ->where('p.site_code', $siteCode) + ->whereNull('sl.settlement_period_id') ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]); }) ->update(['settlement_period_id' => $periodId]); diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php b/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php index b417927..f9dd310 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php @@ -2,6 +2,7 @@ namespace App\Services\AgentSettlement; +use App\Support\AgentSettlementPeriodWindow; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -14,8 +15,10 @@ final class AgentSettlementPeriodOpenService public function open(array $data): object { $siteId = (int) $data['admin_site_id']; - $start = (string) $data['period_start']; - $end = (string) $data['period_end']; + [$start, $end] = AgentSettlementPeriodWindow::normalizeInputBounds( + (string) $data['period_start'], + (string) $data['period_end'], + ); $existingSameRange = DB::table('settlement_periods') ->where('admin_site_id', $siteId) @@ -43,6 +46,12 @@ final class AgentSettlementPeriodOpenService ]); } + if ($this->overlapsExistingPeriod($siteId, $start, $end)) { + throw ValidationException::withMessages([ + 'period_start' => ['period_overlaps_existing'], + ]); + } + $id = (int) DB::table('settlement_periods')->insertGetId([ 'admin_site_id' => $siteId, 'period_start' => $start, @@ -59,4 +68,13 @@ final class AgentSettlementPeriodOpenService return $row; } + + private function overlapsExistingPeriod(int $siteId, string $start, string $end): bool + { + return DB::table('settlement_periods') + ->where('admin_site_id', $siteId) + ->where('period_start', '<=', $end) + ->where('period_end', '>=', $start) + ->exists(); + } } diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php b/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php index 895795e..dc0b278 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodPipelineService.php @@ -3,7 +3,7 @@ namespace App\Services\AgentSettlement; use App\Models\AdminUser; -use App\Support\AdminDataScope; +use App\Support\AdminAgentSettlementScope; use App\Support\AgentSettlementPeriodWindow; use App\Support\PlayerFundingMode; use Illuminate\Support\Collection; @@ -71,17 +71,18 @@ final class AgentSettlementPeriodPipelineService ->whereBetween('cl.created_at', [$start, $end]); if ($admin !== null) { - AdminDataScope::applyToPlayersAlias($creditQuery, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($creditQuery, $admin, 'p'); } $shareQuery = DB::table('share_ledger as sl') ->join('players as p', 'p.id', '=', 'sl.player_id') ->where('p.site_code', $siteCode) + ->whereNull('sl.settlement_period_id') ->whereBetween('sl.settled_at', [$start, $end]) ->whereNull('sl.reversal_of_id'); if ($admin !== null) { - AdminDataScope::applyToPlayersAlias($shareQuery, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($shareQuery, $admin, 'p'); } $shareAgg = (clone $shareQuery) diff --git a/app/Services/AgentSettlement/AgentSettlementReportQueryService.php b/app/Services/AgentSettlement/AgentSettlementReportQueryService.php index 2fd0411..2724f9f 100644 --- a/app/Services/AgentSettlement/AgentSettlementReportQueryService.php +++ b/app/Services/AgentSettlement/AgentSettlementReportQueryService.php @@ -332,23 +332,17 @@ final class AgentSettlementReportQueryService private function applyPlayerSubtree(Builder $query, AdminUser $admin, string $alias = 'p'): void { - AdminDataScope::applyToPlayersAlias($query, $admin, $alias); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, $alias); } private function applyAgentSubtree(Builder $query, AdminUser $admin, string $agentNodeColumn): void { - $subtreeIds = AdminAgentSettlementScope::subtreeAgentNodeIds($admin); - if ($subtreeIds === null) { + $actorId = AdminAgentSettlementScope::boundAgentNodeId($admin); + if ($actorId === null) { return; } - if ($subtreeIds === []) { - $query->whereRaw('0 = 1'); - - return; - } - - $query->whereIn($agentNodeColumn, $subtreeIds); + $query->where($agentNodeColumn, $actorId); } private function siteCodeForAdmin(AdminUser $admin, int $periodId): string diff --git a/app/Services/AgentSettlement/SettlementBillDownlineShareBuilder.php b/app/Services/AgentSettlement/SettlementBillDownlineShareBuilder.php new file mode 100644 index 0000000..ddb6219 --- /dev/null +++ b/app/Services/AgentSettlement/SettlementBillDownlineShareBuilder.php @@ -0,0 +1,102 @@ + + * } + */ + public function forBill(object $bill): array + { + if ((string) $bill->bill_type !== 'agent' || (string) $bill->owner_type !== 'agent') { + return ['total' => 0, 'items' => []]; + } + + $ownerId = (int) $bill->owner_id; + $periodId = (int) $bill->settlement_period_id; + if ($ownerId <= 0 || $periodId <= 0) { + return ['total' => 0, 'items' => []]; + } + + $owner = AgentNode::query()->find($ownerId); + if ($owner === null) { + return ['total' => 0, 'items' => []]; + } + + $descendantIds = AgentNode::query() + ->where('admin_site_id', (int) $owner->admin_site_id) + ->where('id', '!=', $ownerId) + ->where('path', 'like', $owner->path.'%') + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + if ($descendantIds === []) { + return ['total' => 0, 'items' => []]; + } + + $rows = DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->where('bill_type', 'agent') + ->where('owner_type', 'agent') + ->whereIn('owner_id', $descendantIds) + ->orderBy('owner_id') + ->get(['owner_id', 'meta_json']); + + if ($rows->isEmpty()) { + return ['total' => 0, 'items' => []]; + } + + $agentIds = $rows->pluck('owner_id')->map(static fn ($id): int => (int) $id)->all(); + $agents = $this->partyEnrichment->loadAgents($agentIds); + + $items = []; + $total = 0; + foreach ($rows as $row) { + $shareProfit = $this->shareProfitFromMeta($row->meta_json ?? null); + if ($shareProfit === 0) { + continue; + } + + $agentId = (int) $row->owner_id; + $items[] = [ + 'owner_id' => $agentId, + 'owner_label' => $this->partyEnrichment->formatAgent($agents->get($agentId), $agentId), + 'share_profit' => $shareProfit, + ]; + $total += $shareProfit; + } + + usort($items, static fn (array $a, array $b): int => $b['share_profit'] <=> $a['share_profit'] + ?: $a['owner_label'] <=> $b['owner_label']); + + return [ + 'total' => $total, + 'items' => $items, + ]; + } + + private function shareProfitFromMeta(mixed $metaJson): int + { + if ($metaJson === null || $metaJson === '') { + return 0; + } + + $decoded = is_string($metaJson) ? json_decode($metaJson, true) : $metaJson; + + return is_array($decoded) ? (int) ($decoded['share_profit'] ?? 0) : 0; + } +} diff --git a/app/Services/AgentSettlement/SettlementCenterLedgerService.php b/app/Services/AgentSettlement/SettlementCenterLedgerService.php index 4d3aa3a..e630446 100644 --- a/app/Services/AgentSettlement/SettlementCenterLedgerService.php +++ b/app/Services/AgentSettlement/SettlementCenterLedgerService.php @@ -3,7 +3,6 @@ namespace App\Services\AgentSettlement; use App\Models\AdminUser; -use App\Support\AdminDataScope; use App\Support\AdminAgentSettlementScope; use App\Support\AgentSettlementPeriodWindow; use App\Support\CurrencyFormatter; @@ -246,7 +245,7 @@ final class SettlementCenterLedgerService ->whereNull('sl.reversal_of_id') ->selectRaw("'share' as entry_kind, sl.id as entry_id, sl.settled_at as sort_at"); - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); $this->applyLedgerPlayerFilters($query, 'p', $filters); if ($range !== null) { @@ -276,7 +275,7 @@ final class SettlementCenterLedgerService ->where('p.funding_mode', PlayerFundingMode::CREDIT) ->selectRaw("'credit' as entry_kind, cl.id as entry_id, cl.created_at as sort_at"); - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); $this->applyLedgerPlayerFilters($query, 'p', $filters); if ($range !== null) { @@ -408,7 +407,7 @@ final class SettlementCenterLedgerService $outer->whereNull('p.id') ->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void { $scoped->where('p.site_code', $siteCode); - AdminDataScope::applyToPlayersAlias($scoped, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p'); $this->applyLedgerPlayerFilters($scoped, 'p', $filters); }); }); @@ -767,7 +766,7 @@ final class SettlementCenterLedgerService 'sla.name as share_agent_name', ]); - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); return $query->get()->all(); } @@ -840,7 +839,7 @@ final class SettlementCenterLedgerService $query->where('sb.settlement_period_id', $periodId); } - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); $map = []; foreach ($query->limit(500)->get() as $bill) { @@ -907,7 +906,7 @@ final class SettlementCenterLedgerService ]) ->orderByDesc('cl.id'); - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); if ($playerId !== null && $playerId > 0) { $query->where('p.id', $playerId); @@ -969,7 +968,7 @@ final class SettlementCenterLedgerService ]) ->orderByDesc('cl.id'); - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); $this->applyLedgerPlayerFilters($query, 'p', $filters); if ($range !== null) { @@ -1023,7 +1022,7 @@ final class SettlementCenterLedgerService 'pa.name as parent_agent_name', ]); - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); return $query->get()->all(); } @@ -1082,7 +1081,7 @@ final class SettlementCenterLedgerService $query->where('p.id', $playerId); } - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); $siteIds = $admin->accessibleAdminSiteIds(); if ($siteIds !== null) { @@ -1212,7 +1211,7 @@ final class SettlementCenterLedgerService $query->where('p.id', $playerId); } - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); $siteIds = $admin->accessibleAdminSiteIds(); if ($siteIds !== null) { @@ -1272,7 +1271,7 @@ final class SettlementCenterLedgerService 'pa.name as parent_agent_name', ]); - AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); $this->applyLedgerSiteScope($query, $admin, 'sp'); return $query->get()->all(); diff --git a/app/Services/AgentSettlement/SettlementPartyEnrichment.php b/app/Services/AgentSettlement/SettlementPartyEnrichment.php index 01421e5..8d43304 100644 --- a/app/Services/AgentSettlement/SettlementPartyEnrichment.php +++ b/app/Services/AgentSettlement/SettlementPartyEnrichment.php @@ -100,6 +100,7 @@ final class SettlementPartyEnrichment return $map; } + /** 结算展示:仅代理名称;编号为内部标识,无名称时才回退 code。 */ public function formatAgent(?object $agent, int $fallbackId): string { if ($agent === null) { @@ -107,13 +108,13 @@ final class SettlementPartyEnrichment } $name = trim((string) ($agent->name ?? '')); - $code = trim((string) ($agent->code ?? '')); - - if ($name !== '' && $code !== '') { - return "{$name} ({$code})"; + if ($name !== '') { + return $name; } - return $name !== '' ? $name : ($code !== '' ? $code : "agent#{$fallbackId}"); + $code = trim((string) ($agent->code ?? '')); + + return $code !== '' ? $code : "agent#{$fallbackId}"; } public function formatPlayerUsername(?object $player): ?string @@ -150,4 +151,129 @@ final class SettlementPartyEnrichment return "{$type}#{$id}"; } + + /** + * @param iterable $rows + * @return list> + */ + public function enrichBillRows(iterable $rows): array + { + $items = collect($rows); + if ($items->isEmpty()) { + return []; + } + + $playerIds = []; + $agentIds = []; + foreach ($items as $row) { + if ((string) $row->owner_type === 'player') { + $playerIds[] = (int) $row->owner_id; + } elseif ((string) $row->owner_type === 'agent') { + $agentIds[] = (int) $row->owner_id; + } + if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) { + $agentIds[] = (int) $row->counterparty_id; + } + } + + $players = $playerIds !== [] + ? DB::table('players') + ->whereIn('id', array_unique($playerIds)) + ->select(['id', 'username', 'site_player_id', 'agent_node_id', 'funding_mode', 'auth_source']) + ->get() + ->keyBy('id') + : collect(); + + foreach ($players as $player) { + $aid = (int) ($player->agent_node_id ?? 0); + if ($aid > 0) { + $agentIds[] = $aid; + } + } + + $agents = $this->loadAgents($agentIds); + + $out = []; + foreach ($items as $row) { + $out[] = $this->enrichBillRowWithLookups($row, $players, $agents); + } + + return $out; + } + + /** @return array */ + public function enrichBillRow(object $row): array + { + return $this->enrichBillRows([$row])[0]; + } + + /** + * @param Collection $players + * @param Collection $agents + * @return array + */ + private function enrichBillRowWithLookups(object $row, Collection $players, Collection $agents): array + { + $item = (array) $row; + $ownerType = (string) $row->owner_type; + $counterType = (string) $row->counterparty_type; + $counterId = (int) $row->counterparty_id; + + $item['owner_label'] = $this->legacyOwnerLabel($ownerType, (int) $row->owner_id, $players, $agents); + $item['counterparty_label'] = $this->formatCounterpartyLabel($counterType, $counterId, $agents); + + $item['player_username'] = null; + $item['player_site_player_id'] = null; + $item['player_id_display'] = null; + $item['direct_agent_label'] = null; + $item['superior_agent_label'] = null; + $item['owner_party_label'] = null; + + if ($ownerType === 'player') { + $player = $players->get((int) $row->owner_id); + $item['player_username'] = $this->formatPlayerUsername($player); + $item['player_site_player_id'] = $this->formatPlayerSiteId($player); + $item['player_id_display'] = (int) $row->owner_id; + $item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null; + $item['owner_auth_source'] = $player !== null ? $player->auth_source : null; + + $directId = $counterType === 'agent' ? $counterId : (int) ($player->agent_node_id ?? 0); + $line = $this->agentLineLabels($directId > 0 ? $directId : null, $agents); + $item['direct_agent_label'] = $line['direct_agent_label']; + $item['superior_agent_label'] = $line['parent_agent_label']; + } elseif ($ownerType === 'agent') { + $ownerAgentId = (int) $row->owner_id; + $item['owner_party_label'] = $this->formatAgent($agents->get($ownerAgentId), $ownerAgentId); + $item['superior_agent_label'] = $counterType === 'platform' + ? 'platform' + : $this->formatCounterpartyLabel($counterType, $counterId, $agents); + } + + return $item; + } + + /** + * @param Collection $players + * @param Collection $agents + */ + private function legacyOwnerLabel( + string $type, + int $id, + Collection $players, + Collection $agents, + ): string { + if ($type === 'player') { + $player = $players->get($id); + + return $player !== null + ? (string) ($player->username ?: $player->site_player_id ?: "player#{$id}") + : "player#{$id}"; + } + + if ($type === 'agent') { + return $this->formatAgent($agents->get($id), $id); + } + + return "{$type}#{$id}"; + } } diff --git a/app/Services/AgentSettlement/SettlementPeriodOpenHintsService.php b/app/Services/AgentSettlement/SettlementPeriodOpenHintsService.php new file mode 100644 index 0000000..c9889db --- /dev/null +++ b/app/Services/AgentSettlement/SettlementPeriodOpenHintsService.php @@ -0,0 +1,199 @@ +, + * pending_activity_dates: list, + * unpaid_bill_dates: list + * } + */ + public function hints(int $adminSiteId): array + { + $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); + if ($siteCode === '') { + return $this->emptyHints(); + } + + $periodRows = DB::table('settlement_periods') + ->where('admin_site_id', $adminSiteId) + ->orderBy('period_start') + ->get(['period_start', 'period_end', 'status']); + + $occupiedPeriodDates = []; + foreach ($periodRows as $row) { + foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) { + $occupiedPeriodDates[$day] = true; + } + } + + $lastPeriod = DB::table('settlement_periods') + ->where('admin_site_id', $adminSiteId) + ->whereIn('status', ['closed', 'completed']) + ->orderByDesc('period_end') + ->first(); + + $pendingActivityDates = DB::table('share_ledger as sl') + ->join('players as p', 'p.id', '=', 'sl.player_id') + ->where('p.site_code', $siteCode) + ->whereNull('sl.settlement_period_id') + ->whereNull('sl.reversal_of_id') + ->selectRaw('DATE(sl.settled_at) as activity_day') + ->groupBy('activity_day') + ->orderBy('activity_day') + ->pluck('activity_day') + ->map(static fn ($day): string => (string) $day) + ->values() + ->all(); + + $unpaidPeriodRows = DB::table('settlement_periods as sp') + ->where('sp.admin_site_id', $adminSiteId) + ->whereIn('sp.status', ['closed', 'completed']) + ->whereExists(function ($query): void { + $query->selectRaw('1') + ->from('settlement_bills as sb') + ->whereColumn('sb.settlement_period_id', 'sp.id') + ->where('sb.unpaid_amount', '>', 0) + ->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']); + }) + ->orderBy('sp.period_start') + ->get(['sp.period_start', 'sp.period_end']); + + $unpaidBillDates = []; + foreach ($unpaidPeriodRows as $row) { + foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) { + $unpaidBillDates[$day] = true; + } + } + + $suggested = $this->suggestRange($lastPeriod, $pendingActivityDates, $occupiedPeriodDates); + + return [ + 'suggested_start' => $suggested['start'], + 'suggested_end' => $suggested['end'], + 'occupied_period_dates' => array_keys($occupiedPeriodDates), + 'pending_activity_dates' => $pendingActivityDates, + 'unpaid_bill_dates' => array_keys($unpaidBillDates), + ]; + } + + /** + * @param list $pendingActivityDates UTC `Y-m-d` + * @param array $occupiedPeriodDates + * @return array{start: string, end: string} + */ + private function suggestRange(?object $lastPeriod, array $pendingActivityDates, array $occupiedPeriodDates): array + { + $lastEndDay = $lastPeriod !== null + ? Carbon::parse((string) $lastPeriod->period_end)->utc()->startOfDay() + : null; + + $freePending = array_values(array_filter( + $pendingActivityDates, + static fn (string $day): bool => ! isset($occupiedPeriodDates[$day]), + )); + + if ($freePending !== []) { + $minDay = Carbon::parse($freePending[0])->utc()->startOfDay(); + $maxDay = Carbon::parse($freePending[array_key_last($freePending)])->utc()->startOfDay(); + $startDay = $lastEndDay !== null + ? ($lastEndDay->copy()->addDay()->lessThanOrEqualTo($minDay) ? $lastEndDay->copy()->addDay() : $minDay) + : $minDay; + + $candidate = [ + 'start' => $startDay->format('Y-m-d'), + 'end' => $maxDay->format('Y-m-d'), + ]; + + return $this->withoutOccupiedOverlap($candidate, $occupiedPeriodDates); + } + + if ($lastEndDay !== null) { + $startDay = $lastEndDay->copy()->addDay(); + $endDay = Carbon::now('UTC')->subDay()->startOfDay(); + if ($endDay->lessThan($startDay)) { + return ['start' => '', 'end' => '']; + } + + return $this->withoutOccupiedOverlap([ + 'start' => $startDay->format('Y-m-d'), + 'end' => $endDay->format('Y-m-d'), + ], $occupiedPeriodDates); + } + + return ['start' => '', 'end' => '']; + } + + /** + * @param array{start: string, end: string} $candidate + * @param array $occupiedPeriodDates + * @return array{start: string, end: string} + */ + private function withoutOccupiedOverlap(array $candidate, array $occupiedPeriodDates): array + { + if ($candidate['start'] === '' || $candidate['end'] === '') { + return ['start' => '', 'end' => '']; + } + + if ($this->rangeOverlapsOccupied($candidate['start'], $candidate['end'], $occupiedPeriodDates)) { + return ['start' => '', 'end' => '']; + } + + return $candidate; + } + + /** + * @param array $occupiedPeriodDates + */ + private function rangeOverlapsOccupied(string $startYmd, string $endYmd, array $occupiedPeriodDates): bool + { + $cursor = Carbon::parse($startYmd)->utc()->startOfDay(); + $end = Carbon::parse($endYmd)->utc()->startOfDay(); + + while ($cursor->lessThanOrEqualTo($end)) { + if (isset($occupiedPeriodDates[$cursor->format('Y-m-d')])) { + return true; + } + $cursor->addDay(); + } + + return false; + } + + /** @return list 站点本地日历 `Y-m-d`(东八区,与后台开账日期选择一致) */ + private function expandPeriodToUtcDays(string $periodStart, string $periodEnd): array + { + $dates = []; + $tz = 'Asia/Shanghai'; + $cursor = Carbon::parse($periodStart)->timezone($tz)->startOfDay(); + $end = Carbon::parse($periodEnd)->timezone($tz)->startOfDay(); + + while ($cursor->lessThanOrEqualTo($end)) { + $dates[] = $cursor->format('Y-m-d'); + $cursor->addDay(); + } + + return $dates; + } + + /** @return array{suggested_start: string, suggested_end: string, occupied_period_dates: list, pending_activity_dates: list, unpaid_bill_dates: list} */ + private function emptyHints(): array + { + return [ + 'suggested_start' => '', + 'suggested_end' => '', + 'occupied_period_dates' => [], + 'pending_activity_dates' => [], + 'unpaid_bill_dates' => [], + ]; + } +} diff --git a/app/Services/AgentSettlement/ShareLedgerScopedProfitAggregator.php b/app/Services/AgentSettlement/ShareLedgerScopedProfitAggregator.php index 20e6cbe..461ab61 100644 --- a/app/Services/AgentSettlement/ShareLedgerScopedProfitAggregator.php +++ b/app/Services/AgentSettlement/ShareLedgerScopedProfitAggregator.php @@ -4,7 +4,9 @@ namespace App\Services\AgentSettlement; use App\Models\AdminUser; use App\Support\AdminAgentScope; +use App\Support\AdminDataScope; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Facades\DB; /** 按登录视角(平台 / 绑定代理)汇总占成流水 allocations 中的本级输赢。 */ final class ShareLedgerScopedProfitAggregator @@ -54,6 +56,56 @@ final class ShareLedgerScopedProfitAggregator return (int) ((clone $shareQuery)->sum('sl.game_win_loss') ?? 0); } + /** 绑定代理账号:区间内本级占成收益合计(minor)。 */ + public function sumShareProfitForAdmin(AdminUser $admin, string $dateFrom, string $dateTo): int + { + $viewer = $this->resolveViewer($admin); + if ($viewer['scope'] !== 'agent') { + return 0; + } + + return $this->sumForShareQuery( + $this->shareLedgerBaseQuery($admin, $dateFrom, $dateTo), + $viewer['key'], + ); + } + + /** + * @return array business_date (Y-m-d) => share_profit_minor + */ + public function shareProfitByBusinessDate(AdminUser $admin, string $dateFrom, string $dateTo): array + { + $viewer = $this->resolveViewer($admin); + if ($viewer['scope'] !== 'agent') { + return []; + } + + $rows = $this->shareLedgerBaseQuery($admin, $dateFrom, $dateTo) + ->selectRaw('DATE(sl.settled_at) as business_date') + ->addSelect(['sl.allocations_json', 'sl.game_win_loss', 'sl.basic_rebate', 'sl.share_snapshot']) + ->get(); + + $byDate = []; + foreach ($rows as $row) { + $date = (string) $row->business_date; + $byDate[$date] = ($byDate[$date] ?? 0) + $this->profitFromRow($row, $viewer['key']); + } + + return $byDate; + } + + private function shareLedgerBaseQuery(AdminUser $admin, string $dateFrom, string $dateTo): Builder + { + $query = DB::table('share_ledger as sl') + ->join('players as p', 'p.id', '=', 'sl.player_id') + ->whereNull('sl.reversal_of_id') + ->whereDate('sl.settled_at', '>=', $dateFrom) + ->whereDate('sl.settled_at', '<=', $dateTo); + AdminDataScope::applyToPlayersAlias($query, $admin, 'p'); + + return $query; + } + private function profitFromRow(object $row, string $profitKey): int { $allocations = $this->decodeJsonObject($row->allocations_json ?? null); diff --git a/app/Support/AdminAgentSettlementScope.php b/app/Support/AdminAgentSettlementScope.php index c8465eb..5b91423 100644 --- a/app/Support/AdminAgentSettlementScope.php +++ b/app/Support/AdminAgentSettlementScope.php @@ -6,7 +6,7 @@ use App\Models\AdminUser; use App\Models\AgentNode; use Illuminate\Database\Query\Builder; -/** 代理账单按管理员可访问站点 + 代理子树过滤。 */ +/** 结算中心账单:站点范围 + 绑定代理仅见直属边(玩家↔直属代理、代理↔直接上下级)。 */ final class AdminAgentSettlementScope { /** @@ -32,6 +32,17 @@ final class AdminAgentSettlementScope return $ids; } + public static function boundAgentNodeId(AdminUser $admin): ?int + { + if ($admin->isSuperAdmin()) { + return null; + } + + $actor = AdminAgentScope::primaryAgentNode($admin); + + return $actor !== null ? (int) $actor->id : null; + } + public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void { $siteIds = $admin->accessibleAdminSiteIds(); @@ -52,7 +63,7 @@ final class AdminAgentSettlementScope { $siteIds = $admin->accessibleAdminSiteIds(); if ($siteIds === null) { - self::applySubtreeToBillsQuery($query, $admin, $billsAlias); + self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias); return; } @@ -70,35 +81,72 @@ final class AdminAgentSettlementScope ->whereIn('settlement_periods.admin_site_id', $siteIds); }); - self::applySubtreeToBillsQuery($query, $admin, $billsAlias); + self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias); } - /** 绑定代理仅见本子树玩家账单 + owner 为本子树节点的代理账单。 */ + /** @deprecated 使用 {@see applyDirectEdgeScopeToBillsQuery} */ public static function applySubtreeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void { - $subtreeIds = self::subtreeAgentNodeIds($admin); - if ($subtreeIds === null) { + self::applyDirectEdgeScopeToBillsQuery($query, $admin, $billsAlias); + } + + /** + * 绑定代理: + * - 玩家账单:仅直属玩家(players.agent_node_id = 本节点) + * - 代理账单:owner=本节点(向上)或 counterparty=本节点(下级向我结) + */ + /** + * 结算中心玩家维度:绑定代理仅见直属玩家;站点财务/超管见全站(由调用方再限 site_code)。 + */ + public static function applyDirectPlayersToAlias(Builder $query, AdminUser $admin, string $alias = 'p'): void + { + if ($admin->isSuperAdmin() || self::canManageSitePeriods($admin)) { return; } - if ($subtreeIds === []) { + $actorId = self::boundAgentNodeId($admin); + if ($actorId === null) { $query->whereRaw('0 = 1'); return; } - $query->where(function (Builder $outer) use ($billsAlias, $subtreeIds): void { - $outer->where(function (Builder $player) use ($billsAlias, $subtreeIds): void { + if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) { + return; + } + + $query->where($alias.'.agent_node_id', $actorId); + } + + public static function applyDirectEdgeScopeToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void + { + $actorId = self::boundAgentNodeId($admin); + if ($actorId === null) { + return; + } + + $query->where(function (Builder $outer) use ($billsAlias, $actorId): void { + $outer->where(function (Builder $player) use ($billsAlias, $actorId): void { $player->where($billsAlias.'.owner_type', 'player') - ->whereExists(function (Builder $exists) use ($billsAlias, $subtreeIds): void { + ->whereExists(function (Builder $exists) use ($billsAlias, $actorId): void { $exists->selectRaw('1') ->from('players') ->whereColumn('players.id', $billsAlias.'.owner_id') - ->whereIn('players.agent_node_id', $subtreeIds); + ->where('players.agent_node_id', $actorId); }); - })->orWhere(function (Builder $agent) use ($billsAlias, $subtreeIds): void { + })->orWhere(function (Builder $agent) use ($billsAlias, $actorId): void { $agent->where($billsAlias.'.owner_type', 'agent') - ->whereIn($billsAlias.'.owner_id', $subtreeIds); + ->where(function (Builder $edge) use ($billsAlias, $actorId): void { + $edge->where($billsAlias.'.owner_id', $actorId) + ->orWhere(function (Builder $incoming) use ($billsAlias, $actorId): void { + $incoming->where($billsAlias.'.counterparty_type', 'agent') + ->where($billsAlias.'.counterparty_id', $actorId); + }) + ->orWhere(function (Builder $platform) use ($billsAlias, $actorId): void { + $platform->where($billsAlias.'.counterparty_type', 'platform') + ->where($billsAlias.'.owner_id', $actorId); + }); + }); }); }); } @@ -147,6 +195,19 @@ final class AdminAgentSettlementScope } } + /** 坏账核销 / 补差冲正仅站点财务或超管(绑定代理不可操作)。 */ + public static function canPerformFinanceAdjustments(AdminUser $admin): bool + { + return self::canManageSitePeriods($admin); + } + + public static function assertCanPerformFinanceAdjustments(AdminUser $admin): void + { + if (! self::canPerformFinanceAdjustments($admin)) { + abort(403, 'agent_bound_cannot_finance_adjust'); + } + } + public static function billAccessible(AdminUser $admin, int $settlementBillId): bool { $siteIds = $admin->accessibleAdminSiteIds(); @@ -157,7 +218,13 @@ final class AdminAgentSettlementScope $bill = \Illuminate\Support\Facades\DB::table('settlement_bills as sb') ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') ->where('sb.id', $settlementBillId) - ->select(['sb.owner_type', 'sb.owner_id', 'sp.admin_site_id']) + ->select([ + 'sb.owner_type', + 'sb.owner_id', + 'sb.counterparty_type', + 'sb.counterparty_id', + 'sp.admin_site_id', + ]) ->first(); if ($bill === null) { @@ -168,23 +235,127 @@ final class AdminAgentSettlementScope return false; } - $subtreeIds = self::subtreeAgentNodeIds($admin); - if ($subtreeIds === null) { + $actorId = self::boundAgentNodeId($admin); + if ($actorId === null) { return true; } + return self::billMatchesDirectEdgeScope($actorId, $bill); + } + + public static function canOperateBill(AdminUser $admin, int $settlementBillId): bool + { + if (! self::billAccessible($admin, $settlementBillId)) { + return false; + } + + if (self::canManageSitePeriods($admin)) { + return true; + } + + $bill = \Illuminate\Support\Facades\DB::table('settlement_bills') + ->where('id', $settlementBillId) + ->select(['owner_type', 'owner_id', 'counterparty_type', 'counterparty_id', 'net_amount']) + ->first(); + + if ($bill === null) { + return false; + } + + $actorId = self::boundAgentNodeId($admin); + if ($actorId === null) { + return true; + } + + return self::billOperableByBoundAgent($actorId, $bill); + } + + public static function assertCanOperateBill(AdminUser $admin, int $settlementBillId): void + { + abort_if(! self::billAccessible($admin, $settlementBillId), 404); + + if (self::canManageSitePeriods($admin)) { + return; + } + + $bill = \Illuminate\Support\Facades\DB::table('settlement_bills') + ->where('id', $settlementBillId) + ->select(['owner_type', 'owner_id', 'counterparty_type', 'counterparty_id', 'net_amount']) + ->first(); + + abort_if($bill === null, 404); + + $actorId = self::boundAgentNodeId($admin); + abort_if($actorId === null, 403, 'agent_cannot_operate_bill'); + + abort_if( + ! self::billOperableByBoundAgent($actorId, $bill), + 403, + (string) $bill->owner_type === 'player' ? 'agent_cannot_operate_player_bill' : 'agent_cannot_operate_bill', + ); + } + + private static function billMatchesDirectEdgeScope(int $actorId, object $bill): bool + { if ((string) $bill->owner_type === 'player') { $agentNodeId = (int) (\Illuminate\Support\Facades\DB::table('players') ->where('id', (int) $bill->owner_id) ->value('agent_node_id') ?? 0); - return $agentNodeId > 0 && in_array($agentNodeId, $subtreeIds, true); + return $agentNodeId === $actorId; } if ((string) $bill->owner_type === 'agent') { - return in_array((int) $bill->owner_id, $subtreeIds, true); + return self::agentBillOnDirectEdge($actorId, $bill); } return false; } + + private static function billOperableByBoundAgent(int $actorId, object $bill): bool + { + if ((string) $bill->owner_type === 'player') { + return (string) $bill->counterparty_type === 'agent' + && (int) $bill->counterparty_id === $actorId; + } + + if ((string) $bill->owner_type === 'agent') { + if (! self::agentBillOnDirectEdge($actorId, $bill)) { + return false; + } + + [$payeeType, $payeeId] = self::billPayeeParty($bill); + + return $payeeType === 'agent' && $payeeId === $actorId; + } + + return false; + } + + /** net>0:counterparty 为收款方;net<0:owner 为收款方。 */ + private static function billPayeeParty(object $bill): array + { + if ((int) $bill->net_amount < 0) { + return [(string) $bill->owner_type, (int) $bill->owner_id]; + } + + return [(string) $bill->counterparty_type, (int) $bill->counterparty_id]; + } + + private static function agentBillOnDirectEdge(int $actorId, object $bill): bool + { + $ownerId = (int) $bill->owner_id; + $counterType = (string) $bill->counterparty_type; + $counterId = (int) $bill->counterparty_id; + + if ($ownerId === $actorId) { + return true; + } + + if ($counterType === 'agent' && $counterId === $actorId) { + return true; + } + + return $counterType === 'platform' && $ownerId === $actorId; + } } diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php index 8026b1b..d0a11db 100644 --- a/app/Support/AdminAuthProfile.php +++ b/app/Support/AdminAuthProfile.php @@ -29,6 +29,7 @@ final class AdminAuthProfile * agent: ?array{ * id: int, * admin_site_id: int, + * admin_site_name: string, * site_code: string, * path: string, * code: string, @@ -73,6 +74,7 @@ final class AdminAuthProfile * @return array{ * id: int, * admin_site_id: int, + * admin_site_name: string, * site_code: string, * path: string, * code: string, @@ -93,13 +95,18 @@ final class AdminAuthProfile return null; } - $siteCode = AdminSite::query()->where('id', (int) $node->admin_site_id)->value('code'); + $site = AdminSite::query() + ->where('id', (int) $node->admin_site_id) + ->first(['code', 'name']); + $siteCode = is_string($site?->code) ? $site->code : ''; + $siteName = is_string($site?->name) ? $site->name : ''; $profile = AgentProfile::query()->where('agent_node_id', $node->id)->first(); return [ 'id' => (int) $node->id, 'admin_site_id' => (int) $node->admin_site_id, - 'site_code' => is_string($siteCode) && $siteCode !== '' ? $siteCode : '', + 'admin_site_name' => $siteName, + 'site_code' => $siteCode !== '' ? $siteCode : '', 'path' => (string) $node->path, 'code' => (string) $node->code, 'name' => (string) $node->name, diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 5916f96..f44ef0e 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -447,6 +447,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage', 'agent.node.view'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage', 'prd.agent.view']], ['code' => 'admin.agent-nodes.profile.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.open-hints', 'module_code' => 'settlement', 'name' => '开账建议与日历标记', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods/open-hints', 'route_name' => 'api.v1.admin.settlement-periods.open-hints', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], ['code' => 'admin.settlement-periods.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']], diff --git a/app/Support/AgentDefaultRolePermissions.php b/app/Support/AgentDefaultRolePermissions.php index 6bfd706..f2a4aac 100644 --- a/app/Support/AgentDefaultRolePermissions.php +++ b/app/Support/AgentDefaultRolePermissions.php @@ -21,7 +21,6 @@ final class AgentDefaultRolePermissions 'prd.agent.role.view', 'prd.agent.user.view', 'prd.tickets.view', - 'prd.report.view', 'prd.settlement.agent.view', ]; diff --git a/app/Support/AgentProfileCapabilityFilter.php b/app/Support/AgentProfileCapabilityFilter.php index f1cc1d8..3e9aae3 100644 --- a/app/Support/AgentProfileCapabilityFilter.php +++ b/app/Support/AgentProfileCapabilityFilter.php @@ -2,6 +2,7 @@ namespace App\Support; +use App\Models\AgentNode; use App\Models\AgentProfile; /** @@ -36,13 +37,25 @@ final class AgentProfileCapabilityFilter 'prd.player_freeze.manage', ]; + private const SETTLEMENT_AGENT_MANAGE_CODE = 'settlement.agent.manage'; + + /** 绑定代理主账号均可登记/确认与本节点直属边相关的账单(玩家↔直属代理、代理↔直接上下级)。 */ + public static function qualifiesForSettlementAgentManage(?AgentNode $node): bool + { + return $node !== null; + } + /** * 按 Profile 能力收紧或补足登录态 permission_code(平台 agent 角色模板未必含 manage)。 * * @param list $permissionCodes * @return list */ - public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array + public static function applyToMenuActionCodes( + array $permissionCodes, + ?AgentProfile $profile, + ?AgentNode $node = null, + ): array { $set = []; foreach ($permissionCodes as $code) { @@ -71,6 +84,10 @@ final class AgentProfileCapabilityFilter } } + if (self::qualifiesForSettlementAgentManage($node)) { + $set[self::SETTLEMENT_AGENT_MANAGE_CODE] = true; + } + $out = array_keys($set); sort($out); @@ -81,9 +98,12 @@ final class AgentProfileCapabilityFilter * @param list $permissionCodes * @return list */ - public static function filterMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array - { - return self::applyToMenuActionCodes($permissionCodes, $profile); + public static function filterMenuActionCodes( + array $permissionCodes, + ?AgentProfile $profile, + ?AgentNode $node = null, + ): array { + return self::applyToMenuActionCodes($permissionCodes, $profile, $node); } /** diff --git a/app/Support/AgentSettlementPeriodWindow.php b/app/Support/AgentSettlementPeriodWindow.php index ef2afd5..e3bc4fb 100644 --- a/app/Support/AgentSettlementPeriodWindow.php +++ b/app/Support/AgentSettlementPeriodWindow.php @@ -3,8 +3,9 @@ namespace App\Support; use Carbon\Carbon; +use Illuminate\Validation\ValidationException; -/** 账期起止统一为日界(startOfDay / endOfDay),供聚合、流水、关账回填共用。 */ +/** 账期起止边界:开账时规范化写入,关账/聚合/流水筛选共用同一对 UTC 时刻。 */ final class AgentSettlementPeriodWindow { /** @@ -13,8 +14,8 @@ final class AgentSettlementPeriodWindow public static function bounds(string $periodStart, string $periodEnd): array { return [ - Carbon::parse($periodStart)->startOfDay(), - Carbon::parse($periodEnd)->endOfDay(), + Carbon::parse($periodStart)->utc(), + Carbon::parse($periodEnd)->utc(), ]; } @@ -27,4 +28,42 @@ final class AgentSettlementPeriodWindow return [$start->toDateTimeString(), $end->toDateTimeString()]; } + + /** + * 开账 API:支持 `Y-m-d` 或带时刻字符串;前者按 UTC 自然日扩界,后者按 UTC 解释。 + * + * @return array{0: string, 1: string} + */ + public static function normalizeInputBounds(string $periodStart, string $periodEnd): array + { + $startRaw = trim($periodStart); + $endRaw = trim($periodEnd); + + if ($startRaw === '' || $endRaw === '') { + throw ValidationException::withMessages([ + 'period_start' => ['required'], + ]); + } + + $startAt = self::isDateOnly($startRaw) + ? Carbon::parse($startRaw.' 00:00:00', 'UTC') + : Carbon::parse($startRaw)->utc(); + + $endAt = self::isDateOnly($endRaw) + ? Carbon::parse($endRaw.' 23:59:59', 'UTC') + : Carbon::parse($endRaw)->utc(); + + if ($endAt->lessThan($startAt)) { + throw ValidationException::withMessages([ + 'period_end' => ['after:period_start'], + ]); + } + + return [$startAt->toDateTimeString(), $endAt->toDateTimeString()]; + } + + private static function isDateOnly(string $value): bool + { + return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value); + } } diff --git a/database/migrations/2026_06_12_120000_seed_settlement_period_open_hints_api_resource.php b/database/migrations/2026_06_12_120000_seed_settlement_period_open_hints_api_resource.php new file mode 100644 index 0000000..511c318 --- /dev/null +++ b/database/migrations/2026_06_12_120000_seed_settlement_period_open_hints_api_resource.php @@ -0,0 +1,86 @@ +pluck('id', 'permission_code'); + $resource = collect(AdminAuthorizationRegistry::resources()) + ->firstWhere('code', self::RESOURCE_CODE); + + if (! is_array($resource)) { + return; + } + + $resourceId = DB::table('admin_api_resources') + ->where('code', $resource['code']) + ->value('id'); + + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => $resource['code'], + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->update($payload); + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + foreach ($resource['permission_codes'] as $permissionCode) { + $menuActionId = $menuActionIds[$permissionCode] ?? null; + if ($menuActionId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $menuActionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + $resourceId = DB::table('admin_api_resources') + ->where('code', self::RESOURCE_CODE) + ->value('id'); + + if ($resourceId === null) { + return; + } + + DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete(); + DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete(); + } +}; diff --git a/database/migrations/2026_06_12_130000_remove_report_view_from_agent_role.php b/database/migrations/2026_06_12_130000_remove_report_view_from_agent_role.php new file mode 100644 index 0000000..a9ecd82 --- /dev/null +++ b/database/migrations/2026_06_12_130000_remove_report_view_from_agent_role.php @@ -0,0 +1,32 @@ +each(static function (AgentNode $node): void { + $ownerRole = AdminRole::query() + ->where('owner_agent_id', $node->id) + ->where('slug', 'agent_owner_'.$node->id) + ->first(); + + if ($ownerRole === null) { + return; + } + + $ownerRole->syncLegacyPermissionSlugs(AgentDefaultRolePermissions::ownerSlugsForNode($node)); + }); + } + + public function down(): void + { + // 产品策略调整,回滚不恢复报表中心权限。 + } +}; diff --git a/database/seeders/AgentSettlementAdjustmentDemoSeeder.php b/database/seeders/AgentSettlementAdjustmentDemoSeeder.php new file mode 100644 index 0000000..28f852b --- /dev/null +++ b/database/seeders/AgentSettlementAdjustmentDemoSeeder.php @@ -0,0 +1,145 @@ +environment('production') && ! config('app.debug')) { + $this->command?->warn('跳过:production 且 APP_DEBUG=false 时不写入演示调账数据。'); + + return; + } + + $adminUserId = (int) (DB::table('admin_users')->where('username', 'admin')->value('id') ?? 0); + /** @var AgentSettlementBillAdjustmentService $adjustments */ + $adjustments = app(AgentSettlementBillAdjustmentService::class); + + $scenarios = [ + [ + 'original_bill_id' => 35, + 'amount' => 12000, + 'adjustment_type' => 'adjustment', + 'reason' => self::DEMO_REASON_PREFIX.' 代理 good 回水漏计,人工补差', + 'confirm' => false, + ], + [ + 'original_bill_id' => 34, + 'amount' => -5600, + 'adjustment_type' => 'reversal', + 'reason' => self::DEMO_REASON_PREFIX.' 代理 321321 占成分摊复核有误,冲正', + 'confirm' => true, + ], + [ + 'original_bill_id' => 31, + 'amount' => 800, + 'adjustment_type' => 'adjustment', + 'reason' => self::DEMO_REASON_PREFIX.' 代理 good 平台四舍五入补差', + 'confirm' => false, + ], + ]; + + DB::transaction(function () use ($scenarios, $adjustments, $adminUserId): void { + foreach ($scenarios as $scenario) { + $this->seedScenario($adjustments, $adminUserId, $scenario); + } + }); + + $this->command?->info('代理结算补差/冲正演示数据已写入(前缀 '.self::DEMO_REASON_PREFIX.')。'); + } + + /** + * @param array{original_bill_id:int,amount:int,adjustment_type:string,reason:string,confirm:bool} $scenario + */ + private function seedScenario( + AgentSettlementBillAdjustmentService $adjustments, + int $adminUserId, + array $scenario, + ): void { + $originalBillId = (int) $scenario['original_bill_id']; + $reason = (string) $scenario['reason']; + + if ($this->demoAdjustmentExists($originalBillId, $reason)) { + $this->command?->line("跳过 bill #{$originalBillId}:已存在相同演示记录。"); + + return; + } + + $original = DB::table('settlement_bills')->where('id', $originalBillId)->first(); + if ($original === null) { + $this->command?->warn("跳过 bill #{$originalBillId}:原账单不存在。"); + + return; + } + + if ((string) $original->bill_type !== 'agent') { + $this->command?->warn("跳过 bill #{$originalBillId}:非代理账单。"); + + return; + } + + if ($original->locked_at === null) { + DB::table('settlement_bills')->where('id', $originalBillId)->update([ + 'locked_at' => now(), + 'updated_at' => now(), + ]); + } + + if ((string) $original->status === 'pending_confirm') { + DB::table('settlement_bills')->where('id', $originalBillId)->update([ + 'status' => 'confirmed', + 'confirmed_at' => now(), + 'locked_at' => now(), + 'updated_at' => now(), + ]); + } + + $newBillId = $adjustments->createAdjustment( + $originalBillId, + (int) $scenario['amount'], + (string) $scenario['adjustment_type'], + $reason, + $adminUserId, + ); + + if ($scenario['confirm']) { + $now = now(); + DB::table('settlement_bills')->where('id', $newBillId)->update([ + 'status' => 'confirmed', + 'confirmed_at' => $now, + 'locked_at' => $now, + 'updated_at' => $now, + ]); + } + + $this->command?->line(sprintf( + '已创建 %s bill #%d ← 原代理账单 #%d(%s)', + (string) $scenario['adjustment_type'], + $newBillId, + $originalBillId, + $reason, + )); + } + + private function demoAdjustmentExists(int $originalBillId, string $reason): bool + { + return DB::table('settlement_adjustments') + ->where('original_bill_id', $originalBillId) + ->where('reason', $reason) + ->exists(); + } +} diff --git a/lang/en/validation_business.php b/lang/en/validation_business.php index 7a87f02..88d4e1c 100644 --- a/lang/en/validation_business.php +++ b/lang/en/validation_business.php @@ -27,6 +27,7 @@ return [ 'parent_overdue' => 'The parent agent has overdue bills. This operation is not allowed.', 'period_already_open' => 'A period with this date range is already open. Close it instead of opening again.', 'period_site_has_open' => 'This site already has an open period. Close it before opening a new one.', + 'period_overlaps_existing' => 'This period overlaps an existing one. Adjust the start and end dates.', 'period_not_found' => 'Settlement period not found or not accessible.', 'period_already_closed' => 'This period is already closed.', 'share_snapshot_missing' => 'Some ledger rows are missing share snapshots. Complete draw settlement first.', diff --git a/lang/zh/validation_business.php b/lang/zh/validation_business.php index d16905f..031bfe9 100644 --- a/lang/zh/validation_business.php +++ b/lang/zh/validation_business.php @@ -31,6 +31,7 @@ return [ 'parent_overdue' => '上级代理存在逾期未结账单,禁止此操作。', 'period_already_open' => '该时间范围的账期已在进行中,请直接关账,勿重复开期。', 'period_site_has_open' => '本站已有进行中账期,请先关账后再开新账期。', + 'period_overlaps_existing' => '账期时间与已有账期重叠,请调整起止日期。', 'period_not_found' => '账期不存在或无权访问。', 'period_already_closed' => '该账期已关账,请勿重复操作。', 'share_snapshot_missing' => '账期内存在缺少占成快照的流水,无法关账。请先完成开奖结算或联系技术支持。', diff --git a/routes/api/v1/admin/agent-settlement.php b/routes/api/v1/admin/agent-settlement.php index 15b3510..19370f7 100644 --- a/routes/api/v1/admin/agent-settlement.php +++ b/routes/api/v1/admin/agent-settlement.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillIndexCo use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillPaymentController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillShowController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodCloseController; +use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodOpenHintsController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodIndexController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportIndexController; @@ -18,6 +19,8 @@ use Illuminate\Support\Facades\Route; Route::middleware('admin.api-resource') ->group(function (): void { + Route::get('settlement-periods/open-hints', AgentSettlementPeriodOpenHintsController::class) + ->name('api.v1.admin.settlement-periods.open-hints'); Route::get('settlement-periods', AgentSettlementPeriodIndexController::class) ->name('api.v1.admin.settlement-periods.index'); Route::post('settlement-periods', AgentSettlementPeriodStoreController::class) diff --git a/scripts/dev-cleanup-settlement-periods.php b/scripts/dev-cleanup-settlement-periods.php new file mode 100644 index 0000000..21e1fb6 --- /dev/null +++ b/scripts/dev-cleanup-settlement-periods.php @@ -0,0 +1,165 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +$siteId = 1; +$keepPeriodIds = [5, 8, 9]; +$deletePeriodIds = [1, 2, 3, 4, 6, 7]; + +DB::transaction(function () use ($siteId, $keepPeriodIds, $deletePeriodIds): void { + $deleteBillIds = DB::table('settlement_bills') + ->whereIn('settlement_period_id', $deletePeriodIds) + ->pluck('id') + ->map(static fn ($id): int => (int) $id) + ->all(); + + if ($deleteBillIds !== []) { + DB::table('payment_records')->whereIn('settlement_bill_id', $deleteBillIds)->delete(); + DB::table('settlement_adjustments') + ->where(function ($query) use ($deletePeriodIds, $deleteBillIds): void { + $query->whereIn('settlement_period_id', $deletePeriodIds) + ->orWhereIn('original_bill_id', $deleteBillIds); + }) + ->delete(); + DB::table('settlement_bills')->whereIn('id', $deleteBillIds)->delete(); + } + + DB::table('settlement_periods')->whereIn('id', $deletePeriodIds)->delete(); + + // 规范化保留账期(UTC 时刻 = 东八区本地自然日边界,与开账 API 一致) + DB::table('settlement_periods')->where('id', 5)->update([ + 'admin_site_id' => $siteId, + 'period_start' => '2026-05-24 16:00:00', + 'period_end' => '2026-05-31 15:59:59', + 'status' => 'closed', + 'updated_at' => now(), + ]); + + DB::table('settlement_periods')->where('id', 9)->update([ + 'admin_site_id' => $siteId, + 'period_start' => '2026-05-31 16:00:00', + 'period_end' => '2026-06-07 15:59:59', + 'status' => 'completed', + 'updated_at' => now(), + ]); + + DB::table('settlement_periods')->where('id', 8)->update([ + 'admin_site_id' => $siteId, + 'period_start' => '2026-06-07 16:00:00', + 'period_end' => '2026-06-14 15:59:59', + 'status' => 'closed', + 'updated_at' => now(), + ]); + + // 6/8–6/14 流水归属第二周账期 + DB::table('share_ledger') + ->whereIn('id', [910, 911, 912, 913, 914]) + ->update([ + 'settlement_period_id' => 8, + 'updated_at' => now(), + ]); + + // 待入账流水改到 6/15、6/17,供下一期开账演示 + DB::table('share_ledger')->where('id', 915)->update([ + 'settlement_period_id' => null, + 'settled_at' => '2026-06-15 10:00:00', + 'updated_at' => now(), + ]); + DB::table('share_ledger')->where('id', 916)->update([ + 'settlement_period_id' => null, + 'settled_at' => '2026-06-17 14:30:00', + 'updated_at' => now(), + ]); + + // 第二周账期账单(待收付,用于日历「未结清」标记) + DB::table('settlement_bills')->where('settlement_period_id', 8)->delete(); + + $now = now(); + DB::table('settlement_bills')->insert([ + [ + 'settlement_period_id' => 8, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => 5, + 'counterparty_type' => 'agent', + 'counterparty_id' => 4, + 'gross_win_loss' => 8000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'platform_rounding_adjustment' => 0, + 'net_amount' => 8000, + 'paid_amount' => 0, + 'unpaid_amount' => 8000, + 'status' => 'confirmed', + 'confirmed_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'settlement_period_id' => 8, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => 4, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'gross_win_loss' => 8000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'platform_rounding_adjustment' => 0, + 'net_amount' => 6400, + 'paid_amount' => 0, + 'unpaid_amount' => 6400, + 'status' => 'confirmed', + 'confirmed_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'settlement_period_id' => 8, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => 1, + 'counterparty_type' => 'platform', + 'counterparty_id' => 0, + 'gross_win_loss' => 8000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'platform_rounding_adjustment' => 0, + 'net_amount' => 1600, + 'paid_amount' => 0, + 'unpaid_amount' => 1600, + 'status' => 'pending_confirm', + 'confirmed_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); +}); + +$rows = DB::select( + 'SELECT id, period_start::date AS ps, period_end::date AS pe, status FROM settlement_periods WHERE admin_site_id = ? ORDER BY period_start', + [$siteId], +); + +echo "Settlement periods after cleanup:\n"; +foreach ($rows as $row) { + echo sprintf(" #%d %s ~ %s [%s]\n", $row->id, $row->ps, $row->pe, $row->status); +} + +$unassigned = (int) DB::table('share_ledger as sl') + ->join('players as p', 'p.id', '=', 'sl.player_id') + ->where('p.site_code', 'default_site') + ->whereNull('sl.settlement_period_id') + ->whereNull('sl.reversal_of_id') + ->count(); + +echo "\nUnassigned share_ledger (default_site): {$unassigned}\n"; diff --git a/tests/Feature/AdminAgentDashboardOverviewTest.php b/tests/Feature/AdminAgentDashboardOverviewTest.php index e7325bc..75d2021 100644 --- a/tests/Feature/AdminAgentDashboardOverviewTest.php +++ b/tests/Feature/AdminAgentDashboardOverviewTest.php @@ -2,7 +2,9 @@ use App\Models\AdminUser; use App\Models\AgentNode; +use App\Models\Player; use App\Support\AgentPlatformRole; +use App\Support\PlayerFundingMode; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -65,3 +67,160 @@ test('agent dashboard returns agent overview for operator with dashboard permiss ->assertJsonPath('data.agent_overview.agent_node_id', $branch->id) ->assertJsonPath('data.agent_overview.agent_code', 'dash-branch'); }); + +test('agent dashboard profit uses share profit not team house gross', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'super_dash_share', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'dash-share-branch', + 'name' => 'Dash Share Branch', + 'can_create_player' => true, + ]); + + $operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first(); + expect($operator)->not->toBeNull(); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:dash-share-player', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'dash_share_player', + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $branch->id, + ]); + + $draw = \App\Models\Draw::query()->create([ + 'draw_no' => 'DRAW-DASH-SHARE', + 'business_date' => now()->toDateString(), + 'sequence_no' => random_int(1, 9999), + 'status' => \App\Lottery\DrawStatus::Open->value, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-DASH-SHARE', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 10_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 10_000, + 'total_estimated_payout' => 0, + 'status' => 'confirmed', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $ticketItemId = (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => 'T-DASH-SHARE', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => null, + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 2, + 'digit_slot' => null, + 'bet_mode' => null, + 'unit_bet_amount' => 10_000, + 'total_bet_amount' => 10_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 10_000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'settled_lose', + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $settledAt = now()->toDateTimeString(); + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $ticketItemId, + 'player_id' => $player->id, + 'agent_node_id' => $branch->id, + 'agent_path' => json_encode([$branch->id]), + 'share_snapshot' => json_encode([ + 'total_shares' => [(string) $branch->code => 30.0], + 'chain_codes' => [(string) $branch->code], + ]), + 'game_win_loss' => 1_000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 1_000, + 'allocations_json' => json_encode([ + (string) $branch->code => 300, + 'platform' => 700, + ]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard') + ->assertOk() + ->assertJsonPath('data.agent_overview.profit_scope', 'share_profit') + ->assertJsonPath('data.agent_overview.today_profit_minor', 300); +}); + +test('agent bound admin cannot open platform pnl settlement report', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'super_report_block', + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $branch = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'report-block-branch', + 'name' => 'Report Block Branch', + ]); + + $operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first(); + expect($operator)->not->toBeNull(); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subDay()->toDateString(), + 'period_end' => now()->addDay()->toDateString(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-reports?type=platform_pnl&settlement_period_id='.$periodId) + ->assertForbidden(); +}); diff --git a/tests/Feature/AdminAgentProfileCapabilityPermissionTest.php b/tests/Feature/AdminAgentProfileCapabilityPermissionTest.php index 298319f..a0f54cd 100644 --- a/tests/Feature/AdminAgentProfileCapabilityPermissionTest.php +++ b/tests/Feature/AdminAgentProfileCapabilityPermissionTest.php @@ -69,6 +69,8 @@ test('agent profile switches strip create player and child manage from effective expect($fresh->hasPermissionCode('agent.node.manage'))->toBeFalse(); expect($fresh->hasPermissionCode('service.players.manage'))->toBeFalse(); + expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue(); + expect($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage'); expect($profile['agent']['can_create_child_agent'])->toBeFalse(); expect($profile['agent']['can_create_player'])->toBeFalse(); }); @@ -123,5 +125,96 @@ test('agent profile switches on grant create capabilities even when platform age expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue(); expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue(); expect($fresh->adminPermissionSlugs())->toContain('prd.agent.manage') - ->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage'); + ->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage') + ->and($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage'); + expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue(); +}); + +test('line root bound agent receives settlement manage at login', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $admin = AdminUser::query()->create([ + 'username' => 'settle_root_agent', + 'name' => 'Settle Root Agent', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => $rootId, + 'is_primary' => true, + 'granted_at' => now(), + ]); + $admin->syncPrimaryPlatformAgentRole($rootId); + + $fresh = $admin->fresh(); + + expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue(); + expect($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage'); +}); + +test('agent with downline children receives settlement manage at login', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $parent = AgentNode::query()->create([ + 'admin_site_id' => $siteId, + 'parent_id' => $rootId, + 'path' => '/', + 'depth' => 1, + 'code' => 'settle-parent', + 'name' => 'Settle Parent', + 'status' => 1, + ]); + $parent->path = "/{$rootId}/{$parent->id}/"; + $parent->save(); + + $child = AgentNode::query()->create([ + 'admin_site_id' => $siteId, + 'parent_id' => $parent->id, + 'path' => "/{$rootId}/{$parent->id}/", + 'depth' => 2, + 'code' => 'settle-child', + 'name' => 'Settle Child', + 'status' => 1, + ]); + $child->path = "/{$rootId}/{$parent->id}/{$child->id}/"; + $child->save(); + + AgentProfile::query()->create([ + 'agent_node_id' => $parent->id, + 'total_share_rate' => 20, + 'credit_limit' => 0, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0, + 'default_player_rebate' => 0, + 'can_grant_extra_rebate' => false, + 'can_create_child_agent' => false, + 'can_create_player' => false, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'settle_parent_agent', + 'name' => 'Settle Parent Agent', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => $parent->id, + 'is_primary' => true, + 'granted_at' => now(), + ]); + $admin->syncPrimaryPlatformAgentRole($parent->id); + + $fresh = $admin->fresh(); + + expect($fresh->hasPermissionCode('settlement.agent.manage'))->toBeTrue(); + expect($fresh->adminPermissionSlugs())->toContain('prd.settlement.agent.manage'); }); diff --git a/tests/Feature/AdminAgentSettlementBillApiTest.php b/tests/Feature/AdminAgentSettlementBillApiTest.php index dbb8bf8..c03f683 100644 --- a/tests/Feature/AdminAgentSettlementBillApiTest.php +++ b/tests/Feature/AdminAgentSettlementBillApiTest.php @@ -31,3 +31,162 @@ test('settlement bills index api resource is configured after migrations', funct ->assertOk() ->assertJsonPath('data.items', fn ($items) => is_array($items)); }); + +test('settlement bill show returns enriched party labels', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $childId = (int) DB::table('agent_nodes')->insertGetId([ + 'admin_site_id' => $siteId, + 'parent_id' => $rootId, + 'code' => 'bill_show_child', + 'name' => 'Bill Show Child', + 'depth' => 1, + 'path' => '/'.$rootId.'/', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('agent_nodes')->where('id', $childId)->update([ + 'path' => '/'.$rootId.'/'.$childId.'/', + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeek(), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => $childId, + 'counterparty_type' => 'agent', + 'counterparty_id' => $rootId, + 'net_amount' => 6400, + 'unpaid_amount' => 6400, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'bill_show_super', + 'name' => 'Bill Show Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $rootName = (string) DB::table('agent_nodes')->where('id', $rootId)->value('name'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-bills/'.$billId) + ->assertOk() + ->assertJsonPath('data.bill.owner_party_label', 'Bill Show Child') + ->assertJsonPath('data.bill.superior_agent_label', $rootName); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-bills?bill_id='.$billId) + ->assertOk() + ->assertJsonPath('data.items.0.owner_party_label', 'Bill Show Child') + ->assertJsonPath('data.items.0.superior_agent_label', $rootName); +}); + +test('settlement bill show returns downline share breakdown for parent agent bill', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $parentId = (int) DB::table('agent_nodes')->insertGetId([ + 'admin_site_id' => $siteId, + 'parent_id' => $rootId, + 'code' => 'downline_parent', + 'name' => 'Downline Parent', + 'depth' => 1, + 'path' => '/'.$rootId.'/', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('agent_nodes')->where('id', $parentId)->update([ + 'path' => '/'.$rootId.'/'.$parentId.'/', + ]); + $childId = (int) DB::table('agent_nodes')->insertGetId([ + 'admin_site_id' => $siteId, + 'parent_id' => $parentId, + 'code' => 'downline_child', + 'name' => 'Downline Child', + 'depth' => 2, + 'path' => '/'.$rootId.'/'.$parentId.'/', + 'status' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('agent_nodes')->where('id', $childId)->update([ + 'path' => '/'.$rootId.'/'.$parentId.'/'.$childId.'/', + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeek(), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('settlement_bills')->insert([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => $childId, + 'counterparty_type' => 'agent', + 'counterparty_id' => $parentId, + 'net_amount' => 3916, + 'unpaid_amount' => 3916, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'meta_json' => json_encode(['share_profit' => 484]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $parentBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => $parentId, + 'counterparty_type' => 'agent', + 'counterparty_id' => $rootId, + 'gross_win_loss' => 4400, + 'net_amount' => 3520, + 'unpaid_amount' => 3520, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'meta_json' => json_encode(['share_profit' => 396]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'downline_share_super', + 'name' => 'Downline Share Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-bills/'.$parentBillId) + ->assertOk() + ->assertJsonPath('data.downline_shares.total', 484) + ->assertJsonPath('data.downline_shares.items.0.owner_label', 'Downline Child') + ->assertJsonPath('data.downline_shares.items.0.share_profit', 484); +}); diff --git a/tests/Feature/AgentSettlementBadDebtTest.php b/tests/Feature/AgentSettlementBadDebtTest.php index e493ea8..29e5662 100644 --- a/tests/Feature/AgentSettlementBadDebtTest.php +++ b/tests/Feature/AgentSettlementBadDebtTest.php @@ -88,3 +88,56 @@ test('admin can write off player bill bad debt and complete period when all sett 'status' => 'completed', ]); }); + +test('bound agent with settlement manage cannot write off bad debt', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subDays(7), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => $rootId, + 'counterparty_type' => 'platform', + 'counterparty_id' => 0, + 'net_amount' => 5000, + 'paid_amount' => 0, + 'unpaid_amount' => 5000, + 'status' => 'confirmed', + 'confirmed_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'bad_debt_bound_root', + 'name' => 'Bad Debt Bound Root', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => $rootId, + 'is_primary' => true, + 'granted_at' => now(), + ]); + $admin->syncPrimaryPlatformAgentRole($rootId); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/settlement-bills/'.$billId.'/bad-debt-write-off', [ + 'reason' => 'should fail', + ]) + ->assertForbidden(); +}); diff --git a/tests/Feature/AgentSettlementBillDirectEdgeScopeTest.php b/tests/Feature/AgentSettlementBillDirectEdgeScopeTest.php new file mode 100644 index 0000000..feaf62f --- /dev/null +++ b/tests/Feature/AgentSettlementBillDirectEdgeScopeTest.php @@ -0,0 +1,351 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); + $this->artisan('lottery:agent-roles-sync')->assertExitCode(0); +}); + +test('bound parent agent cannot see player bills under direct child agent', function (): void { + [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); + + $playerBillId = insertPlayerBill($periodId, $player->id, $child->id, 4400); + insertAgentBill($periodId, $child->id, $parent->id, 4400); + insertAgentBill($periodId, $parent->id, $rootId, 3520); + + $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_scope'); + $childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_scope'); + $parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + expect(\App\Support\AdminAgentSettlementScope::billAccessible($childAdmin, $playerBillId))->toBeTrue() + ->and(\App\Support\AdminAgentSettlementScope::billAccessible($parentAdmin, $playerBillId))->toBeFalse(); + + $this->withHeader('Authorization', 'Bearer '.$parentToken) + ->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId) + ->assertOk() + ->assertJsonPath('data.items', function (array $items) use ($playerBillId): bool { + $ids = array_column($items, 'id'); + + return ! in_array($playerBillId, $ids, true); + }); + + $this->withHeader('Authorization', 'Bearer '.$parentToken) + ->getJson('/api/v1/admin/settlement-bills/'.$playerBillId) + ->assertNotFound(); + + $this->actingAs($childAdmin, 'sanctum') + ->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId.'&bill_id='.$playerBillId) + ->assertOk() + ->assertJsonPath('data.items.0.id', $playerBillId); + + $this->actingAs($childAdmin, 'sanctum') + ->getJson('/api/v1/admin/settlement-bills/'.$playerBillId) + ->assertOk() + ->assertJsonPath('data.bill.id', $playerBillId); +}); + +test('bound parent agent sees direct child agent bill but not deeper chain bills', function (): void { + [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); + + $childToParentBillId = insertAgentBill($periodId, $child->id, $parent->id, 4400); + $parentToRootBillId = insertAgentBill($periodId, $parent->id, $rootId, 3520); + + $super = AdminUser::query()->create([ + 'username' => 'edge_grand_super_'.uniqid(), + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $grandchild = app(\App\Services\Agent\AgentNodeService::class)->createChild( + $super, + [ + 'parent_id' => $child->id, + 'code' => 'edge-grandchild', + 'name' => 'Edge Grandchild', + ], + ); + $deepBillId = insertAgentBill($periodId, $grandchild->id, $child->id, 1200); + + $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_edge'); + $parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $response = $this->withHeader('Authorization', 'Bearer '.$parentToken) + ->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId) + ->assertOk(); + + $ids = array_column($response->json('data.items'), 'id'); + + expect($ids)->toContain($childToParentBillId) + ->and($ids)->toContain($parentToRootBillId) + ->and($ids)->not->toContain($deepBillId); +}); + +test('bound parent agent cannot confirm or pay player bill under child', function (): void { + [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); + + $playerBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $child->id, + 'net_amount' => 4400, + 'unpaid_amount' => 4400, + 'paid_amount' => 0, + 'status' => 'pending_confirm', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_pay'); + $parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$parentToken) + ->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/confirm') + ->assertNotFound(); + + DB::table('settlement_bills')->where('id', $playerBillId)->update(['status' => 'confirmed']); + + $this->withHeader('Authorization', 'Bearer '.$parentToken) + ->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/payments', [ + 'amount' => 4400, + ]) + ->assertNotFound(); +}); + +test('parent agent can register payment on child agent bill where parent is payee', function (): void { + [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); + + $agentBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => $child->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $parent->id, + 'net_amount' => 4400, + 'unpaid_amount' => 4400, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_payee'); + $childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_payer'); + + $this->actingAs($parentAdmin, 'sanctum') + ->postJson('/api/v1/admin/settlement-bills/'.$agentBillId.'/payments', ['amount' => 4400]) + ->assertOk(); + + $this->actingAs($childAdmin, 'sanctum') + ->postJson('/api/v1/admin/settlement-bills/'.$agentBillId.'/payments', ['amount' => 4400]) + ->assertForbidden(); +}); + +test('settlement credit ledger excludes players under child agent for parent viewer', function (): void { + [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -100, + 'reason' => 'bet_hold', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_ledger'); + + $this->actingAs($parentAdmin, 'sanctum') + ->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId) + ->assertOk() + ->assertJsonPath('data.items', fn (array $items): bool => count($items) === 0); +}); + +test('direct child agent can confirm and pay own player bill', function (): void { + [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); + + $playerBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $child->id, + 'net_amount' => 4400, + 'unpaid_amount' => 4400, + 'paid_amount' => 0, + 'status' => 'pending_confirm', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_pay'); + + $this->actingAs($childAdmin, 'sanctum') + ->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/confirm') + ->assertOk(); + + $this->actingAs($childAdmin, 'sanctum') + ->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/payments', [ + 'amount' => 4400, + ]) + ->assertOk() + ->assertJsonPath('data.bill.status', 'settled'); +}); + +/** + * @return array{0: int, 1: string, 2: int, 3: \App\Models\AgentNode, 4: \App\Models\AgentNode, 5: Player, 6: int} + */ +function seedDirectEdgeSettlementFixture(): array +{ + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $super = AdminUser::query()->create([ + 'username' => 'edge_scope_super_'.uniqid(), + 'name' => 'Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $service = app(\App\Services\Agent\AgentNodeService::class); + $parent = $service->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'edge-parent-'.uniqid(), + 'name' => 'Edge Parent', + ]); + $child = $service->createChild($super, [ + 'parent_id' => $parent->id, + 'code' => 'edge-child-'.uniqid(), + 'name' => 'Edge Child', + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:edge-'.uniqid(), + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'edge_player_'.uniqid(), + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $child->id, + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeek(), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId]; +} + +function insertPlayerBill(int $periodId, int $playerId, int $agentId, int $amount): int +{ + return (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $playerId, + 'counterparty_type' => 'agent', + 'counterparty_id' => $agentId, + 'net_amount' => $amount, + 'unpaid_amount' => $amount, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function insertAgentBill(int $periodId, int $ownerId, int $counterpartyId, int $amount): int +{ + return (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => $ownerId, + 'counterparty_type' => 'agent', + 'counterparty_id' => $counterpartyId, + 'net_amount' => $amount, + 'unpaid_amount' => $amount, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function createBoundSettlementAgentAdmin(int $siteId, \App\Models\AgentNode $agent, string $prefix): AdminUser +{ + $admin = AdminUser::query()->create([ + 'username' => $prefix.'_'.uniqid(), + 'name' => ucfirst($prefix), + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + DB::table('admin_user_agents')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'is_primary' => true, + 'granted_at' => now(), + ]); + + $admin->syncPrimaryPlatformAgentRole((int) $agent->id); + + return $admin->fresh(); +} + +function grantSettlementManageToAgentAdmin(AdminUser $admin, \App\Models\AgentNode $agent): void +{ + $now = now(); + $roleId = DB::table('admin_roles')->insertGetId([ + 'slug' => 'settle_manage_'.$admin->id, + 'code' => 'settle_manage_'.$admin->id, + 'name' => 'Settlement Manage', + 'status' => 1, + 'is_system' => false, + 'sort_order' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $actionIds = DB::table('admin_menu_actions') + ->whereIn('permission_code', ['settlement.agent.manage', 'prd.settlement.agent.manage']) + ->pluck('id'); + + foreach ($actionIds as $actionId) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $roleId, + 'menu_action_id' => (int) $actionId, + ]); + } + + DB::table('admin_user_agent_roles')->insert([ + 'admin_user_id' => $admin->id, + 'agent_node_id' => (int) $agent->id, + 'role_id' => $roleId, + 'granted_at' => $now, + ]); +} diff --git a/tests/Feature/AgentSettlementListsApiTest.php b/tests/Feature/AgentSettlementListsApiTest.php index a86e29f..cd50e95 100644 --- a/tests/Feature/AgentSettlementListsApiTest.php +++ b/tests/Feature/AgentSettlementListsApiTest.php @@ -7,8 +7,25 @@ use Illuminate\Support\Facades\Hash; uses(RefreshDatabase::class); +beforeEach(function (): void { + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + test('settlement payments and adjustments index return items', 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'); + $playerId = (int) DB::table('players')->insertGetId([ + 'site_code' => (string) DB::table('admin_sites')->where('id', $siteId)->value('code'), + 'site_player_id' => 'lists-player', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'lists_player', + 'default_currency' => 'NPR', + 'status' => 0, + 'agent_node_id' => $rootId, + 'created_at' => now(), + 'updated_at' => now(), + ]); $periodId = (int) DB::table('settlement_periods')->insertGetId([ 'admin_site_id' => $siteId, 'period_start' => now()->subWeek(), @@ -22,9 +39,9 @@ test('settlement payments and adjustments index return items', function (): void 'settlement_period_id' => $periodId, 'bill_type' => 'player', 'owner_type' => 'player', - 'owner_id' => 1, + 'owner_id' => $playerId, 'counterparty_type' => 'agent', - 'counterparty_id' => 1, + 'counterparty_id' => $rootId, 'net_amount' => 1000, 'unpaid_amount' => 0, 'paid_amount' => 1000, @@ -36,9 +53,9 @@ test('settlement payments and adjustments index return items', function (): void DB::table('payment_records')->insert([ 'settlement_bill_id' => $billId, 'payer_type' => 'player', - 'payer_id' => 1, + 'payer_id' => $playerId, 'payee_type' => 'agent', - 'payee_id' => 1, + 'payee_id' => $rootId, 'amount' => 1000, 'method' => 'cash', 'status' => 'confirmed', diff --git a/tests/Feature/AgentSettlementPeriodOpenHintsTest.php b/tests/Feature/AgentSettlementPeriodOpenHintsTest.php new file mode 100644 index 0000000..8e356fa --- /dev/null +++ b/tests/Feature/AgentSettlementPeriodOpenHintsTest.php @@ -0,0 +1,197 @@ +artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +test('settlement period open hints api resource is configured after migrations', function (): void { + expect( + DB::table('admin_api_resources') + ->where('route_name', 'api.v1.admin.settlement-periods.open-hints') + ->where('status', 1) + ->exists(), + )->toBeTrue(); +}); + +test('settlement period open hints returns suggested range and calendar markers', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + DB::table('settlement_periods')->insert([ + 'admin_site_id' => $siteId, + 'period_start' => '2026-04-30 16:00:00', + 'period_end' => '2026-05-31 15:59:59', + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $unpaidPeriodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => '2026-03-31 16:00:00', + 'period_end' => '2026-04-03 15:59:59', + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('settlement_bills')->insert([ + 'settlement_period_id' => $unpaidPeriodId, + 'bill_type' => 'agent', + 'owner_type' => 'agent', + 'owner_id' => $rootId, + 'counterparty_type' => 'platform', + 'counterparty_id' => 0, + 'net_amount' => 1000, + 'unpaid_amount' => 1000, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $rootId, + 'site_player_id' => 'hints-player', + 'username' => 'hints_player', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => 'DRAW-HINTS', + 'business_date' => '2026-06-05', + 'sequence_no' => 1, + 'status' => DrawStatus::Open->value, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-HINTS', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 10_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 10_000, + 'total_estimated_payout' => 0, + 'status' => 'confirmed', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $itemId = (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => 'T-HINTS', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => null, + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 2, + 'digit_slot' => null, + 'bet_mode' => null, + 'unit_bet_amount' => 10_000, + 'total_bet_amount' => 10_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 10_000, + 'odds_snapshot_json' => null, + 'rule_snapshot_json' => null, + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'settled_lose', + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $settledAt = '2026-06-05 12:00:00'; + DB::table('share_ledger')->insert([ + 'ticket_item_id' => $itemId, + 'player_id' => $player->id, + 'agent_node_id' => $rootId, + 'agent_path' => json_encode([$rootId]), + 'share_snapshot' => json_encode(['total_shares' => [$siteCode => 100]]), + 'game_win_loss' => 1000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 1000, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt, + 'settlement_period_id' => null, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'open_hints_admin', + 'name' => 'Hints', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-periods/open-hints?admin_site_id='.$siteId) + ->assertOk() + ->assertJsonPath('data.suggested_start', '2026-06-01') + ->assertJsonPath('data.suggested_end', '2026-06-05') + ->assertJsonPath('data.pending_activity_dates.0', '2026-06-05') + ->assertJsonFragment(['2026-05-01']) + ->assertJsonFragment(['2026-04-01']) + ->assertJsonFragment(['2026-04-02']) + ->assertJsonFragment(['2026-04-03']); +}); + +test('settlement period open hints does not suggest range overlapping occupied periods', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + + DB::table('settlement_periods')->insert([ + 'admin_site_id' => $siteId, + 'period_start' => '2026-05-31 16:00:00', + 'period_end' => '2026-06-30 15:59:59', + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'open_hints_overlap_admin', + 'name' => 'Hints Overlap', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-periods/open-hints?admin_site_id='.$siteId) + ->assertOk() + ->assertJsonPath('data.suggested_start', '') + ->assertJsonPath('data.suggested_end', '') + ->assertJsonFragment(['2026-06-01']) + ->assertJsonFragment(['2026-06-30']); +}); diff --git a/tests/Feature/AgentSettlementPeriodOpenTest.php b/tests/Feature/AgentSettlementPeriodOpenTest.php index 10ba0e2..e31a40f 100644 --- a/tests/Feature/AgentSettlementPeriodOpenTest.php +++ b/tests/Feature/AgentSettlementPeriodOpenTest.php @@ -40,6 +40,40 @@ test('cannot open duplicate settlement period for same range', function (): void ); }); +test('cannot open settlement period overlapping a closed period', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $super = \App\Models\AdminUser::query()->create([ + 'username' => 'period_overlap_super', + 'name' => 'Super', + 'email' => null, + 'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + DB::table('settlement_periods')->insert([ + 'admin_site_id' => $siteId, + 'period_start' => '2026-05-01 00:00:00', + 'period_end' => '2026-05-31 23:59:59', + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/settlement-periods', [ + 'admin_site_id' => $siteId, + 'period_start' => '2026-05-15 00:00:00', + 'period_end' => '2026-06-15 23:59:59', + ]) + ->assertStatus(422) + ->assertJsonPath( + 'data.errors.period_start.0', + trans('validation.business.period_overlaps_existing'), + ); +}); + test('cannot open second settlement period while another is open on same site', function (): void { $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $super = \App\Models\AdminUser::query()->create([ diff --git a/tests/Unit/AgentDefaultRolePermissionsTest.php b/tests/Unit/AgentDefaultRolePermissionsTest.php index 015e80d..cafcb42 100644 --- a/tests/Unit/AgentDefaultRolePermissionsTest.php +++ b/tests/Unit/AgentDefaultRolePermissionsTest.php @@ -3,12 +3,13 @@ use App\Models\AgentProfile; use App\Support\AgentDefaultRolePermissions; -test('base owner slugs include dashboard and settlement view but not wallet reconcile', function (): void { +test('base owner slugs include dashboard and settlement view but not wallet reconcile or platform reports', function (): void { $slugs = AgentDefaultRolePermissions::baseSlugs(); expect($slugs) ->toContain('prd.dashboard.view') ->toContain('prd.settlement.agent.view') + ->not->toContain('prd.report.view') ->not->toContain('prd.wallet_reconcile.view') ->not->toContain('prd.wallet_reconcile.view_cs'); });