feat: 增强代理和玩家管理功能
- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。 - 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。 - 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。 - 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。 - 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
This commit is contained in:
13
AGENTS.md
13
AGENTS.md
@@ -19,3 +19,16 @@
|
||||
## 后台 RBAC
|
||||
|
||||
改 `app/Support/AdminAuthorizationRegistry.php` 后,在已有库执行 `php artisan lottery:admin-auth-sync --audit`(见 `docs/admin-rbac.md`)。`migrate:fresh --seed` 会走迁移内的 resync,一般不必再手动 sync。
|
||||
|
||||
## 双模式玩家(主站钱包 / 代理信用)
|
||||
|
||||
- `players.auth_source`:`main_site_sso`(主站 JWT)与 `lottery_native`(彩票端账号密码)。
|
||||
- `players.funding_mode`:`wallet`(主站划转)与 `credit`(授信下注);**禁止**仅用整站 `credit_line_mode` 代替玩家级判断,用 `PlayerFundingMode::usesCredit($player)`。
|
||||
- 生产环境配置独立 `LOTTERY_NATIVE_JWT_SECRET`(勿与主站 SSO 混用)。
|
||||
|
||||
## 信用占成盘(代理账期结算)
|
||||
|
||||
- 业务真理源:`docs/信用占成盘代理系统设计说明文档.md`;实施路线:`docs/信用占成盘代理体系改造计划.md`。
|
||||
- **代理账期**代码包:`App\Services\AgentSettlement\`(勿与彩票开奖 `App\Services\Settlement\` / `SettlementBatch` 混用)。
|
||||
- **禁止**在生产关账路径使用 `DesignDocExample12` 硬编码账单;仅单元/Feature 测试可引用。
|
||||
- 占成账单聚合必须读注单**快照**(`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。
|
||||
|
||||
20
app/Console/Commands/AgentOwnerPermissionsResyncCommand.php
Normal file
20
app/Console/Commands/AgentOwnerPermissionsResyncCommand.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/** @deprecated 经营代理已改用平台角色 slug=agent */
|
||||
final class AgentOwnerPermissionsResyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'lottery:agent-owner-permissions-resync';
|
||||
|
||||
protected $description = '已废弃:请使用 lottery:agent-roles-sync,并在「平台角色管理」编辑「代理」角色';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->warn('agent_owner_* 已不再用于经营主账号;正在转调 lottery:agent-roles-sync …');
|
||||
|
||||
return $this->call('lottery:agent-roles-sync');
|
||||
}
|
||||
}
|
||||
38
app/Console/Commands/AgentRolesSyncCommand.php
Normal file
38
app/Console/Commands/AgentRolesSyncCommand.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AgentDefaultRolePermissions;
|
||||
use App\Support\AgentPlatformRole;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentRolesSyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'lottery:agent-roles-sync';
|
||||
|
||||
protected $description = '确保平台「代理」角色存在,并将所有经营代理主账号绑定到该角色(权限在平台角色管理维护)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$platform = AgentDefaultRolePermissions::ensurePlatformAgentRole();
|
||||
$this->info('平台角色 agent(#'.$platform->id.')权限数: '.count($platform->legacyPermissionSlugs()));
|
||||
|
||||
$bindingCount = 0;
|
||||
foreach (DB::table('admin_user_agents')->get(['admin_user_id', 'agent_node_id']) as $binding) {
|
||||
$user = AdminUser::query()->find((int) $binding->admin_user_id);
|
||||
if ($user === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user->syncPrimaryPlatformAgentRole((int) $binding->agent_node_id);
|
||||
$bindingCount++;
|
||||
}
|
||||
|
||||
$this->info("已绑定 {$bindingCount} 个经营代理主账号到平台「代理」角色。");
|
||||
$this->line('调整权限请编辑:平台角色管理 → 代理');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
36
app/Console/Commands/CreditLineDisableAllSitesCommand.php
Normal file
36
app/Console/Commands/CreditLineDisableAllSitesCommand.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreditLineDisableAllSitesCommand extends Command
|
||||
{
|
||||
protected $signature = 'lottery:credit-line-disable-all-sites';
|
||||
|
||||
protected $description = 'Disable credit_line_mode on all admin sites (rollback)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$sites = DB::table('admin_sites')->get();
|
||||
$count = 0;
|
||||
|
||||
foreach ($sites as $site) {
|
||||
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
|
||||
if (! is_array($extra)) {
|
||||
$extra = [];
|
||||
}
|
||||
unset($extra['credit_line_mode']);
|
||||
DB::table('admin_sites')->where('id', $site->id)->update([
|
||||
'extra_json' => json_encode($extra),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("Disabled credit_line_mode on {$count} site(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
36
app/Console/Commands/CreditLineEnableAllSitesCommand.php
Normal file
36
app/Console/Commands/CreditLineEnableAllSitesCommand.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreditLineEnableAllSitesCommand extends Command
|
||||
{
|
||||
protected $signature = 'lottery:credit-line-enable-all-sites';
|
||||
|
||||
protected $description = 'Enable credit_line_mode on all admin sites (big bang switch)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$sites = DB::table('admin_sites')->get();
|
||||
$count = 0;
|
||||
|
||||
foreach ($sites as $site) {
|
||||
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
|
||||
if (! is_array($extra)) {
|
||||
$extra = [];
|
||||
}
|
||||
$extra['credit_line_mode'] = true;
|
||||
DB::table('admin_sites')->where('id', $site->id)->update([
|
||||
'extra_json' => json_encode($extra),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("Enabled credit_line_mode on {$count} site(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
32
app/Console/Commands/SettlementMarkOverdueBillsCommand.php
Normal file
32
app/Console/Commands/SettlementMarkOverdueBillsCommand.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SettlementMarkOverdueBillsCommand extends Command
|
||||
{
|
||||
protected $signature = 'settlement:mark-overdue-bills {--days=7}';
|
||||
|
||||
protected $description = 'Mark unpaid settlement bills as overdue past grace days';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = max(1, (int) $this->option('days'));
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$updated = DB::table('settlement_bills')
|
||||
->whereIn('status', ['confirmed', 'partial_paid'])
|
||||
->where('unpaid_amount', '>', 0)
|
||||
->where('updated_at', '<', $cutoff)
|
||||
->update([
|
||||
'status' => 'overdue',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->info("Marked {$updated} bill(s) overdue.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Support\AdminAgentLineSettlementPermissionMenuActionSync;
|
||||
use App\Support\AdminAgentPermissionMenuActionSync;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use App\Support\AdminDrawPermissionMenuActionSync;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
|
||||
final class SyncAdminAuthorizationCommand extends Command
|
||||
{
|
||||
@@ -92,6 +93,13 @@ final class SyncAdminAuthorizationCommand extends Command
|
||||
count(AdminAuthorizationRegistry::resources()),
|
||||
));
|
||||
|
||||
PlatformSystemRoles::ensureAll();
|
||||
$super = PlatformSystemRoles::ensureSuperAdminRole();
|
||||
$this->info(sprintf(
|
||||
'Platform system roles synced (super_admin permissions: %d).',
|
||||
count($super->legacyPermissionSlugs()),
|
||||
));
|
||||
|
||||
if ((bool) $this->option('audit')) {
|
||||
return $this->call('lottery:admin-auth-audit');
|
||||
}
|
||||
|
||||
42
app/Console/Commands/SyncAgentAllocatedCreditCommand.php
Normal file
42
app/Console/Commands/SyncAgentAllocatedCreditCommand.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Services\Agent\AgentCreditAllocatedSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/** 按直属玩家/下级代理授信重算 allocated_credit,修复历史增量漂移。 */
|
||||
final class SyncAgentAllocatedCreditCommand extends Command
|
||||
{
|
||||
protected $signature = 'lottery:sync-agent-allocated-credit {--site= : 仅处理指定 admin_sites.code}';
|
||||
|
||||
protected $description = '重算代理已下发额度(allocated_credit)';
|
||||
|
||||
public function handle(AgentCreditAllocatedSyncService $sync): int
|
||||
{
|
||||
$siteCode = $this->option('site');
|
||||
$query = AgentNode::query()->orderBy('id');
|
||||
if (is_string($siteCode) && $siteCode !== '') {
|
||||
$query->whereHas('adminSite', static fn ($q) => $q->where('code', $siteCode));
|
||||
}
|
||||
|
||||
$nodes = $query->get();
|
||||
$updated = 0;
|
||||
foreach ($nodes as $node) {
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||
$before = $profile !== null ? (int) $profile->allocated_credit : null;
|
||||
$sync->syncForAgent($node);
|
||||
$profile?->refresh();
|
||||
$after = $profile !== null ? (int) $profile->allocated_credit : null;
|
||||
if ($before !== $after) {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('已处理 '.count($nodes).' 个代理节点,其中 '.$updated.' 个 allocated_credit 有变更。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ final class AgentLineStoreController extends Controller
|
||||
$site = $result['site'];
|
||||
$node = $result['agent_node'];
|
||||
|
||||
$payload = AgentLinePresenter::provisioned($site, $node, $result['secrets']);
|
||||
$payload = AgentLinePresenter::provisioned($site, $node);
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Agent;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -22,10 +23,21 @@ final class AgentNodeChildrenController extends Controller
|
||||
return $denied;
|
||||
}
|
||||
|
||||
$items = $agent_node->children()
|
||||
->orderBy('code')
|
||||
$children = $agent_node->children()->orderBy('code')->get();
|
||||
$profiles = AgentProfile::query()
|
||||
->whereIn('agent_node_id', $children->pluck('id'))
|
||||
->get()
|
||||
->map(static fn (AgentNode $child): array => AgentNodePresenter::item($child))
|
||||
->keyBy('agent_node_id');
|
||||
|
||||
$items = $children
|
||||
->map(static function (AgentNode $child) use ($profiles): array {
|
||||
$profile = $profiles->get($child->id);
|
||||
|
||||
return AgentNodePresenter::item(
|
||||
$child,
|
||||
$profile instanceof AgentProfile ? $profile : null,
|
||||
);
|
||||
})
|
||||
->all();
|
||||
|
||||
return ApiResponse::success(['items' => $items]);
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Agent;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use App\Http\Requests\Admin\AdminAgentProfileUpdateRequest;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Services\Agent\AgentNodeService;
|
||||
use App\Services\Agent\AgentProfileService;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Support\AdminAgentScope;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -22,20 +23,28 @@ final class AgentNodeProfileController extends Controller
|
||||
abort_if($admin === null, 401);
|
||||
abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403);
|
||||
|
||||
$service = app(AgentProfileService::class);
|
||||
$profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $agent_node->id]);
|
||||
$parent = $agent_node->parent_id !== null
|
||||
? AgentNode::query()->find($agent_node->parent_id)
|
||||
: null;
|
||||
|
||||
return ApiResponse::success(app(AgentProfileService::class)->present($profile));
|
||||
return ApiResponse::success([
|
||||
...$service->present($profile),
|
||||
'parent_caps' => $service->parentCapsForNode($parent),
|
||||
'risk_tags' => $agent_node->risk_tags ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(
|
||||
AdminAgentProfileUpdateRequest $request,
|
||||
AgentNode $agent_node,
|
||||
AgentProfileService $service,
|
||||
AgentNodeService $agentNodeService,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403);
|
||||
abort_if(! AdminAgentScope::nodeProfileEditableBy($admin, $agent_node), 403);
|
||||
|
||||
$parent = $agent_node->parent_id !== null
|
||||
? AgentNode::query()->find($agent_node->parent_id)
|
||||
@@ -46,9 +55,33 @@ final class AgentNodeProfileController extends Controller
|
||||
$service->assertChildCapabilityGrantsWithinParent($parent, $payload, $admin);
|
||||
}
|
||||
|
||||
$profile = $service->upsertForNode($agent_node, $payload, $parent);
|
||||
$agentNodeService->syncPrimaryOwnerRoleFromProfile($agent_node, $profile);
|
||||
$beforeProfile = AgentProfile::query()->where('agent_node_id', $agent_node->id)->first();
|
||||
$beforeJson = $beforeProfile !== null ? $service->present($beforeProfile) : [];
|
||||
|
||||
return ApiResponse::success($service->present($profile));
|
||||
if ($request->has('risk_tags')) {
|
||||
$agent_node->risk_tags = array_values(array_unique(array_filter(
|
||||
array_map('strval', $request->input('risk_tags', [])),
|
||||
)));
|
||||
$agent_node->save();
|
||||
}
|
||||
|
||||
$profile = $service->upsertForNode($agent_node, $payload, $parent);
|
||||
$afterJson = array_merge($service->present($profile), [
|
||||
'risk_tags' => $agent_node->risk_tags ?? [],
|
||||
]);
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
$request,
|
||||
moduleCode: 'agent',
|
||||
actionCode: 'agent_profile.update',
|
||||
targetType: 'agent_node',
|
||||
targetId: (string) $agent_node->id,
|
||||
beforeJson: $beforeJson,
|
||||
afterJson: $afterJson,
|
||||
);
|
||||
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
return ApiResponse::success($afterJson);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AgentSettlement\SettlementCenterLedgerService;
|
||||
use App\Services\AgentSettlement\SettlementLedgerListFilters;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\PaginationTrait;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 结算中心:信用盘玩家实时流水({@see credit_ledger}),与关账后 {@see settlement_bills} 不同。
|
||||
*/
|
||||
final class AdminCreditLedgerIndexController extends Controller
|
||||
{
|
||||
use PaginationTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly SettlementCenterLedgerService $ledgerService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$adminSiteId = (int) $request->query('admin_site_id', 0);
|
||||
abort_if($adminSiteId <= 0, 422, 'admin_site_id required');
|
||||
abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $adminSiteId), 403);
|
||||
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
|
||||
abort_if($siteCode === '', 422, 'admin_site not found');
|
||||
|
||||
$periodId = (int) $request->query('settlement_period_id', 0);
|
||||
if ($periodId > 0) {
|
||||
abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403);
|
||||
}
|
||||
|
||||
$filters = SettlementLedgerListFilters::fromQuery(array_merge(
|
||||
$request->query(),
|
||||
$periodId > 0 ? ['settlement_period_id' => $periodId] : [],
|
||||
));
|
||||
|
||||
$perPage = $this->perPage($request, 'per_page', 20, 100);
|
||||
$page = $this->page($request);
|
||||
|
||||
$result = $this->ledgerService->listUnified(
|
||||
$admin,
|
||||
$siteCode,
|
||||
$page,
|
||||
$perPage,
|
||||
$filters,
|
||||
);
|
||||
|
||||
return ApiResponse::success($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementAdjustmentIndexController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$periodId = (int) $request->query('settlement_period_id', 0);
|
||||
$adminSiteId = (int) $request->query('admin_site_id', 0);
|
||||
$adjustmentType = trim((string) $request->query('adjustment_type', ''));
|
||||
|
||||
$query = DB::table('settlement_adjustments as sa')
|
||||
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
|
||||
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
|
||||
->select([
|
||||
'sa.*',
|
||||
'sp.period_start',
|
||||
'sp.period_end',
|
||||
'sp.admin_site_id',
|
||||
'sb.bill_type as original_bill_type',
|
||||
'sb.owner_type as original_owner_type',
|
||||
'sb.owner_id as original_owner_id',
|
||||
])
|
||||
->orderByDesc('sa.id');
|
||||
|
||||
if ($periodId > 0) {
|
||||
$query->where('sa.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
if ($adminSiteId > 0) {
|
||||
$query->where('sp.admin_site_id', $adminSiteId);
|
||||
}
|
||||
|
||||
if ($adjustmentType !== '') {
|
||||
$query->where('sa.adjustment_type', $adjustmentType);
|
||||
}
|
||||
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds !== null) {
|
||||
if ($siteIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
} else {
|
||||
$query->whereIn('sp.admin_site_id', $siteIds);
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $query->limit(200)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use App\Http\Requests\Admin\AdminSettlementBillAdjustmentRequest;
|
||||
use App\Services\AgentSettlement\AgentSettlementBillAdjustmentService;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementBillAdjustmentController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
AdminSettlementBillAdjustmentRequest $request,
|
||||
int $settlement_bill,
|
||||
AgentSettlementBillAdjustmentService $adjustments,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
||||
|
||||
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||
abort_if($before === null, 404);
|
||||
|
||||
$newBillId = $adjustments->createAdjustment(
|
||||
$settlement_bill,
|
||||
(int) $request->validated('amount'),
|
||||
(string) ($request->validated('adjustment_type') ?? 'adjustment'),
|
||||
$request->validated('reason'),
|
||||
(int) $admin->id,
|
||||
);
|
||||
|
||||
$after = DB::table('settlement_bills')->where('id', $newBillId)->first();
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
$request,
|
||||
moduleCode: 'settlement',
|
||||
actionCode: 'settlement_bill.adjustment',
|
||||
targetType: 'settlement_bill',
|
||||
targetId: (string) $newBillId,
|
||||
beforeJson: (array) $before,
|
||||
afterJson: (array) $after,
|
||||
);
|
||||
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
return ApiResponse::success([
|
||||
'original_bill_id' => $settlement_bill,
|
||||
'adjustment_bill_id' => $newBillId,
|
||||
'bill' => $after,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use App\Http\Requests\Admin\AdminSettlementBillBadDebtRequest;
|
||||
use App\Services\AgentSettlement\AgentSettlementBadDebtService;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementBillBadDebtWriteOffController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
AdminSettlementBillBadDebtRequest $request,
|
||||
int $settlement_bill,
|
||||
AgentSettlementBadDebtService $badDebt,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
||||
|
||||
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||
abort_if($before === null, 404);
|
||||
|
||||
$archiveBillId = $badDebt->writeOff(
|
||||
$settlement_bill,
|
||||
$request->validated('reason'),
|
||||
(int) $admin->id,
|
||||
);
|
||||
|
||||
$after = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
$request,
|
||||
moduleCode: 'settlement',
|
||||
actionCode: 'settlement_bill.bad_debt',
|
||||
targetType: 'settlement_bill',
|
||||
targetId: (string) $settlement_bill,
|
||||
beforeJson: (array) $before,
|
||||
afterJson: (array) $after,
|
||||
);
|
||||
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
return ApiResponse::success([
|
||||
'original_bill_id' => $settlement_bill,
|
||||
'bad_debt_bill_id' => $archiveBillId,
|
||||
'bill' => $after,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use App\Models\Player;
|
||||
use App\Services\AgentSettlement\SettlementPaymentService;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -18,7 +17,7 @@ final class AgentSettlementBillConfirmController extends Controller
|
||||
public function __invoke(
|
||||
Request $request,
|
||||
int $settlement_bill,
|
||||
PlayerCreditService $creditService,
|
||||
SettlementPaymentService $payments,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
@@ -28,21 +27,7 @@ final class AgentSettlementBillConfirmController extends Controller
|
||||
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||
abort_if($bill === null, 404);
|
||||
|
||||
$unpaid = (int) $bill->unpaid_amount;
|
||||
DB::table('settlement_bills')->where('id', $settlement_bill)->update([
|
||||
'paid_amount' => (int) $bill->paid_amount + $unpaid,
|
||||
'unpaid_amount' => 0,
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
|
||||
$player = Player::query()->find((int) $bill->owner_id);
|
||||
if ($player !== null) {
|
||||
$creditService->releaseFromSettlement($player, $unpaid, $settlement_bill);
|
||||
}
|
||||
}
|
||||
$payments->confirmBill($settlement_bill);
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
@@ -51,8 +36,8 @@ final class AgentSettlementBillConfirmController extends Controller
|
||||
actionCode: 'settlement_bill.confirm',
|
||||
targetType: 'settlement_bill',
|
||||
targetId: (string) $settlement_bill,
|
||||
beforeJson: ['status' => (string) $bill->status, 'unpaid_amount' => $unpaid],
|
||||
afterJson: ['status' => 'confirmed', 'paid_amount' => (int) $bill->paid_amount + $unpaid],
|
||||
beforeJson: ['status' => (string) $bill->status],
|
||||
afterJson: ['status' => 'confirmed'],
|
||||
);
|
||||
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementBillIndexController extends Controller
|
||||
@@ -17,15 +18,142 @@ final class AgentSettlementBillIndexController extends Controller
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$periodId = (int) $request->query('settlement_period_id', 0);
|
||||
$query = DB::table('settlement_bills')->orderByDesc('id');
|
||||
$adminSiteId = (int) $request->query('admin_site_id', 0);
|
||||
|
||||
$query = DB::table('settlement_bills as sb')
|
||||
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->select([
|
||||
'sb.*',
|
||||
'sp.period_start',
|
||||
'sp.period_end',
|
||||
'sp.admin_site_id',
|
||||
])
|
||||
->orderByDesc('sb.id');
|
||||
|
||||
if ($periodId > 0) {
|
||||
$query->where('settlement_period_id', $periodId);
|
||||
$query->where('sb.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin);
|
||||
if ($adminSiteId > 0) {
|
||||
$query->where('sp.admin_site_id', $adminSiteId);
|
||||
}
|
||||
|
||||
$billType = (string) $request->query('bill_type', '');
|
||||
if ($billType !== '') {
|
||||
$query->where('sb.bill_type', $billType);
|
||||
}
|
||||
|
||||
$scope = (string) $request->query('scope', '');
|
||||
match ($scope) {
|
||||
'pending_confirm' => $query->where('sb.status', 'pending_confirm'),
|
||||
'awaiting_payment' => $query
|
||||
->whereIn('sb.status', ['confirmed', 'partial_paid', 'overdue'])
|
||||
->where('sb.unpaid_amount', '>', 0),
|
||||
'settled' => $query->where('sb.status', 'settled'),
|
||||
'adjustment' => $query->whereIn('sb.bill_type', ['adjustment', 'reversal']),
|
||||
default => null,
|
||||
};
|
||||
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
|
||||
|
||||
/** @var Collection<int, object> $items */
|
||||
$items = $query->limit(200)->get();
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $query->limit(100)->get(),
|
||||
'items' => $this->enrichBillRows($items),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $items
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function enrichBillRows(Collection $items): array
|
||||
{
|
||||
if ($items->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$playerIds = [];
|
||||
$agentIds = [];
|
||||
foreach ($items as $row) {
|
||||
if ((string) $row->owner_type === 'player') {
|
||||
$playerIds[] = (int) $row->owner_id;
|
||||
} elseif ((string) $row->owner_type === 'agent') {
|
||||
$agentIds[] = (int) $row->owner_id;
|
||||
}
|
||||
if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) {
|
||||
$agentIds[] = (int) $row->counterparty_id;
|
||||
}
|
||||
}
|
||||
|
||||
$players = $playerIds !== []
|
||||
? DB::table('players')
|
||||
->whereIn('id', array_unique($playerIds))
|
||||
->select(['id', 'username', 'site_player_id', 'funding_mode', 'auth_source'])
|
||||
->get()
|
||||
->keyBy('id')
|
||||
: collect();
|
||||
$agents = $agentIds !== []
|
||||
? DB::table('agent_nodes')->whereIn('id', array_unique($agentIds))->get()->keyBy('id')
|
||||
: collect();
|
||||
|
||||
$out = [];
|
||||
foreach ($items as $row) {
|
||||
$item = (array) $row;
|
||||
$item['owner_label'] = $this->resolvePartyLabel(
|
||||
(string) $row->owner_type,
|
||||
(int) $row->owner_id,
|
||||
$players,
|
||||
$agents,
|
||||
);
|
||||
$item['counterparty_label'] = $this->resolvePartyLabel(
|
||||
(string) $row->counterparty_type,
|
||||
(int) $row->counterparty_id,
|
||||
$players,
|
||||
$agents,
|
||||
);
|
||||
if ((string) $row->owner_type === 'player') {
|
||||
$player = $players->get((int) $row->owner_id);
|
||||
$item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null;
|
||||
$item['owner_auth_source'] = $player !== null ? $player->auth_source : null;
|
||||
}
|
||||
$out[] = $item;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $players
|
||||
* @param Collection<int, object> $agents
|
||||
*/
|
||||
private function resolvePartyLabel(
|
||||
string $type,
|
||||
int $id,
|
||||
Collection $players,
|
||||
Collection $agents,
|
||||
): string {
|
||||
if ($type === 'platform' || $id <= 0) {
|
||||
return 'platform';
|
||||
}
|
||||
|
||||
if ($type === 'player') {
|
||||
$player = $players->get($id);
|
||||
|
||||
return $player !== null
|
||||
? (string) ($player->username ?: $player->site_player_id)
|
||||
: "player#{$id}";
|
||||
}
|
||||
|
||||
if ($type === 'agent') {
|
||||
$agent = $agents->get($id);
|
||||
|
||||
return $agent !== null
|
||||
? (string) ($agent->name ?: $agent->code)
|
||||
: "agent#{$id}";
|
||||
}
|
||||
|
||||
return "{$type}#{$id}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use App\Http\Requests\Admin\AdminSettlementBillPaymentRequest;
|
||||
use App\Services\AgentSettlement\SettlementPaymentService;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementBillPaymentController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
AdminSettlementBillPaymentRequest $request,
|
||||
int $settlement_bill,
|
||||
SettlementPaymentService $payments,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
||||
|
||||
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||
abort_if($before === null, 404);
|
||||
|
||||
$validated = $request->validated();
|
||||
$payments->recordPayment(
|
||||
$settlement_bill,
|
||||
(int) $validated['amount'],
|
||||
(int) $admin->id,
|
||||
[
|
||||
'method' => $validated['method'] ?? null,
|
||||
'proof' => $validated['proof'] ?? null,
|
||||
'remark' => $validated['remark'] ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
$after = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
$request,
|
||||
moduleCode: 'settlement',
|
||||
actionCode: 'settlement_bill.payment',
|
||||
targetType: 'settlement_bill',
|
||||
targetId: (string) $settlement_bill,
|
||||
beforeJson: (array) $before,
|
||||
afterJson: (array) $after,
|
||||
);
|
||||
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
return ApiResponse::success(['bill' => $after]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementBillShowController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, int $settlement_bill): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
|
||||
|
||||
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||
abort_if($bill === null, 404);
|
||||
|
||||
$payments = DB::table('payment_records')
|
||||
->where('settlement_bill_id', $settlement_bill)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$rebateAllocations = DB::table('rebate_allocations')
|
||||
->where('settlement_bill_id', $settlement_bill)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$adjustments = DB::table('settlement_adjustments')
|
||||
->where('original_bill_id', $settlement_bill)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
$meta = $bill->meta_json ?? null;
|
||||
$tierSettlements = null;
|
||||
if (is_string($meta) && $meta !== '') {
|
||||
$decoded = json_decode($meta, true);
|
||||
$tierSettlements = is_array($decoded) ? ($decoded['edge'] ?? $decoded['tier_settlements'] ?? null) : null;
|
||||
}
|
||||
|
||||
return ApiResponse::success([
|
||||
'bill' => $bill,
|
||||
'payments' => $payments,
|
||||
'rebate_allocations' => $rebateAllocations,
|
||||
'adjustments' => $adjustments,
|
||||
'tier_edge' => $tierSettlements,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementPaymentIndexController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$periodId = (int) $request->query('settlement_period_id', 0);
|
||||
$adminSiteId = (int) $request->query('admin_site_id', 0);
|
||||
|
||||
$query = DB::table('payment_records as pr')
|
||||
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->select([
|
||||
'pr.*',
|
||||
'sb.bill_type',
|
||||
'sb.owner_type',
|
||||
'sb.owner_id',
|
||||
'sb.counterparty_type',
|
||||
'sb.counterparty_id',
|
||||
'sp.period_start',
|
||||
'sp.period_end',
|
||||
'sp.admin_site_id',
|
||||
])
|
||||
->orderByDesc('pr.id');
|
||||
|
||||
if ($periodId > 0) {
|
||||
$query->where('sb.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
if ($adminSiteId > 0) {
|
||||
$query->where('sp.admin_site_id', $adminSiteId);
|
||||
}
|
||||
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds !== null) {
|
||||
if ($siteIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
} else {
|
||||
$query->whereIn('sp.admin_site_id', $siteIds);
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $query->limit(200)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AgentSettlement\AgentSettlementPeriodSummaryService;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementPeriodIndexController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
Request $request,
|
||||
AgentSettlementPeriodSummaryService $summaryService,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$query = DB::table('settlement_periods')->orderByDesc('id');
|
||||
AdminAgentSettlementScope::applyToPeriodsQuery($query, $admin);
|
||||
|
||||
$siteId = (int) $request->query('admin_site_id', 0);
|
||||
if ($siteId > 0) {
|
||||
$query->where('admin_site_id', $siteId);
|
||||
}
|
||||
|
||||
$periods = $query->limit(100)->get();
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $summaryService->attachToPeriodRows($periods),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AgentSettlement\AgentSettlementReportQueryService;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** @deprecated 请用 AgentSettlementReportShowController?type=summary */
|
||||
final class AgentSettlementReportIndexController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, AgentSettlementReportQueryService $reports): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$periodId = (int) $request->query('settlement_period_id', 0);
|
||||
|
||||
return ApiResponse::success($reports->summary($admin, $periodId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AgentSettlement\AgentSettlementReportQueryService;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
final class AgentSettlementReportShowController extends Controller
|
||||
{
|
||||
private const TYPES = [
|
||||
'summary',
|
||||
'player_win_loss',
|
||||
'agent_share',
|
||||
'rebate',
|
||||
'credit',
|
||||
'unpaid_bills',
|
||||
'overdue',
|
||||
'platform_pnl',
|
||||
'draw_period',
|
||||
];
|
||||
|
||||
public function __invoke(Request $request, AgentSettlementReportQueryService $reports): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$type = (string) $request->query('type', 'summary');
|
||||
abort_unless(in_array($type, self::TYPES, true), 404);
|
||||
|
||||
$periodId = (int) $request->query('settlement_period_id', 0);
|
||||
$period = $this->resolvePeriod($periodId, $request);
|
||||
|
||||
$data = match ($type) {
|
||||
'summary' => $reports->summary($admin, $periodId),
|
||||
'player_win_loss' => [
|
||||
'items' => $reports->playerWinLoss($admin, $periodId, $period['start'], $period['end']),
|
||||
],
|
||||
'agent_share' => [
|
||||
'items' => $reports->agentShare($admin, $period['start'], $period['end']),
|
||||
],
|
||||
'rebate' => $reports->rebate($admin, $periodId, $period['start'], $period['end']),
|
||||
'credit' => $reports->credit($admin),
|
||||
'unpaid_bills' => [
|
||||
'items' => $reports->unpaidBills($admin, $periodId),
|
||||
],
|
||||
'overdue' => [
|
||||
'items' => $reports->overdue($admin),
|
||||
],
|
||||
'platform_pnl' => $periodId > 0
|
||||
? $reports->platformPnl($admin, $periodId)
|
||||
: ['error' => 'settlement_period_id_required'],
|
||||
'draw_period' => [
|
||||
'items' => $reports->drawPeriod($admin, $period['start'], $period['end']),
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
return ApiResponse::success([
|
||||
'type' => $type,
|
||||
'settlement_period_id' => $periodId > 0 ? $periodId : null,
|
||||
'period_start' => $period['start'],
|
||||
'period_end' => $period['end'],
|
||||
'data' => $data,
|
||||
'footnote' => $type === 'summary'
|
||||
? null
|
||||
: 'agent_credit_line_settlement',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{start: string, end: string}
|
||||
*/
|
||||
private function resolvePeriod(int $periodId, Request $request): array
|
||||
{
|
||||
if ($periodId > 0) {
|
||||
$row = DB::table('settlement_periods')->where('id', $periodId)->first();
|
||||
abort_if($row === null, 404);
|
||||
|
||||
return [
|
||||
'start' => (string) $row->period_start,
|
||||
'end' => (string) $row->period_end,
|
||||
];
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'period_start' => ['required_with:period_end', 'date'],
|
||||
'period_end' => ['required_with:period_start', 'date', 'after_or_equal:period_start'],
|
||||
]);
|
||||
|
||||
$start = $request->query('period_start');
|
||||
$end = $request->query('period_end');
|
||||
if ($start && $end) {
|
||||
return ['start' => (string) $start, 'end' => (string) $end];
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
return [
|
||||
'start' => $now->copy()->subDays(7)->toDateTimeString(),
|
||||
'end' => $now->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@ use App\Support\ApiResponse;
|
||||
use App\Models\SettlementBatch;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\AdminDrawResponsePolicy;
|
||||
use App\Support\AdminScopePolicy;
|
||||
use App\Support\ApiMessage;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/draws/{draw}/finance-summary — 单期投注/派彩汇总(客服/财务视角,PRD §15.4)。
|
||||
@@ -23,6 +26,17 @@ final class AdminDrawFinanceSummaryController extends Controller
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if(! $admin instanceof AdminUser, 401);
|
||||
|
||||
if (! AdminDrawResponsePolicy::canViewDrawFinance($admin)) {
|
||||
return ApiMessage::errorResponse(
|
||||
$request,
|
||||
'admin.permission_denied',
|
||||
ErrorCode::AdminForbidden->value,
|
||||
null,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
$scope = AdminScopePolicy::resolveContext($request, $admin);
|
||||
|
||||
$drawId = (int) $draw->id;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Models\TicketItem;
|
||||
@@ -10,6 +9,8 @@ use App\Models\TicketOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Support\AdminApiList;
|
||||
use App\Support\AdminScopeContext;
|
||||
use App\Support\AdminDrawApiPresenter;
|
||||
use App\Support\AdminDrawResponsePolicy;
|
||||
use App\Support\AdminScopePolicy;
|
||||
use App\Services\LotterySettings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -44,19 +45,30 @@ final class AdminDrawIndexController extends Controller
|
||||
/** @var LengthAwarePaginator $paginator */
|
||||
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||
|
||||
$statsByDrawId = $this->aggregateListStats(
|
||||
$paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(),
|
||||
$scope,
|
||||
);
|
||||
$statsByDrawId = AdminDrawResponsePolicy::canViewDrawFinance($admin)
|
||||
? $this->aggregateListStats(
|
||||
$paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(),
|
||||
$scope,
|
||||
)
|
||||
: [];
|
||||
|
||||
return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row, $statsByDrawId), [
|
||||
'schedule' => [
|
||||
'timezone' => LotterySettings::drawTimezone(),
|
||||
'interval_minutes' => LotterySettings::drawIntervalMinutes(),
|
||||
'betting_window_seconds' => LotterySettings::drawBettingWindowSeconds(),
|
||||
'close_before_draw_seconds' => LotterySettings::drawCloseBeforeDrawSeconds(),
|
||||
return AdminApiList::jsonWith(
|
||||
$paginator,
|
||||
fn (Draw $row): array => AdminDrawApiPresenter::listRow(
|
||||
$row,
|
||||
$statsByDrawId[(int) $row->id] ?? null,
|
||||
$admin,
|
||||
),
|
||||
[
|
||||
'schedule' => [
|
||||
'timezone' => LotterySettings::drawTimezone(),
|
||||
'interval_minutes' => LotterySettings::drawIntervalMinutes(),
|
||||
'betting_window_seconds' => LotterySettings::drawBettingWindowSeconds(),
|
||||
'close_before_draw_seconds' => LotterySettings::drawCloseBeforeDrawSeconds(),
|
||||
],
|
||||
'capabilities' => AdminDrawResponsePolicy::capabilities($admin),
|
||||
],
|
||||
]);
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,38 +141,4 @@ final class AdminDrawIndexController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{total_bet_minor: int, total_payout_minor: int, profit_loss_minor: int}> $statsByDrawId
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function row(Draw $draw, array $statsByDrawId): array
|
||||
{
|
||||
$stats = $statsByDrawId[(int) $draw->id] ?? [
|
||||
'total_bet_minor' => 0,
|
||||
'total_payout_minor' => 0,
|
||||
'profit_loss_minor' => 0,
|
||||
];
|
||||
|
||||
return [
|
||||
'id' => (int) $draw->id,
|
||||
'draw_no' => $draw->draw_no,
|
||||
'business_date' => $draw->business_date instanceof Carbon
|
||||
? $draw->business_date->format('Y-m-d')
|
||||
: (string) $draw->business_date,
|
||||
'sequence_no' => (int) $draw->sequence_no,
|
||||
'status' => $draw->status,
|
||||
'start_time' => $draw->start_time?->toIso8601String(),
|
||||
'close_time' => $draw->close_time?->toIso8601String(),
|
||||
'draw_time' => $draw->draw_time?->toIso8601String(),
|
||||
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
|
||||
'result_source' => $draw->result_source,
|
||||
'current_result_version' => (int) $draw->current_result_version,
|
||||
'settle_version' => (int) $draw->settle_version,
|
||||
'is_reopened' => (bool) $draw->is_reopened,
|
||||
'total_bet_minor' => $stats['total_bet_minor'],
|
||||
'total_payout_minor' => $stats['total_payout_minor'],
|
||||
'profit_loss_minor' => $stats['profit_loss_minor'],
|
||||
'updated_at' => $draw->updated_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,56 +4,46 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminDrawApiPresenter;
|
||||
use App\Support\AdminDrawResponsePolicy;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/draws/{draw}/result-batches — 开奖批次与号码(审核/结果核对)。
|
||||
*/
|
||||
final class AdminDrawResultBatchesIndexController extends Controller
|
||||
{
|
||||
public function __invoke(Draw $draw): JsonResponse
|
||||
public function __invoke(Request $request, Draw $draw): JsonResponse
|
||||
{
|
||||
$batches = $draw->resultBatches()
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$manage = AdminDrawResponsePolicy::canManageDrawResults($admin);
|
||||
|
||||
$query = $draw->resultBatches()
|
||||
->with(['items' => function ($q): void {
|
||||
$q->orderBy('prize_type')->orderBy('prize_index');
|
||||
}])
|
||||
->orderByDesc('result_version')
|
||||
->get();
|
||||
->orderByDesc('result_version');
|
||||
|
||||
if (! $manage) {
|
||||
$query->where('status', DrawResultBatchStatus::Published->value);
|
||||
}
|
||||
|
||||
$batches = $query->get();
|
||||
|
||||
return ApiResponse::success([
|
||||
'draw_id' => (int) $draw->id,
|
||||
'draw_no' => $draw->draw_no,
|
||||
'draw_status' => $draw->status,
|
||||
'batches' => $batches->map(fn (DrawResultBatch $b) => $this->serializeBatch($b))->all(),
|
||||
'capabilities' => AdminDrawResponsePolicy::capabilities($admin),
|
||||
'batches' => $batches
|
||||
->map(fn (DrawResultBatch $b): array => AdminDrawApiPresenter::resultBatch($b, $admin))
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function serializeBatch(DrawResultBatch $batch): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $batch->id,
|
||||
'result_version' => (int) $batch->result_version,
|
||||
'source_type' => $batch->source_type,
|
||||
'rng_seed_hash' => $batch->rng_seed_hash,
|
||||
'status' => $batch->status,
|
||||
'created_by' => $batch->created_by,
|
||||
'confirmed_by' => $batch->confirmed_by,
|
||||
'confirmed_at' => $batch->confirmed_at?->toIso8601String(),
|
||||
'created_at' => $batch->created_at?->toIso8601String(),
|
||||
'updated_at' => $batch->updated_at?->toIso8601String(),
|
||||
'items' => $batch->items->map(fn (DrawResultItem $item) => [
|
||||
'prize_type' => $item->prize_type,
|
||||
'prize_index' => (int) $item->prize_index,
|
||||
'number_4d' => $item->number_4d,
|
||||
'suffix_3d' => $item->suffix_3d,
|
||||
'suffix_2d' => $item->suffix_2d,
|
||||
'head_digit' => $item->head_digit,
|
||||
'tail_digit' => $item->tail_digit,
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Draw;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Support\AdminDrawApiPresenter;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
|
||||
/**
|
||||
@@ -19,41 +19,13 @@ final class AdminDrawShowController extends Controller
|
||||
private readonly DrawHallSnapshotBuilder $hallPreview,
|
||||
) {}
|
||||
|
||||
public function __invoke(Draw $draw): JsonResponse
|
||||
public function __invoke(Request $request, Draw $draw): JsonResponse
|
||||
{
|
||||
$nowUtc = now()->utc();
|
||||
$batchCounts = [
|
||||
'total' => $draw->resultBatches()->count(),
|
||||
'pending_review' => $draw->resultBatches()
|
||||
->where('status', DrawResultBatchStatus::PendingReview->value)
|
||||
->count(),
|
||||
'published' => $draw->resultBatches()
|
||||
->where('status', DrawResultBatchStatus::Published->value)
|
||||
->count(),
|
||||
];
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
return ApiResponse::success([
|
||||
'id' => (int) $draw->id,
|
||||
'draw_no' => $draw->draw_no,
|
||||
'business_date' => $draw->business_date instanceof Carbon
|
||||
? $draw->business_date->format('Y-m-d')
|
||||
: (string) $draw->business_date,
|
||||
'sequence_no' => (int) $draw->sequence_no,
|
||||
/** 数据库当期状态(权威) */
|
||||
'status' => $draw->status,
|
||||
/** 与玩家大厅 snapshot 对齐的展示态(未跑 tick 时可能与 status 不一致) */
|
||||
'hall_preview_status' => $this->hallPreview->effectiveHallDisplayStatus($draw, $nowUtc),
|
||||
'start_time' => $draw->start_time?->toIso8601String(),
|
||||
'close_time' => $draw->close_time?->toIso8601String(),
|
||||
'draw_time' => $draw->draw_time?->toIso8601String(),
|
||||
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
|
||||
'result_source' => $draw->result_source,
|
||||
'current_result_version' => (int) $draw->current_result_version,
|
||||
'settle_version' => (int) $draw->settle_version,
|
||||
'is_reopened' => (bool) $draw->is_reopened,
|
||||
'created_at' => $draw->created_at?->toIso8601String(),
|
||||
'updated_at' => $draw->updated_at?->toIso8601String(),
|
||||
'result_batch_counts' => $batchCounts,
|
||||
]);
|
||||
return ApiResponse::success(
|
||||
AdminDrawApiPresenter::show($draw, $admin, $this->hallPreview),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AgentNode;
|
||||
use App\Support\AdminIntegrationSiteAccess;
|
||||
use App\Support\AdminIntegrationSitePresenter;
|
||||
|
||||
@@ -16,9 +17,18 @@ final class AdminIntegrationSiteIndexController extends Controller
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$items = AdminIntegrationSiteAccess::queryFor($admin)
|
||||
->get()
|
||||
->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem($site))
|
||||
$sites = AdminIntegrationSiteAccess::queryFor($admin)->get();
|
||||
$rootSiteIds = AgentNode::query()
|
||||
->where('depth', 0)
|
||||
->whereIn('admin_site_id', $sites->pluck('id'))
|
||||
->pluck('admin_site_id')
|
||||
->flip();
|
||||
|
||||
$items = $sites
|
||||
->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem(
|
||||
$site,
|
||||
isset($rootSiteIds[(int) $site->id]),
|
||||
))
|
||||
->all();
|
||||
|
||||
return ApiResponse::success(['items' => $items]);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Integration;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use App\Models\AdminSite;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Support\AdminIntegrationSiteAccess;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/** GET /api/v1/admin/integration-sites/{admin_site}/secrets */
|
||||
final class AdminIntegrationSiteSecretsController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, AdminSite $admin_site): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
|
||||
return ApiMessage::errorResponse($request, 'admin.site_access_denied', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
|
||||
if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('integration.site.manage')) {
|
||||
return ApiMessage::errorResponse($request, 'admin.permission_denied', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
|
||||
$sso = $admin_site->decryptedSsoJwtSecret();
|
||||
$wallet = $admin_site->decryptedWalletApiKey();
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$admin,
|
||||
$request,
|
||||
moduleCode: 'integration',
|
||||
actionCode: 'reveal_secrets',
|
||||
targetType: 'admin_site',
|
||||
targetId: (string) $admin_site->id,
|
||||
afterJson: ['code' => $admin_site->code],
|
||||
);
|
||||
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
|
||||
|
||||
return ApiResponse::success([
|
||||
'sso_jwt_secret' => is_string($sso) ? $sso : '',
|
||||
'wallet_api_key' => is_string($wallet) ? $wallet : '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Integration;
|
||||
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AuditLogger;
|
||||
@@ -11,8 +10,6 @@ use App\Services\Integration\IntegrationSiteService;
|
||||
use App\Support\AdminIntegrationSitePresenter;
|
||||
use App\Http\Requests\Admin\AdminIntegrationSiteStoreRequest;
|
||||
use App\Http\Middleware\RecordAdminApiAudit;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiMessage;
|
||||
|
||||
final class AdminIntegrationSiteStoreController extends Controller
|
||||
{
|
||||
@@ -23,19 +20,6 @@ final class AdminIntegrationSiteStoreController extends Controller
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
if (! $admin->isSuperAdmin()) {
|
||||
return ApiMessage::errorResponse(
|
||||
$request,
|
||||
'admin.integration_site_store_deprecated',
|
||||
ErrorCode::AdminForbidden->value,
|
||||
['hint' => 'Use POST /api/v1/admin/agent-lines to provision a new agent line.'],
|
||||
403,
|
||||
)->withHeaders([
|
||||
'Deprecation' => 'true',
|
||||
'Link' => '</api/v1/admin/agent-lines>; rel="successor-version"',
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $service->create($request->validated());
|
||||
$site = $result['site'];
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Player;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiResponse;
|
||||
@@ -10,7 +11,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminSiteScope;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/** DELETE /api/v1/admin/players/{player} */
|
||||
final class AdminPlayerDestroyController extends Controller
|
||||
@@ -26,16 +27,15 @@ final class AdminPlayerDestroyController extends Controller
|
||||
|
||||
$hasWallets = Player::query()
|
||||
->whereKey($player->getKey())
|
||||
->whereHas('wallets', static fn (HasMany $q) => $q->whereRaw('balance != 0'))
|
||||
->whereHas('wallets', static fn (Builder $q) => $q->where('balance', '!=', 0))
|
||||
->exists();
|
||||
|
||||
if ($hasWallets) {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_wallet_balance_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
|
||||
$hasTickets = Player::query()
|
||||
->whereKey($player->getKey())
|
||||
->whereHas('ticketOrders')
|
||||
$hasTickets = TicketOrder::query()
|
||||
->where('player_id', $player->getKey())
|
||||
->exists();
|
||||
|
||||
if ($hasTickets) {
|
||||
|
||||
@@ -37,7 +37,11 @@ final class AdminPlayerIndexController extends Controller
|
||||
$term = '%'.addcslashes($keyword, '%_\\').'%';
|
||||
$q->where(static function ($sub) use ($term): void {
|
||||
$sub->where('site_player_id', 'like', $term)
|
||||
->orWhere('username', 'like', $term);
|
||||
->orWhere('username', 'like', $term)
|
||||
->orWhere('nickname', 'like', $term);
|
||||
if (ctype_digit($keyword)) {
|
||||
$sub->orWhere('id', (int) $keyword);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ use App\Models\AgentNode;
|
||||
use App\Services\Agent\AgentProfileService;
|
||||
use App\Services\Agent\RebateLimitValidator;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** POST /api/v1/admin/players */
|
||||
final class AdminPlayerStoreController extends Controller
|
||||
@@ -46,15 +50,44 @@ final class AdminPlayerStoreController extends Controller
|
||||
return ApiMessage::errorResponse($request, 'admin.player_create_site_forbidden', ErrorCode::AdminForbidden->value, null, 403);
|
||||
}
|
||||
|
||||
$isNative = $request->filled('password');
|
||||
$sitePlayerId = (string) ($request->validated('site_player_id') ?? '');
|
||||
if ($isNative) {
|
||||
$sitePlayerId = $sitePlayerId !== ''
|
||||
? $sitePlayerId
|
||||
: 'native:'.Str::lower(Str::ulid());
|
||||
}
|
||||
|
||||
if ($sitePlayerId === '') {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_site_player_id_required', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
|
||||
$exists = Player::query()
|
||||
->where('site_code', $request->validated('site_code'))
|
||||
->where('site_player_id', $request->validated('site_player_id'))
|
||||
->where('site_code', $siteCode)
|
||||
->where('site_player_id', $sitePlayerId)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_already_registered', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
|
||||
if ($isNative) {
|
||||
$username = trim((string) $request->validated('username', ''));
|
||||
if ($username === '') {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_native_username_required', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
|
||||
$usernameTaken = Player::query()
|
||||
->where('site_code', $siteCode)
|
||||
->where('username', $username)
|
||||
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE)
|
||||
->exists();
|
||||
|
||||
if ($usernameTaken) {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_username_taken', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
}
|
||||
|
||||
$agentNodeId = $admin->isSuperAdmin()
|
||||
? $this->resolveAgentNodeIdForSuperAdmin($request->validated('agent_node_id'), $siteCode)
|
||||
: $admin->primaryAgentNodeId();
|
||||
@@ -85,33 +118,58 @@ final class AdminPlayerStoreController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $request->validated('site_code'),
|
||||
'agent_node_id' => $agentNodeId,
|
||||
'site_player_id' => $request->validated('site_player_id'),
|
||||
'username' => $request->validated('username'),
|
||||
'nickname' => $request->validated('nickname'),
|
||||
'default_currency' => $request->validated('default_currency', 'NPR'),
|
||||
'status' => $request->validated('status', 0),
|
||||
]);
|
||||
$creditLimit = $request->has('credit_limit')
|
||||
? (int) $request->input('credit_limit', 0)
|
||||
: ($isNative ? 0 : 0);
|
||||
|
||||
if ($request->has('credit_limit')) {
|
||||
$playerCreditService->upsertAccount($player, [
|
||||
'credit_limit' => (int) $request->input('credit_limit', 0),
|
||||
]);
|
||||
}
|
||||
$player = \Illuminate\Support\Facades\DB::transaction(function () use (
|
||||
$agent,
|
||||
$agentProfileService,
|
||||
$playerCreditService,
|
||||
$request,
|
||||
$isNative,
|
||||
$siteCode,
|
||||
$agentNodeId,
|
||||
$sitePlayerId,
|
||||
$creditLimit,
|
||||
): Player {
|
||||
$agentProfileService->assertMayIncreasePlayerCredit($agent, $creditLimit);
|
||||
|
||||
if ($request->has('rebate_rate')) {
|
||||
\Illuminate\Support\Facades\DB::table('player_rebate_profiles')->insert([
|
||||
'player_id' => $player->id,
|
||||
'game_type' => '*',
|
||||
'inherit_from_agent' => false,
|
||||
'rebate_rate' => (float) $request->input('rebate_rate', 0),
|
||||
'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $agentNodeId,
|
||||
'site_player_id' => $sitePlayerId,
|
||||
'auth_source' => $isNative ? PlayerAuthSource::LOTTERY_NATIVE : PlayerAuthSource::MAIN_SITE_SSO,
|
||||
'funding_mode' => $isNative ? PlayerFundingMode::CREDIT : PlayerFundingMode::WALLET,
|
||||
'username' => $isNative ? trim((string) $request->validated('username')) : $request->validated('username'),
|
||||
'password_hash' => $isNative ? Hash::make((string) $request->validated('password')) : null,
|
||||
'nickname' => $request->validated('nickname'),
|
||||
'default_currency' => $request->validated('default_currency', 'NPR'),
|
||||
'status' => $request->validated('status', 0),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->has('credit_limit') || $isNative) {
|
||||
$playerCreditService->upsertAccount($player, [
|
||||
'credit_limit' => $creditLimit,
|
||||
]);
|
||||
}
|
||||
|
||||
$agentProfileService->refreshAllocatedCredit($agent);
|
||||
|
||||
if ($request->has('rebate_rate')) {
|
||||
\Illuminate\Support\Facades\DB::table('player_rebate_profiles')->insert([
|
||||
'player_id' => $player->id,
|
||||
'game_type' => '*',
|
||||
'inherit_from_agent' => false,
|
||||
'rebate_rate' => (float) $request->input('rebate_rate', 0),
|
||||
'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $player;
|
||||
});
|
||||
|
||||
return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -2,20 +2,31 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Player;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminSiteScope;
|
||||
use App\Support\PlayerApiPresenter;
|
||||
use App\Http\Requests\Admin\AdminPlayerUpdateRequest;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\Player;
|
||||
use App\Services\Agent\AgentProfileService;
|
||||
use App\Services\Agent\RebateLimitValidator;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use App\Services\Player\PlayerRebateProfileService;
|
||||
use App\Support\AdminSiteScope;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\PlayerApiPresenter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** PUT /api/v1/admin/players/{player} */
|
||||
final class AdminPlayerUpdateController extends Controller
|
||||
{
|
||||
public function __invoke(AdminPlayerUpdateRequest $request, Player $player): JsonResponse
|
||||
{
|
||||
public function __invoke(
|
||||
AdminPlayerUpdateRequest $request,
|
||||
Player $player,
|
||||
AgentProfileService $agentProfileService,
|
||||
PlayerCreditService $playerCreditService,
|
||||
RebateLimitValidator $rebateLimitValidator,
|
||||
PlayerRebateProfileService $rebateProfileService,
|
||||
): JsonResponse {
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
@@ -29,7 +40,57 @@ final class AdminPlayerUpdateController extends Controller
|
||||
$data['status'] = (int) $data['status'];
|
||||
}
|
||||
|
||||
$player->fill(array_filter($data, static fn ($v) => $v !== ''));
|
||||
$agent = $player->agent_node_id !== null
|
||||
? AgentNode::query()->find((int) $player->agent_node_id)
|
||||
: null;
|
||||
|
||||
if ($agent !== null && $request->has('rebate_rate')) {
|
||||
$rebateLimitValidator->assertPlayerRebateWithinAgent(
|
||||
$agent,
|
||||
(float) $request->input('rebate_rate', 0),
|
||||
(float) $request->input('extra_rebate_rate', 0),
|
||||
);
|
||||
}
|
||||
|
||||
if ($request->has('credit_limit') && $agent !== null) {
|
||||
$newLimit = (int) $request->input('credit_limit', 0);
|
||||
$creditRow = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
|
||||
$previous = (int) ($creditRow->credit_limit ?? 0);
|
||||
$usedCredit = (int) ($creditRow->used_credit ?? 0) + (int) ($creditRow->frozen_credit ?? 0);
|
||||
$agentProfileService->adjustPlayerCreditAllocation($agent, $previous, $newLimit, $usedCredit);
|
||||
$playerCreditService->upsertAccount($player, ['credit_limit' => $newLimit]);
|
||||
$agentProfileService->refreshAllocatedCredit($agent);
|
||||
unset($data['credit_limit']);
|
||||
}
|
||||
|
||||
if ($request->has('rebate_rate')) {
|
||||
DB::table('player_rebate_profiles')->updateOrInsert(
|
||||
['player_id' => $player->id, 'game_type' => '*'],
|
||||
[
|
||||
'inherit_from_agent' => false,
|
||||
'rebate_rate' => (float) $request->input('rebate_rate', 0),
|
||||
'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0),
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
],
|
||||
);
|
||||
unset($data['rebate_rate'], $data['extra_rebate_rate']);
|
||||
}
|
||||
|
||||
if ($request->has('rebate_profiles') && $agent !== null) {
|
||||
$rebateProfileService->syncProfiles($player->id, $agent, $request->input('rebate_profiles', []));
|
||||
unset($data['rebate_profiles']);
|
||||
}
|
||||
|
||||
if ($request->has('risk_tags')) {
|
||||
$player->risk_tags = array_values(array_unique(array_filter(
|
||||
array_map('strval', $request->input('risk_tags', [])),
|
||||
)));
|
||||
unset($data['risk_tags']);
|
||||
}
|
||||
|
||||
unset($data['rebate_profiles']);
|
||||
$player->fill(array_filter($data, static fn ($v) => $v !== '' && ! is_array($v)));
|
||||
$player->save();
|
||||
|
||||
return ApiResponse::success(PlayerApiPresenter::listItem($player));
|
||||
|
||||
@@ -32,7 +32,7 @@ final class AdminReportRebateCommissionController extends Controller
|
||||
$scope,
|
||||
);
|
||||
|
||||
return AdminApiList::json($paginator, static function (object $row): array {
|
||||
$response = AdminApiList::json($paginator, static function (object $row): array {
|
||||
return [
|
||||
'play_code' => (string) $row->play_code,
|
||||
'total_rebate_minor' => (int) $row->total_rebate_minor,
|
||||
@@ -40,5 +40,13 @@ final class AdminReportRebateCommissionController extends Controller
|
||||
'ticket_item_count' => (int) $row->ticket_item_count,
|
||||
];
|
||||
});
|
||||
|
||||
$payload = $response->getData(true);
|
||||
if (is_array($payload)) {
|
||||
$payload['disclaimer'] = 'wallet_instant_rebate_not_agent_period_settlement';
|
||||
$response->setData($payload);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminAccountScopeGuard;
|
||||
use App\Support\AdminRoleApiPresenter;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
|
||||
final class AdminRoleDestroyController extends Controller
|
||||
{
|
||||
@@ -19,8 +20,8 @@ final class AdminRoleDestroyController extends Controller
|
||||
{
|
||||
AdminAccountScopeGuard::assertSystemRole($admin_role);
|
||||
|
||||
if ($admin_role->slug === AdminRole::ROLE_SUPER_ADMIN) {
|
||||
return ApiMessage::errorResponse($request, 'admin.role_cannot_delete_super_admin', ErrorCode::ValidationFailed->value, null, 422);
|
||||
if (PlatformSystemRoles::isFixedSlug((string) $admin_role->slug)) {
|
||||
return ApiMessage::errorResponse($request, 'admin.role_builtin_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
if ((bool) $admin_role->is_system) {
|
||||
return ApiMessage::errorResponse($request, 'admin.role_builtin_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminRoleApiPresenter;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
|
||||
final class AdminRoleIndexController extends Controller
|
||||
{
|
||||
@@ -14,6 +15,7 @@ final class AdminRoleIndexController extends Controller
|
||||
{
|
||||
$roles = AdminRole::query()
|
||||
->where('scope_type', AdminRole::SCOPE_SYSTEM)
|
||||
->whereIn('slug', PlatformSystemRoles::fixedSlugs())
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
@@ -9,8 +9,11 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminPermissionInheritance;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\AdminAccountScopeGuard;
|
||||
use App\Support\AdminRoleApiPresenter;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
use App\Http\Requests\Admin\AdminRolePermissionSyncRequest;
|
||||
|
||||
final class AdminRolePermissionSyncController extends Controller
|
||||
@@ -19,6 +22,16 @@ final class AdminRolePermissionSyncController extends Controller
|
||||
{
|
||||
AdminAccountScopeGuard::assertSystemRole($admin_role);
|
||||
|
||||
if ($admin_role->slug === PlatformSystemRoles::SLUG_SUPER_ADMIN) {
|
||||
return ApiMessage::errorResponse(
|
||||
$request,
|
||||
'admin.role_super_admin_permissions_fixed',
|
||||
ErrorCode::ValidationFailed->value,
|
||||
null,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
$slugs = AdminPermissionInheritance::expand(
|
||||
array_values(array_unique($request->validated('permission_slugs', []))),
|
||||
);
|
||||
|
||||
@@ -2,53 +2,22 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\User;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminPermissionInheritance;
|
||||
use App\Support\AdminRoleApiPresenter;
|
||||
use App\Http\Requests\Admin\AdminRoleStoreRequest;
|
||||
|
||||
final class AdminRoleStoreController extends Controller
|
||||
{
|
||||
public function __invoke(AdminRoleStoreRequest $request): JsonResponse
|
||||
{
|
||||
$permissionSlugs = AdminPermissionInheritance::expand(
|
||||
array_values(array_unique($request->validated('permission_slugs', []))),
|
||||
);
|
||||
|
||||
$role = DB::transaction(function () use ($request, $permissionSlugs): AdminRole {
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => $request->validated('slug'),
|
||||
'code' => $request->validated('slug'),
|
||||
'name' => $request->validated('name'),
|
||||
'description' => $request->validated('description'),
|
||||
'status' => $request->validated('status', 1),
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
'owner_agent_id' => null,
|
||||
'delegated_from_role_id' => null,
|
||||
]);
|
||||
$role->syncLegacyPermissionSlugs($permissionSlugs);
|
||||
|
||||
return $role->fresh();
|
||||
});
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$request->lotteryAdmin(),
|
||||
return ApiMessage::errorResponse(
|
||||
$request,
|
||||
'system',
|
||||
'admin_role.create',
|
||||
'admin_role',
|
||||
(string) $role->id,
|
||||
'admin.platform_roles_fixed',
|
||||
ErrorCode::ValidationFailed->value,
|
||||
null,
|
||||
AdminRoleApiPresenter::item($role),
|
||||
422,
|
||||
);
|
||||
|
||||
return ApiResponse::success(AdminRoleApiPresenter::item($role));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Http\Controllers\Api\V1\Admin\User;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminAccountScopeGuard;
|
||||
use App\Support\AdminRoleApiPresenter;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
use App\Http\Requests\Admin\AdminRoleUpdateRequest;
|
||||
|
||||
final class AdminRoleUpdateController extends Controller
|
||||
@@ -17,6 +20,16 @@ final class AdminRoleUpdateController extends Controller
|
||||
{
|
||||
AdminAccountScopeGuard::assertSystemRole($admin_role);
|
||||
|
||||
if ($admin_role->slug === PlatformSystemRoles::SLUG_SUPER_ADMIN) {
|
||||
return ApiMessage::errorResponse(
|
||||
$request,
|
||||
'admin.role_super_admin_metadata_fixed',
|
||||
ErrorCode::ValidationFailed->value,
|
||||
null,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
$before = AdminRoleApiPresenter::item($admin_role);
|
||||
|
||||
$payload = [];
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminAccountScopeGuard;
|
||||
use App\Support\AdminUserApiPresenter;
|
||||
use App\Support\AdminPlatformUserSiteGuard;
|
||||
use App\Http\Requests\Admin\AdminUserRoleSyncRequest;
|
||||
|
||||
/** PUT /api/v1/admin/admin-users/{admin_user}/roles */
|
||||
@@ -15,10 +16,16 @@ final class AdminUserRoleSyncController extends Controller
|
||||
{
|
||||
public function __invoke(AdminUserRoleSyncRequest $request, AdminUser $admin_user): JsonResponse
|
||||
{
|
||||
/** @var AdminUser $actor */
|
||||
$actor = $request->lotteryAdmin();
|
||||
|
||||
AdminAccountScopeGuard::assertPlatformAccount($admin_user);
|
||||
|
||||
$siteId = (int) $request->validated('admin_site_id');
|
||||
AdminPlatformUserSiteGuard::assertActorCanAssignSite($actor, $siteId);
|
||||
|
||||
$slugs = array_values(array_unique($request->validated('role_slugs')));
|
||||
$admin_user->syncSystemRoleSlugs($slugs);
|
||||
$admin_user->syncSystemRoleSlugsForSite($siteId, $slugs);
|
||||
|
||||
$admin_user->load('roles');
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminUserApiPresenter;
|
||||
use App\Support\AdminPlatformUserSiteGuard;
|
||||
use App\Http\Requests\Admin\AdminUserStoreRequest;
|
||||
|
||||
/**
|
||||
@@ -28,8 +29,10 @@ final class AdminUserStoreController extends Controller
|
||||
: null;
|
||||
|
||||
$roleSlugs = array_values(array_unique($request->validated('role_slugs')));
|
||||
$siteId = (int) $request->validated('admin_site_id');
|
||||
AdminPlatformUserSiteGuard::assertActorCanAssignSite($actor, $siteId);
|
||||
|
||||
$user = DB::transaction(function () use ($request, $email, $roleSlugs): AdminUser {
|
||||
$user = DB::transaction(function () use ($request, $email, $roleSlugs, $siteId): AdminUser {
|
||||
$created = AdminUser::query()->create([
|
||||
'username' => $request->validated('username'),
|
||||
'name' => $request->validated('nickname'),
|
||||
@@ -37,7 +40,7 @@ final class AdminUserStoreController extends Controller
|
||||
'password' => $request->validated('password'),
|
||||
'status' => $request->validated('status', 0),
|
||||
]);
|
||||
$created->syncSystemRoleSlugs($roleSlugs);
|
||||
$created->syncSystemRoleSlugsForSite($siteId, $roleSlugs);
|
||||
|
||||
return $created;
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Wallet;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\PaginationTrait;
|
||||
@@ -9,8 +10,10 @@ use Illuminate\Http\JsonResponse;
|
||||
use App\Support\AdminScopePolicy;
|
||||
use App\Support\AgentNodeApiPresenter;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\WalletTransactionListRequest;
|
||||
use App\Services\Wallet\PlayerLedgerLogsService;
|
||||
|
||||
/**
|
||||
* 后台:彩票钱包流水列表 {@see wallet_txns}。
|
||||
@@ -33,6 +36,10 @@ final class WalletTransactionListController extends Controller
|
||||
|
||||
private const ALLOWED_STATUS = ['posted', 'pending_reconcile', 'reversed'];
|
||||
|
||||
public function __construct(
|
||||
private readonly PlayerLedgerLogsService $ledgerLogs,
|
||||
) {}
|
||||
|
||||
public function __invoke(WalletTransactionListRequest $request): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
@@ -44,6 +51,20 @@ final class WalletTransactionListController extends Controller
|
||||
$perPage = $this->perPage($request, 'per_page', 10, 100);
|
||||
$page = $this->page($request);
|
||||
|
||||
if (! empty($validated['player_id'])) {
|
||||
$player = Player::query()->find((int) $validated['player_id']);
|
||||
if ($player !== null && PlayerFundingMode::usesCredit($player)) {
|
||||
$credit = $this->ledgerLogs->listForAdminPlayer(
|
||||
$player,
|
||||
$page,
|
||||
$perPage,
|
||||
isset($validated['biz_type']) ? (string) $validated['biz_type'] : null,
|
||||
);
|
||||
|
||||
return ApiResponse::success($credit);
|
||||
}
|
||||
}
|
||||
|
||||
$query = WalletTxn::query()
|
||||
->with([
|
||||
'player:id,site_code,site_player_id,username,nickname,agent_node_id',
|
||||
@@ -141,6 +162,9 @@ final class WalletTransactionListController extends Controller
|
||||
'remark' => $t->remark,
|
||||
'created_at' => $t->created_at?->toIso8601String(),
|
||||
'updated_at' => $t->updated_at?->toIso8601String(),
|
||||
'ledger_source' => 'wallet_txn',
|
||||
'funding_mode' => $p?->funding_mode,
|
||||
'auth_source' => $p?->auth_source,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ final class MeController extends Controller
|
||||
'id' => $player->id,
|
||||
'site_code' => $player->site_code,
|
||||
'site_player_id' => $player->site_player_id,
|
||||
'auth_source' => $player->auth_source,
|
||||
'funding_mode' => $player->funding_mode,
|
||||
'username' => $player->username,
|
||||
'nickname' => $player->nickname,
|
||||
'default_currency' => $player->default_currency,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Player;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Player\PlayerAuthLoginRequest;
|
||||
use App\Services\Player\PlayerNativeAuthService;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Exceptions\PlayerAuthenticationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/** POST /api/v1/player/auth/login — 代理线下玩家账号密码登录 */
|
||||
final class PlayerAuthLoginController extends Controller
|
||||
{
|
||||
public function __invoke(PlayerAuthLoginRequest $request, PlayerNativeAuthService $auth): JsonResponse
|
||||
{
|
||||
try {
|
||||
$data = $auth->login(
|
||||
(string) $request->validated('site_code'),
|
||||
(string) $request->validated('username'),
|
||||
(string) $request->validated('password'),
|
||||
);
|
||||
} catch (PlayerAuthenticationException $e) {
|
||||
return ApiResponse::error(
|
||||
$e->getMessage(),
|
||||
$e->lotteryCode,
|
||||
null,
|
||||
$e->httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse::success($data);
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,12 @@ use App\Lottery\ErrorCode;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Support\CurrencyResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Support\CreditAmountScale;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Wallet\HttpMainSiteWalletBalanceClient;
|
||||
@@ -44,6 +47,37 @@ final class WalletBalanceController extends Controller
|
||||
return $currencyCode;
|
||||
}
|
||||
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
$credit = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
|
||||
$limitMajor = (int) ($credit->credit_limit ?? 0);
|
||||
$usedMajor = (int) ($credit->used_credit ?? 0);
|
||||
$frozenMajor = (int) ($credit->frozen_credit ?? 0);
|
||||
$availableMajor = max(0, $limitMajor - $usedMajor - $frozenMajor);
|
||||
|
||||
$limitMinor = CreditAmountScale::majorToMinor($limitMajor, $currencyCode);
|
||||
$usedMinor = CreditAmountScale::majorToMinor($usedMajor, $currencyCode);
|
||||
$frozenMinor = CreditAmountScale::majorToMinor($frozenMajor, $currencyCode);
|
||||
$availableMinor = CreditAmountScale::majorToMinor($availableMajor, $currencyCode);
|
||||
|
||||
return ApiResponse::success([
|
||||
'balance' => $limitMinor,
|
||||
'balance_formatted' => CurrencyFormatter::fromMinor($limitMinor),
|
||||
'available_balance' => $availableMinor,
|
||||
'available_balance_formatted' => CurrencyFormatter::fromMinor($availableMinor),
|
||||
'credit_limit' => $limitMinor,
|
||||
'used_credit' => $usedMinor,
|
||||
'credit_line_mode' => true,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'auth_source' => $player->auth_source,
|
||||
'main_balance' => null,
|
||||
'main_balance_formatted' => null,
|
||||
'currency_code' => $currencyCode,
|
||||
'wallet_type' => self::WALLET_TYPE_LOTTERY,
|
||||
'frozen_balance' => $frozenMinor,
|
||||
'frozen_balance_formatted' => CurrencyFormatter::fromMinor($frozenMinor),
|
||||
]);
|
||||
}
|
||||
|
||||
$wallet = PlayerWallet::query()->firstOrCreate(
|
||||
[
|
||||
'player_id' => $player->id,
|
||||
@@ -69,6 +103,9 @@ final class WalletBalanceController extends Controller
|
||||
'balance_formatted' => CurrencyFormatter::fromMinor($balance),
|
||||
'available_balance' => $available,
|
||||
'available_balance_formatted' => CurrencyFormatter::fromMinor($available),
|
||||
'credit_line_mode' => false,
|
||||
'funding_mode' => PlayerFundingMode::WALLET,
|
||||
'auth_source' => $player->auth_source,
|
||||
'main_balance' => $mainBalance,
|
||||
'main_balance_formatted' => $mainBalance !== null
|
||||
? CurrencyFormatter::fromMinor($mainBalance)
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Wallet;
|
||||
|
||||
use App\Models\WalletTxn;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\TransferOrder;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\TransferOrder;
|
||||
use App\Support\PaginationTrait;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Wallet\PlayerLedgerLogsService;
|
||||
|
||||
/**
|
||||
* PRD §10.1.1:`GET /api/v1/wallet/logs` — 钱包流水。
|
||||
* PRD §10.1.1:`GET /api/v1/wallet/logs` — 钱包/信用流水(按玩家资金模式分表)。
|
||||
*
|
||||
* Query:`page`、`size`(每页条数,默认 20)、`type`(逗号分隔:transfer_in,transfer_out,bet,prize,refund,reversal)
|
||||
*/
|
||||
@@ -21,15 +20,9 @@ final class WalletLogsController extends Controller
|
||||
{
|
||||
use PaginationTrait;
|
||||
|
||||
/** PRD 对外类型 → 本地 biz_type */
|
||||
private const TYPE_TO_BIZ = [
|
||||
'transfer_in' => ['transfer_in'],
|
||||
'transfer_out' => ['transfer_out'],
|
||||
'refund' => ['transfer_out_refund'],
|
||||
'reversal' => ['reversal', 'bet_reverse'],
|
||||
'bet' => ['bet_deduct', 'bet'],
|
||||
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
|
||||
];
|
||||
public function __construct(
|
||||
private readonly PlayerLedgerLogsService $ledgerLogs,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
@@ -38,45 +31,21 @@ final class WalletLogsController extends Controller
|
||||
|
||||
$perPage = $this->perPage($request, 'size', 20, 100);
|
||||
$page = $this->page($request);
|
||||
|
||||
$currencyCode = strtoupper(trim((string) $request->query('currency', '')));
|
||||
$typeFilter = (string) $request->query('type', '');
|
||||
|
||||
$pendingPayload = $this->pendingReconcilePayload((int) $player->id, $currencyCode);
|
||||
|
||||
$bizFilter = $this->resolveBizTypeFilter((string) $request->query('type', ''));
|
||||
|
||||
if (is_array($bizFilter) && $bizFilter === []) {
|
||||
return ApiResponse::success([
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'pending_reconcile' => $pendingPayload,
|
||||
]);
|
||||
}
|
||||
|
||||
$query = WalletTxn::query()
|
||||
->where('player_id', $player->id)
|
||||
->with('wallet')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($currencyCode !== '') {
|
||||
$query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode));
|
||||
}
|
||||
|
||||
if ($bizFilter !== null) {
|
||||
$query->whereIn('biz_type', $bizFilter);
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$items = $paginator->getCollection()->map(fn (WalletTxn $txn) => $this->formatTxn($txn));
|
||||
$result = $this->ledgerLogs->listForPlayerApi($player, $page, $perPage, $currencyCode, $typeFilter);
|
||||
|
||||
return ApiResponse::success([
|
||||
'items' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'items' => $result['items'],
|
||||
'total' => $result['total'],
|
||||
'page' => $result['page'],
|
||||
'per_page' => $result['per_page'],
|
||||
'ledger_source' => $result['ledger_source'],
|
||||
'funding_mode' => $result['funding_mode'],
|
||||
'auth_source' => $result['auth_source'],
|
||||
'pending_reconcile' => $pendingPayload,
|
||||
]);
|
||||
}
|
||||
@@ -97,84 +66,6 @@ final class WalletLogsController extends Controller
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null null 表示不过滤;空列表表示过滤后无合法 type(结果应为空)
|
||||
*/
|
||||
private function resolveBizTypeFilter(string $raw): ?array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = array_filter(array_map('trim', explode(',', $raw)));
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$biz = [];
|
||||
foreach ($parts as $p) {
|
||||
$key = Str::lower($p);
|
||||
if (! isset(self::TYPE_TO_BIZ[$key])) {
|
||||
continue;
|
||||
}
|
||||
foreach (self::TYPE_TO_BIZ[$key] as $b) {
|
||||
$biz[] = $b;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($biz));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatTxn(WalletTxn $txn): array
|
||||
{
|
||||
$currency = $txn->wallet?->currency_code ?? '';
|
||||
$amount = (int) $txn->amount;
|
||||
$balanceAfter = (int) $txn->balance_after;
|
||||
|
||||
return [
|
||||
'log_id' => $txn->txn_no,
|
||||
'type' => $this->bizToPublicType((string) $txn->biz_type),
|
||||
'biz_type' => $txn->biz_type,
|
||||
'amount' => $this->signedAmount($txn),
|
||||
'amount_formatted' => CurrencyFormatter::fromMinor($amount),
|
||||
'amount_abs' => $amount,
|
||||
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount),
|
||||
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
|
||||
'currency_code' => $currency,
|
||||
'balance_after' => $balanceAfter,
|
||||
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter),
|
||||
'ref_id' => $txn->biz_no,
|
||||
'idempotent_key' => $txn->idempotent_key,
|
||||
'external_ref_no' => $txn->external_ref_no,
|
||||
'status' => $txn->status,
|
||||
'remark' => $txn->remark,
|
||||
'created_at' => $txn->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function bizToPublicType(string $biz): string
|
||||
{
|
||||
return match ($biz) {
|
||||
'transfer_out_refund' => 'refund',
|
||||
'bet_deduct', 'bet' => 'bet',
|
||||
'bet_reverse' => 'reversal',
|
||||
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
|
||||
'reversal' => 'reversal',
|
||||
default => $biz,
|
||||
};
|
||||
}
|
||||
|
||||
private function signedAmount(WalletTxn $txn): int
|
||||
{
|
||||
$a = (int) $txn->amount;
|
||||
|
||||
return (int) $txn->direction === 1 ? $a : -$a;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -4,12 +4,15 @@ namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules;
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use App\Rules\WalletApiUrlRule;
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
final class AdminAgentLineStoreRequest extends ApiFormRequest
|
||||
{
|
||||
use AgentProfileFieldRules;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
@@ -18,29 +21,56 @@ final class AdminAgentLineStoreRequest extends ApiFormRequest
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->prepareAgentProfileFieldsForValidation();
|
||||
|
||||
if ($this->has('site_code')) {
|
||||
$this->merge([
|
||||
'site_code' => strtolower(trim((string) $this->input('site_code'))),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->has('code')) {
|
||||
$this->merge([
|
||||
'code' => strtolower(trim((string) $this->input('code'))),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('admin_sites', 'code')],
|
||||
'site_code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::exists('admin_sites', 'code')],
|
||||
'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('agent_nodes', 'code')],
|
||||
'name' => ['required', 'string', 'max:128'],
|
||||
'username' => ['required', 'string', 'max:64'],
|
||||
'username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')],
|
||||
'password' => ['required', 'string', 'min:8', 'max:128'],
|
||||
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('admin_users', 'email')],
|
||||
'currency_code' => ['sometimes', 'string', 'max:16'],
|
||||
'status' => ['sometimes', 'integer', 'in:0,1'],
|
||||
'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()],
|
||||
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],
|
||||
'wallet_credit_path' => ['sometimes', 'string', 'max:128'],
|
||||
'wallet_balance_path' => ['sometimes', 'string', 'max:128'],
|
||||
'wallet_timeout_seconds' => ['sometimes', 'integer', 'min:1', 'max:120'],
|
||||
'iframe_allowed_origins' => ['nullable', 'array'],
|
||||
'iframe_allowed_origins.*' => ['string', 'max:512'],
|
||||
'lottery_h5_base_url' => ['nullable', 'string', 'max:512'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
...$this->agentProfileFieldRules(),
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$siteCode = (string) $this->input('site_code');
|
||||
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||
if ($site === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasRoot = AgentNode::query()
|
||||
->where('admin_site_id', $site->id)
|
||||
->where('depth', 0)
|
||||
->exists();
|
||||
|
||||
if ($hasRoot) {
|
||||
$validator->errors()->add('site_code', 'site_root_exists');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ final class AdminPlayerStoreRequest extends ApiFormRequest
|
||||
{
|
||||
return [
|
||||
'site_code' => ['required', 'string', 'max:64'],
|
||||
'site_player_id' => ['required', 'string', 'max:128'],
|
||||
'site_player_id' => ['nullable', 'string', 'max:128'],
|
||||
'username' => ['nullable', 'string', 'max:128'],
|
||||
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
||||
'nickname' => ['nullable', 'string', 'max:128'],
|
||||
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
|
||||
'status' => ['sometimes', 'integer', 'in:0,1,2'],
|
||||
|
||||
@@ -24,6 +24,16 @@ final class AdminPlayerUpdateRequest extends ApiFormRequest
|
||||
'nickname' => ['sometimes', 'nullable', 'string', 'max:128'],
|
||||
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
|
||||
'status' => ['sometimes', 'integer', Rule::in([0, 1, 2])],
|
||||
'credit_limit' => ['sometimes', 'integer', 'min:0'],
|
||||
'rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
|
||||
'extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
|
||||
'rebate_profiles' => ['sometimes', 'array'],
|
||||
'rebate_profiles.*.game_type' => ['required_with:rebate_profiles', 'string', 'max:32'],
|
||||
'rebate_profiles.*.rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
|
||||
'rebate_profiles.*.extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
|
||||
'rebate_profiles.*.inherit_from_agent' => ['sometimes', 'boolean'],
|
||||
'risk_tags' => ['sometimes', 'array'],
|
||||
'risk_tags.*' => ['string', 'max:64'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
final class AdminSettlementBillAdjustmentRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'integer', 'not_in:0'],
|
||||
'adjustment_type' => ['sometimes', 'string', 'in:adjustment,reversal'],
|
||||
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
final class AdminSettlementBillBadDebtRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
final class AdminSettlementBillPaymentRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'integer', 'min:1'],
|
||||
'method' => ['sometimes', 'nullable', 'string', 'max:32'],
|
||||
'proof' => ['sometimes', 'nullable', 'string', 'max:2000'],
|
||||
'remark' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ final class AdminUserRoleSyncRequest extends ApiFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'admin_site_id' => ['required', 'integer', 'exists:admin_sites,id'],
|
||||
'role_slugs' => ['required', 'array', 'min:1'],
|
||||
'role_slugs.*' => ['string', 'max:64', 'distinct', 'exists:admin_roles,slug'],
|
||||
];
|
||||
|
||||
@@ -52,6 +52,7 @@ final class AdminUserStoreRequest extends ApiFormRequest
|
||||
'email' => ['nullable', 'string', 'email', 'max:255'],
|
||||
'password' => ['required', 'string', 'min:8', 'max:256'],
|
||||
'status' => ['sometimes', 'integer', 'in:0,1'],
|
||||
'admin_site_id' => ['required', 'integer', 'exists:admin_sites,id'],
|
||||
'role_slugs' => ['required', 'array', 'min:1'],
|
||||
'role_slugs.*' => ['string', 'max:64', 'distinct', 'exists:admin_roles,slug'],
|
||||
];
|
||||
|
||||
@@ -18,6 +18,8 @@ trait AgentProfileFieldRules
|
||||
'can_grant_extra_rebate' => ['sometimes', 'boolean'],
|
||||
'can_create_child_agent' => ['sometimes', 'boolean'],
|
||||
'can_create_player' => ['sometimes', 'boolean'],
|
||||
'risk_tags' => ['sometimes', 'array'],
|
||||
'risk_tags.*' => ['string', 'max:64'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
22
app/Http/Requests/Player/PlayerAuthLoginRequest.php
Normal file
22
app/Http/Requests/Player/PlayerAuthLoginRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Player;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
final class PlayerAuthLoginRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'site_code' => ['required', 'string', 'max:64'],
|
||||
'username' => ['required', 'string', 'max:128'],
|
||||
'password' => ['required', 'string', 'min:6', 'max:128'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ enum ErrorCode: int
|
||||
/** 幂等键与已有订单冲突(金额/币种/方向不一致) */
|
||||
case WalletIdempotentConflict = 1010;
|
||||
|
||||
/** 信用盘玩家不可主站钱包划转 */
|
||||
case WalletCreditPlayerNoTransfer = 1011;
|
||||
|
||||
/* ========== 2000–2999 下注 / 注单(PRD 保留,业务未实现时亦可提前登记) ========== */
|
||||
|
||||
/** PRD:当期已封盘 */
|
||||
@@ -112,6 +115,15 @@ enum ErrorCode: int
|
||||
/** 账号已冻结或禁止登录(status ≠ active) */
|
||||
case PlayerAccountSuspended = 8005;
|
||||
|
||||
/** 原生登录:账号或密码错误 */
|
||||
case PlayerCredentialsInvalid = 8006;
|
||||
|
||||
/** 原生登录:失败次数过多已锁定 */
|
||||
case PlayerLoginLocked = 8007;
|
||||
|
||||
/** 原生登录:非彩票账号密码登录类型 */
|
||||
case PlayerNativeLoginRequired = 8008;
|
||||
|
||||
/* ========== 8100–8199 管理端 API ========== */
|
||||
|
||||
/** 未登录或 Token 无效 */
|
||||
|
||||
@@ -103,6 +103,24 @@ final class AdminRole extends Model
|
||||
return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($codes);
|
||||
}
|
||||
|
||||
/** 授予当前库中全部启用的 menu_action(用于超级管理员)。 */
|
||||
public function syncAllActiveMenuActions(): void
|
||||
{
|
||||
$ids = DB::table('admin_menu_actions')
|
||||
->where('status', 1)
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
DB::table('admin_role_menu_actions')->where('role_id', $this->id)->delete();
|
||||
foreach ($ids as $mid) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $this->id,
|
||||
'menu_action_id' => $mid,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $slugs
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,10 @@ namespace App\Models;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\AdminPermissionBridge;
|
||||
use App\Support\AgentProfileCapabilityFilter;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
@@ -122,6 +125,12 @@ final class AdminUser extends Authenticatable
|
||||
});
|
||||
}
|
||||
|
||||
/** 经营代理主账号:仅平台角色 slug=agent(见 {@see \App\Support\AgentPlatformRole})。 */
|
||||
public function syncPrimaryPlatformAgentRole(int $agentNodeId): void
|
||||
{
|
||||
$this->syncAgentRoleIds($agentNodeId, [\App\Support\AgentPlatformRole::id()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -210,13 +219,22 @@ final class AdminUser extends Authenticatable
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台账号角色同步:仅允许系统角色,不同步代理角色。
|
||||
* 平台账号角色同步:仅允许系统角色,不同步代理角色(默认站点,兼容旧调用)。
|
||||
*
|
||||
* @param list<string> $slugs
|
||||
*/
|
||||
public function syncSystemRoleSlugs(array $slugs): void
|
||||
{
|
||||
$siteId = self::defaultAdminSiteId();
|
||||
$this->syncSystemRoleSlugsForSite(self::defaultAdminSiteId(), $slugs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台账号在指定站点上的系统角色(全量替换该站点 pivot)。
|
||||
*
|
||||
* @param list<string> $slugs
|
||||
*/
|
||||
public function syncSystemRoleSlugsForSite(int $siteId, array $slugs): void
|
||||
{
|
||||
$slugs = array_values(array_unique($slugs));
|
||||
$roleIds = DB::table('admin_roles')
|
||||
->where('scope_type', AdminRole::SCOPE_SYSTEM)
|
||||
@@ -389,7 +407,19 @@ final class AdminUser extends Authenticatable
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($merged);
|
||||
$codes = array_keys($merged);
|
||||
|
||||
return AgentProfileCapabilityFilter::applyToMenuActionCodes($codes, $this->primaryAgentProfile());
|
||||
}
|
||||
|
||||
private function primaryAgentProfile(): ?AgentProfile
|
||||
{
|
||||
$agentId = $this->primaryAgentNodeId();
|
||||
if ($agentId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AgentProfile::query()->where('agent_node_id', $agentId)->first();
|
||||
}
|
||||
|
||||
/** 是否具备指定权限:`prd.*` 走 legacy_map;否则按 permission_code 精确匹配。含 `super_admin` 全放行。 */
|
||||
|
||||
@@ -18,6 +18,7 @@ final class AgentNode extends Model
|
||||
'code',
|
||||
'name',
|
||||
'status',
|
||||
'risk_tags',
|
||||
'created_by',
|
||||
'extra_json',
|
||||
];
|
||||
@@ -30,6 +31,7 @@ final class AgentNode extends Model
|
||||
'depth' => 'integer',
|
||||
'status' => 'integer',
|
||||
'extra_json' => 'array',
|
||||
'risk_tags' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\PlayerAuthSource;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -15,11 +16,21 @@ final class Player extends Model
|
||||
'site_code',
|
||||
'agent_node_id',
|
||||
'site_player_id',
|
||||
'auth_source',
|
||||
'funding_mode',
|
||||
'username',
|
||||
'password_hash',
|
||||
'nickname',
|
||||
'default_currency',
|
||||
'status',
|
||||
'risk_tags',
|
||||
'last_login_at',
|
||||
'login_failed_count',
|
||||
'login_locked_until',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password_hash',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -27,9 +38,17 @@ final class Player extends Model
|
||||
return [
|
||||
'agent_node_id' => 'integer',
|
||||
'last_login_at' => 'datetime',
|
||||
'login_failed_count' => 'integer',
|
||||
'login_locked_until' => 'datetime',
|
||||
'risk_tags' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function isLotteryNative(): bool
|
||||
{
|
||||
return (string) $this->auth_source === PlayerAuthSource::LOTTERY_NATIVE;
|
||||
}
|
||||
|
||||
public function wallets(): HasMany
|
||||
{
|
||||
return $this->hasMany(PlayerWallet::class);
|
||||
|
||||
@@ -29,6 +29,7 @@ final class AdminDashboardSnapshotBuilder
|
||||
public function __construct(
|
||||
private readonly DrawHallSnapshotBuilder $hallSnapshot,
|
||||
private readonly AdminReportQueryService $reportQuery,
|
||||
private readonly AgentDashboardOverviewBuilder $agentOverview,
|
||||
) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
@@ -55,8 +56,13 @@ final class AdminDashboardSnapshotBuilder
|
||||
'draw_finance_risk' => $canDraw,
|
||||
'wallet_transfer_view' => $canWallet,
|
||||
],
|
||||
'agent_overview' => null,
|
||||
];
|
||||
|
||||
if ($admin->primaryAgentNode() !== null) {
|
||||
$out['agent_overview'] = $this->agentOverview->build($admin);
|
||||
}
|
||||
|
||||
if ($canDraw) {
|
||||
$this->fillPlatformOverview($out, $scope);
|
||||
}
|
||||
|
||||
97
app/Services/Admin/AgentDashboardOverviewBuilder.php
Normal file
97
app/Services/Admin/AgentDashboardOverviewBuilder.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Models\Player;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** 代理账号仪表盘:授信、团队规模、待结账单摘要。 */
|
||||
final class AgentDashboardOverviewBuilder
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function build(AdminUser $admin): ?array
|
||||
{
|
||||
if (! $admin->hasPermissionCode('dashboard.view')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$node = $admin->primaryAgentNode();
|
||||
if ($node === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||
$subtreeIds = AgentNode::query()
|
||||
->where('path', 'like', $node->path.'%')
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
$directChildCount = AgentNode::query()
|
||||
->where('parent_id', $node->id)
|
||||
->count();
|
||||
|
||||
$directPlayerCount = 0;
|
||||
if (Schema::hasColumn('players', 'agent_node_id')) {
|
||||
$directPlayerCount = Player::query()
|
||||
->where('agent_node_id', $node->id)
|
||||
->count();
|
||||
}
|
||||
|
||||
$pendingBillStats = $this->pendingBillStats($admin, $subtreeIds);
|
||||
|
||||
return [
|
||||
'agent_node_id' => (int) $node->id,
|
||||
'agent_code' => (string) $node->code,
|
||||
'agent_name' => (string) $node->name,
|
||||
'depth' => (int) $node->depth,
|
||||
'credit_limit' => (int) ($profile?->credit_limit ?? 0),
|
||||
'allocated_credit' => (int) ($profile?->allocated_credit ?? 0),
|
||||
'used_credit' => (int) ($profile?->used_credit ?? 0),
|
||||
'available_credit' => max(
|
||||
0,
|
||||
(int) ($profile?->credit_limit ?? 0) - (int) ($profile?->allocated_credit ?? 0),
|
||||
),
|
||||
'total_share_rate' => (float) ($profile?->total_share_rate ?? 0),
|
||||
'settlement_cycle' => (string) ($profile?->settlement_cycle ?? 'weekly'),
|
||||
'can_create_child_agent' => $profile === null || $profile->can_create_child_agent,
|
||||
'can_create_player' => $profile === null || $profile->can_create_player,
|
||||
'direct_child_count' => $directChildCount,
|
||||
'subtree_agent_count' => count($subtreeIds),
|
||||
'direct_player_count' => $directPlayerCount,
|
||||
'pending_bill_count' => $pendingBillStats['count'],
|
||||
'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $subtreeIds
|
||||
* @return array{count: int, unpaid_minor: int}
|
||||
*/
|
||||
private function pendingBillStats(AdminUser $admin, array $subtreeIds): array
|
||||
{
|
||||
if ($subtreeIds === []) {
|
||||
return ['count' => 0, 'unpaid_minor' => 0];
|
||||
}
|
||||
|
||||
$query = DB::table('settlement_bills')
|
||||
->where('bill_type', 'agent')
|
||||
->where('owner_type', 'agent')
|
||||
->whereIn('owner_id', $subtreeIds)
|
||||
->whereIn('status', ['pending', 'pending_confirm', 'partial']);
|
||||
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin);
|
||||
|
||||
return [
|
||||
'count' => (int) $query->count(),
|
||||
'unpaid_minor' => (int) $query->sum('unpaid_amount'),
|
||||
];
|
||||
}
|
||||
}
|
||||
55
app/Services/Agent/AgentCreditAllocatedSyncService.php
Normal file
55
app/Services/Agent/AgentCreditAllocatedSyncService.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Agent;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 按「下发即占用」真理源重算代理已下发额度:
|
||||
* 直属玩家 credit_limit 之和 + 直属下级代理 credit_limit 之和。
|
||||
*/
|
||||
final class AgentCreditAllocatedSyncService
|
||||
{
|
||||
public function syncForAgent(AgentNode $agent): void
|
||||
{
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
|
||||
if ($profile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expected = $this->calculateAllocatedCredit($agent);
|
||||
if ((int) $profile->allocated_credit === $expected) {
|
||||
return;
|
||||
}
|
||||
|
||||
$profile->allocated_credit = $expected;
|
||||
$profile->save();
|
||||
}
|
||||
|
||||
public function syncForAgentId(int $agentNodeId): void
|
||||
{
|
||||
$agent = AgentNode::query()->find($agentNodeId);
|
||||
if ($agent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->syncForAgent($agent);
|
||||
}
|
||||
|
||||
public function calculateAllocatedCredit(AgentNode $agent): int
|
||||
{
|
||||
$playerTotal = (int) DB::table('player_credit_accounts as pca')
|
||||
->join('players as p', 'p.id', '=', 'pca.player_id')
|
||||
->where('p.agent_node_id', $agent->id)
|
||||
->sum('pca.credit_limit');
|
||||
|
||||
$childIds = AgentNode::query()->where('parent_id', $agent->id)->pluck('id');
|
||||
$childAgentTotal = $childIds->isEmpty()
|
||||
? 0
|
||||
: (int) AgentProfile::query()->whereIn('agent_node_id', $childIds)->sum('credit_limit');
|
||||
|
||||
return $playerTotal + $childAgentTotal;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Support\AdminUserStatus;
|
||||
use App\Support\AgentPlatformRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -16,25 +17,6 @@ final class AgentNodeService
|
||||
private readonly AgentProfileService $agentProfileService,
|
||||
) {}
|
||||
|
||||
/** @var list<string> */
|
||||
private const BASE_AGENT_ROLE_SLUGS = [
|
||||
'prd.agent.view',
|
||||
'prd.tickets.view',
|
||||
'prd.report.view',
|
||||
'prd.wallet_reconcile.view',
|
||||
'prd.wallet_reconcile.view_cs',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const CHILD_AGENT_MANAGE_SLUGS = ['prd.agent.manage', 'prd.agent.profile.manage'];
|
||||
|
||||
/** @var list<string> */
|
||||
private const PLAYER_MANAGE_SLUGS = [
|
||||
'prd.users.manage',
|
||||
'prd.users.view_finance',
|
||||
'prd.users.view_cs',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* parent_id: int,
|
||||
@@ -120,19 +102,7 @@ final class AgentNodeService
|
||||
$node->path = (string) $parent->path.$node->id.'/';
|
||||
$node->save();
|
||||
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'agent_owner_'.$node->id,
|
||||
'code' => 'agent_owner_'.$node->id,
|
||||
'name' => '代理账号',
|
||||
'description' => '系统自动生成的一代理一账号默认角色',
|
||||
'status' => $status === 0 ? 0 : 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'scope_type' => AdminRole::SCOPE_AGENT,
|
||||
'owner_agent_id' => $node->id,
|
||||
'delegated_from_role_id' => null,
|
||||
]);
|
||||
$role->syncLegacyPermissionSlugs($this->buildRoleSlugsForNewChild($payload, $actor));
|
||||
AgentPlatformRole::resolve();
|
||||
|
||||
$user = AdminUser::query()->create([
|
||||
'username' => $username,
|
||||
@@ -148,9 +118,9 @@ final class AgentNodeService
|
||||
'is_primary' => true,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]);
|
||||
AgentPlatformRole::assignPrimaryOperator($user, $node);
|
||||
|
||||
$profile = $this->agentProfileService->upsertForNode($node, [
|
||||
$this->agentProfileService->upsertForNode($node, [
|
||||
'total_share_rate' => (float) ($payload['total_share_rate'] ?? 0),
|
||||
'credit_limit' => (int) ($payload['credit_limit'] ?? 0),
|
||||
'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0),
|
||||
@@ -161,8 +131,6 @@ final class AgentNodeService
|
||||
'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
|
||||
], $parent);
|
||||
|
||||
$this->syncPrimaryOwnerRoleFromProfile($node, $profile);
|
||||
|
||||
return $node->fresh(['adminSite']);
|
||||
});
|
||||
}
|
||||
@@ -352,32 +320,7 @@ final class AgentNodeService
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($node, $username, $password, $email, $status): AdminUser {
|
||||
$role = AdminRole::query()
|
||||
->where('owner_agent_id', $node->id)
|
||||
->where('slug', 'agent_owner_'.$node->id)
|
||||
->first();
|
||||
|
||||
if ($role === null) {
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'agent_owner_'.$node->id,
|
||||
'code' => 'agent_owner_'.$node->id,
|
||||
'name' => '代理账号',
|
||||
'description' => '系统自动生成的一代理一账号默认角色',
|
||||
'status' => $status === 0 ? 0 : 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'scope_type' => AdminRole::SCOPE_AGENT,
|
||||
'owner_agent_id' => $node->id,
|
||||
'delegated_from_role_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||
$role->syncLegacyPermissionSlugs(
|
||||
$profile !== null
|
||||
? $this->roleSlugsFromProfile($profile)
|
||||
: $this->defaultOwnerRoleSlugs(),
|
||||
);
|
||||
AgentPlatformRole::resolve();
|
||||
|
||||
$user = AdminUser::query()->create([
|
||||
'username' => $username,
|
||||
@@ -393,93 +336,12 @@ final class AgentNodeService
|
||||
'is_primary' => true,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]);
|
||||
AgentPlatformRole::assignPrimaryOperator($user, $node);
|
||||
|
||||
return $user;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function defaultOwnerRoleSlugs(): array
|
||||
{
|
||||
return array_values(array_unique(array_merge(
|
||||
self::BASE_AGENT_ROLE_SLUGS,
|
||||
self::PLAYER_MANAGE_SLUGS,
|
||||
)));
|
||||
}
|
||||
|
||||
public function syncPrimaryOwnerRoleFromProfile(AgentNode $node, ?AgentProfile $profile = null): void
|
||||
{
|
||||
$profile ??= AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||
if ($profile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$role = AdminRole::query()
|
||||
->where('owner_agent_id', $node->id)
|
||||
->where('slug', 'agent_owner_'.$node->id)
|
||||
->first();
|
||||
|
||||
if ($role === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$role->syncLegacyPermissionSlugs($this->roleSlugsFromProfile($profile));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildRoleSlugsForNewChild(array $payload, AdminUser $actor): array
|
||||
{
|
||||
$slugs = self::BASE_AGENT_ROLE_SLUGS;
|
||||
if ((bool) ($payload['can_create_child_agent'] ?? false)) {
|
||||
$slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS);
|
||||
}
|
||||
if ((bool) ($payload['can_create_player'] ?? true)) {
|
||||
$slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS);
|
||||
}
|
||||
|
||||
return $this->filterSlugsByActor($actor, $slugs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roleSlugsFromProfile(AgentProfile $profile): array
|
||||
{
|
||||
$slugs = self::BASE_AGENT_ROLE_SLUGS;
|
||||
if ($profile->can_create_child_agent) {
|
||||
$slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS);
|
||||
}
|
||||
if ($profile->can_create_player) {
|
||||
$slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS);
|
||||
}
|
||||
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $slugs
|
||||
* @return list<string>
|
||||
*/
|
||||
private function filterSlugsByActor(AdminUser $actor, array $slugs): array
|
||||
{
|
||||
if ($actor->isSuperAdmin()) {
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
$mine = array_fill_keys($actor->adminPermissionSlugs(), true);
|
||||
|
||||
return array_values(array_filter(
|
||||
$slugs,
|
||||
static fn (string $slug): bool => isset($mine[$slug]),
|
||||
));
|
||||
}
|
||||
|
||||
private function resolveCodeForCreate(AgentNode $parent, mixed $rawCode, string $username): string
|
||||
{
|
||||
$preferred = trim((string) $rawCode);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Support\AdminAgentScope;
|
||||
use App\Support\AgentOverdueGuard;
|
||||
use App\Support\AgentSettlementCycle;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -16,6 +17,7 @@ final class AgentProfileService
|
||||
private readonly ShareRateValidator $shareRateValidator,
|
||||
private readonly CreditAllocationValidator $creditAllocationValidator,
|
||||
private readonly RebateLimitValidator $rebateLimitValidator,
|
||||
private readonly AgentCreditAllocatedSyncService $allocatedSync,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -39,10 +41,17 @@ final class AgentProfileService
|
||||
$previousCredit = (int) $profile->credit_limit;
|
||||
$isNew = ! $profile->exists;
|
||||
|
||||
if (! $isNew && $creditLimit < (int) $profile->allocated_credit) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['below_allocated'],
|
||||
]);
|
||||
if ($parent !== null && ! $isNew) {
|
||||
$this->allocatedSync->syncForAgent($parent);
|
||||
}
|
||||
|
||||
if (! $isNew) {
|
||||
$this->allocatedSync->syncForAgent($node);
|
||||
if ($creditLimit < (int) $profile->allocated_credit) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['below_allocated'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($parent !== null) {
|
||||
@@ -53,7 +62,7 @@ final class AgentProfileService
|
||||
}
|
||||
|
||||
if ($defaultRebate > $rebateLimit && $rebateLimit > 0) {
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
throw ValidationException::withMessages([
|
||||
'default_player_rebate' => ['exceeds_limit'],
|
||||
]);
|
||||
}
|
||||
@@ -77,14 +86,7 @@ final class AgentProfileService
|
||||
$profile->save();
|
||||
|
||||
if ($parent !== null) {
|
||||
$parentProfile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
|
||||
if ($parentProfile !== null) {
|
||||
$creditDelta = $isNew ? $creditLimit : ($creditLimit - $previousCredit);
|
||||
if ($creditDelta !== 0) {
|
||||
$parentProfile->allocated_credit = max(0, (int) $parentProfile->allocated_credit + $creditDelta);
|
||||
$parentProfile->save();
|
||||
}
|
||||
}
|
||||
$this->allocatedSync->syncForAgent($parent);
|
||||
}
|
||||
|
||||
return $profile;
|
||||
@@ -96,6 +98,9 @@ final class AgentProfileService
|
||||
*/
|
||||
public function present(AgentProfile $profile): array
|
||||
{
|
||||
$this->allocatedSync->syncForAgentId((int) $profile->agent_node_id);
|
||||
$profile->refresh();
|
||||
|
||||
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
|
||||
|
||||
return [
|
||||
@@ -119,6 +124,65 @@ final class AgentProfileService
|
||||
return AgentProfile::query()->where('agent_node_id', $agentNodeId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function parentCapsForNode(?AgentNode $parent): ?array
|
||||
{
|
||||
if ($parent === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->allocatedSync->syncForAgent($parent);
|
||||
|
||||
$profile = $this->profileForNode((int) $parent->id);
|
||||
if ($profile === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'agent_node_id' => (int) $parent->id,
|
||||
'total_share_rate' => (float) $profile->total_share_rate,
|
||||
'rebate_limit' => (float) $profile->rebate_limit,
|
||||
'available_credit' => max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit),
|
||||
];
|
||||
}
|
||||
|
||||
/** 玩家授信写入前:校验代理可下发是否足够(按当前库内已占用重算)。 */
|
||||
public function assertMayIncreasePlayerCredit(AgentNode $agent, int $additionalCredit): void
|
||||
{
|
||||
if ($additionalCredit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertAgentProfileExists($agent);
|
||||
$this->allocatedSync->syncForAgent($agent);
|
||||
$this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $additionalCredit);
|
||||
}
|
||||
|
||||
/** 玩家授信变更后:按直属玩家+直属下级代理重算已下发额度(无 profile 时跳过)。 */
|
||||
public function refreshAllocatedCredit(AgentNode $agent): void
|
||||
{
|
||||
$this->allocatedSync->syncForAgent($agent);
|
||||
}
|
||||
|
||||
public function adjustPlayerCreditAllocation(AgentNode $agent, int $previousLimit, int $newLimit, int $playerUsedCredit = 0): void
|
||||
{
|
||||
if ($newLimit < $playerUsedCredit) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['below_player_used'],
|
||||
]);
|
||||
}
|
||||
|
||||
$delta = $newLimit - $previousLimit;
|
||||
$this->assertAgentProfileExists($agent);
|
||||
$this->allocatedSync->syncForAgent($agent);
|
||||
|
||||
if ($delta > 0) {
|
||||
$this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $delta);
|
||||
}
|
||||
}
|
||||
|
||||
public function assertActorMayCreateChildAgent(AdminUser $admin): void
|
||||
{
|
||||
if ($admin->isSuperAdmin()) {
|
||||
@@ -135,6 +199,12 @@ final class AgentProfileService
|
||||
'parent_id' => ['cannot_create_child_agent'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'parent_id' => ['agent_overdue'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function assertActorMayCreatePlayer(AdminUser $admin): void
|
||||
@@ -153,6 +223,12 @@ final class AgentProfileService
|
||||
'site_code' => ['cannot_create_player'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'site_code' => ['agent_overdue'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,4 +269,15 @@ final class AgentProfileService
|
||||
|
||||
return $profile === null || $profile->can_create_player;
|
||||
}
|
||||
|
||||
private function assertAgentProfileExists(AgentNode $agent): void
|
||||
{
|
||||
if (AgentProfile::query()->where('agent_node_id', $agent->id)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['agent_profile_required'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,41 +2,29 @@
|
||||
|
||||
namespace App\Services\Agent;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Services\Integration\IntegrationSiteService;
|
||||
use App\Support\AdminUserStatus;
|
||||
use App\Support\AgentPlatformRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class AgentSiteProvisioningService
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const LINE_ROOT_ROLE_SLUGS = [
|
||||
'prd.agent.view',
|
||||
'prd.agent.manage',
|
||||
'prd.users.manage',
|
||||
'prd.users.view_finance',
|
||||
'prd.users.view_cs',
|
||||
'prd.tickets.view',
|
||||
'prd.report.view',
|
||||
'prd.wallet_reconcile.view',
|
||||
'prd.wallet_reconcile.view_cs',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly IntegrationSiteService $integrationSiteService,
|
||||
private readonly AgentProfileService $agentProfileService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload site fields + name, username, password, email?, status?
|
||||
* @return array{site: AdminSite, agent_node: AgentNode, secrets: array{sso_jwt_secret: string, wallet_api_key: string}}
|
||||
* 在已存在的接入站点上创建一级代理(根节点)及后台登录账号。
|
||||
*
|
||||
* @param array<string, mixed> $payload site_code, code, name, username, password, email?, status?, profile fields
|
||||
* @return array{site: AdminSite, agent_node: AgentNode}
|
||||
*/
|
||||
public function createRootAgent(AdminUser $actor, array $payload): array
|
||||
{
|
||||
$siteCode = strtolower(trim((string) ($payload['site_code'] ?? '')));
|
||||
$code = strtolower(trim((string) ($payload['code'] ?? '')));
|
||||
$name = trim((string) ($payload['name'] ?? ''));
|
||||
$username = trim((string) ($payload['username'] ?? ''));
|
||||
@@ -44,8 +32,9 @@ final class AgentSiteProvisioningService
|
||||
$email = isset($payload['email']) ? trim((string) $payload['email']) : null;
|
||||
$status = (int) ($payload['status'] ?? 1);
|
||||
|
||||
if ($code === '' || $name === '' || $username === '' || $password === '') {
|
||||
if ($siteCode === '' || $code === '' || $name === '' || $username === '' || $password === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'site_code' => $siteCode === '' ? ['required'] : [],
|
||||
'code' => $code === '' ? ['required'] : [],
|
||||
'name' => $name === '' ? ['required'] : [],
|
||||
'username' => $username === '' ? ['required'] : [],
|
||||
@@ -53,6 +42,11 @@ final class AgentSiteProvisioningService
|
||||
]);
|
||||
}
|
||||
|
||||
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||
if ($site === null) {
|
||||
throw ValidationException::withMessages(['site_code' => ['exists']]);
|
||||
}
|
||||
|
||||
if (AgentNode::query()->where('code', $code)->exists()) {
|
||||
throw ValidationException::withMessages(['code' => ['unique']]);
|
||||
}
|
||||
@@ -61,28 +55,18 @@ final class AgentSiteProvisioningService
|
||||
throw ValidationException::withMessages(['username' => ['unique']]);
|
||||
}
|
||||
|
||||
$siteData = array_merge($payload, [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
'status' => $status === 0 ? 0 : 1,
|
||||
]);
|
||||
$existingRoot = AgentNode::query()
|
||||
->where('admin_site_id', $site->id)
|
||||
->where('depth', 0)
|
||||
->first();
|
||||
|
||||
return DB::transaction(function () use ($actor, $siteData, $code, $name, $username, $password, $email, $status): array {
|
||||
$created = $this->integrationSiteService->create($siteData);
|
||||
$site = $created['site'];
|
||||
$secrets = $created['secrets'];
|
||||
|
||||
$existingRoot = AgentNode::query()
|
||||
->where('admin_site_id', $site->id)
|
||||
->where('depth', 0)
|
||||
->first();
|
||||
|
||||
if ($existingRoot !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => ['site_root_exists'],
|
||||
]);
|
||||
}
|
||||
if ($existingRoot !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'site_code' => ['site_root_exists'],
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($actor, $site, $code, $name, $username, $password, $email, $status, $payload): array {
|
||||
$node = AgentNode::query()->create([
|
||||
'admin_site_id' => $site->id,
|
||||
'parent_id' => null,
|
||||
@@ -97,19 +81,7 @@ final class AgentSiteProvisioningService
|
||||
$node->path = '/'.$node->id.'/';
|
||||
$node->save();
|
||||
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'agent_owner_'.$node->id,
|
||||
'code' => 'agent_owner_'.$node->id,
|
||||
'name' => '代理账号',
|
||||
'description' => '线路根代理默认角色',
|
||||
'status' => $status === 0 ? 0 : 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 0,
|
||||
'scope_type' => AdminRole::SCOPE_AGENT,
|
||||
'owner_agent_id' => $node->id,
|
||||
'delegated_from_role_id' => null,
|
||||
]);
|
||||
$role->syncLegacyPermissionSlugs(self::LINE_ROOT_ROLE_SLUGS);
|
||||
AgentPlatformRole::resolve();
|
||||
|
||||
$user = AdminUser::query()->create([
|
||||
'username' => $username,
|
||||
@@ -125,14 +97,15 @@ final class AgentSiteProvisioningService
|
||||
'is_primary' => true,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]);
|
||||
AgentPlatformRole::assignPrimaryOperator($user, $node);
|
||||
|
||||
$defaults = config('agent_line_defaults', []);
|
||||
$this->agentProfileService->upsertForNode($node, [
|
||||
'total_share_rate' => (float) ($payload['total_share_rate'] ?? 100),
|
||||
'credit_limit' => (int) ($payload['credit_limit'] ?? 0),
|
||||
'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0),
|
||||
'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? 0),
|
||||
'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? 'weekly'),
|
||||
'total_share_rate' => (float) ($payload['total_share_rate'] ?? $defaults['total_share_rate'] ?? 100),
|
||||
'credit_limit' => (int) ($payload['credit_limit'] ?? $defaults['credit_limit'] ?? 0),
|
||||
'rebate_limit' => (float) ($payload['rebate_limit'] ?? $defaults['rebate_limit'] ?? 0),
|
||||
'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? $defaults['default_player_rebate'] ?? 0),
|
||||
'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? $defaults['settlement_cycle'] ?? 'weekly'),
|
||||
'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? true),
|
||||
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true),
|
||||
'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
|
||||
@@ -141,7 +114,6 @@ final class AgentSiteProvisioningService
|
||||
return [
|
||||
'site' => $site->fresh(),
|
||||
'agent_node' => $node->fresh(['adminSite']),
|
||||
'secrets' => $secrets,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,12 +4,19 @@ namespace App\Services\Agent;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Support\AgentOverdueGuard;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class CreditAllocationValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentCreditAllocatedSyncService $allocatedSync,
|
||||
) {}
|
||||
|
||||
public function assertAllocationWithinParent(AgentNode $parent, int $additionalCredit): void
|
||||
{
|
||||
$this->allocatedSync->syncForAgent($parent);
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
|
||||
if ($profile === null) {
|
||||
return;
|
||||
@@ -25,15 +32,37 @@ final class CreditAllocationValidator
|
||||
|
||||
public function assertPlayerCreditWithinAgent(AgentNode $agent, int $playerCreditLimit): void
|
||||
{
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
|
||||
if ($profile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($playerCreditLimit < 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['invalid'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->assertPlayerCreditDeltaWithinAgent($agent, $playerCreditLimit);
|
||||
}
|
||||
|
||||
public function assertPlayerCreditDeltaWithinAgent(AgentNode $agent, int $additionalCredit): void
|
||||
{
|
||||
if ($additionalCredit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
AgentOverdueGuard::assertAgentMayGrantCredit((int) $agent->id);
|
||||
|
||||
$this->allocatedSync->syncForAgent($agent);
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
|
||||
if ($profile === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['agent_profile_required'],
|
||||
]);
|
||||
}
|
||||
|
||||
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
|
||||
if ($additionalCredit > $available) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit_limit' => ['exceeds_available'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
151
app/Services/AgentSettlement/AgentGameSettlementRecorder.php
Normal file
151
app/Services/AgentSettlement/AgentGameSettlementRecorder.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 彩票注单游戏结算侧入账:快照、占成流水、回水计提、玩家已用额度。
|
||||
*/
|
||||
final class AgentGameSettlementRecorder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BetSettlementSnapshotBuilder $snapshotBuilder,
|
||||
private readonly ShareSettlementCalculator $calculator,
|
||||
private readonly PlayerCreditService $playerCreditService,
|
||||
) {}
|
||||
|
||||
public function shouldRecord(TicketItem $item): bool
|
||||
{
|
||||
$player = $item->player;
|
||||
if ($player === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PlayerFundingMode::usesCredit($player)
|
||||
&& (int) ($player->agent_node_id ?? 0) > 0;
|
||||
}
|
||||
|
||||
public function recordForTicketItem(TicketItem $item, int $netWin, string $terminalStatus): void
|
||||
{
|
||||
if (! $this->shouldRecord($item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$player = $item->player;
|
||||
if ($player === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$gameType = trim((string) ($item->play_code ?? '')) ?: '*';
|
||||
$snapshot = $this->snapshotBuilder->buildForPlayer($player, $gameType);
|
||||
|
||||
$gameWinLoss = $this->platformWinLoss($item, $netWin, $terminalStatus);
|
||||
$validBet = (int) $item->total_bet_amount;
|
||||
$basicRebate = (int) round($validBet * $snapshot['rebate_rate']);
|
||||
$extraRebate = (int) round($validBet * $snapshot['extra_rebate_rate']);
|
||||
|
||||
$extraByCode = [];
|
||||
if ($extraRebate > 0 && $snapshot['chain_codes'] !== []) {
|
||||
$leaf = $snapshot['chain_codes'][0];
|
||||
$extraByCode[$leaf] = $extraRebate;
|
||||
}
|
||||
|
||||
$result = $this->calculator->calculate(
|
||||
sharedNetWinLoss: 0,
|
||||
totalSharesByCode: $snapshot['total_shares'],
|
||||
extraRebateByCode: $extraByCode,
|
||||
gameWinLoss: $gameWinLoss,
|
||||
basicRebate: $basicRebate,
|
||||
chainFromPlayer: $snapshot['chain_codes'],
|
||||
);
|
||||
|
||||
$settledAt = now();
|
||||
|
||||
DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate): void {
|
||||
$item->forceFill([
|
||||
'agent_node_id' => $snapshot['agent_node_id'],
|
||||
'share_snapshot' => [
|
||||
'total_shares' => $snapshot['total_shares'],
|
||||
'actual_shares' => $snapshot['actual_shares'],
|
||||
'chain_codes' => $snapshot['chain_codes'],
|
||||
],
|
||||
'agent_rebate_rate_snapshot' => $snapshot['rebate_rate'],
|
||||
'agent_settled_at' => $settledAt,
|
||||
])->save();
|
||||
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $item->id,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $snapshot['agent_node_id'],
|
||||
'agent_path' => json_encode($snapshot['agent_path']),
|
||||
'share_snapshot' => json_encode($snapshot),
|
||||
'game_win_loss' => (int) round($gameWinLoss),
|
||||
'basic_rebate' => $basicRebate,
|
||||
'shared_net_win_loss' => (int) round($result->sharedNetWinLoss),
|
||||
'allocations_json' => json_encode($result->finalProfits),
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
|
||||
if ($basicRebate > 0) {
|
||||
DB::table('rebate_records')->insert([
|
||||
'player_id' => $player->id,
|
||||
'ticket_item_id' => $item->id,
|
||||
'game_type' => $gameType,
|
||||
'valid_bet_amount' => $validBet,
|
||||
'rebate_rate' => $snapshot['rebate_rate'],
|
||||
'rebate_amount' => $basicRebate,
|
||||
'rebate_type' => 'basic',
|
||||
'owner_agent_id' => $snapshot['agent_node_id'],
|
||||
'status' => 'accrued',
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($extraRebate > 0) {
|
||||
DB::table('rebate_records')->insert([
|
||||
'player_id' => $player->id,
|
||||
'ticket_item_id' => $item->id,
|
||||
'game_type' => $gameType,
|
||||
'valid_bet_amount' => $validBet,
|
||||
'rebate_rate' => $snapshot['extra_rebate_rate'],
|
||||
'rebate_amount' => $extraRebate,
|
||||
'rebate_type' => 'extra',
|
||||
'owner_agent_id' => $snapshot['agent_node_id'],
|
||||
'status' => 'accrued',
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
}
|
||||
|
||||
$holdAmount = (int) $item->actual_deduct_amount;
|
||||
if ($holdAmount > 0) {
|
||||
$this->playerCreditService->releaseBetHold($player, $holdAmount, $item->id);
|
||||
}
|
||||
|
||||
if ($gameWinLoss > 0) {
|
||||
$this->playerCreditService->applySettledLoss($player, (int) round($gameWinLoss), $item->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function platformWinLoss(TicketItem $item, int $netWin, string $terminalStatus): float
|
||||
{
|
||||
if ($terminalStatus === 'settled_lose') {
|
||||
return (float) max(0, (int) $item->actual_deduct_amount);
|
||||
}
|
||||
|
||||
if ($netWin > 0) {
|
||||
return -1 * (float) $netWin;
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
232
app/Services/AgentSettlement/AgentPeriodAggregator.php
Normal file
232
app/Services/AgentSettlement/AgentPeriodAggregator.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentPeriodAggregator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShareSettlementCalculator $calculator,
|
||||
private readonly BetSettlementSnapshotBuilder $snapshotBuilder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* players: array<int, array<string, mixed>>,
|
||||
* agent_edges: array<string, int>,
|
||||
* agent_subtrees: array<int, array<string, mixed>>,
|
||||
* platform_share_profit: int,
|
||||
* }
|
||||
*/
|
||||
public function aggregate(int $adminSiteId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
|
||||
$codeToId = AgentNode::query()
|
||||
->where('admin_site_id', $adminSiteId)
|
||||
->pluck('id', 'code')
|
||||
->mapWithKeys(fn ($id, $code): array => [(string) $code => (int) $id])
|
||||
->all();
|
||||
|
||||
$rows = DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->select([
|
||||
'sl.player_id',
|
||||
'sl.ticket_item_id',
|
||||
'sl.agent_node_id',
|
||||
'sl.share_snapshot',
|
||||
'sl.game_win_loss',
|
||||
'sl.basic_rebate',
|
||||
])
|
||||
->orderBy('sl.id')
|
||||
->get();
|
||||
|
||||
$players = [];
|
||||
$agentEdges = [];
|
||||
$agentSubtrees = [];
|
||||
$platformShareProfit = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$playerId = (int) $row->player_id;
|
||||
$snapshot = $this->resolveSnapshotFromLedgerRow($row);
|
||||
if ($snapshot === null) {
|
||||
$player = Player::query()->find($playerId);
|
||||
if ($player === null) {
|
||||
continue;
|
||||
}
|
||||
$built = $this->snapshotBuilder->buildForPlayer($player);
|
||||
$snapshot = [
|
||||
'total_shares' => $built['total_shares'],
|
||||
'chain_codes' => $built['chain_codes'],
|
||||
];
|
||||
}
|
||||
|
||||
$gameWinLoss = (int) $row->game_win_loss;
|
||||
$basicRebate = (int) $row->basic_rebate;
|
||||
$extraRebate = $this->extraRebateForTicketItem(
|
||||
(int) $row->ticket_item_id,
|
||||
$periodStart,
|
||||
$periodEnd,
|
||||
);
|
||||
|
||||
$extraByCode = [];
|
||||
if ($extraRebate > 0 && $snapshot['chain_codes'] !== []) {
|
||||
$extraByCode[$snapshot['chain_codes'][0]] = $extraRebate;
|
||||
}
|
||||
|
||||
$result = $this->calculator->calculate(
|
||||
sharedNetWinLoss: 0,
|
||||
totalSharesByCode: $snapshot['total_shares'],
|
||||
extraRebateByCode: $extraByCode,
|
||||
gameWinLoss: $gameWinLoss,
|
||||
basicRebate: $basicRebate,
|
||||
chainFromPlayer: $snapshot['chain_codes'],
|
||||
);
|
||||
|
||||
$net = (int) round($result->playerNetSettlement);
|
||||
|
||||
if (! isset($players[$playerId])) {
|
||||
$players[$playerId] = [
|
||||
'agent_node_id' => (int) $row->agent_node_id,
|
||||
'game_win_loss' => 0,
|
||||
'basic_rebate' => 0,
|
||||
'extra_rebate' => 0,
|
||||
'net_amount' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$players[$playerId]['game_win_loss'] += $gameWinLoss;
|
||||
$players[$playerId]['basic_rebate'] += $basicRebate;
|
||||
$players[$playerId]['extra_rebate'] += $extraRebate;
|
||||
$players[$playerId]['net_amount'] += $net;
|
||||
|
||||
foreach ($result->tierSettlements as $edge => $amount) {
|
||||
$agentEdges[$edge] = ($agentEdges[$edge] ?? 0) + (int) round($amount);
|
||||
}
|
||||
|
||||
$platformShareProfit += (int) round($result->finalProfits['platform'] ?? 0);
|
||||
|
||||
$pathIds = $this->resolveAgentPathIds($row, $snapshot, $codeToId);
|
||||
foreach ($pathIds as $agentId) {
|
||||
if (! isset($agentSubtrees[$agentId])) {
|
||||
$agentSubtrees[$agentId] = [
|
||||
'gross_win_loss' => 0,
|
||||
'basic_rebate' => 0,
|
||||
'extra_rebate' => 0,
|
||||
'share_profit' => 0,
|
||||
'player_count' => 0,
|
||||
'_players_seen' => [],
|
||||
];
|
||||
}
|
||||
$agentSubtrees[$agentId]['gross_win_loss'] += $gameWinLoss;
|
||||
$agentSubtrees[$agentId]['basic_rebate'] += $basicRebate;
|
||||
$agentSubtrees[$agentId]['extra_rebate'] += $extraRebate;
|
||||
if (! in_array($playerId, $agentSubtrees[$agentId]['_players_seen'], true)) {
|
||||
$agentSubtrees[$agentId]['_players_seen'][] = $playerId;
|
||||
$agentSubtrees[$agentId]['player_count']++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($snapshot['chain_codes'] as $code) {
|
||||
$agentId = $codeToId[$code] ?? 0;
|
||||
if ($agentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$profit = (int) round($result->finalProfits[$code] ?? 0);
|
||||
$agentSubtrees[$agentId]['share_profit'] = ($agentSubtrees[$agentId]['share_profit'] ?? 0) + $profit;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($agentSubtrees as $id => $subtree) {
|
||||
unset($agentSubtrees[$id]['_players_seen']);
|
||||
}
|
||||
|
||||
return [
|
||||
'players' => $players,
|
||||
'agent_edges' => $agentEdges,
|
||||
'agent_subtrees' => $agentSubtrees,
|
||||
'platform_share_profit' => $platformShareProfit,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{chain_codes: list<string>, total_shares: array<string, float>} $snapshot
|
||||
* @param array<string, int> $codeToId
|
||||
* @return list<int>
|
||||
*/
|
||||
private function resolveAgentPathIds(object $row, array $snapshot, array $codeToId): array
|
||||
{
|
||||
$raw = $row->share_snapshot ?? null;
|
||||
if ($raw !== null && $raw !== '') {
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (is_array($decoded) && is_array($decoded['agent_path'] ?? null)) {
|
||||
return array_values(array_map(intval(...), $decoded['agent_path']));
|
||||
}
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($snapshot['chain_codes'] as $code) {
|
||||
$id = $codeToId[$code] ?? 0;
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public function siteIdForPeriod(int $periodId): int
|
||||
{
|
||||
return (int) DB::table('settlement_periods')->where('id', $periodId)->value('admin_site_id');
|
||||
}
|
||||
|
||||
private function extraRebateForTicketItem(int $ticketItemId, string $periodStart, string $periodEnd): int
|
||||
{
|
||||
if ($ticketItemId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) DB::table('rebate_records')
|
||||
->where('ticket_item_id', $ticketItemId)
|
||||
->where('rebate_type', 'extra')
|
||||
->whereIn('status', ['accrued', 'reversed'])
|
||||
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||
->sum('rebate_amount');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total_shares: array<string, float>, chain_codes: list<string>}|null
|
||||
*/
|
||||
private function resolveSnapshotFromLedgerRow(object $row): ?array
|
||||
{
|
||||
$raw = $row->share_snapshot ?? null;
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$totalShares = $decoded['total_shares'] ?? null;
|
||||
$chainCodes = $decoded['chain_codes'] ?? null;
|
||||
if (! is_array($totalShares) || ! is_array($chainCodes) || $chainCodes === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shares = [];
|
||||
foreach ($totalShares as $code => $rate) {
|
||||
$shares[(string) $code] = (float) $rate;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_shares' => $shares,
|
||||
'chain_codes' => array_values(array_map(strval(...), $chainCodes)),
|
||||
];
|
||||
}
|
||||
}
|
||||
125
app/Services/AgentSettlement/AgentSettlementBadDebtService.php
Normal file
125
app/Services/AgentSettlement/AgentSettlementBadDebtService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** 坏账核销:原账单保留,记 bad_debt 调整与归档单(§2、§21.1)。 */
|
||||
final class AgentSettlementBadDebtService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
|
||||
) {}
|
||||
|
||||
public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int
|
||||
{
|
||||
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
|
||||
if ($original === null) {
|
||||
throw new \InvalidArgumentException('bill_not_found');
|
||||
}
|
||||
|
||||
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'period' => ['completed'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['not_eligible'],
|
||||
]);
|
||||
}
|
||||
|
||||
$unpaid = (int) $original->unpaid_amount;
|
||||
if ($unpaid <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['no_unpaid'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['not_eligible'],
|
||||
]);
|
||||
}
|
||||
|
||||
return (int) DB::transaction(function () use ($original, $originalBillId, $unpaid, $reason, $adminUserId): int {
|
||||
$now = now();
|
||||
$periodId = (int) $original->settlement_period_id;
|
||||
|
||||
$archiveBillId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'bad_debt',
|
||||
'owner_type' => (string) $original->owner_type,
|
||||
'owner_id' => (int) $original->owner_id,
|
||||
'counterparty_type' => (string) $original->counterparty_type,
|
||||
'counterparty_id' => (int) $original->counterparty_id,
|
||||
'gross_win_loss' => 0,
|
||||
'rebate_amount' => 0,
|
||||
'adjustment_amount' => -$unpaid,
|
||||
'platform_rounding_adjustment' => 0,
|
||||
'net_amount' => 0,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => 0,
|
||||
'status' => 'settled',
|
||||
'reversed_bill_id' => $originalBillId,
|
||||
'meta_json' => json_encode([
|
||||
'original_bill_id' => $originalBillId,
|
||||
'written_off_amount' => $unpaid,
|
||||
'original_net_amount' => (int) $original->net_amount,
|
||||
]),
|
||||
'locked_at' => $now,
|
||||
'confirmed_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('settlement_adjustments')->insert([
|
||||
'settlement_period_id' => $periodId,
|
||||
'original_bill_id' => $originalBillId,
|
||||
'adjustment_type' => 'bad_debt',
|
||||
'amount' => $unpaid,
|
||||
'reason' => $reason,
|
||||
'created_by' => $adminUserId > 0 ? $adminUserId : null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('settlement_bills')->where('id', $originalBillId)->update([
|
||||
'unpaid_amount' => 0,
|
||||
'status' => 'settled',
|
||||
'meta_json' => json_encode(array_merge(
|
||||
$this->decodeMeta($original->meta_json),
|
||||
[
|
||||
'bad_debt_bill_id' => $archiveBillId,
|
||||
'written_off_amount' => $unpaid,
|
||||
],
|
||||
)),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->periodCompletion->syncIfReady($periodId);
|
||||
|
||||
return $archiveBillId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeMeta(mixed $metaJson): array
|
||||
{
|
||||
if ($metaJson === null || $metaJson === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($metaJson)) {
|
||||
return $metaJson;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $metaJson, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* 已锁定账单的补差/冲正单(§21.1、§21.2)。
|
||||
*/
|
||||
final class AgentSettlementBillAdjustmentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentSettlementBillGuard $billGuard,
|
||||
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
|
||||
) {}
|
||||
|
||||
public function createAdjustment(
|
||||
int $originalBillId,
|
||||
int $amount,
|
||||
string $adjustmentType,
|
||||
?string $reason,
|
||||
int $adminUserId,
|
||||
): int {
|
||||
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
|
||||
if ($original === null) {
|
||||
throw new \InvalidArgumentException('bill_not_found');
|
||||
}
|
||||
|
||||
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'period' => ['completed'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'settled', 'overdue'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['not_locked'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($amount === 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => ['zero'],
|
||||
]);
|
||||
}
|
||||
|
||||
$type = in_array($adjustmentType, ['adjustment', 'reversal'], true)
|
||||
? $adjustmentType
|
||||
: 'adjustment';
|
||||
|
||||
return (int) DB::transaction(function () use ($original, $amount, $type, $reason, $adminUserId): int {
|
||||
$now = now();
|
||||
$newBillId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => (int) $original->settlement_period_id,
|
||||
'bill_type' => $type,
|
||||
'owner_type' => (string) $original->owner_type,
|
||||
'owner_id' => (int) $original->owner_id,
|
||||
'counterparty_type' => (string) $original->counterparty_type,
|
||||
'counterparty_id' => (int) $original->counterparty_id,
|
||||
'gross_win_loss' => 0,
|
||||
'rebate_amount' => 0,
|
||||
'adjustment_amount' => $amount,
|
||||
'platform_rounding_adjustment' => 0,
|
||||
'net_amount' => $amount,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => abs($amount),
|
||||
'status' => 'pending_confirm',
|
||||
'reversed_bill_id' => (int) $original->id,
|
||||
'meta_json' => json_encode([
|
||||
'original_bill_id' => (int) $original->id,
|
||||
'original_net_amount' => (int) $original->net_amount,
|
||||
]),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('settlement_adjustments')->insert([
|
||||
'settlement_period_id' => (int) $original->settlement_period_id,
|
||||
'original_bill_id' => (int) $original->id,
|
||||
'adjustment_type' => $type,
|
||||
'amount' => $amount,
|
||||
'reason' => $reason,
|
||||
'created_by' => $adminUserId > 0 ? $adminUserId : null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return $newBillId;
|
||||
});
|
||||
}
|
||||
}
|
||||
51
app/Services/AgentSettlement/AgentSettlementBillGuard.php
Normal file
51
app/Services/AgentSettlement/AgentSettlementBillGuard.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class AgentSettlementBillGuard
|
||||
{
|
||||
private const LOCKED_STATUSES = ['confirmed', 'partial_paid', 'settled', 'overdue', 'reversed'];
|
||||
|
||||
public function __construct(
|
||||
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
|
||||
) {}
|
||||
|
||||
public function assertPeriodMutable(int $billId): void
|
||||
{
|
||||
$periodId = (int) DB::table('settlement_bills')->where('id', $billId)->value('settlement_period_id');
|
||||
if ($this->periodCompletion->isPeriodReadOnly($periodId)) {
|
||||
throw ValidationException::withMessages([
|
||||
'period' => ['completed'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function assertNetAmountMutable(int $billId): void
|
||||
{
|
||||
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
|
||||
if ($bill === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array((string) $bill->status, self::LOCKED_STATUSES, true) || $bill->locked_at !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['locked'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function markConfirmed(int $billId): void
|
||||
{
|
||||
$this->assertPeriodMutable($billId);
|
||||
|
||||
DB::table('settlement_bills')->where('id', $billId)->update([
|
||||
'status' => 'confirmed',
|
||||
'locked_at' => now(),
|
||||
'confirmed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,20 @@
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Support\Settlement\DesignDocExample12;
|
||||
use App\Models\AgentNode;
|
||||
use App\Services\Agent\AgentCreditAllocatedSyncService;
|
||||
use App\Support\AgentSettlementProductionGuard;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementPeriodCloseService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShareSettlementCalculator $calculator,
|
||||
private readonly AgentPeriodAggregator $aggregator,
|
||||
private readonly SettlementBillGenerator $billGenerator,
|
||||
private readonly PeriodCloseRebateService $periodCloseRebate,
|
||||
private readonly UnsettledTicketPeriodWarning $unsettledWarning,
|
||||
private readonly PlatformRoundingAdjuster $platformRounding,
|
||||
private readonly AgentCreditAllocatedSyncService $allocatedSync,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -16,51 +23,74 @@ final class AgentSettlementPeriodCloseService
|
||||
*/
|
||||
public function closePeriod(int $periodId): array
|
||||
{
|
||||
AgentSettlementProductionGuard::assertProductionCloseAllowed();
|
||||
|
||||
$period = DB::table('settlement_periods')->where('id', $periodId)->first();
|
||||
if ($period === null) {
|
||||
throw new \InvalidArgumentException('period_not_found');
|
||||
}
|
||||
|
||||
$result = $this->calculator->calculate(
|
||||
sharedNetWinLoss: DesignDocExample12::SHARED_NET_WIN_LOSS,
|
||||
totalSharesByCode: [
|
||||
'A' => DesignDocExample12::TOTAL_SHARE_A,
|
||||
'B' => DesignDocExample12::TOTAL_SHARE_B,
|
||||
'C' => DesignDocExample12::TOTAL_SHARE_C,
|
||||
],
|
||||
extraRebateByCode: ['C' => DesignDocExample12::EXTRA_REBATE_BY_C],
|
||||
gameWinLoss: DesignDocExample12::GAME_WIN_LOSS,
|
||||
basicRebate: DesignDocExample12::BASIC_REBATE,
|
||||
chainFromPlayer: ['C', 'B', 'A'],
|
||||
if ((string) $period->status === 'closed') {
|
||||
throw new \InvalidArgumentException('period_already_closed');
|
||||
}
|
||||
|
||||
$adminSiteId = (int) $period->admin_site_id;
|
||||
$aggregate = $this->aggregator->aggregate(
|
||||
$adminSiteId,
|
||||
(string) $period->period_start,
|
||||
(string) $period->period_end,
|
||||
);
|
||||
|
||||
$playerBillId = DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => 0,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => 0,
|
||||
'gross_win_loss' => DesignDocExample12::GAME_WIN_LOSS,
|
||||
'rebate_amount' => DesignDocExample12::BASIC_REBATE + DesignDocExample12::EXTRA_REBATE_BY_C,
|
||||
'adjustment_amount' => 0,
|
||||
'net_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT,
|
||||
'status' => 'pending',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
if ($aggregate['players'] === []) {
|
||||
throw new \InvalidArgumentException('period_no_ledger_rows');
|
||||
}
|
||||
|
||||
$billIds = $this->billGenerator->generate($periodId, $adminSiteId, $aggregate);
|
||||
|
||||
$roundingDiff = $this->platformRounding->apply($periodId, $aggregate);
|
||||
|
||||
$rebateStats = $this->periodCloseRebate->dispatchAndAllocate(
|
||||
$periodId,
|
||||
(string) $period->period_start,
|
||||
(string) $period->period_end,
|
||||
);
|
||||
|
||||
$unsettled = $this->unsettledWarning->countForSite(
|
||||
$adminSiteId,
|
||||
(string) $period->period_start,
|
||||
(string) $period->period_end,
|
||||
);
|
||||
|
||||
DB::table('settlement_periods')->where('id', $periodId)->update([
|
||||
'status' => 'closed',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('share_ledger')
|
||||
->whereBetween('settled_at', [$period->period_start, $period->period_end])
|
||||
->update(['settlement_period_id' => $periodId]);
|
||||
|
||||
$this->reconcileAllocatedCreditForSite($adminSiteId);
|
||||
|
||||
return [
|
||||
'period_id' => $periodId,
|
||||
'settlement' => $result,
|
||||
'player_bill_id' => $playerBillId,
|
||||
'bill_ids' => $billIds,
|
||||
'player_count' => count($aggregate['players']),
|
||||
'agent_edges' => $aggregate['agent_edges'],
|
||||
'rebate_dispatched' => $rebateStats['dispatched'],
|
||||
'rebate_allocations' => $rebateStats['allocations'],
|
||||
'unsettled_ticket_count' => $unsettled['count'],
|
||||
'unsettled_ticket_sample' => $unsettled['ticket_item_ids'],
|
||||
'platform_rounding_adjustment' => $roundingDiff,
|
||||
];
|
||||
}
|
||||
|
||||
/** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */
|
||||
private function reconcileAllocatedCreditForSite(int $adminSiteId): void
|
||||
{
|
||||
$nodes = AgentNode::query()->where('admin_site_id', $adminSiteId)->get();
|
||||
foreach ($nodes as $node) {
|
||||
$this->allocatedSync->syncForAgent($node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 账期全部账单结清或核销后标记为 completed(§4)。 */
|
||||
final class AgentSettlementPeriodCompletionService
|
||||
{
|
||||
public function syncIfReady(int $periodId): void
|
||||
{
|
||||
if ($periodId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$period = DB::table('settlement_periods')->where('id', $periodId)->first();
|
||||
if ($period === null || (string) $period->status !== 'closed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasOpenSettlementWork($periodId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('settlement_periods')
|
||||
->where('id', $periodId)
|
||||
->update([
|
||||
'status' => 'completed',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function isPeriodReadOnly(int $periodId): bool
|
||||
{
|
||||
if ($periodId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = DB::table('settlement_periods')->where('id', $periodId)->value('status');
|
||||
|
||||
return $status !== null && (string) $status === 'completed';
|
||||
}
|
||||
|
||||
private function hasOpenSettlementWork(int $periodId): bool
|
||||
{
|
||||
return DB::table('settlement_bills')
|
||||
->where('settlement_period_id', $periodId)
|
||||
->whereNotIn('bill_type', ['bad_debt'])
|
||||
->where(function ($query): void {
|
||||
$query->where('status', 'pending_confirm')
|
||||
->orWhere(function ($inner): void {
|
||||
$inner->whereIn('status', ['confirmed', 'partial_paid', 'overdue'])
|
||||
->where('unpaid_amount', '>', 0);
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 账期窗口内信用流水与占成流水笔数(关账前诊断)。 */
|
||||
final class AgentSettlementPeriodPipelineService
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, object> $periods settlement_periods 行,须含 id、period_start、period_end、admin_site_id
|
||||
* @return array<int, array{credit_ledger_count: int, share_ledger_count: int}>
|
||||
*/
|
||||
public function countsForPeriods(Collection $periods): array
|
||||
{
|
||||
if ($periods->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$siteIds = $periods->pluck('admin_site_id')->map(static fn ($id): int => (int) $id)->unique()->all();
|
||||
$siteCodes = DB::table('admin_sites')
|
||||
->whereIn('id', $siteIds)
|
||||
->pluck('code', 'id');
|
||||
|
||||
$out = [];
|
||||
foreach ($periods as $period) {
|
||||
$periodId = (int) $period->id;
|
||||
$siteCode = (string) ($siteCodes[(int) $period->admin_site_id] ?? '');
|
||||
if ($siteCode === '') {
|
||||
$out[$periodId] = ['credit_ledger_count' => 0, 'share_ledger_count' => 0];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = Carbon::parse($period->period_start)->startOfDay();
|
||||
$end = Carbon::parse($period->period_end)->endOfDay();
|
||||
|
||||
$creditCount = (int) DB::table('credit_ledger as cl')
|
||||
->join('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'cl.owner_id')
|
||||
->where('cl.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->where('p.funding_mode', PlayerFundingMode::CREDIT)
|
||||
->whereBetween('cl.created_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
$shareCount = (int) DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
$out[$periodId] = [
|
||||
'credit_ledger_count' => $creditCount,
|
||||
'share_ledger_count' => $shareCount,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 账期维度账单笔数与待办汇总(结算中心看板)。 */
|
||||
final class AgentSettlementPeriodSummaryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentSettlementPeriodPipelineService $pipelineService,
|
||||
) {}
|
||||
/**
|
||||
* @param list<int> $periodIds
|
||||
* @return array<int, array<string, int>>
|
||||
*/
|
||||
public function summariesForPeriodIds(array $periodIds): array
|
||||
{
|
||||
if ($periodIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('settlement_bills')
|
||||
->whereIn('settlement_period_id', $periodIds)
|
||||
->groupBy('settlement_period_id')
|
||||
->selectRaw('settlement_period_id')
|
||||
->selectRaw("SUM(CASE WHEN bill_type = 'player' THEN 1 ELSE 0 END) as player_bills")
|
||||
->selectRaw("SUM(CASE WHEN bill_type = 'agent' THEN 1 ELSE 0 END) as agent_bills")
|
||||
->selectRaw("SUM(CASE WHEN bill_type IN ('adjustment', 'reversal') THEN 1 ELSE 0 END) as adjustment_bills")
|
||||
->selectRaw("SUM(CASE WHEN status = 'pending_confirm' THEN 1 ELSE 0 END) as pending_confirm")
|
||||
->selectRaw("SUM(CASE WHEN status IN ('confirmed', 'partial_paid', 'overdue') AND unpaid_amount > 0 THEN 1 ELSE 0 END) as awaiting_payment")
|
||||
->selectRaw("SUM(CASE WHEN status = 'settled' THEN 1 ELSE 0 END) as settled")
|
||||
->selectRaw('COALESCE(SUM(unpaid_amount), 0) as total_unpaid')
|
||||
->get();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$periodId = (int) $row->settlement_period_id;
|
||||
$out[$periodId] = [
|
||||
'player_bills' => (int) $row->player_bills,
|
||||
'agent_bills' => (int) $row->agent_bills,
|
||||
'adjustment_bills' => (int) $row->adjustment_bills,
|
||||
'pending_confirm' => (int) $row->pending_confirm,
|
||||
'awaiting_payment' => (int) $row->awaiting_payment,
|
||||
'settled' => (int) $row->settled,
|
||||
'total_unpaid' => (int) $row->total_unpaid,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $periods
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function attachToPeriodRows(Collection $periods): array
|
||||
{
|
||||
$ids = $periods->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||
$summaries = $this->summariesForPeriodIds($ids);
|
||||
$pipelines = $this->pipelineService->countsForPeriods($periods);
|
||||
$empty = [
|
||||
'player_bills' => 0,
|
||||
'agent_bills' => 0,
|
||||
'adjustment_bills' => 0,
|
||||
'pending_confirm' => 0,
|
||||
'awaiting_payment' => 0,
|
||||
'settled' => 0,
|
||||
'total_unpaid' => 0,
|
||||
];
|
||||
$emptyPipeline = ['credit_ledger_count' => 0, 'share_ledger_count' => 0];
|
||||
|
||||
$items = [];
|
||||
foreach ($periods as $period) {
|
||||
$row = (array) $period;
|
||||
$row['summary'] = $summaries[(int) $period->id] ?? $empty;
|
||||
$row['pipeline'] = $pipelines[(int) $period->id] ?? $emptyPipeline;
|
||||
$items[] = $row;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** §21.12 信用占成盘报表最低集。 */
|
||||
final class AgentSettlementReportQueryService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function summary(AdminUser $admin, int $periodId = 0): array
|
||||
{
|
||||
$query = DB::table('settlement_bills');
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin);
|
||||
if ($periodId > 0) {
|
||||
$query->where('settlement_period_id', $periodId);
|
||||
}
|
||||
$rows = $query->get();
|
||||
|
||||
return [
|
||||
'bill_count' => $rows->count(),
|
||||
'total_net' => (int) $rows->sum('net_amount'),
|
||||
'total_unpaid' => (int) $rows->sum('unpaid_amount'),
|
||||
'overdue_count' => $rows->where('status', 'overdue')->count(),
|
||||
'platform_rounding_total' => (int) $rows->sum('platform_rounding_adjustment'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function playerWinLoss(AdminUser $admin, int $periodId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, $periodId);
|
||||
|
||||
return DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->leftJoin('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->groupBy('sl.player_id', 'p.username', 'p.agent_node_id', 'ti.play_code')
|
||||
->selectRaw('sl.player_id, p.username, p.agent_node_id, COALESCE(ti.play_code, ?) as game_type', ['*'])
|
||||
->selectRaw('SUM(sl.game_win_loss) as game_win_loss')
|
||||
->selectRaw('SUM(sl.basic_rebate) as basic_rebate')
|
||||
->orderByDesc('game_win_loss')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'player_id' => (int) $r->player_id,
|
||||
'username' => (string) ($r->username ?? ''),
|
||||
'agent_node_id' => (int) $r->agent_node_id,
|
||||
'game_type' => (string) $r->game_type,
|
||||
'game_win_loss' => (int) $r->game_win_loss,
|
||||
'basic_rebate' => (int) $r->basic_rebate,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function agentShare(AdminUser $admin, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, 0);
|
||||
|
||||
return DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->groupBy('sl.agent_node_id')
|
||||
->selectRaw('sl.agent_node_id, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as entry_count')
|
||||
->orderByDesc('game_win_loss')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'agent_node_id' => (int) $r->agent_node_id,
|
||||
'game_win_loss' => (int) $r->game_win_loss,
|
||||
'basic_rebate' => (int) $r->basic_rebate,
|
||||
'entry_count' => (int) $r->entry_count,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rebate(AdminUser $admin, int $periodId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, $periodId);
|
||||
|
||||
$base = DB::table('rebate_records as rr')
|
||||
->join('players as p', 'p.id', '=', 'rr.player_id')
|
||||
->where('p.site_code', $siteCode);
|
||||
|
||||
$accrued = (clone $base)->where('rr.status', 'accrued')->sum('rr.rebate_amount');
|
||||
$inBill = (clone $base)->where('rr.status', 'in_bill')->sum('rr.rebate_amount');
|
||||
$settled = (clone $base)->where('rr.status', 'settled')->sum('rr.rebate_amount');
|
||||
$allocated = (int) DB::table('rebate_allocations as ra')
|
||||
->join('rebate_records as rr', 'rr.id', '=', 'ra.rebate_record_id')
|
||||
->join('players as p', 'p.id', '=', 'rr.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->when($periodId > 0, fn (Builder $q) => $q->where('rr.settlement_period_id', $periodId))
|
||||
->sum('ra.allocated_amount');
|
||||
|
||||
return [
|
||||
'accrued_total' => (int) $accrued,
|
||||
'in_bill_total' => (int) $inBill,
|
||||
'settled_total' => (int) $settled,
|
||||
'allocated_total' => $allocated,
|
||||
'by_type' => DB::table('rebate_records as rr')
|
||||
->join('players as p', 'p.id', '=', 'rr.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('rr.created_at', [$periodStart, $periodEnd])
|
||||
->groupBy('rr.rebate_type', 'rr.status')
|
||||
->selectRaw('rr.rebate_type, rr.status, SUM(rr.rebate_amount) as total, COUNT(*) as cnt')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'rebate_type' => (string) $r->rebate_type,
|
||||
'status' => (string) $r->status,
|
||||
'total' => (int) $r->total,
|
||||
'count' => (int) $r->cnt,
|
||||
])
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{agents: list<array<string, mixed>>, players: list<array<string, mixed>>}
|
||||
*/
|
||||
public function credit(AdminUser $admin): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, 0);
|
||||
|
||||
$agents = DB::table('agent_profiles as ap')
|
||||
->join('agent_nodes as an', 'an.id', '=', 'ap.agent_node_id')
|
||||
->join('admin_sites as s', 's.id', '=', 'an.admin_site_id')
|
||||
->where('s.code', $siteCode)
|
||||
->selectRaw('ap.agent_node_id, an.code, an.name, ap.credit_limit, ap.allocated_credit, (ap.credit_limit - ap.allocated_credit) as available_credit')
|
||||
->orderBy('an.depth')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'agent_node_id' => (int) $r->agent_node_id,
|
||||
'code' => (string) $r->code,
|
||||
'name' => (string) $r->name,
|
||||
'credit_limit' => (int) $r->credit_limit,
|
||||
'allocated_credit' => (int) $r->allocated_credit,
|
||||
'available_credit' => (int) $r->available_credit,
|
||||
])
|
||||
->all();
|
||||
|
||||
$players = DB::table('player_credit_accounts as pc')
|
||||
->join('players as p', 'p.id', '=', 'pc.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->selectRaw('pc.player_id, p.username, pc.credit_limit, pc.used_credit, pc.frozen_credit, (pc.credit_limit - pc.used_credit - pc.frozen_credit) as available_credit')
|
||||
->orderByDesc('pc.used_credit')
|
||||
->limit(500)
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'player_id' => (int) $r->player_id,
|
||||
'username' => (string) ($r->username ?? ''),
|
||||
'credit_limit' => (int) $r->credit_limit,
|
||||
'used_credit' => (int) $r->used_credit,
|
||||
'frozen_credit' => (int) $r->frozen_credit,
|
||||
'available_credit' => max(0, (int) $r->available_credit),
|
||||
])
|
||||
->all();
|
||||
|
||||
return ['agents' => $agents, 'players' => $players];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function unpaidBills(AdminUser $admin, int $periodId = 0): array
|
||||
{
|
||||
$query = DB::table('settlement_bills as sb')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->where('sb.unpaid_amount', '>', 0)
|
||||
->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']);
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
|
||||
if ($periodId > 0) {
|
||||
$query->where('sb.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
return $query
|
||||
->select([
|
||||
'sb.id',
|
||||
'sb.bill_type',
|
||||
'sb.owner_type',
|
||||
'sb.owner_id',
|
||||
'sb.counterparty_type',
|
||||
'sb.counterparty_id',
|
||||
'sb.net_amount',
|
||||
'sb.unpaid_amount',
|
||||
'sb.status',
|
||||
'sp.period_start',
|
||||
'sp.period_end',
|
||||
])
|
||||
->orderByDesc('sb.unpaid_amount')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'bill_id' => (int) $r->id,
|
||||
'bill_type' => (string) $r->bill_type,
|
||||
'owner_type' => (string) $r->owner_type,
|
||||
'owner_id' => (int) $r->owner_id,
|
||||
'counterparty_type' => (string) $r->counterparty_type,
|
||||
'counterparty_id' => (int) $r->counterparty_id,
|
||||
'net_amount' => (int) $r->net_amount,
|
||||
'unpaid_amount' => (int) $r->unpaid_amount,
|
||||
'status' => (string) $r->status,
|
||||
'period_start' => (string) $r->period_start,
|
||||
'period_end' => (string) $r->period_end,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function overdue(AdminUser $admin): array
|
||||
{
|
||||
$query = DB::table('settlement_bills as sb')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->where('sb.status', 'overdue')
|
||||
->where('sb.unpaid_amount', '>', 0);
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
|
||||
|
||||
return $query
|
||||
->select([
|
||||
'sb.id',
|
||||
'sb.bill_type',
|
||||
'sb.owner_type',
|
||||
'sb.owner_id',
|
||||
'sb.unpaid_amount',
|
||||
'sb.updated_at',
|
||||
'sp.period_end',
|
||||
])
|
||||
->orderBy('sb.updated_at')
|
||||
->get()
|
||||
->map(static function (object $r): array {
|
||||
$days = Carbon::parse((string) $r->updated_at)->diffInDays(now());
|
||||
|
||||
return [
|
||||
'bill_id' => (int) $r->id,
|
||||
'bill_type' => (string) $r->bill_type,
|
||||
'owner_type' => (string) $r->owner_type,
|
||||
'owner_id' => (int) $r->owner_id,
|
||||
'unpaid_amount' => (int) $r->unpaid_amount,
|
||||
'overdue_days' => $days,
|
||||
'period_end' => (string) $r->period_end,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function platformPnl(AdminUser $admin, int $periodId): array
|
||||
{
|
||||
$query = DB::table('settlement_bills as sb')
|
||||
->where('sb.settlement_period_id', $periodId)
|
||||
->where(function (Builder $q): void {
|
||||
$q->where('sb.counterparty_type', 'platform')
|
||||
->orWhere('sb.owner_type', 'platform');
|
||||
});
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
|
||||
$rows = $query->get();
|
||||
|
||||
return [
|
||||
'platform_bill_net' => (int) $rows->sum('net_amount'),
|
||||
'platform_rounding_adjustment' => (int) $rows->sum('platform_rounding_adjustment'),
|
||||
'share_profit_meta' => (int) $rows->sum(fn (object $r): int => (int) (json_decode((string) ($r->meta_json ?? '{}'), true)['platform_share_profit'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function drawPeriod(AdminUser $admin, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, 0);
|
||||
|
||||
return DB::table('share_ledger as sl')
|
||||
->join('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->join('draws as d', 'd.id', '=', 'ti.draw_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->groupBy('ti.draw_id', 'd.draw_no')
|
||||
->selectRaw('ti.draw_id, d.draw_no, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as ticket_count')
|
||||
->orderBy('d.draw_no')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'draw_id' => (int) $r->draw_id,
|
||||
'draw_no' => (string) $r->draw_no,
|
||||
'game_win_loss' => (int) $r->game_win_loss,
|
||||
'basic_rebate' => (int) $r->basic_rebate,
|
||||
'ticket_count' => (int) $r->ticket_count,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function siteCodeForAdmin(AdminUser $admin, int $periodId): string
|
||||
{
|
||||
if ($periodId > 0) {
|
||||
$siteId = (int) DB::table('settlement_periods')->where('id', $periodId)->value('admin_site_id');
|
||||
|
||||
return (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
}
|
||||
|
||||
$siteId = (int) ($admin->admin_site_id ?? 0);
|
||||
if ($siteId > 0) {
|
||||
return (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
}
|
||||
|
||||
return (string) DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||
}
|
||||
}
|
||||
116
app/Services/AgentSettlement/BetSettlementSnapshotBuilder.php
Normal file
116
app/Services/AgentSettlement/BetSettlementSnapshotBuilder.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class BetSettlementSnapshotBuilder
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* agent_node_id: int,
|
||||
* agent_path: list<int>,
|
||||
* chain_codes: list<string>,
|
||||
* total_shares: array<string, float>,
|
||||
* actual_shares: array<string, float>,
|
||||
* rebate_rate: float,
|
||||
* extra_rebate_rate: float,
|
||||
* }
|
||||
*/
|
||||
public function buildForPlayer(Player $player, string $gameType = '*'): array
|
||||
{
|
||||
$agentNodeId = (int) $player->agent_node_id;
|
||||
if ($agentNodeId <= 0) {
|
||||
throw new \InvalidArgumentException('player_missing_agent');
|
||||
}
|
||||
|
||||
$pathIds = [];
|
||||
$chainCodes = [];
|
||||
$totalShares = [];
|
||||
$nodeId = $agentNodeId;
|
||||
|
||||
while ($nodeId > 0) {
|
||||
$node = AgentNode::query()->find($nodeId);
|
||||
if ($node === null) {
|
||||
break;
|
||||
}
|
||||
array_unshift($pathIds, (int) $node->id);
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||
$code = (string) $node->code;
|
||||
$chainCodes[] = $code;
|
||||
$totalShares[$code] = (float) ($profile?->total_share_rate ?? 0);
|
||||
$nodeId = (int) ($node->parent_id ?? 0);
|
||||
}
|
||||
|
||||
$orderedBottomUp = $chainCodes;
|
||||
$actual = $this->resolveActualShares($totalShares, $orderedBottomUp);
|
||||
|
||||
$rebate = $this->resolvePlayerRebateRate((int) $player->id, $agentNodeId, $gameType);
|
||||
|
||||
return [
|
||||
'agent_node_id' => $agentNodeId,
|
||||
'agent_path' => $pathIds,
|
||||
'chain_codes' => $orderedBottomUp,
|
||||
'total_shares' => $totalShares,
|
||||
'actual_shares' => $actual,
|
||||
'rebate_rate' => $rebate['rebate_rate'],
|
||||
'extra_rebate_rate' => $rebate['extra_rebate_rate'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, float> $totalShares
|
||||
* @param list<string> $orderedBottomUp
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private function resolveActualShares(array $totalShares, array $orderedBottomUp): array
|
||||
{
|
||||
$actual = [];
|
||||
$prev = 0.0;
|
||||
foreach ($orderedBottomUp as $code) {
|
||||
$total = (float) ($totalShares[$code] ?? 0);
|
||||
$actual[$code] = max(0, $total - $prev);
|
||||
$prev = $total;
|
||||
}
|
||||
$actual['platform'] = max(0, 100 - $prev);
|
||||
|
||||
return $actual;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rebate_rate: float, extra_rebate_rate: float}
|
||||
*/
|
||||
private function resolvePlayerRebateRate(int $playerId, int $agentNodeId, string $gameType = '*'): array
|
||||
{
|
||||
$gameType = trim($gameType) !== '' ? trim($gameType) : '*';
|
||||
|
||||
$row = DB::table('player_rebate_profiles')
|
||||
->where('player_id', $playerId)
|
||||
->where('game_type', $gameType)
|
||||
->first();
|
||||
|
||||
if ($row === null && $gameType !== '*') {
|
||||
$row = DB::table('player_rebate_profiles')
|
||||
->where('player_id', $playerId)
|
||||
->where('game_type', '*')
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($row !== null && ! (bool) $row->inherit_from_agent) {
|
||||
return [
|
||||
'rebate_rate' => (float) $row->rebate_rate,
|
||||
'extra_rebate_rate' => (float) $row->extra_rebate_rate,
|
||||
];
|
||||
}
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $agentNodeId)->first();
|
||||
|
||||
return [
|
||||
'rebate_rate' => (float) ($profile?->default_player_rebate ?? 0),
|
||||
'extra_rebate_rate' => 0.0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Support\CreditAmountScale;
|
||||
use App\Models\TicketItem;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class GameSettlementReversalService
|
||||
{
|
||||
public function reverseTicketItem(TicketItem $item): void
|
||||
{
|
||||
$ledger = DB::table('share_ledger')->where('ticket_item_id', $item->id)->whereNull('reversal_of_id')->first();
|
||||
if ($ledger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settledAt = now();
|
||||
|
||||
DB::transaction(function () use ($item, $ledger, $settledAt): void {
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $item->id,
|
||||
'player_id' => $ledger->player_id,
|
||||
'agent_node_id' => $ledger->agent_node_id,
|
||||
'agent_path' => $ledger->agent_path,
|
||||
'share_snapshot' => $ledger->share_snapshot,
|
||||
'game_win_loss' => -1 * (int) $ledger->game_win_loss,
|
||||
'basic_rebate' => -1 * (int) $ledger->basic_rebate,
|
||||
'shared_net_win_loss' => -1 * (int) $ledger->shared_net_win_loss,
|
||||
'allocations_json' => $ledger->allocations_json,
|
||||
'reversal_of_id' => $ledger->id,
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
|
||||
$rebates = DB::table('rebate_records')
|
||||
->where('ticket_item_id', $item->id)
|
||||
->where('status', 'accrued')
|
||||
->get();
|
||||
|
||||
foreach ($rebates as $rebate) {
|
||||
DB::table('rebate_records')->insert([
|
||||
'player_id' => $rebate->player_id,
|
||||
'ticket_item_id' => $item->id,
|
||||
'game_type' => $rebate->game_type,
|
||||
'valid_bet_amount' => $rebate->valid_bet_amount,
|
||||
'rebate_rate' => $rebate->rebate_rate,
|
||||
'rebate_amount' => -1 * (int) $rebate->rebate_amount,
|
||||
'rebate_type' => $rebate->rebate_type,
|
||||
'owner_agent_id' => $rebate->owner_agent_id,
|
||||
'status' => 'reversed',
|
||||
'reversal_of_id' => $rebate->id,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
DB::table('rebate_records')->where('id', $rebate->id)->update(['status' => 'reversed']);
|
||||
}
|
||||
|
||||
$player = Player::query()->find((int) $ledger->player_id);
|
||||
if ($player !== null && PlayerFundingMode::usesCredit($player) && (int) $ledger->game_win_loss > 0) {
|
||||
$playerId = (int) $ledger->player_id;
|
||||
$row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first();
|
||||
if ($row !== null) {
|
||||
$deltaMinor = (int) $ledger->game_win_loss;
|
||||
$deltaMajor = CreditAmountScale::minorToMajor(
|
||||
$deltaMinor,
|
||||
(string) $player->default_currency,
|
||||
);
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $playerId)
|
||||
->update([
|
||||
'used_credit' => max(0, (int) $row->used_credit - $deltaMajor),
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
239
app/Services/AgentSettlement/PeriodCloseRebateService.php
Normal file
239
app/Services/AgentSettlement/PeriodCloseRebateService.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 账期关账:回水计提 → in_bill,并写入 rebate_allocations(§7、§9.3)。
|
||||
*/
|
||||
final class PeriodCloseRebateService
|
||||
{
|
||||
/**
|
||||
* @return array{dispatched: int, allocations: int}
|
||||
*/
|
||||
public function dispatchAndAllocate(
|
||||
int $periodId,
|
||||
string $periodStart,
|
||||
string $periodEnd,
|
||||
): array {
|
||||
$rebateIds = $this->dispatchAccruedToPeriod($periodId, $periodStart, $periodEnd);
|
||||
$allocationCount = $this->buildAllocations($periodId, $rebateIds);
|
||||
|
||||
return [
|
||||
'dispatched' => count($rebateIds),
|
||||
'allocations' => $allocationCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function dispatchAccruedToPeriod(int $periodId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$ids = DB::table('rebate_records as rr')
|
||||
->join('ticket_items as ti', 'ti.id', '=', 'rr.ticket_item_id')
|
||||
->join('share_ledger as sl', function ($join): void {
|
||||
$join->on('sl.ticket_item_id', '=', 'ti.id')
|
||||
->whereNull('sl.reversal_of_id');
|
||||
})
|
||||
->where('rr.status', 'accrued')
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->pluck('rr.id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
DB::table('rebate_records')
|
||||
->whereIn('id', $ids)
|
||||
->update([
|
||||
'settlement_period_id' => $periodId,
|
||||
'status' => 'in_bill',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $rebateIds
|
||||
*/
|
||||
private function buildAllocations(int $periodId, array $rebateIds): int
|
||||
{
|
||||
if ($rebateIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$playerBills = DB::table('settlement_bills')
|
||||
->where('settlement_period_id', $periodId)
|
||||
->where('bill_type', 'player')
|
||||
->get()
|
||||
->keyBy('owner_id');
|
||||
|
||||
$count = 0;
|
||||
$rebates = DB::table('rebate_records')
|
||||
->whereIn('id', $rebateIds)
|
||||
->where('status', 'in_bill')
|
||||
->get();
|
||||
|
||||
foreach ($rebates as $rebate) {
|
||||
$playerId = (int) $rebate->player_id;
|
||||
$bill = $playerBills->get($playerId);
|
||||
$billId = $bill !== null ? (int) $bill->id : null;
|
||||
|
||||
if ((string) $rebate->rebate_type === 'extra') {
|
||||
$count += $this->insertExtraAllocation($rebate, $billId);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$count += $this->insertBasicShareAllocations($rebate, $billId);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function insertExtraAllocation(object $rebate, ?int $billId): int
|
||||
{
|
||||
$agentId = (int) ($rebate->owner_agent_id ?? 0);
|
||||
if ($agentId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
DB::table('rebate_allocations')->insert([
|
||||
'rebate_record_id' => (int) $rebate->id,
|
||||
'settlement_bill_id' => $billId,
|
||||
'participant_type' => 'agent',
|
||||
'participant_id' => $agentId,
|
||||
'actual_share_rate' => 0,
|
||||
'allocated_amount' => (int) $rebate->rebate_amount,
|
||||
'allocation_rule' => 'owner',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function insertBasicShareAllocations(object $rebate, ?int $billId): int
|
||||
{
|
||||
$ticketItemId = (int) ($rebate->ticket_item_id ?? 0);
|
||||
if ($ticketItemId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ledger = DB::table('share_ledger')
|
||||
->where('ticket_item_id', $ticketItemId)
|
||||
->whereNull('reversal_of_id')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($ledger === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$snapshot = $this->decodeSnapshot($ledger->share_snapshot);
|
||||
if ($snapshot === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$amount = (int) $rebate->rebate_amount;
|
||||
if ($amount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$shares = $snapshot['actual_shares'];
|
||||
$rows = [];
|
||||
$allocatedSum = 0;
|
||||
$participants = [];
|
||||
|
||||
foreach ($shares as $code => $rate) {
|
||||
if ($code === 'platform') {
|
||||
$participants[] = ['type' => 'platform', 'id' => 0, 'rate' => (float) $rate];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$node = AgentNode::query()->where('code', (string) $code)->first();
|
||||
if ($node === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participants[] = ['type' => 'agent', 'id' => (int) $node->id, 'rate' => (float) $rate];
|
||||
}
|
||||
|
||||
foreach ($participants as $index => $p) {
|
||||
$isLast = $index === count($participants) - 1;
|
||||
$slice = $isLast
|
||||
? $amount - $allocatedSum
|
||||
: (int) round($amount * ($p['rate'] / 100), 0, PHP_ROUND_HALF_UP);
|
||||
$allocatedSum += $slice;
|
||||
|
||||
$rows[] = [
|
||||
'rebate_record_id' => (int) $rebate->id,
|
||||
'settlement_bill_id' => $billId,
|
||||
'participant_type' => (string) $p['type'],
|
||||
'participant_id' => (int) $p['id'],
|
||||
'actual_share_rate' => $p['rate'],
|
||||
'allocated_amount' => $slice,
|
||||
'allocation_rule' => 'share',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
DB::table('rebate_allocations')->insert($rows);
|
||||
}
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actual_shares: array<string, float>}|null
|
||||
*/
|
||||
private function decodeSnapshot(mixed $raw): ?array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$actual = $decoded['actual_shares'] ?? null;
|
||||
if (! is_array($actual) || $actual === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shares = [];
|
||||
foreach ($actual as $code => $rate) {
|
||||
$shares[(string) $code] = (float) $rate;
|
||||
}
|
||||
|
||||
return ['actual_shares' => $shares];
|
||||
}
|
||||
|
||||
public function markRebatesSettledForBill(int $billId): void
|
||||
{
|
||||
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
|
||||
if ($bill === null || (string) $bill->bill_type !== 'player') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('rebate_records')
|
||||
->where('player_id', (int) $bill->owner_id)
|
||||
->where('settlement_period_id', (int) $bill->settlement_period_id)
|
||||
->where('status', 'in_bill')
|
||||
->update([
|
||||
'status' => 'settled',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
app/Services/AgentSettlement/PlatformRoundingAdjuster.php
Normal file
72
app/Services/AgentSettlement/PlatformRoundingAdjuster.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 账期汇总尾差归平台(§21.8)。
|
||||
*/
|
||||
final class PlatformRoundingAdjuster
|
||||
{
|
||||
/**
|
||||
* @param array{players: array<int, array<string, mixed>>, agent_edges: array<string, int>, agent_subtrees: array<int, array<string, mixed>>} $aggregate
|
||||
*/
|
||||
public function apply(int $periodId, array $aggregate): int
|
||||
{
|
||||
$playerNet = 0;
|
||||
foreach ($aggregate['players'] as $row) {
|
||||
$playerNet += (int) $row['net_amount'];
|
||||
}
|
||||
|
||||
$shareProfitTotal = 0;
|
||||
foreach ($aggregate['agent_subtrees'] as $subtree) {
|
||||
$shareProfitTotal += (int) ($subtree['share_profit'] ?? 0);
|
||||
}
|
||||
$shareProfitTotal += (int) ($aggregate['platform_share_profit'] ?? 0);
|
||||
|
||||
$diff = $playerNet - $shareProfitTotal;
|
||||
if ($diff === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$platformBill = DB::table('settlement_bills')
|
||||
->where('settlement_period_id', $periodId)
|
||||
->where('bill_type', 'agent')
|
||||
->where('counterparty_type', 'platform')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if ($platformBill === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$net = (int) $platformBill->net_amount + $diff;
|
||||
DB::table('settlement_bills')->where('id', (int) $platformBill->id)->update([
|
||||
'platform_rounding_adjustment' => $diff,
|
||||
'net_amount' => $net,
|
||||
'unpaid_amount' => abs($net),
|
||||
'meta_json' => json_encode(array_merge(
|
||||
$this->decodeMeta($platformBill->meta_json),
|
||||
['platform_rounding_adjustment' => $diff],
|
||||
)),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeMeta(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
123
app/Services/AgentSettlement/SettlementBillGenerator.php
Normal file
123
app/Services/AgentSettlement/SettlementBillGenerator.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SettlementBillGenerator
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* players: array<int, array<string, mixed>>,
|
||||
* agent_edges: array<string, int>,
|
||||
* agent_subtrees: array<int, array<string, mixed>>,
|
||||
* platform_share_profit?: int,
|
||||
* } $aggregate
|
||||
* @return list<int> bill ids
|
||||
*/
|
||||
public function generate(int $periodId, int $adminSiteId, array $aggregate): array
|
||||
{
|
||||
$billIds = [];
|
||||
$now = now();
|
||||
$subtrees = $aggregate['agent_subtrees'] ?? [];
|
||||
|
||||
foreach ($aggregate['players'] as $playerId => $row) {
|
||||
$net = (int) $row['net_amount'];
|
||||
$billIds[] = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $playerId,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => (int) $row['agent_node_id'],
|
||||
'gross_win_loss' => (int) $row['game_win_loss'],
|
||||
'rebate_amount' => (int) $row['basic_rebate'] + (int) $row['extra_rebate'],
|
||||
'adjustment_amount' => 0,
|
||||
'platform_rounding_adjustment' => 0,
|
||||
'net_amount' => $net,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => abs($net),
|
||||
'status' => 'pending_confirm',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($aggregate['agent_edges'] as $edge => $amount) {
|
||||
if ($amount === 0 || str_starts_with($edge, 'P_to_')) {
|
||||
continue;
|
||||
}
|
||||
$parsed = $this->parseEdge($edge);
|
||||
if ($parsed === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$fromType, $fromId, $toType, $toId] = $parsed;
|
||||
$subtree = $subtrees[$fromId] ?? null;
|
||||
|
||||
$billIds[] = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'agent',
|
||||
'owner_type' => $fromType,
|
||||
'owner_id' => $fromId,
|
||||
'counterparty_type' => $toType,
|
||||
'counterparty_id' => $toId,
|
||||
'gross_win_loss' => (int) ($subtree['gross_win_loss'] ?? 0),
|
||||
'rebate_amount' => (int) ($subtree['basic_rebate'] ?? 0) + (int) ($subtree['extra_rebate'] ?? 0),
|
||||
'adjustment_amount' => 0,
|
||||
'platform_rounding_adjustment' => 0,
|
||||
'net_amount' => $amount,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => $amount,
|
||||
'status' => 'pending_confirm',
|
||||
'meta_json' => json_encode([
|
||||
'edge' => $edge,
|
||||
'share_profit' => (int) ($subtree['share_profit'] ?? 0),
|
||||
'player_count' => (int) ($subtree['player_count'] ?? 0),
|
||||
'platform_share_profit' => $toType === 'platform' ? (int) ($aggregate['platform_share_profit'] ?? 0) : null,
|
||||
]),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $billIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int, 2: string, 3: int}|null
|
||||
*/
|
||||
private function parseEdge(string $edge): ?array
|
||||
{
|
||||
if (preg_match('/^P_to_(.+)$/', $edge, $m)) {
|
||||
$agent = AgentNode::query()->where('code', $m[1])->first();
|
||||
if ($agent === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['agent', (int) $agent->id, 'player', 0];
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+)_to_platform$/', $edge, $m)) {
|
||||
$agent = AgentNode::query()->where('code', $m[1])->first();
|
||||
if ($agent === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['agent', (int) $agent->id, 'platform', 0];
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+)_to_(.+)$/', $edge, $m)) {
|
||||
$from = AgentNode::query()->where('code', $m[1])->first();
|
||||
$to = AgentNode::query()->where('code', $m[2])->first();
|
||||
if ($from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['agent', (int) $from->id, 'agent', (int) $to->id];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
598
app/Services/AgentSettlement/SettlementCenterLedgerService.php
Normal file
598
app/Services/AgentSettlement/SettlementCenterLedgerService.php
Normal file
@@ -0,0 +1,598 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AdminDataScope;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 结算中心统一账务流水(credit_ledger + 收付 + 调账)。 */
|
||||
final class SettlementCenterLedgerService
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* items: list<array<string, mixed>>,
|
||||
* total: int,
|
||||
* page: int,
|
||||
* per_page: int,
|
||||
* ledger_source: string,
|
||||
* }
|
||||
*/
|
||||
public function listUnified(
|
||||
AdminUser $admin,
|
||||
string $siteCode,
|
||||
int $page,
|
||||
int $perPage,
|
||||
SettlementLedgerListFilters $filters = new SettlementLedgerListFilters,
|
||||
): array {
|
||||
$periodId = $filters->settlementPeriodId;
|
||||
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
|
||||
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
|
||||
|
||||
$items = [];
|
||||
$includeCredit = $this->includeEntryKind($filters, 'credit');
|
||||
$includePayment = $this->includeEntryKind($filters, 'payment');
|
||||
$includeAdjustment = $this->includeEntryKind($filters, 'adjustment');
|
||||
|
||||
if ($includeCredit) {
|
||||
$creditRows = $this->fetchCreditRows($admin, $siteCode, $range, $filters->playerId);
|
||||
foreach ($creditRows as $row) {
|
||||
$pid = (int) $row->player_id;
|
||||
$bill = $playerBills[$pid] ?? null;
|
||||
$items[] = $this->formatCreditEntry($row, $bill);
|
||||
}
|
||||
}
|
||||
if ($includePayment) {
|
||||
foreach ($this->fetchPaymentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
|
||||
$items[] = $this->formatPaymentEntry($row);
|
||||
}
|
||||
}
|
||||
if ($includeAdjustment) {
|
||||
foreach ($this->fetchAdjustmentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
|
||||
if ($filters->badDebtOnly && (string) $row->adjustment_type !== 'bad_debt') {
|
||||
continue;
|
||||
}
|
||||
$items[] = $this->formatAdjustmentEntry($row);
|
||||
}
|
||||
}
|
||||
|
||||
$items = $this->applyFilters($items, $filters);
|
||||
|
||||
usort($items, static function (array $a, array $b): int {
|
||||
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
|
||||
});
|
||||
|
||||
$total = count($items);
|
||||
$offset = max(0, ($page - 1) * $perPage);
|
||||
$pageItems = array_slice($items, $offset, $perPage);
|
||||
|
||||
return [
|
||||
'items' => array_values($pageItems),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'ledger_source' => 'settlement_ledger',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Carbon|null, 1: Carbon|null}
|
||||
*/
|
||||
private function includeEntryKind(SettlementLedgerListFilters $filters, string $kind): bool
|
||||
{
|
||||
if ($filters->badDebtOnly) {
|
||||
return $kind === 'adjustment';
|
||||
}
|
||||
|
||||
$selected = $filters->entryKind;
|
||||
if ($selected === null || $selected === '' || $selected === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $selected === $kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function applyFilters(array $items, SettlementLedgerListFilters $filters): array
|
||||
{
|
||||
return array_values(array_filter($items, function (array $row) use ($filters): bool {
|
||||
if ($filters->badDebtOnly) {
|
||||
if (($row['entry_kind'] ?? '') !== 'adjustment' || ($row['biz_type'] ?? '') !== 'bad_debt') {
|
||||
return false;
|
||||
}
|
||||
} elseif ($filters->entryKind === 'adjustment') {
|
||||
if (($row['entry_kind'] ?? '') === 'adjustment' && ($row['biz_type'] ?? '') === 'bad_debt') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($filters->txnNo !== null) {
|
||||
$needle = strtolower($filters->txnNo);
|
||||
$hay = strtolower((string) ($row['txn_no'] ?? ''));
|
||||
if (! str_contains($hay, $needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($filters->playerAccount !== null) {
|
||||
$needle = strtolower($filters->playerAccount);
|
||||
$haystack = strtolower(implode(' ', array_filter([
|
||||
(string) ($row['username'] ?? ''),
|
||||
(string) ($row['nickname'] ?? ''),
|
||||
(string) ($row['site_player_id'] ?? ''),
|
||||
])));
|
||||
if (! str_contains($haystack, $needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($filters->bizType !== null && ($row['biz_type'] ?? '') !== $filters->bizType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($filters->billStatus !== null && ($row['bill_status'] ?? '') !== $filters->billStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($filters->actionableOnly) {
|
||||
$actions = $row['available_actions'] ?? [];
|
||||
$operational = array_filter(
|
||||
$actions,
|
||||
static fn (string $a): bool => ! in_array($a, ['view_player', 'view_bill'], true),
|
||||
);
|
||||
if ($operational === []) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
private function resolveCreatedRange(
|
||||
?int $settlementPeriodId,
|
||||
?string $createdFrom,
|
||||
?string $createdTo,
|
||||
): ?array {
|
||||
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
|
||||
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
|
||||
if ($period === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
Carbon::parse($period->period_start)->startOfDay(),
|
||||
Carbon::parse($period->period_end)->endOfDay(),
|
||||
];
|
||||
}
|
||||
|
||||
$from = $createdFrom !== null && $createdFrom !== ''
|
||||
? Carbon::parse($createdFrom)->startOfDay()
|
||||
: null;
|
||||
$to = $createdTo !== null && $createdTo !== ''
|
||||
? Carbon::parse($createdTo)->endOfDay()
|
||||
: null;
|
||||
|
||||
if ($from === null && $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
$from ?? Carbon::parse('1970-01-01')->startOfDay(),
|
||||
$to ?? Carbon::now()->endOfDay(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array
|
||||
{
|
||||
$query = DB::table('settlement_bills as sb')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->join('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'sb.owner_id')->where('sb.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->where('sb.bill_type', 'player')
|
||||
->select([
|
||||
'sb.id',
|
||||
'sb.owner_id as player_id',
|
||||
'sb.status',
|
||||
'sb.bill_type',
|
||||
'sb.net_amount',
|
||||
'sb.unpaid_amount',
|
||||
'sb.paid_amount',
|
||||
'sb.settlement_period_id',
|
||||
])
|
||||
->orderByDesc('sb.id');
|
||||
|
||||
if ($periodId !== null && $periodId > 0) {
|
||||
abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403);
|
||||
$query->where('sb.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
$map = [];
|
||||
foreach ($query->limit(500)->get() as $bill) {
|
||||
$pid = (int) $bill->player_id;
|
||||
if (! isset($map[$pid])) {
|
||||
$map[$pid] = $bill;
|
||||
|
||||
continue;
|
||||
}
|
||||
$existing = $map[$pid];
|
||||
if ((string) $bill->status === 'pending_confirm') {
|
||||
$map[$pid] = $bill;
|
||||
} elseif ((string) $existing->status !== 'pending_confirm'
|
||||
&& (int) $bill->unpaid_amount > 0
|
||||
&& (int) $existing->unpaid_amount <= 0) {
|
||||
$map[$pid] = $bill;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: Carbon, 1: Carbon}|null $range
|
||||
* @return list<object>
|
||||
*/
|
||||
private function fetchCreditRows(
|
||||
AdminUser $admin,
|
||||
string $siteCode,
|
||||
?array $range,
|
||||
?int $playerId,
|
||||
): array {
|
||||
$query = DB::table('credit_ledger as cl')
|
||||
->join('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'cl.owner_id')
|
||||
->where('cl.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->where('p.funding_mode', PlayerFundingMode::CREDIT)
|
||||
->select([
|
||||
'cl.id',
|
||||
'cl.amount',
|
||||
'cl.reason',
|
||||
'cl.ref_type',
|
||||
'cl.ref_id',
|
||||
'cl.created_at',
|
||||
'p.id as player_id',
|
||||
'p.site_code',
|
||||
'p.site_player_id',
|
||||
'p.username',
|
||||
'p.nickname',
|
||||
'p.funding_mode',
|
||||
'p.auth_source',
|
||||
'p.default_currency',
|
||||
])
|
||||
->orderByDesc('cl.id');
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
if ($playerId !== null && $playerId > 0) {
|
||||
$query->where('p.id', $playerId);
|
||||
}
|
||||
|
||||
if ($range !== null) {
|
||||
$query->whereBetween('cl.created_at', $range);
|
||||
}
|
||||
|
||||
return $query->limit(500)->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<object>
|
||||
*/
|
||||
private function fetchPaymentRows(
|
||||
AdminUser $admin,
|
||||
string $siteCode,
|
||||
?int $periodId,
|
||||
?int $playerId,
|
||||
): array {
|
||||
$query = DB::table('payment_records as pr')
|
||||
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->join('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'sb.owner_id')
|
||||
->where('sb.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->select([
|
||||
'pr.id',
|
||||
'pr.amount',
|
||||
'pr.method',
|
||||
'pr.status',
|
||||
'pr.created_at',
|
||||
'pr.settlement_bill_id',
|
||||
'sb.status as bill_status',
|
||||
'sb.bill_type',
|
||||
'sb.unpaid_amount',
|
||||
'p.id as player_id',
|
||||
'p.site_player_id',
|
||||
'p.username',
|
||||
'p.nickname',
|
||||
'p.auth_source',
|
||||
'p.funding_mode',
|
||||
'p.default_currency',
|
||||
])
|
||||
->orderByDesc('pr.id');
|
||||
|
||||
if ($periodId !== null && $periodId > 0) {
|
||||
$query->where('sb.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
if ($playerId !== null && $playerId > 0) {
|
||||
$query->where('p.id', $playerId);
|
||||
}
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds !== null) {
|
||||
if ($siteIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->whereIn('sp.admin_site_id', $siteIds);
|
||||
}
|
||||
|
||||
return $query->limit(300)->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<object>
|
||||
*/
|
||||
private function fetchAdjustmentRows(
|
||||
AdminUser $admin,
|
||||
string $siteCode,
|
||||
?int $periodId,
|
||||
?int $playerId,
|
||||
): array {
|
||||
$query = DB::table('settlement_adjustments as sa')
|
||||
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
|
||||
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
|
||||
->leftJoin('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'sb.owner_id')
|
||||
->where('sb.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->select([
|
||||
'sa.id',
|
||||
'sa.amount',
|
||||
'sa.adjustment_type',
|
||||
'sa.reason',
|
||||
'sa.created_at',
|
||||
'sa.original_bill_id as settlement_bill_id',
|
||||
'sb.status as bill_status',
|
||||
'sb.bill_type',
|
||||
'sb.unpaid_amount',
|
||||
'p.id as player_id',
|
||||
'p.site_player_id',
|
||||
'p.username',
|
||||
'p.nickname',
|
||||
'p.auth_source',
|
||||
'p.funding_mode',
|
||||
'p.default_currency',
|
||||
])
|
||||
->orderByDesc('sa.id');
|
||||
|
||||
if ($periodId !== null && $periodId > 0) {
|
||||
$query->where('sa.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
if ($playerId !== null && $playerId > 0) {
|
||||
$query->where('p.id', $playerId);
|
||||
}
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds !== null) {
|
||||
if ($siteIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->whereIn('sp.admin_site_id', $siteIds);
|
||||
}
|
||||
|
||||
return $query->limit(300)->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatCreditEntry(object $row, ?object $bill): array
|
||||
{
|
||||
$amount = (int) $row->amount;
|
||||
$billId = $bill !== null ? (int) $bill->id : null;
|
||||
|
||||
return $this->baseRow(
|
||||
entryKind: 'credit',
|
||||
entryId: (int) $row->id,
|
||||
txnPrefix: 'CL',
|
||||
playerId: (int) $row->player_id,
|
||||
row: $row,
|
||||
bizType: (string) $row->reason,
|
||||
signedAmount: $amount,
|
||||
createdAt: $row->created_at,
|
||||
ledgerSource: 'credit_ledger',
|
||||
settlementBillId: $billId,
|
||||
billStatus: $bill !== null ? (string) $bill->status : null,
|
||||
billType: $bill !== null ? (string) $bill->bill_type : null,
|
||||
billUnpaid: $bill !== null ? (int) $bill->unpaid_amount : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatPaymentEntry(object $row): array
|
||||
{
|
||||
$amount = (int) $row->amount;
|
||||
|
||||
return $this->baseRow(
|
||||
entryKind: 'payment',
|
||||
entryId: (int) $row->id,
|
||||
txnPrefix: 'PAY',
|
||||
playerId: (int) $row->player_id,
|
||||
row: $row,
|
||||
bizType: 'payment_record',
|
||||
signedAmount: $amount,
|
||||
createdAt: $row->created_at,
|
||||
ledgerSource: 'payment_record',
|
||||
settlementBillId: (int) $row->settlement_bill_id,
|
||||
billStatus: (string) ($row->bill_status ?? ''),
|
||||
billType: (string) ($row->bill_type ?? ''),
|
||||
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
|
||||
refLabel: 'bill#'.$row->settlement_bill_id.($row->method ? ' · '.$row->method : ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatAdjustmentEntry(object $row): array
|
||||
{
|
||||
$amount = (int) $row->amount;
|
||||
$type = (string) $row->adjustment_type;
|
||||
|
||||
return $this->baseRow(
|
||||
entryKind: 'adjustment',
|
||||
entryId: (int) $row->id,
|
||||
txnPrefix: 'ADJ',
|
||||
playerId: (int) $row->player_id,
|
||||
row: $row,
|
||||
bizType: $type,
|
||||
signedAmount: $amount,
|
||||
createdAt: $row->created_at,
|
||||
ledgerSource: 'settlement_adjustment',
|
||||
settlementBillId: (int) $row->settlement_bill_id,
|
||||
billStatus: (string) ($row->bill_status ?? ''),
|
||||
billType: (string) ($row->bill_type ?? ''),
|
||||
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
|
||||
refLabel: $row->reason !== null && $row->reason !== ''
|
||||
? (string) $row->reason
|
||||
: 'bill#'.$row->settlement_bill_id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function baseRow(
|
||||
string $entryKind,
|
||||
int $entryId,
|
||||
string $txnPrefix,
|
||||
int $playerId,
|
||||
object $row,
|
||||
string $bizType,
|
||||
int $signedAmount,
|
||||
mixed $createdAt,
|
||||
string $ledgerSource,
|
||||
?int $settlementBillId,
|
||||
?string $billStatus,
|
||||
?string $billType,
|
||||
?int $billUnpaid,
|
||||
?string $refLabel = null,
|
||||
): array {
|
||||
$amountAbs = abs($signedAmount);
|
||||
$currency = (string) ($row->default_currency ?? '');
|
||||
|
||||
return [
|
||||
'entry_kind' => $entryKind,
|
||||
'id' => $entryId,
|
||||
'row_key' => $entryKind.'-'.$entryId,
|
||||
'txn_no' => $txnPrefix.'-'.$entryId,
|
||||
'player_id' => $playerId,
|
||||
'site_code' => $row->site_code ?? null,
|
||||
'site_player_id' => $row->site_player_id ?? null,
|
||||
'username' => $row->username ?? null,
|
||||
'nickname' => $row->nickname ?? null,
|
||||
'biz_type' => $bizType,
|
||||
'biz_no' => $refLabel ?? $this->creditRefLabel($row),
|
||||
'direction' => $signedAmount >= 0 ? 1 : 2,
|
||||
'amount' => $amountAbs,
|
||||
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
|
||||
'signed_amount' => $signedAmount,
|
||||
'currency_code' => $currency,
|
||||
'status' => 'posted',
|
||||
'created_at' => $createdAt !== null ? Carbon::parse($createdAt)->toIso8601String() : null,
|
||||
'ledger_source' => $ledgerSource,
|
||||
'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
|
||||
'auth_source' => $row->auth_source ?? null,
|
||||
'settlement_bill_id' => $settlementBillId,
|
||||
'bill_status' => $billStatus,
|
||||
'bill_type' => $billType,
|
||||
'bill_unpaid_amount' => $billUnpaid,
|
||||
'available_actions' => $this->resolveActions(
|
||||
$entryKind,
|
||||
$settlementBillId,
|
||||
$billStatus,
|
||||
$billType,
|
||||
$billUnpaid,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveActions(
|
||||
string $entryKind,
|
||||
?int $billId,
|
||||
?string $billStatus,
|
||||
?string $billType,
|
||||
?int $billUnpaid,
|
||||
): array {
|
||||
$actions = ['view_player'];
|
||||
|
||||
if ($billId === null || $billId <= 0) {
|
||||
return $actions;
|
||||
}
|
||||
|
||||
$actions[] = 'view_bill';
|
||||
|
||||
if ($billStatus === 'pending_confirm') {
|
||||
$actions[] = 'confirm';
|
||||
}
|
||||
|
||||
if ($billStatus !== null
|
||||
&& in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
|
||||
&& ($billUnpaid ?? 0) > 0) {
|
||||
$actions[] = 'payment';
|
||||
}
|
||||
|
||||
if ($billStatus !== null
|
||||
&& in_array($billStatus, ['confirmed', 'partial_paid', 'settled', 'overdue'], true)
|
||||
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
|
||||
$actions[] = 'adjustment';
|
||||
$actions[] = 'reversal';
|
||||
}
|
||||
|
||||
if ($billStatus !== null
|
||||
&& in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
|
||||
&& ($billUnpaid ?? 0) > 0
|
||||
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
|
||||
$actions[] = 'bad_debt';
|
||||
}
|
||||
|
||||
return array_values(array_unique($actions));
|
||||
}
|
||||
|
||||
private function creditRefLabel(object $row): ?string
|
||||
{
|
||||
if (! isset($row->ref_type) || $row->ref_type === null || $row->ref_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $row->ref_type.'#'.$row->ref_id;
|
||||
}
|
||||
}
|
||||
54
app/Services/AgentSettlement/SettlementLedgerListFilters.php
Normal file
54
app/Services/AgentSettlement/SettlementLedgerListFilters.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
/** 结算中心账务流水列表筛选。 */
|
||||
final class SettlementLedgerListFilters
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?int $settlementPeriodId = null,
|
||||
public readonly ?int $playerId = null,
|
||||
public readonly ?string $entryKind = null,
|
||||
public readonly ?string $txnNo = null,
|
||||
public readonly ?string $playerAccount = null,
|
||||
public readonly ?string $bizType = null,
|
||||
public readonly ?string $billStatus = null,
|
||||
public readonly bool $actionableOnly = false,
|
||||
public readonly ?string $createdFrom = null,
|
||||
public readonly ?string $createdTo = null,
|
||||
public readonly bool $badDebtOnly = false,
|
||||
) {}
|
||||
|
||||
public static function fromQuery(array $query): self
|
||||
{
|
||||
$playerId = (int) ($query['player_id'] ?? 0);
|
||||
|
||||
return new self(
|
||||
settlementPeriodId: self::positiveInt($query['settlement_period_id'] ?? null),
|
||||
playerId: $playerId > 0 ? $playerId : null,
|
||||
entryKind: self::nonEmptyString($query['entry_kind'] ?? null),
|
||||
txnNo: self::nonEmptyString($query['txn_no'] ?? null),
|
||||
playerAccount: self::nonEmptyString($query['player_account'] ?? null),
|
||||
bizType: self::nonEmptyString($query['reason'] ?? $query['biz_type'] ?? null),
|
||||
billStatus: self::nonEmptyString($query['bill_status'] ?? null),
|
||||
actionableOnly: filter_var($query['actionable_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
createdFrom: self::nonEmptyString($query['created_from'] ?? null),
|
||||
createdTo: self::nonEmptyString($query['created_to'] ?? null),
|
||||
badDebtOnly: filter_var($query['bad_debt_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
);
|
||||
}
|
||||
|
||||
private static function positiveInt(mixed $value): ?int
|
||||
{
|
||||
$id = (int) $value;
|
||||
|
||||
return $id > 0 ? $id : null;
|
||||
}
|
||||
|
||||
private static function nonEmptyString(mixed $value): ?string
|
||||
{
|
||||
$s = trim((string) $value);
|
||||
|
||||
return $s !== '' ? $s : null;
|
||||
}
|
||||
}
|
||||
81
app/Services/AgentSettlement/SettlementPaymentService.php
Normal file
81
app/Services/AgentSettlement/SettlementPaymentService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SettlementPaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentSettlementBillGuard $billGuard,
|
||||
private readonly PlayerCreditService $playerCreditService,
|
||||
private readonly PeriodCloseRebateService $periodCloseRebate,
|
||||
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
|
||||
) {}
|
||||
|
||||
public function confirmBill(int $billId): void
|
||||
{
|
||||
$this->billGuard->markConfirmed($billId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{method?: string|null, proof?: string|null, remark?: string|null} $meta
|
||||
*/
|
||||
public function recordPayment(int $billId, int $amount, int $adminUserId, array $meta = []): void
|
||||
{
|
||||
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
|
||||
if ($bill === null) {
|
||||
throw new \InvalidArgumentException('bill_not_found');
|
||||
}
|
||||
|
||||
$this->billGuard->assertPeriodMutable($billId);
|
||||
|
||||
$amount = min($amount, (int) $bill->unpaid_amount);
|
||||
if ($amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('payment_records')->insert([
|
||||
'settlement_bill_id' => $billId,
|
||||
'payer_type' => (string) $bill->owner_type,
|
||||
'payer_id' => (int) $bill->owner_id,
|
||||
'payee_type' => (string) $bill->counterparty_type,
|
||||
'payee_id' => (int) $bill->counterparty_id,
|
||||
'amount' => $amount,
|
||||
'method' => $meta['method'] ?? null,
|
||||
'proof' => $meta['proof'] ?? null,
|
||||
'remark' => $meta['remark'] ?? null,
|
||||
'status' => 'confirmed',
|
||||
'created_by' => $adminUserId,
|
||||
'confirmed_by' => $adminUserId,
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$newPaid = (int) $bill->paid_amount + $amount;
|
||||
$newUnpaid = max(0, (int) $bill->unpaid_amount - $amount);
|
||||
$status = $newUnpaid === 0 ? 'settled' : 'partial_paid';
|
||||
|
||||
DB::table('settlement_bills')->where('id', $billId)->update([
|
||||
'paid_amount' => $newPaid,
|
||||
'unpaid_amount' => $newUnpaid,
|
||||
'status' => $status,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
|
||||
$player = Player::query()->find((int) $bill->owner_id);
|
||||
if ($player !== null) {
|
||||
$this->playerCreditService->releaseFromSettlement($player, $amount, $billId);
|
||||
if ($status === 'settled') {
|
||||
$this->periodCloseRebate->markRebatesSettledForBill($billId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->periodCompletion->syncIfReady((int) $bill->settlement_period_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class UnsettledTicketPeriodWarning
|
||||
{
|
||||
/**
|
||||
* @return array{count: int, ticket_item_ids: list<int>}
|
||||
*/
|
||||
public function countForSite(int $adminSiteId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
|
||||
|
||||
$rows = DB::table('ticket_items as ti')
|
||||
->join('players as p', 'p.id', '=', 'ti.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereIn('ti.status', ['pending_draw', 'pending_confirm', 'pending_payout'])
|
||||
->whereBetween('ti.created_at', [$periodStart, $periodEnd])
|
||||
->pluck('ti.id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
return [
|
||||
'count' => count($rows),
|
||||
'ticket_item_ids' => array_slice($rows, 0, 20),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
namespace App\Services\Player;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Support\AgentOverdueGuard;
|
||||
use App\Support\CreditAmountScale;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -14,19 +17,33 @@ final class PlayerCreditService
|
||||
public function upsertAccount(Player $player, array $payload): void
|
||||
{
|
||||
$limit = max(0, (int) ($payload['credit_limit'] ?? 0));
|
||||
$now = now();
|
||||
$exists = DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->id)
|
||||
->exists();
|
||||
|
||||
DB::table('player_credit_accounts')->updateOrInsert(
|
||||
['player_id' => $player->id],
|
||||
[
|
||||
'credit_limit' => $limit,
|
||||
'used_credit' => DB::raw('COALESCE(used_credit, 0)'),
|
||||
'frozen_credit' => DB::raw('COALESCE(frozen_credit, 0)'),
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
],
|
||||
);
|
||||
if ($exists) {
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->id)
|
||||
->update([
|
||||
'credit_limit' => $limit,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => $limit,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 可用授信(主货币整数,与后台「授信额度」一致)。 */
|
||||
public function availableCredit(Player $player): int
|
||||
{
|
||||
$row = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
|
||||
@@ -37,34 +54,45 @@ final class PlayerCreditService
|
||||
return max(0, (int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit);
|
||||
}
|
||||
|
||||
public function holdForBet(Player $player, int $amount): void
|
||||
/** 可用授信(最小货币单位,供玩家端钱包/下注与钱包余额 API 对齐)。 */
|
||||
public function availableCreditMinor(Player $player, ?string $currencyCode = null): int
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
$currency = $currencyCode ?? (string) $player->default_currency;
|
||||
|
||||
return CreditAmountScale::majorToMinor($this->availableCredit($player), $currency);
|
||||
}
|
||||
|
||||
public function holdForBet(Player $player, int $amountMinor): void
|
||||
{
|
||||
if ($amountMinor <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! \App\Support\CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) {
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$available = $this->availableCredit($player);
|
||||
if ($amount > $available) {
|
||||
$currency = (string) $player->default_currency;
|
||||
$availableMinor = $this->availableCreditMinor($player, $currency);
|
||||
if ($amountMinor > $availableMinor) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit' => ['insufficient'],
|
||||
]);
|
||||
}
|
||||
|
||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
||||
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->id)
|
||||
->update([
|
||||
'used_credit' => DB::raw('used_credit + '.$amount),
|
||||
'used_credit' => DB::raw('used_credit + '.$majorDelta),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -$amount,
|
||||
'amount' => -$amountMinor,
|
||||
'reason' => 'bet_hold',
|
||||
'ref_type' => 'bet',
|
||||
'ref_id' => null,
|
||||
@@ -73,23 +101,97 @@ final class PlayerCreditService
|
||||
]);
|
||||
}
|
||||
|
||||
public function releaseFromSettlement(Player $player, int $amount, int $billId): void
|
||||
public function applySettledLoss(Player $player, int $amountMinor, int $ticketItemId): void
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
if ($amountMinor <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currency = (string) $player->default_currency;
|
||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
||||
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->id)
|
||||
->update([
|
||||
'used_credit' => DB::raw('GREATEST(0, used_credit - '.$amount.')'),
|
||||
'used_credit' => DB::raw('used_credit + '.$majorDelta),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => $amount,
|
||||
'amount' => -$amountMinor,
|
||||
'reason' => 'game_settlement_loss',
|
||||
'ref_type' => 'ticket_item',
|
||||
'ref_id' => $ticketItemId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function assertMayPlaceBet(Player $player, int $amountMinor): void
|
||||
{
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$overdue = DB::table('settlement_bills')
|
||||
->where('owner_type', 'player')
|
||||
->where('owner_id', $player->id)
|
||||
->where('status', 'overdue')
|
||||
->where('unpaid_amount', '>', 0)
|
||||
->exists();
|
||||
|
||||
if ($overdue) {
|
||||
throw ValidationException::withMessages([
|
||||
'credit' => ['overdue'],
|
||||
]);
|
||||
}
|
||||
|
||||
$agentNodeId = (int) ($player->agent_node_id ?? 0);
|
||||
if ($agentNodeId > 0) {
|
||||
AgentOverdueGuard::assertAgentMayGrantCredit($agentNodeId);
|
||||
}
|
||||
|
||||
$this->holdForBet($player, $amountMinor);
|
||||
}
|
||||
|
||||
public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void
|
||||
{
|
||||
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->decreaseUsedCredit($player, $amountMinor);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => $amountMinor,
|
||||
'reason' => 'bet_hold_release',
|
||||
'ref_type' => 'ticket_item',
|
||||
'ref_id' => $ticketItemId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void
|
||||
{
|
||||
if ($amountMinor <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->decreaseUsedCredit($player, $amountMinor);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => $amountMinor,
|
||||
'reason' => 'settlement_confirm',
|
||||
'ref_type' => 'settlement_bill',
|
||||
'ref_id' => $billId,
|
||||
@@ -97,4 +199,26 @@ final class PlayerCreditService
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function decreaseUsedCredit(Player $player, int $amountMinor): void
|
||||
{
|
||||
if ($amountMinor <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$playerId = (int) $player->id;
|
||||
$row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first();
|
||||
if ($row === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency);
|
||||
$next = max(0, (int) $row->used_credit - $majorDelta);
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $playerId)
|
||||
->update([
|
||||
'used_credit' => $next,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
131
app/Services/Player/PlayerNativeAuthService.php
Normal file
131
app/Services/Player/PlayerNativeAuthService.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Player;
|
||||
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Exceptions\PlayerAuthenticationException;
|
||||
|
||||
final class PlayerNativeAuthService
|
||||
{
|
||||
/**
|
||||
* @return array{access_token: string, expires_in: int, token_type: string, player: array<string, mixed>}
|
||||
*/
|
||||
public function login(string $siteCode, string $username, string $password): array
|
||||
{
|
||||
$username = trim($username);
|
||||
$siteCode = trim($siteCode);
|
||||
if ($siteCode === '' || $username === '' || $password === '') {
|
||||
throw new PlayerAuthenticationException(
|
||||
'账号或密码错误',
|
||||
ErrorCode::PlayerCredentialsInvalid->value,
|
||||
);
|
||||
}
|
||||
|
||||
$player = Player::query()
|
||||
->where('site_code', $siteCode)
|
||||
->where('username', $username)
|
||||
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE)
|
||||
->first();
|
||||
|
||||
if ($player === null || ! is_string($player->password_hash) || $player->password_hash === '') {
|
||||
throw new PlayerAuthenticationException(
|
||||
'账号或密码错误',
|
||||
ErrorCode::PlayerCredentialsInvalid->value,
|
||||
);
|
||||
}
|
||||
|
||||
if ($player->login_locked_until !== null && $player->login_locked_until->isFuture()) {
|
||||
throw new PlayerAuthenticationException(
|
||||
'登录已锁定',
|
||||
ErrorCode::PlayerLoginLocked->value,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) $player->status !== 0) {
|
||||
throw new PlayerAuthenticationException(
|
||||
'账号已冻结',
|
||||
ErrorCode::PlayerAccountSuspended->value,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if (! Hash::check($password, $player->password_hash)) {
|
||||
$this->recordFailedLogin($player);
|
||||
|
||||
throw new PlayerAuthenticationException(
|
||||
'账号或密码错误',
|
||||
ErrorCode::PlayerCredentialsInvalid->value,
|
||||
);
|
||||
}
|
||||
|
||||
$player->forceFill([
|
||||
'login_failed_count' => 0,
|
||||
'login_locked_until' => null,
|
||||
'last_login_at' => now(),
|
||||
])->save();
|
||||
|
||||
$ttl = (int) config('lottery.player_auth.native.ttl_seconds', 28800);
|
||||
$token = $this->issueToken($player, $ttl);
|
||||
|
||||
return [
|
||||
'access_token' => $token,
|
||||
'expires_in' => $ttl,
|
||||
'token_type' => 'Bearer',
|
||||
'player' => [
|
||||
'id' => (int) $player->id,
|
||||
'site_code' => $player->site_code,
|
||||
'username' => $player->username,
|
||||
'nickname' => $player->nickname,
|
||||
'funding_mode' => $player->funding_mode,
|
||||
'auth_source' => $player->auth_source,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function issueToken(Player $player, ?int $ttlSeconds = null): string
|
||||
{
|
||||
$secret = (string) config('lottery.player_auth.native.secret', '');
|
||||
if ($secret === '') {
|
||||
throw new PlayerAuthenticationException(
|
||||
'原生登录未配置',
|
||||
ErrorCode::PlayerSsoSecretNotConfigured->value,
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
$ttl = $ttlSeconds ?? (int) config('lottery.player_auth.native.ttl_seconds', 28800);
|
||||
$now = time();
|
||||
$playerIdKey = (string) config('lottery.player_auth.native.claim_player_id', 'player_id');
|
||||
$authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source');
|
||||
|
||||
$payload = [
|
||||
$playerIdKey => (int) $player->id,
|
||||
$authKey => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'site_code' => (string) $player->site_code,
|
||||
'iat' => $now,
|
||||
'exp' => $now + $ttl,
|
||||
];
|
||||
|
||||
return JWT::encode($payload, $secret, (string) config('lottery.player_auth.jwt.algorithm', 'HS256'));
|
||||
}
|
||||
|
||||
private function recordFailedLogin(Player $player): void
|
||||
{
|
||||
$max = (int) config('lottery.player_auth.native.max_login_attempts', 8);
|
||||
$lockMinutes = (int) config('lottery.player_auth.native.lock_minutes', 15);
|
||||
$count = (int) $player->login_failed_count + 1;
|
||||
|
||||
$updates = ['login_failed_count' => $count];
|
||||
if ($count >= $max) {
|
||||
$updates['login_locked_until'] = now()->addMinutes($lockMinutes);
|
||||
$updates['login_failed_count'] = 0;
|
||||
}
|
||||
|
||||
$player->forceFill($updates)->save();
|
||||
}
|
||||
}
|
||||
65
app/Services/Player/PlayerRebateProfileService.php
Normal file
65
app/Services/Player/PlayerRebateProfileService.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Player;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Services\Agent\RebateLimitValidator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class PlayerRebateProfileService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RebateLimitValidator $rebateLimitValidator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<array{game_type: string, rebate_rate?: float, extra_rebate_rate?: float, inherit_from_agent?: bool}> $profiles
|
||||
*/
|
||||
public function syncProfiles(int $playerId, AgentNode $agent, array $profiles): void
|
||||
{
|
||||
if ($profiles === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
foreach ($profiles as $row) {
|
||||
$gameType = trim((string) ($row['game_type'] ?? '*')) ?: '*';
|
||||
$inherit = (bool) ($row['inherit_from_agent'] ?? false);
|
||||
$rebateRate = (float) ($row['rebate_rate'] ?? 0);
|
||||
$extraRate = (float) ($row['extra_rebate_rate'] ?? 0);
|
||||
|
||||
if (! $inherit) {
|
||||
$this->rebateLimitValidator->assertPlayerRebateWithinAgent($agent, $rebateRate, $extraRate);
|
||||
}
|
||||
|
||||
DB::table('player_rebate_profiles')->updateOrInsert(
|
||||
['player_id' => $playerId, 'game_type' => $gameType],
|
||||
[
|
||||
'inherit_from_agent' => $inherit,
|
||||
'rebate_rate' => $inherit ? 0 : $rebateRate,
|
||||
'extra_rebate_rate' => $inherit ? 0 : $extraRate,
|
||||
'updated_at' => $now,
|
||||
'created_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{game_type: string, rebate_rate: float, extra_rebate_rate: float, inherit_from_agent: bool}>
|
||||
*/
|
||||
public function listForPlayer(int $playerId): array
|
||||
{
|
||||
return DB::table('player_rebate_profiles')
|
||||
->where('player_id', $playerId)
|
||||
->orderBy('game_type')
|
||||
->get()
|
||||
->map(static fn (object $row): array => [
|
||||
'game_type' => (string) $row->game_type,
|
||||
'rebate_rate' => (float) $row->rebate_rate,
|
||||
'extra_rebate_rate' => (float) $row->extra_rebate_rate,
|
||||
'inherit_from_agent' => (bool) $row->inherit_from_agent,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ use Firebase\JWT\Key;
|
||||
use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerAutoRegistrationDefaults;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use App\Support\PlayerTokenAesUnwrap;
|
||||
use Illuminate\Database\QueryException;
|
||||
use App\Exceptions\PlayerAuthenticationException;
|
||||
@@ -63,26 +65,30 @@ final class PlayerTokenResolver
|
||||
$player = $this->resolveDevToken($token);
|
||||
} else {
|
||||
$jwtPlain = $this->unwrapOpaqueToJwtString($token);
|
||||
$siteCode = $this->partnerSiteConfigResolver->peekSiteCodeFromJwt($jwtPlain);
|
||||
if ($siteCode === null) {
|
||||
throw new PlayerAuthenticationException('JWT 缺少站点标识', ErrorCode::PlayerTokenInvalid->value);
|
||||
}
|
||||
if ($this->peekAuthSourceFromJwt($jwtPlain) === PlayerAuthSource::LOTTERY_NATIVE) {
|
||||
$player = $this->resolveNativeJwt($jwtPlain);
|
||||
} else {
|
||||
$siteCode = $this->partnerSiteConfigResolver->peekSiteCodeFromJwt($jwtPlain);
|
||||
if ($siteCode === null) {
|
||||
throw new PlayerAuthenticationException('JWT 缺少站点标识', ErrorCode::PlayerTokenInvalid->value);
|
||||
}
|
||||
|
||||
$siteConfig = $this->partnerSiteConfigResolver->resolveBySiteCode($siteCode);
|
||||
if (! $siteConfig->enabled) {
|
||||
throw new PlayerAuthenticationException('站点已停用', ErrorCode::PlayerAccountSuspended->value, 403);
|
||||
}
|
||||
$siteConfig = $this->partnerSiteConfigResolver->resolveBySiteCode($siteCode);
|
||||
if (! $siteConfig->enabled) {
|
||||
throw new PlayerAuthenticationException('站点已停用', ErrorCode::PlayerAccountSuspended->value, 403);
|
||||
}
|
||||
|
||||
$secret = $siteConfig->ssoJwtSecret;
|
||||
if (! is_string($secret) || $secret === '') {
|
||||
throw new PlayerAuthenticationException(
|
||||
'SSO 未配置(站点 '.$siteCode.')',
|
||||
ErrorCode::PlayerSsoSecretNotConfigured->value,
|
||||
503,
|
||||
);
|
||||
}
|
||||
$secret = $siteConfig->ssoJwtSecret;
|
||||
if (! is_string($secret) || $secret === '') {
|
||||
throw new PlayerAuthenticationException(
|
||||
'SSO 未配置(站点 '.$siteCode.')',
|
||||
ErrorCode::PlayerSsoSecretNotConfigured->value,
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
$player = $this->resolveJwt($jwtPlain, $secret);
|
||||
$player = $this->resolveSsoJwt($jwtPlain, $secret);
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertPlayerActive($player);
|
||||
@@ -127,7 +133,7 @@ final class PlayerTokenResolver
|
||||
{
|
||||
$jwtPlain = $this->unwrapOpaqueToJwtString($opaque);
|
||||
|
||||
return $this->resolveJwt($jwtPlain, $secret);
|
||||
return $this->resolveSsoJwt($jwtPlain, $secret);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,7 +156,48 @@ final class PlayerTokenResolver
|
||||
return preg_match('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $token) === 1;
|
||||
}
|
||||
|
||||
private function resolveJwt(string $jwt, string $secret): Player
|
||||
private function resolveNativeJwt(string $jwt): Player
|
||||
{
|
||||
$secret = (string) config('lottery.player_auth.native.secret', '');
|
||||
if ($secret === '') {
|
||||
throw new PlayerAuthenticationException(
|
||||
'原生登录未配置',
|
||||
ErrorCode::PlayerSsoSecretNotConfigured->value,
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
$alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256');
|
||||
|
||||
try {
|
||||
/** @var object $claims */
|
||||
$claims = JWT::decode($jwt, new Key($secret, $alg));
|
||||
} catch (\Throwable) {
|
||||
throw new PlayerAuthenticationException('Token 无效或已过期', ErrorCode::PlayerTokenInvalid->value);
|
||||
}
|
||||
|
||||
$this->assertNativeJwtTemporalPolicy($claims);
|
||||
|
||||
$playerIdKey = (string) config('lottery.player_auth.native.claim_player_id', 'player_id');
|
||||
$authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source');
|
||||
$playerId = (int) data_get($claims, $playerIdKey, 0);
|
||||
$authSource = data_get($claims, $authKey);
|
||||
|
||||
if ($playerId <= 0 || $authSource !== PlayerAuthSource::LOTTERY_NATIVE) {
|
||||
throw new PlayerAuthenticationException('JWT 缺少玩家标识', ErrorCode::PlayerTokenInvalid->value);
|
||||
}
|
||||
|
||||
$player = Player::query()->find($playerId);
|
||||
if ($player === null || ! $player->isLotteryNative()) {
|
||||
throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value);
|
||||
}
|
||||
|
||||
$player->forceFill(['last_login_at' => now()])->save();
|
||||
|
||||
return $player->refresh();
|
||||
}
|
||||
|
||||
private function resolveSsoJwt(string $jwt, string $secret): Player
|
||||
{
|
||||
$alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256');
|
||||
|
||||
@@ -178,6 +225,8 @@ final class PlayerTokenResolver
|
||||
$now = now();
|
||||
$defaults = [
|
||||
...PlayerAutoRegistrationDefaults::profileFields(),
|
||||
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
|
||||
'funding_mode' => PlayerFundingMode::WALLET,
|
||||
'default_currency' => LotterySettings::defaultCurrency(),
|
||||
'status' => self::PLAYER_STATUS_ACTIVE,
|
||||
'last_login_at' => $now,
|
||||
@@ -209,6 +258,58 @@ final class PlayerTokenResolver
|
||||
return $player->refresh();
|
||||
}
|
||||
|
||||
private function peekAuthSourceFromJwt(string $jwt): ?string
|
||||
{
|
||||
$parts = explode('.', trim($jwt));
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode($this->base64UrlDecode($parts[1]), true);
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source');
|
||||
$value = $payload[$authKey] ?? null;
|
||||
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $segment): string
|
||||
{
|
||||
$remainder = strlen($segment) % 4;
|
||||
if ($remainder > 0) {
|
||||
$segment .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
|
||||
$decoded = base64_decode(strtr($segment, '-_', '+/'), true);
|
||||
|
||||
return is_string($decoded) ? $decoded : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $claims
|
||||
*/
|
||||
private function assertNativeJwtTemporalPolicy(object $claims): void
|
||||
{
|
||||
if (! isset($claims->exp) || ! is_numeric($claims->exp)) {
|
||||
throw new PlayerAuthenticationException('JWT 缺少过期时间', ErrorCode::PlayerTokenInvalid->value);
|
||||
}
|
||||
|
||||
$maxTtl = (int) config('lottery.player_auth.native.ttl_seconds', 28800);
|
||||
if (isset($claims->iat) && is_numeric($claims->iat)) {
|
||||
$iat = (int) $claims->iat;
|
||||
$exp = (int) $claims->exp;
|
||||
if ($exp - $iat > $maxTtl) {
|
||||
throw new PlayerAuthenticationException(
|
||||
'JWT 有效期超过允许的 '.(string) $maxTtl.' 秒',
|
||||
ErrorCode::PlayerTokenInvalid->value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 短效 SSO:JWT 须有 exp(由 decode 校验),可选要求 iat,且 exp-iat 不得超过配置秒数。
|
||||
*
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
|
||||
use App\Services\Ticket\RiskPoolService;
|
||||
use App\Services\Jackpot\JackpotBurstAllocator;
|
||||
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
|
||||
|
||||
/**
|
||||
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 待审核)。
|
||||
@@ -32,6 +33,7 @@ final class SettlementOrchestrator
|
||||
private readonly RiskPoolService $riskPool,
|
||||
private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
|
||||
private readonly DrawHallSnapshotBuilder $hallSnapshot,
|
||||
private readonly AgentGameSettlementRecorder $agentGameSettlement,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -183,13 +185,16 @@ final class SettlementOrchestrator
|
||||
'match_detail_json' => $p['match_detail'],
|
||||
]);
|
||||
|
||||
$terminalStatus = $finalCredit > 0 ? 'pending_payout' : 'settled_lose';
|
||||
$item->forceFill([
|
||||
'win_amount' => $net,
|
||||
'jackpot_win_amount' => $jackpotShare,
|
||||
'settled_at' => null,
|
||||
'status' => $finalCredit > 0 ? 'pending_payout' : 'settled_lose',
|
||||
'status' => $terminalStatus,
|
||||
])->save();
|
||||
|
||||
$this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus);
|
||||
|
||||
if ($finalCredit > 0) {
|
||||
$winCount++;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use App\Exceptions\IdempotentTicketReplayException;
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Services\Jackpot\JackpotContributionService;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
use App\Support\CreditLineMode;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
|
||||
final class TicketPlacementService
|
||||
@@ -42,8 +42,10 @@ final class TicketPlacementService
|
||||
? (string) $payload['client_trace_id']
|
||||
: null;
|
||||
|
||||
$drawNo = (string) $payload['draw_id'];
|
||||
$drawIdForIdempotency = Draw::query()->where('draw_no', $drawNo)->value('id');
|
||||
$drawNo = trim((string) ($payload['draw_id'] ?? ''));
|
||||
$drawIdForIdempotency = $drawNo === ''
|
||||
? null
|
||||
: Draw::query()->where('draw_no', $drawNo)->value('id');
|
||||
|
||||
if ($clientTraceId !== null && $drawIdForIdempotency !== null) {
|
||||
$existing = TicketOrder::query()
|
||||
@@ -75,9 +77,10 @@ final class TicketPlacementService
|
||||
$payload,
|
||||
$expectedVersions,
|
||||
$clientTraceId,
|
||||
$drawNo,
|
||||
): array {
|
||||
$draw = Draw::query()
|
||||
->where('draw_no', (string) $payload['draw_id'])
|
||||
->where('draw_no', $drawNo)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($draw === null) {
|
||||
@@ -156,20 +159,24 @@ final class TicketPlacementService
|
||||
);
|
||||
}
|
||||
|
||||
$wallet = PlayerWallet::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('wallet_type', 'lottery')
|
||||
->where('currency_code', $currencyCode)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($wallet !== null && (int) $wallet->status !== 0) {
|
||||
throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value);
|
||||
}
|
||||
$creditLine = PlayerFundingMode::usesCredit($player);
|
||||
|
||||
$walletBalance = $wallet !== null ? (int) $wallet->balance : 0;
|
||||
$walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0);
|
||||
if ($walletAvailable < $totalActualDeduct) {
|
||||
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
|
||||
if (! $creditLine) {
|
||||
$wallet = PlayerWallet::query()
|
||||
->where('player_id', $player->id)
|
||||
->where('wallet_type', 'lottery')
|
||||
->where('currency_code', $currencyCode)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($wallet !== null && (int) $wallet->status !== 0) {
|
||||
throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value);
|
||||
}
|
||||
|
||||
$walletBalance = $wallet !== null ? (int) $wallet->balance : 0;
|
||||
$walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0);
|
||||
if ($walletAvailable < $totalActualDeduct) {
|
||||
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -301,17 +308,17 @@ final class TicketPlacementService
|
||||
'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm',
|
||||
])->save();
|
||||
|
||||
if (CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) {
|
||||
$this->playerCreditService->holdForBet($player, $successTotalActualDeduct);
|
||||
if ($creditLine) {
|
||||
$this->playerCreditService->assertMayPlaceBet($player, $successTotalActualDeduct);
|
||||
} else {
|
||||
$this->ticketWalletService->reserveBetDeduct(
|
||||
$player,
|
||||
$currencyCode,
|
||||
$successTotalActualDeduct,
|
||||
$order,
|
||||
);
|
||||
}
|
||||
|
||||
$this->ticketWalletService->reserveBetDeduct(
|
||||
$player,
|
||||
$currencyCode,
|
||||
$successTotalActualDeduct,
|
||||
$order,
|
||||
);
|
||||
|
||||
return [
|
||||
'order' => $order,
|
||||
'draw_id' => (int) $draw->id,
|
||||
@@ -331,13 +338,20 @@ final class TicketPlacementService
|
||||
$order = TicketOrder::query()->whereKey($placement['order']->id)->firstOrFail();
|
||||
$draw = Draw::query()->whereKey((int) $placement['draw_id'])->firstOrFail();
|
||||
|
||||
$creditLine = PlayerFundingMode::usesCredit($player);
|
||||
|
||||
try {
|
||||
$balanceAfter = $this->ticketWalletService->finalizeReservedBetDeduct(
|
||||
$player,
|
||||
(string) $placement['currency_code'],
|
||||
(int) $placement['success_total_actual_deduct'],
|
||||
$order,
|
||||
);
|
||||
$balanceAfter = $creditLine
|
||||
? $this->playerCreditService->availableCreditMinor(
|
||||
$player,
|
||||
(string) $placement['currency_code'],
|
||||
)
|
||||
: $this->ticketWalletService->finalizeReservedBetDeduct(
|
||||
$player,
|
||||
(string) $placement['currency_code'],
|
||||
(int) $placement['success_total_actual_deduct'],
|
||||
$order,
|
||||
);
|
||||
|
||||
DB::transaction(function () use ($order, $draw, $placement): void {
|
||||
$successfulItems = TicketItem::query()
|
||||
@@ -381,7 +395,9 @@ final class TicketPlacementService
|
||||
}
|
||||
|
||||
$order->forceFill(['status' => 'refunded'])->save();
|
||||
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
|
||||
}
|
||||
$this->ticketWalletService->reverseBetDeduct($order);
|
||||
});
|
||||
|
||||
@@ -516,7 +532,7 @@ final class TicketPlacementService
|
||||
*/
|
||||
private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array
|
||||
{
|
||||
if (! CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) {
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
return $evaluated;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ final class TicketPreviewService
|
||||
*/
|
||||
public function preview(array $payload): array
|
||||
{
|
||||
$draw = Draw::query()->where('draw_no', (string) $payload['draw_id'])->first();
|
||||
$drawNo = trim((string) ($payload['draw_id'] ?? ''));
|
||||
$draw = $drawNo === ''
|
||||
? null
|
||||
: Draw::query()->where('draw_no', $drawNo)->first();
|
||||
if ($draw === null) {
|
||||
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Services\LotterySettings;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\QueryException;
|
||||
use App\Exceptions\WalletOperationException;
|
||||
use App\Support\PlayerFundingMode;
|
||||
|
||||
/**
|
||||
* 主站 ↔ 彩票钱包:转入 / 转出(幂等键 + 流水 + 订单)。
|
||||
@@ -68,6 +69,7 @@ final class LotteryTransferService
|
||||
*/
|
||||
public function transferIn(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
|
||||
{
|
||||
$this->assertWalletFundingMode($player);
|
||||
$this->assertPositiveAmount($amountMinor);
|
||||
$currencyCode = $this->normalizeCurrency($currencyCode);
|
||||
$this->assertCurrencyEnabled($currencyCode);
|
||||
@@ -190,6 +192,7 @@ final class LotteryTransferService
|
||||
*/
|
||||
public function transferOut(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
|
||||
{
|
||||
$this->assertWalletFundingMode($player);
|
||||
$this->assertPositiveAmount($amountMinor);
|
||||
$currencyCode = $this->normalizeCurrency($currencyCode);
|
||||
$this->assertCurrencyEnabled($currencyCode);
|
||||
@@ -732,6 +735,17 @@ final class LotteryTransferService
|
||||
}
|
||||
}
|
||||
|
||||
private function assertWalletFundingMode(Player $player): void
|
||||
{
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
throw new WalletOperationException(
|
||||
'credit_player_no_wallet_transfer',
|
||||
ErrorCode::WalletCreditPlayerNoTransfer->value,
|
||||
422,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertPositiveAmount(int $amountMinor): void
|
||||
{
|
||||
if ($amountMinor < 1) {
|
||||
|
||||
578
app/Services/Wallet/PlayerLedgerLogsService.php
Normal file
578
app/Services/Wallet/PlayerLedgerLogsService.php
Normal file
@@ -0,0 +1,578 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Models\WalletTxn;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Support\AdminDataScope;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger}。
|
||||
*/
|
||||
final class PlayerLedgerLogsService
|
||||
{
|
||||
/** PRD 对外类型 → wallet_txns.biz_type */
|
||||
private const WALLET_TYPE_TO_BIZ = [
|
||||
'transfer_in' => ['transfer_in'],
|
||||
'transfer_out' => ['transfer_out'],
|
||||
'refund' => ['transfer_out_refund'],
|
||||
'reversal' => ['reversal', 'bet_reverse'],
|
||||
'bet' => ['bet_deduct', 'bet'],
|
||||
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
|
||||
];
|
||||
|
||||
/** PRD 对外类型 → credit_ledger.reason */
|
||||
private const CREDIT_TYPE_TO_REASON = [
|
||||
'bet' => ['bet_hold', 'game_settlement_loss'],
|
||||
'reversal' => ['bet_hold_release'],
|
||||
'refund' => ['settlement_confirm'],
|
||||
'prize' => [],
|
||||
'transfer_in' => [],
|
||||
'transfer_out' => [],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly PlayerCreditService $playerCreditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* items: list<array<string, mixed>>,
|
||||
* total: int,
|
||||
* page: int,
|
||||
* per_page: int,
|
||||
* ledger_source: string,
|
||||
* funding_mode: string,
|
||||
* auth_source: string|null,
|
||||
* }
|
||||
*/
|
||||
public function listForPlayerApi(
|
||||
Player $player,
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $currencyCode,
|
||||
string $typeFilterRaw,
|
||||
): array {
|
||||
$meta = [
|
||||
'ledger_source' => PlayerFundingMode::usesCredit($player) ? 'credit_ledger' : 'wallet_txn',
|
||||
'funding_mode' => (string) ($player->funding_mode ?? ''),
|
||||
'auth_source' => $player->auth_source,
|
||||
];
|
||||
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
$result = $this->paginateCreditLedger($player, $page, $perPage, $typeFilterRaw);
|
||||
|
||||
return array_merge($result, $meta);
|
||||
}
|
||||
|
||||
$result = $this->paginateWalletTxns($player, $page, $perPage, $currencyCode, $typeFilterRaw);
|
||||
|
||||
return array_merge($result, $meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台玩家详情「钱包流水」:信用盘玩家返回 credit_ledger,字段形状对齐 wallet_txns 列表。
|
||||
*
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
|
||||
*/
|
||||
public function listForAdminPlayer(
|
||||
Player $player,
|
||||
int $page,
|
||||
int $perPage,
|
||||
?string $bizType = null,
|
||||
): array {
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
|
||||
}
|
||||
|
||||
$reasonFilter = $bizType !== null && $bizType !== ''
|
||||
? [trim($bizType)]
|
||||
: null;
|
||||
|
||||
$paginator = $this->creditLedgerQuery($player->id, $reasonFilter)
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$currency = (string) $player->default_currency;
|
||||
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
|
||||
$items = $paginator->getCollection()
|
||||
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
|
||||
$amount = (int) $row->amount;
|
||||
$formatted = $this->formatAdminCreditRow($row, $player, $currency, $runningMinor);
|
||||
$runningMinor -= $amount;
|
||||
|
||||
return $formatted;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算中心:站点下全部信用盘玩家的 {@see credit_ledger} 流水。
|
||||
*
|
||||
* @return array{
|
||||
* items: list<array<string, mixed>>,
|
||||
* total: int,
|
||||
* page: int,
|
||||
* per_page: int,
|
||||
* ledger_source: string,
|
||||
* }
|
||||
*/
|
||||
public function listForAdminCreditIndex(
|
||||
AdminUser $admin,
|
||||
string $siteCode,
|
||||
int $page,
|
||||
int $perPage,
|
||||
?int $settlementPeriodId = null,
|
||||
?int $playerId = null,
|
||||
?string $reason = null,
|
||||
?string $createdFrom = null,
|
||||
?string $createdTo = null,
|
||||
): array {
|
||||
$query = DB::table('credit_ledger as cl')
|
||||
->join('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'cl.owner_id')
|
||||
->where('cl.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->where('p.funding_mode', PlayerFundingMode::CREDIT)
|
||||
->select([
|
||||
'cl.id',
|
||||
'cl.amount',
|
||||
'cl.reason',
|
||||
'cl.ref_type',
|
||||
'cl.ref_id',
|
||||
'cl.created_at',
|
||||
'cl.updated_at',
|
||||
'p.id as player_id',
|
||||
'p.site_code',
|
||||
'p.site_player_id',
|
||||
'p.username',
|
||||
'p.nickname',
|
||||
'p.funding_mode',
|
||||
'p.auth_source',
|
||||
'p.default_currency',
|
||||
])
|
||||
->orderByDesc('cl.id');
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
if ($playerId !== null && $playerId > 0) {
|
||||
$query->where('p.id', $playerId);
|
||||
}
|
||||
|
||||
if ($reason !== null && $reason !== '') {
|
||||
$query->where('cl.reason', $reason);
|
||||
}
|
||||
|
||||
$range = $this->resolveCreatedRange($settlementPeriodId, $createdFrom, $createdTo);
|
||||
if ($range !== null) {
|
||||
$query->whereBetween('cl.created_at', $range);
|
||||
}
|
||||
|
||||
/** @var LengthAwarePaginator $paginator */
|
||||
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
$items = $paginator->getCollection()
|
||||
->map(fn (object $row): array => $this->formatAdminCreditIndexRow($row))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'ledger_source' => 'credit_ledger',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Carbon, 1: Carbon}|null
|
||||
*/
|
||||
private function resolveCreatedRange(
|
||||
?int $settlementPeriodId,
|
||||
?string $createdFrom,
|
||||
?string $createdTo,
|
||||
): ?array {
|
||||
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
|
||||
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
|
||||
if ($period === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
Carbon::parse($period->period_start)->startOfDay(),
|
||||
Carbon::parse($period->period_end)->endOfDay(),
|
||||
];
|
||||
}
|
||||
|
||||
$from = $createdFrom !== null && $createdFrom !== ''
|
||||
? Carbon::parse($createdFrom)->startOfDay()
|
||||
: null;
|
||||
$to = $createdTo !== null && $createdTo !== ''
|
||||
? Carbon::parse($createdTo)->endOfDay()
|
||||
: null;
|
||||
|
||||
if ($from === null && $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
$from ?? Carbon::parse('1970-01-01')->startOfDay(),
|
||||
$to ?? Carbon::now()->endOfDay(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatAdminCreditIndexRow(object $row): array
|
||||
{
|
||||
$amount = (int) $row->amount;
|
||||
$amountAbs = abs($amount);
|
||||
$currency = (string) ($row->default_currency ?? '');
|
||||
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'txn_no' => 'CL-'.$row->id,
|
||||
'player_id' => (int) $row->player_id,
|
||||
'site_code' => (string) $row->site_code,
|
||||
'site_player_id' => $row->site_player_id,
|
||||
'username' => $row->username,
|
||||
'nickname' => $row->nickname,
|
||||
'biz_type' => (string) $row->reason,
|
||||
'type' => $this->creditReasonToPublicType((string) $row->reason),
|
||||
'biz_no' => $this->creditRefLabel($row),
|
||||
'direction' => $amount >= 0 ? 1 : 2,
|
||||
'amount' => $amountAbs,
|
||||
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
|
||||
'signed_amount' => $amount,
|
||||
'currency_code' => $currency,
|
||||
'status' => 'posted',
|
||||
'created_at' => $this->isoTimestamp($row->created_at ?? null),
|
||||
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
|
||||
'ledger_source' => 'credit_ledger',
|
||||
'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
|
||||
'auth_source' => $row->auth_source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
|
||||
*/
|
||||
private function paginateWalletTxns(
|
||||
Player $player,
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $currencyCode,
|
||||
string $typeFilterRaw,
|
||||
): array {
|
||||
$bizFilter = $this->resolveWalletBizFilter($typeFilterRaw);
|
||||
|
||||
if (is_array($bizFilter) && $bizFilter === []) {
|
||||
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
|
||||
}
|
||||
|
||||
$query = WalletTxn::query()
|
||||
->where('player_id', $player->id)
|
||||
->with('wallet')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($currencyCode !== '') {
|
||||
$query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode));
|
||||
}
|
||||
|
||||
if ($bizFilter !== null) {
|
||||
$query->whereIn('biz_type', $bizFilter);
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
$items = $paginator->getCollection()
|
||||
->map(fn (WalletTxn $txn) => $this->formatWalletTxnRow($txn))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
|
||||
*/
|
||||
private function paginateCreditLedger(
|
||||
Player $player,
|
||||
int $page,
|
||||
int $perPage,
|
||||
string $typeFilterRaw,
|
||||
): array {
|
||||
$reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw);
|
||||
|
||||
if (is_array($reasonFilter) && $reasonFilter === []) {
|
||||
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
|
||||
}
|
||||
|
||||
$paginator = $this->creditLedgerQuery((int) $player->id, $reasonFilter)
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$currency = (string) $player->default_currency;
|
||||
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
|
||||
$items = $paginator->getCollection()
|
||||
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
|
||||
$amount = (int) $row->amount;
|
||||
$formatted = $this->formatPlayerCreditRow($row, $player, $currency, $runningMinor);
|
||||
$runningMinor -= $amount;
|
||||
|
||||
return $formatted;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $reasonFilter
|
||||
*/
|
||||
private function creditLedgerQuery(int $playerId, ?array $reasonFilter)
|
||||
{
|
||||
$query = DB::table('credit_ledger')
|
||||
->where('owner_type', 'player')
|
||||
->where('owner_id', $playerId)
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($reasonFilter !== null) {
|
||||
$query->whereIn('reason', $reasonFilter);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function resolveWalletBizFilter(string $raw): ?array
|
||||
{
|
||||
return $this->resolveTypeFilterMap($raw, self::WALLET_TYPE_TO_BIZ);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function resolveCreditReasonFilter(string $raw): ?array
|
||||
{
|
||||
return $this->resolveTypeFilterMap($raw, self::CREDIT_TYPE_TO_REASON);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>> $map
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function resolveTypeFilterMap(string $raw, array $map): ?array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = array_filter(array_map('trim', explode(',', $raw)));
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
foreach ($parts as $part) {
|
||||
$key = Str::lower($part);
|
||||
if (! isset($map[$key])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($map[$key] as $value) {
|
||||
$resolved[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($resolved));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatWalletTxnRow(WalletTxn $txn): array
|
||||
{
|
||||
$currency = $txn->wallet?->currency_code ?? '';
|
||||
$amount = (int) $txn->amount;
|
||||
$balanceAfter = (int) $txn->balance_after;
|
||||
|
||||
return [
|
||||
'log_id' => $txn->txn_no,
|
||||
'type' => $this->walletBizToPublicType((string) $txn->biz_type),
|
||||
'biz_type' => $txn->biz_type,
|
||||
'amount' => $this->signedWalletAmount($txn),
|
||||
'amount_formatted' => CurrencyFormatter::fromMinor($amount),
|
||||
'amount_abs' => $amount,
|
||||
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount),
|
||||
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
|
||||
'currency_code' => $currency,
|
||||
'balance_after' => $balanceAfter,
|
||||
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter),
|
||||
'ref_id' => $txn->biz_no,
|
||||
'idempotent_key' => $txn->idempotent_key,
|
||||
'external_ref_no' => $txn->external_ref_no,
|
||||
'status' => $txn->status,
|
||||
'remark' => $txn->remark,
|
||||
'created_at' => $txn->created_at?->toIso8601String(),
|
||||
'ledger_source' => 'wallet_txn',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatPlayerCreditRow(
|
||||
object $row,
|
||||
Player $player,
|
||||
string $currency,
|
||||
int $balanceAfterMinor,
|
||||
): array {
|
||||
$amount = (int) $row->amount;
|
||||
$amountAbs = abs($amount);
|
||||
$publicType = $this->creditReasonToPublicType((string) $row->reason);
|
||||
|
||||
return [
|
||||
'log_id' => 'CL-'.$row->id,
|
||||
'type' => $publicType,
|
||||
'biz_type' => (string) $row->reason,
|
||||
'amount' => $amount,
|
||||
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
|
||||
'amount_abs' => $amountAbs,
|
||||
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amountAbs),
|
||||
'direction' => $amount >= 0 ? 'in' : 'out',
|
||||
'currency_code' => $currency,
|
||||
'balance_after' => $balanceAfterMinor,
|
||||
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor),
|
||||
'ref_id' => $this->creditRefLabel($row),
|
||||
'idempotent_key' => null,
|
||||
'external_ref_no' => null,
|
||||
'status' => 'posted',
|
||||
'remark' => null,
|
||||
'created_at' => $this->isoTimestamp($row->created_at ?? null),
|
||||
'ledger_source' => 'credit_ledger',
|
||||
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
|
||||
'auth_source' => $player->auth_source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatAdminCreditRow(
|
||||
object $row,
|
||||
Player $player,
|
||||
string $currency,
|
||||
int $balanceAfterMinor,
|
||||
): array {
|
||||
$amount = (int) $row->amount;
|
||||
$amountAbs = abs($amount);
|
||||
$balanceBefore = $amount >= 0
|
||||
? max(0, $balanceAfterMinor - $amount)
|
||||
: $balanceAfterMinor + $amountAbs;
|
||||
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'txn_no' => 'CL-'.$row->id,
|
||||
'player_id' => (int) $player->id,
|
||||
'site_code' => $player->site_code,
|
||||
'site_player_id' => $player->site_player_id,
|
||||
'username' => $player->username,
|
||||
'nickname' => $player->nickname,
|
||||
'wallet_id' => null,
|
||||
'biz_type' => (string) $row->reason,
|
||||
'biz_no' => $this->creditRefLabel($row),
|
||||
'direction' => $amount >= 0 ? 1 : 2,
|
||||
'amount' => $amountAbs,
|
||||
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
|
||||
'balance_before' => $balanceBefore,
|
||||
'balance_before_formatted' => CurrencyFormatter::fromMinor($balanceBefore),
|
||||
'balance_after' => $balanceAfterMinor,
|
||||
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor),
|
||||
'status' => 'posted',
|
||||
'external_ref_no' => null,
|
||||
'idempotent_key' => null,
|
||||
'remark' => null,
|
||||
'created_at' => $this->isoTimestamp($row->created_at ?? null),
|
||||
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
|
||||
'ledger_source' => 'credit_ledger',
|
||||
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
|
||||
'auth_source' => $player->auth_source,
|
||||
];
|
||||
}
|
||||
|
||||
private function creditReasonToPublicType(string $reason): string
|
||||
{
|
||||
return match ($reason) {
|
||||
'bet_hold', 'game_settlement_loss' => 'bet',
|
||||
'bet_hold_release' => 'reversal',
|
||||
'settlement_confirm' => 'refund',
|
||||
default => $reason,
|
||||
};
|
||||
}
|
||||
|
||||
private function walletBizToPublicType(string $biz): string
|
||||
{
|
||||
return match ($biz) {
|
||||
'transfer_out_refund' => 'refund',
|
||||
'bet_deduct', 'bet' => 'bet',
|
||||
'bet_reverse' => 'reversal',
|
||||
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
|
||||
'reversal' => 'reversal',
|
||||
default => $biz,
|
||||
};
|
||||
}
|
||||
|
||||
private function signedWalletAmount(WalletTxn $txn): int
|
||||
{
|
||||
$a = (int) $txn->amount;
|
||||
|
||||
return (int) $txn->direction === 1 ? $a : -$a;
|
||||
}
|
||||
|
||||
private function creditRefLabel(object $row): ?string
|
||||
{
|
||||
if ($row->ref_type === null || $row->ref_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $row->ref_type.'#'.$row->ref_id;
|
||||
}
|
||||
|
||||
private function isoTimestamp(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::parse($value)->toIso8601String();
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,32 @@ final class AdminAgentScope
|
||||
return self::nodeVisibleTo($admin, $node);
|
||||
}
|
||||
|
||||
/** 占成/授信/回水仅可由上级或平台修改,代理本人不可改自己的 profile。 */
|
||||
public static function nodeProfileEditableBy(AdminUser $admin, AgentNode $node): bool
|
||||
{
|
||||
if ($admin->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
! $admin->hasPermissionCode('agent.profile.manage')
|
||||
&& ! $admin->hasPermissionCode('agent.node.manage')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actor = self::primaryAgentNode($admin);
|
||||
if ($actor === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $actor->id === (int) $node->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $node->isDescendantOf($actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<AgentNode>
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,22 @@ use Illuminate\Database\Query\Builder;
|
||||
/** 代理账单按管理员可访问站点过滤。 */
|
||||
final class AdminAgentSettlementScope
|
||||
{
|
||||
public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($siteIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereIn($periodsAlias.'.admin_site_id', $siteIds);
|
||||
}
|
||||
|
||||
public static function applyToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
|
||||
{
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
|
||||
@@ -38,26 +38,34 @@ final class AdminAuthProfile
|
||||
* },
|
||||
* is_super_admin: bool,
|
||||
* operational_permissions: list<string>,
|
||||
* delegation_ceiling: list<string>
|
||||
* delegation_ceiling: list<string>,
|
||||
* accessible_sites?: list<array{id: int, code: string, name: string}>
|
||||
* }
|
||||
*/
|
||||
public static function fromAdmin(AdminUser $admin): array
|
||||
{
|
||||
$fresh = $admin->fresh();
|
||||
$permissionSlugs = $fresh->adminPermissionSlugs();
|
||||
$agent = self::agentContext($fresh);
|
||||
|
||||
return [
|
||||
$payload = [
|
||||
'id' => $fresh->id,
|
||||
'username' => $fresh->username,
|
||||
'nickname' => $fresh->name,
|
||||
'email' => $fresh->email,
|
||||
'permissions' => $permissionSlugs,
|
||||
'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs, $fresh),
|
||||
'agent' => self::agentContext($fresh),
|
||||
'agent' => $agent,
|
||||
'is_super_admin' => $fresh->isSuperAdmin(),
|
||||
'operational_permissions' => $permissionSlugs,
|
||||
'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh),
|
||||
];
|
||||
|
||||
if ($agent === null) {
|
||||
$payload['accessible_sites'] = AdminUserSiteBindingPresenter::accessibleSitesFor($fresh);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -144,7 +144,8 @@ final class AdminAuthorizationRegistry
|
||||
{
|
||||
return [
|
||||
['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'nav_group' => 'overview', 'requiredAny' => ['prd.dashboard.view']],
|
||||
['segment' => 'agents', 'label' => 'Agent lines', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => array_values(array_unique(array_merge(['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage', 'prd.settlement.agent.view', 'prd.settlement.agent.manage'], AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'))))],
|
||||
['segment' => 'agents', 'label' => 'Agent lines', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => array_values(array_unique(array_merge(['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage'], AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'))))],
|
||||
['segment' => 'settlement_center', 'label' => 'Credit settlement', 'href' => '/admin/settlement-center', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/settlement-center', 'requiredAny' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'nav_group' => 'operations', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']],
|
||||
['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'nav_group' => 'operations', 'requiredAny' => ['prd.tickets.view']],
|
||||
['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'nav_group' => 'operations', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']],
|
||||
@@ -157,6 +158,7 @@ final class AdminAuthorizationRegistry
|
||||
['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']],
|
||||
['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']],
|
||||
['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.currency.manage']],
|
||||
['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites')],
|
||||
['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_user.manage']],
|
||||
['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_role.manage']],
|
||||
['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.audit.view']],
|
||||
@@ -426,12 +428,22 @@ final class AdminAuthorizationRegistry
|
||||
|
||||
['code' => 'admin.agent-delegation-grants.index', 'module_code' => 'agent', 'name' => '代理下放上限查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']],
|
||||
['code' => 'admin.agent-delegation-grants.sync', 'module_code' => 'agent', 'name' => '代理下放上限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
|
||||
['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']],
|
||||
['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage', 'agent.node.view'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage', 'prd.agent.view']],
|
||||
['code' => 'admin.agent-nodes.profile.update', 'module_code' => 'agent', 'name' => '代理占成授信更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']],
|
||||
['code' => 'admin.settlement-periods.index', 'module_code' => 'settlement', 'name' => '代理账期列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||
['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-bills.index', 'module_code' => 'settlement', 'name' => '代理账单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills', 'route_name' => 'api.v1.admin.settlement-bills.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-payments.index', 'module_code' => 'settlement', 'name' => '代理账单收付记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-payments', 'route_name' => 'api.v1.admin.settlement-payments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-adjustments.index', 'module_code' => 'settlement', 'name' => '代理账单调账记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-adjustments', 'route_name' => 'api.v1.admin.settlement-adjustments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-bills.show', 'module_code' => 'settlement', 'name' => '代理账单详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}', 'route_name' => 'api.v1.admin.settlement-bills.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-bills.confirm', 'module_code' => 'settlement', 'name' => '确认代理账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/confirm', 'route_name' => 'api.v1.admin.settlement-bills.confirm', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-bills.payments', 'module_code' => 'settlement', 'name' => '登记代理账单收付', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/payments', 'route_name' => 'api.v1.admin.settlement-bills.payments', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-bills.adjustments', 'module_code' => 'settlement', 'name' => '代理账单补差冲正', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/adjustments', 'route_name' => 'api.v1.admin.settlement-bills.adjustments', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-bills.bad-debt-write-off', 'module_code' => 'settlement', 'name' => '代理账单坏账核销', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/bad-debt-write-off', 'route_name' => 'api.v1.admin.settlement-bills.bad-debt-write-off', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-reports.summary', 'module_code' => 'settlement', 'name' => '代理结算报表摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-reports/summary', 'route_name' => 'api.v1.admin.settlement-reports.summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
['code' => 'admin.settlement-reports.show', 'module_code' => 'settlement', 'name' => '信用占成盘报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-reports', 'route_name' => 'api.v1.admin.settlement-reports.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
|
||||
|
||||
['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view']],
|
||||
['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],
|
||||
@@ -468,10 +480,11 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.integration-sites.rotate-secrets', 'module_code' => 'integration', 'name' => '重置接入密钥', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/rotate-secrets', 'route_name' => 'api.v1.admin.integration-sites.rotate-secrets', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']],
|
||||
['code' => 'admin.integration-sites.connectivity-test', 'module_code' => 'integration', 'name' => '接入站点联通检测', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/connectivity-test', 'route_name' => 'api.v1.admin.integration-sites.connectivity-test', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']],
|
||||
['code' => 'admin.integration-sites.export', 'module_code' => 'integration', 'name' => '导出接入参数表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/export', 'route_name' => 'api.v1.admin.integration-sites.export', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']],
|
||||
['code' => 'admin.integration-sites.secrets', 'module_code' => 'integration', 'name' => '查看接入密钥明文', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/secrets', 'route_name' => 'api.v1.admin.integration-sites.secrets', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']],
|
||||
|
||||
['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||
['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||
['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||
['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.payout.view', 'prd.payout.manage', 'prd.payout.review', 'prd.report.view', 'prd.users.view_finance']],
|
||||
['code' => 'admin.draws.result-batches.index', 'module_code' => 'draw', 'name' => '开奖结果批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||
['code' => 'admin.draws.risk-pool-lock-logs.index', 'module_code' => 'risk', 'name' => '风控锁池日志列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pool-lock-logs', 'route_name' => 'api.v1.admin.draws.risk-pool-lock-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']],
|
||||
['code' => 'admin.draws.risk-pools.index', 'module_code' => 'risk', 'name' => '风控池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools', 'route_name' => 'api.v1.admin.draws.risk-pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']],
|
||||
|
||||
@@ -112,7 +112,7 @@ final class AdminDataScope
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereHas($relation, static function (Builder $playerQuery) use ($admin): void {
|
||||
$query->whereHas($relation, static function (\Illuminate\Database\Eloquent\Builder $playerQuery) use ($admin): void {
|
||||
AdminSiteScope::applyToPlayerQuery($playerQuery, $admin);
|
||||
});
|
||||
}
|
||||
|
||||
138
app/Support/AdminDrawApiPresenter.php
Normal file
138
app/Support/AdminDrawApiPresenter.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Draw;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
|
||||
final class AdminDrawApiPresenter
|
||||
{
|
||||
/**
|
||||
* @param array{total_bet_minor: int, total_payout_minor: int, profit_loss_minor: int}|null $stats
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function listRow(Draw $draw, ?array $stats, AdminUser $admin): array
|
||||
{
|
||||
$manage = AdminDrawResponsePolicy::canManageDrawResults($admin);
|
||||
$finance = AdminDrawResponsePolicy::canViewDrawFinance($admin);
|
||||
|
||||
$row = [
|
||||
'id' => (int) $draw->id,
|
||||
'draw_no' => $draw->draw_no,
|
||||
'business_date' => self::formatBusinessDate($draw->business_date),
|
||||
'sequence_no' => (int) $draw->sequence_no,
|
||||
'status' => $draw->status,
|
||||
'start_time' => $draw->start_time?->toIso8601String(),
|
||||
'close_time' => $draw->close_time?->toIso8601String(),
|
||||
'draw_time' => $draw->draw_time?->toIso8601String(),
|
||||
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
|
||||
'updated_at' => $draw->updated_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
if ($manage) {
|
||||
$row['result_source'] = $draw->result_source;
|
||||
$row['current_result_version'] = (int) $draw->current_result_version;
|
||||
$row['settle_version'] = (int) $draw->settle_version;
|
||||
$row['is_reopened'] = (bool) $draw->is_reopened;
|
||||
}
|
||||
|
||||
if ($finance && $stats !== null) {
|
||||
$row['total_bet_minor'] = $stats['total_bet_minor'];
|
||||
$row['total_payout_minor'] = $stats['total_payout_minor'];
|
||||
$row['profit_loss_minor'] = $stats['profit_loss_minor'];
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function show(Draw $draw, AdminUser $admin, DrawHallSnapshotBuilder $hallPreview): array
|
||||
{
|
||||
$manage = AdminDrawResponsePolicy::canManageDrawResults($admin);
|
||||
$nowUtc = now()->utc();
|
||||
|
||||
$batchCounts = [
|
||||
'published' => $draw->resultBatches()
|
||||
->where('status', DrawResultBatchStatus::Published->value)
|
||||
->count(),
|
||||
];
|
||||
|
||||
if ($manage) {
|
||||
$batchCounts['total'] = $draw->resultBatches()->count();
|
||||
$batchCounts['pending_review'] = $draw->resultBatches()
|
||||
->where('status', DrawResultBatchStatus::PendingReview->value)
|
||||
->count();
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'id' => (int) $draw->id,
|
||||
'draw_no' => $draw->draw_no,
|
||||
'business_date' => self::formatBusinessDate($draw->business_date),
|
||||
'sequence_no' => (int) $draw->sequence_no,
|
||||
'status' => $draw->status,
|
||||
'hall_preview_status' => $hallPreview->effectiveHallDisplayStatus($draw, $nowUtc),
|
||||
'start_time' => $draw->start_time?->toIso8601String(),
|
||||
'close_time' => $draw->close_time?->toIso8601String(),
|
||||
'draw_time' => $draw->draw_time?->toIso8601String(),
|
||||
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
|
||||
'result_batch_counts' => $batchCounts,
|
||||
'capabilities' => AdminDrawResponsePolicy::capabilities($admin),
|
||||
];
|
||||
|
||||
if ($manage) {
|
||||
$payload['result_source'] = $draw->result_source;
|
||||
$payload['current_result_version'] = (int) $draw->current_result_version;
|
||||
$payload['settle_version'] = (int) $draw->settle_version;
|
||||
$payload['is_reopened'] = (bool) $draw->is_reopened;
|
||||
$payload['created_at'] = $draw->created_at?->toIso8601String();
|
||||
$payload['updated_at'] = $draw->updated_at?->toIso8601String();
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function resultBatch(DrawResultBatch $batch, AdminUser $admin): array
|
||||
{
|
||||
$manage = AdminDrawResponsePolicy::canManageDrawResults($admin);
|
||||
|
||||
$row = [
|
||||
'id' => (int) $batch->id,
|
||||
'result_version' => (int) $batch->result_version,
|
||||
'status' => $batch->status,
|
||||
'confirmed_at' => $batch->confirmed_at?->toIso8601String(),
|
||||
'items' => $batch->items->map(static fn (DrawResultItem $item): array => [
|
||||
'prize_type' => $item->prize_type,
|
||||
'prize_index' => (int) $item->prize_index,
|
||||
'number_4d' => $item->number_4d,
|
||||
'suffix_3d' => $item->suffix_3d,
|
||||
'suffix_2d' => $item->suffix_2d,
|
||||
'head_digit' => $item->head_digit,
|
||||
'tail_digit' => $item->tail_digit,
|
||||
])->values()->all(),
|
||||
];
|
||||
|
||||
if ($manage) {
|
||||
$row['source_type'] = $batch->source_type;
|
||||
$row['rng_seed_hash'] = $batch->rng_seed_hash;
|
||||
$row['created_by'] = $batch->created_by;
|
||||
$row['confirmed_by'] = $batch->confirmed_by;
|
||||
$row['created_at'] = $batch->created_at?->toIso8601String();
|
||||
$row['updated_at'] = $batch->updated_at?->toIso8601String();
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function formatBusinessDate(mixed $businessDate): string
|
||||
{
|
||||
return $businessDate instanceof Carbon
|
||||
? $businessDate->format('Y-m-d')
|
||||
: (string) $businessDate;
|
||||
}
|
||||
}
|
||||
44
app/Support/AdminDrawResponsePolicy.php
Normal file
44
app/Support/AdminDrawResponsePolicy.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
|
||||
/** 期号 API 响应字段按「开奖查看 / 管理 / 资金」权限裁剪。 */
|
||||
final class AdminDrawResponsePolicy
|
||||
{
|
||||
public static function canManageDrawResults(AdminUser $admin): bool
|
||||
{
|
||||
return $admin->hasAdminPermission('prd.draw_result.manage');
|
||||
}
|
||||
|
||||
public static function canViewDrawFinance(AdminUser $admin): bool
|
||||
{
|
||||
if (self::canManageDrawResults($admin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'prd.payout.view',
|
||||
'prd.payout.manage',
|
||||
'prd.payout.review',
|
||||
'prd.report.view',
|
||||
'prd.users.view_finance',
|
||||
] as $slug) {
|
||||
if ($admin->hasAdminPermission($slug)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array{can_manage_draw_results: bool, can_view_draw_finance: bool} */
|
||||
public static function capabilities(AdminUser $admin): array
|
||||
{
|
||||
return [
|
||||
'can_manage_draw_results' => self::canManageDrawResults($admin),
|
||||
'can_view_draw_finance' => self::canViewDrawFinance($admin),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,17 @@ final class AdminIntegrationSitePresenter
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function listItem(AdminSite $site): array
|
||||
public static function listItem(AdminSite $site, bool $hasLineRoot = false): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $site->id,
|
||||
'code' => (string) $site->code,
|
||||
'name' => (string) $site->name,
|
||||
'has_line_root' => $hasLineRoot,
|
||||
'currency_code' => (string) $site->currency_code,
|
||||
'status' => (int) $site->status,
|
||||
'wallet_api_url' => $site->wallet_api_url,
|
||||
'lottery_h5_base_url' => $site->lottery_h5_base_url,
|
||||
'wallet_timeout_seconds' => (int) ($site->wallet_timeout_seconds ?? 10),
|
||||
'has_sso_secret' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== '',
|
||||
'has_wallet_api_key' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== '',
|
||||
|
||||
@@ -69,8 +69,7 @@ final class AdminPermissionBridge
|
||||
}
|
||||
|
||||
/**
|
||||
* 若管理员拥有的任意 menu_action.permission_code 落在某 `prd.*` 映射集合内,则视为拥有该 `prd.*`
|
||||
*(与路由中间件「满足其一」及 Next 侧栏 `requiredAny` 语义一致)。
|
||||
* 由已授权的 menu_action.permission_code 反推 `prd.*` 展示 slug(须满足映射中的全部 code)。
|
||||
*
|
||||
* @param list<string> $menuActionCodes
|
||||
* @return list<string>
|
||||
@@ -93,12 +92,21 @@ final class AdminPermissionBridge
|
||||
|
||||
$out = [];
|
||||
foreach (self::legacyMap() as $legacySlug => $requiredCodes) {
|
||||
if ($requiredCodes === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasAll = true;
|
||||
foreach ($requiredCodes as $code) {
|
||||
if (isset($set[$code])) {
|
||||
$out[$legacySlug] = true;
|
||||
if (! isset($set[$code])) {
|
||||
$hasAll = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasAll) {
|
||||
$out[$legacySlug] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$keys = array_keys($out);
|
||||
|
||||
31
app/Support/AdminPlatformUserSiteGuard.php
Normal file
31
app/Support/AdminPlatformUserSiteGuard.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** 平台账号创建/改角色时,操作者对目标站点的授权校验。 */
|
||||
final class AdminPlatformUserSiteGuard
|
||||
{
|
||||
public static function assertActorCanAssignSite(AdminUser $actor, int $siteId): void
|
||||
{
|
||||
$site = AdminSite::query()->find($siteId);
|
||||
if ($site === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'admin_site_id' => [trans('validation.exists', ['attribute' => 'admin_site_id'])],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($actor->isSuperAdmin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! AdminIntegrationSiteAccess::canAccess($actor, $site)) {
|
||||
throw ValidationException::withMessages([
|
||||
'admin_site_id' => [trans('admin.site_access_denied')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ final class AdminUserApiPresenter
|
||||
public static function listItem(AdminUser $user): array
|
||||
{
|
||||
$user->loadMissing('roles');
|
||||
$siteBindings = AdminUserSiteBindingPresenter::bindingsFor($user);
|
||||
|
||||
return [
|
||||
'id' => (int) $user->id,
|
||||
@@ -20,6 +21,7 @@ final class AdminUserApiPresenter
|
||||
'status' => (int) $user->status,
|
||||
'account_kind' => $user->isPlatformAccount() ? 'platform' : 'agent',
|
||||
'roles' => $user->adminRoleSlugs(),
|
||||
'site_bindings' => $siteBindings,
|
||||
'direct_permissions' => $user->directLegacyPermissionSlugs(),
|
||||
'effective_permissions' => $user->adminPermissionSlugs(),
|
||||
];
|
||||
|
||||
69
app/Support/AdminUserSiteBindingPresenter.php
Normal file
69
app/Support/AdminUserSiteBindingPresenter.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 平台账号在各站点上的角色绑定(API 展示用)。 */
|
||||
final class AdminUserSiteBindingPresenter
|
||||
{
|
||||
/**
|
||||
* @return list<array{site_id: int, site_code: string, site_name: string, role_slugs: list<string>}>
|
||||
*/
|
||||
public static function bindingsFor(AdminUser $user): array
|
||||
{
|
||||
if ($user->hasPrimaryAgentBinding()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('admin_user_site_roles as usr')
|
||||
->join('admin_sites as s', 's.id', '=', 'usr.site_id')
|
||||
->join('admin_roles as r', 'r.id', '=', 'usr.role_id')
|
||||
->where('usr.admin_user_id', $user->id)
|
||||
->orderBy('s.code')
|
||||
->orderBy('r.slug')
|
||||
->get(['usr.site_id', 's.code as site_code', 's.name as site_name', 'r.slug as role_slug']);
|
||||
|
||||
/** @var array<int, array{site_id: int, site_code: string, site_name: string, role_slugs: list<string>}> $bySite */
|
||||
$bySite = [];
|
||||
foreach ($rows as $row) {
|
||||
$siteId = (int) $row->site_id;
|
||||
if (! isset($bySite[$siteId])) {
|
||||
$bySite[$siteId] = [
|
||||
'site_id' => $siteId,
|
||||
'site_code' => (string) $row->site_code,
|
||||
'site_name' => (string) $row->site_name,
|
||||
'role_slugs' => [],
|
||||
];
|
||||
}
|
||||
$slug = (string) $row->role_slug;
|
||||
if ($slug !== '' && ! in_array($slug, $bySite[$siteId]['role_slugs'], true)) {
|
||||
$bySite[$siteId]['role_slugs'][] = $slug;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($bySite as &$binding) {
|
||||
sort($binding['role_slugs']);
|
||||
}
|
||||
unset($binding);
|
||||
|
||||
return array_values($bySite);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, code: string, name: string}>
|
||||
*/
|
||||
public static function accessibleSitesFor(AdminUser $admin): array
|
||||
{
|
||||
return AdminIntegrationSiteAccess::queryFor($admin)
|
||||
->get(['id', 'code', 'name'])
|
||||
->map(static fn ($site): array => [
|
||||
'id' => (int) $site->id,
|
||||
'code' => (string) $site->code,
|
||||
'name' => (string) $site->name,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
166
app/Support/AgentDefaultRolePermissions.php
Normal file
166
app/Support/AgentDefaultRolePermissions.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
|
||||
/**
|
||||
* 平台「代理」系统角色(slug=agent)的默认 prd.* 模板。
|
||||
* 经营代理主账号只绑定该角色;权限在「平台角色管理」调整,不按线路写 agent_owner_*。
|
||||
*
|
||||
* @see \App\Support\AgentPlatformRole
|
||||
*/
|
||||
final class AgentDefaultRolePermissions
|
||||
{
|
||||
/** 所有经营代理主账号均具备的基础能力(不含钱包对账 / 平台配置)。 */
|
||||
private const BASE_SLUGS = [
|
||||
'prd.dashboard.view',
|
||||
'prd.agent.view',
|
||||
'prd.agent.role.view',
|
||||
'prd.agent.user.view',
|
||||
'prd.tickets.view',
|
||||
'prd.report.view',
|
||||
'prd.settlement.agent.view',
|
||||
];
|
||||
|
||||
private const CHILD_AGENT_MANAGE_SLUGS = [
|
||||
'prd.agent.manage',
|
||||
'prd.agent.profile.manage',
|
||||
];
|
||||
|
||||
private const PLAYER_MANAGE_SLUGS = [
|
||||
'prd.users.manage',
|
||||
'prd.users.view_finance',
|
||||
'prd.users.view_cs',
|
||||
];
|
||||
|
||||
/** 线路根代理(depth=0)在基础包之上额外具备的经营权限。 */
|
||||
private const LINE_ROOT_EXTRA_SLUGS = [
|
||||
'prd.agent.manage',
|
||||
'prd.agent.profile.manage',
|
||||
'prd.agent.role.manage',
|
||||
'prd.agent.user.manage',
|
||||
'prd.users.manage',
|
||||
'prd.users.view_finance',
|
||||
'prd.users.view_cs',
|
||||
'prd.settlement.agent.manage',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function baseSlugs(): array
|
||||
{
|
||||
return self::BASE_SLUGS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function ownerSlugsForNode(AgentNode $node, ?AgentProfile $profile = null): array
|
||||
{
|
||||
if ($node->isRoot()) {
|
||||
return self::lineRootOwnerSlugs();
|
||||
}
|
||||
|
||||
$profile ??= AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||
|
||||
if ($profile === null) {
|
||||
return self::defaultOwnerSlugsWithoutProfile();
|
||||
}
|
||||
|
||||
return self::ownerSlugsFromProfile($profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function lineRootOwnerSlugs(): array
|
||||
{
|
||||
return array_values(array_unique(array_merge(
|
||||
self::BASE_SLUGS,
|
||||
self::LINE_ROOT_EXTRA_SLUGS,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function ownerSlugsFromProfile(AgentProfile $profile): array
|
||||
{
|
||||
$slugs = self::BASE_SLUGS;
|
||||
if ($profile->can_create_child_agent) {
|
||||
$slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS);
|
||||
}
|
||||
if ($profile->can_create_player) {
|
||||
$slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS);
|
||||
}
|
||||
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function defaultOwnerSlugsWithoutProfile(): array
|
||||
{
|
||||
return array_values(array_unique(array_merge(
|
||||
self::BASE_SLUGS,
|
||||
self::PLAYER_MANAGE_SLUGS,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $createPayload
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function ownerSlugsForNewChild(array $createPayload): array
|
||||
{
|
||||
$slugs = self::BASE_SLUGS;
|
||||
if ((bool) ($createPayload['can_create_child_agent'] ?? false)) {
|
||||
$slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS);
|
||||
}
|
||||
if ((bool) ($createPayload['can_create_player'] ?? true)) {
|
||||
$slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS);
|
||||
}
|
||||
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台「代理」系统角色模板(出现在「平台角色管理」列表,供手动分配或作站点 pivot 回退)。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function platformAgentRoleTemplateSlugs(): array
|
||||
{
|
||||
return self::defaultOwnerSlugsWithoutProfile();
|
||||
}
|
||||
|
||||
/** 确保存在 slug=agent 的平台系统角色,并同步模板权限。 */
|
||||
public static function ensurePlatformAgentRole(): AdminRole
|
||||
{
|
||||
$role = AdminRole::query()->updateOrCreate(
|
||||
[
|
||||
'slug' => 'agent',
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
],
|
||||
[
|
||||
'code' => 'agent',
|
||||
'name' => '代理',
|
||||
'description' => '经营代理默认权限模板(与线路内 agent_owner 默认包一致)',
|
||||
'status' => 1,
|
||||
'is_system' => true,
|
||||
'sort_order' => 50,
|
||||
'owner_agent_id' => null,
|
||||
'delegated_from_role_id' => null,
|
||||
],
|
||||
);
|
||||
|
||||
$role->syncLegacyPermissionSlugs(self::platformAgentRoleTemplateSlugs());
|
||||
|
||||
return $role->fresh() ?? $role;
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,10 @@ use App\Models\AgentNode;
|
||||
|
||||
final class AgentLinePresenter
|
||||
{
|
||||
/**
|
||||
* @param array{sso_jwt_secret: string, wallet_api_key: string} $secrets
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function provisioned(AdminSite $site, AgentNode $root, array $secrets): array
|
||||
/** @return array<string, mixed> */
|
||||
public static function provisioned(AdminSite $site, AgentNode $root): array
|
||||
{
|
||||
$sitePayload = AdminIntegrationSitePresenter::withPlainSecretsOnce(
|
||||
AdminIntegrationSitePresenter::detail($site),
|
||||
$secrets,
|
||||
);
|
||||
$sitePayload = AdminIntegrationSitePresenter::detail($site);
|
||||
|
||||
return array_merge($sitePayload, [
|
||||
'agent_node' => AgentNodePresenter::item($root),
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Support;
|
||||
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentNodePresenter
|
||||
@@ -23,7 +25,24 @@ final class AgentNodePresenter
|
||||
* email: ?string
|
||||
* }
|
||||
*/
|
||||
public static function item(AgentNode $node): array
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function profileSummary(AgentProfile $profile): array
|
||||
{
|
||||
return [
|
||||
'total_share_rate' => (float) $profile->total_share_rate,
|
||||
'credit_limit' => (int) $profile->credit_limit,
|
||||
'allocated_credit' => (int) $profile->allocated_credit,
|
||||
'used_credit' => (int) $profile->used_credit,
|
||||
'available_credit' => max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit),
|
||||
'rebate_limit' => (float) $profile->rebate_limit,
|
||||
'default_player_rebate' => (float) $profile->default_player_rebate,
|
||||
'settlement_cycle' => AgentSettlementCycle::normalize($profile->settlement_cycle),
|
||||
];
|
||||
}
|
||||
|
||||
public static function item(AgentNode $node, ?AgentProfile $profile = null): array
|
||||
{
|
||||
$account = DB::table('admin_user_agents as aua')
|
||||
->join('admin_users as au', 'au.id', '=', 'aua.admin_user_id')
|
||||
@@ -35,7 +54,7 @@ final class AgentNodePresenter
|
||||
|
||||
$siteCode = AdminSite::query()->where('id', $node->admin_site_id)->value('code');
|
||||
|
||||
return [
|
||||
$payload = [
|
||||
'id' => (int) $node->id,
|
||||
'admin_site_id' => (int) $node->admin_site_id,
|
||||
'site_code' => $siteCode !== null ? (string) $siteCode : null,
|
||||
@@ -50,6 +69,12 @@ final class AgentNodePresenter
|
||||
'username' => $account?->username !== null ? (string) $account->username : null,
|
||||
'email' => $account?->email !== null ? (string) $account->email : null,
|
||||
];
|
||||
|
||||
if ($profile !== null) {
|
||||
$payload['profile_summary'] = self::profileSummary($profile);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,11 +83,18 @@ final class AgentNodePresenter
|
||||
*/
|
||||
public static function tree(iterable $nodes): array
|
||||
{
|
||||
$nodeList = $nodes instanceof Collection ? $nodes : collect($nodes);
|
||||
$profiles = AgentProfile::query()
|
||||
->whereIn('agent_node_id', $nodeList->pluck('id'))
|
||||
->get()
|
||||
->keyBy('agent_node_id');
|
||||
|
||||
$items = [];
|
||||
$byParent = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$row = self::item($node);
|
||||
foreach ($nodeList as $node) {
|
||||
$profile = $profiles->get($node->id);
|
||||
$row = self::item($node, $profile instanceof AgentProfile ? $profile : null);
|
||||
$row['children'] = [];
|
||||
$items[(int) $node->id] = $row;
|
||||
$parentKey = $node->parent_id !== null ? (int) $node->parent_id : 0;
|
||||
|
||||
31
app/Support/AgentOverdueGuard.php
Normal file
31
app/Support/AgentOverdueGuard.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentOverdueGuard
|
||||
{
|
||||
public static function agentHasOverdueBills(int $agentNodeId): bool
|
||||
{
|
||||
if ($agentNodeId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DB::table('settlement_bills')
|
||||
->where('owner_type', 'agent')
|
||||
->where('owner_id', $agentNodeId)
|
||||
->where('status', 'overdue')
|
||||
->where('unpaid_amount', '>', 0)
|
||||
->exists();
|
||||
}
|
||||
|
||||
public static function assertAgentMayGrantCredit(int $agentNodeId): void
|
||||
{
|
||||
if (self::agentHasOverdueBills($agentNodeId)) {
|
||||
throw \Illuminate\Validation\ValidationException::withMessages([
|
||||
'credit' => ['agent_overdue'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
app/Support/AgentPlatformRole.php
Normal file
47
app/Support/AgentPlatformRole.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** 经营代理主账号统一使用平台系统角色 {@see AdminRole} slug=agent。 */
|
||||
final class AgentPlatformRole
|
||||
{
|
||||
public static function resolve(): AdminRole
|
||||
{
|
||||
return AgentDefaultRolePermissions::ensurePlatformAgentRole();
|
||||
}
|
||||
|
||||
public static function id(): int
|
||||
{
|
||||
$role = self::resolve();
|
||||
|
||||
return (int) $role->id;
|
||||
}
|
||||
|
||||
/** 主账号:仅绑定平台「代理」角色(权限在「平台角色管理」维护)。 */
|
||||
public static function assignPrimaryOperator(AdminUser $user, AgentNode $node): void
|
||||
{
|
||||
$user->syncAgentRoleIds((int) $node->id, [self::id()]);
|
||||
}
|
||||
|
||||
public static function idOrFail(): int
|
||||
{
|
||||
$id = (int) (AdminRole::query()
|
||||
->where('scope_type', AdminRole::SCOPE_SYSTEM)
|
||||
->where('slug', 'agent')
|
||||
->where('status', 1)
|
||||
->value('id') ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'role' => ['platform_agent_role_missing: run php artisan lottery:agent-roles-sync'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
123
app/Support/AgentProfileCapabilityFilter.php
Normal file
123
app/Support/AgentProfileCapabilityFilter.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AgentProfile;
|
||||
|
||||
/**
|
||||
* 将「平台 agent 角色」权限与线路内 {@see AgentProfile} 能力开关联动。
|
||||
*
|
||||
* 主账号绑定 slug=agent,不再按节点维护 agent_owner_*;创建玩家/下级 的开关须在登录态生效。
|
||||
*/
|
||||
final class AgentProfileCapabilityFilter
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const CHILD_AGENT_PERMISSION_CODES = [
|
||||
'agent.node.manage',
|
||||
'agent.profile.manage',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const PLAYER_PERMISSION_CODES = [
|
||||
'service.players.manage',
|
||||
'service.players.freeze',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const CHILD_AGENT_LEGACY_SLUGS = [
|
||||
'prd.agent.manage',
|
||||
'prd.agent.profile.manage',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const PLAYER_LEGACY_SLUGS = [
|
||||
'prd.users.manage',
|
||||
'prd.users.view_finance',
|
||||
'prd.users.view_cs',
|
||||
'prd.player_freeze.manage',
|
||||
];
|
||||
|
||||
/**
|
||||
* 按 Profile 能力收紧或补足登录态 permission_code(平台 agent 角色模板未必含 manage)。
|
||||
*
|
||||
* @param list<string> $permissionCodes
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
|
||||
{
|
||||
if ($profile === null) {
|
||||
return $permissionCodes;
|
||||
}
|
||||
|
||||
$set = [];
|
||||
foreach ($permissionCodes as $code) {
|
||||
if (is_string($code) && $code !== '') {
|
||||
$set[$code] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $profile->can_create_child_agent) {
|
||||
foreach (self::CHILD_AGENT_PERMISSION_CODES as $code) {
|
||||
unset($set[$code]);
|
||||
}
|
||||
} else {
|
||||
foreach (self::CHILD_AGENT_PERMISSION_CODES as $code) {
|
||||
$set[$code] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $profile->can_create_player) {
|
||||
foreach (self::PLAYER_PERMISSION_CODES as $code) {
|
||||
unset($set[$code]);
|
||||
}
|
||||
} else {
|
||||
foreach (self::PLAYER_PERMISSION_CODES as $code) {
|
||||
$set[$code] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$out = array_keys($set);
|
||||
sort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $permissionCodes
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function filterMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
|
||||
{
|
||||
return self::applyToMenuActionCodes($permissionCodes, $profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $legacySlugs
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function filterLegacySlugs(array $legacySlugs, ?AgentProfile $profile): array
|
||||
{
|
||||
if ($profile === null) {
|
||||
return $legacySlugs;
|
||||
}
|
||||
|
||||
$deny = [];
|
||||
if (! $profile->can_create_child_agent) {
|
||||
$deny = array_merge($deny, self::CHILD_AGENT_LEGACY_SLUGS);
|
||||
}
|
||||
if (! $profile->can_create_player) {
|
||||
$deny = array_merge($deny, self::PLAYER_LEGACY_SLUGS);
|
||||
}
|
||||
|
||||
if ($deny === []) {
|
||||
return $legacySlugs;
|
||||
}
|
||||
|
||||
$denySet = array_fill_keys($deny, true);
|
||||
|
||||
return array_values(array_filter(
|
||||
$legacySlugs,
|
||||
static fn (string $slug): bool => ! isset($denySet[$slug]),
|
||||
));
|
||||
}
|
||||
}
|
||||
17
app/Support/AgentSettlementProductionGuard.php
Normal file
17
app/Support/AgentSettlementProductionGuard.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class AgentSettlementProductionGuard
|
||||
{
|
||||
public static function assertProductionCloseAllowed(): void
|
||||
{
|
||||
if (app()->environment('testing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config('agent_settlement.allow_demo_close', false)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,11 @@ final class ApiValidationErrors
|
||||
return $humanized;
|
||||
}
|
||||
|
||||
$compact = self::humanizeCompactEnglish($field, $trimmed, $locale, $attribute);
|
||||
if ($compact !== null) {
|
||||
return $compact;
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
@@ -243,6 +248,91 @@ final class ApiValidationErrors
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Laravel 11 在 locale=en 时常用「{attribute} must not be greater than 1.」短句(无 "The … field" 前缀)。
|
||||
*/
|
||||
private static function humanizeCompactEnglish(
|
||||
string $field,
|
||||
string $message,
|
||||
string $locale,
|
||||
string $attribute,
|
||||
): ?string {
|
||||
if (preg_match('/^(.+?)\s+must not be greater than ([\d.]+)\.?$/i', $message, $max) === 1) {
|
||||
$attribute = self::attributeLabelFromEnglish($max[1], $field, $locale);
|
||||
$custom = self::customRuleLine($field, 'max', $attribute, $locale);
|
||||
if ($custom !== null) {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
return trans('validation.max.numeric', ['attribute' => $attribute, 'max' => $max[2]], $locale);
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+?)\s+may not be greater than ([\d.]+)\.?$/i', $message, $max) === 1) {
|
||||
$attribute = self::attributeLabelFromEnglish($max[1], $field, $locale);
|
||||
|
||||
return trans('validation.max.numeric', ['attribute' => $attribute, 'max' => $max[2]], $locale);
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+?)\s+must be less than or equal to ([\d.]+)\.?$/i', $message, $lte) === 1) {
|
||||
$attribute = self::attributeLabelFromEnglish($lte[1], $field, $locale);
|
||||
|
||||
return trans('validation.lte.numeric', ['attribute' => $attribute, 'value' => $lte[2]], $locale);
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+?)\s+must not be less than ([\d.]+)\.?$/i', $message, $min) === 1) {
|
||||
$attribute = self::attributeLabelFromEnglish($min[1], $field, $locale);
|
||||
$custom = self::customRuleLine($field, 'min', $attribute, $locale);
|
||||
if ($custom !== null) {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
return trans('validation.min.numeric', ['attribute' => $attribute, 'min' => $min[2]], $locale);
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+?)\s+must be at least ([\d.]+)\.?$/i', $message, $min) === 1) {
|
||||
$attribute = self::attributeLabelFromEnglish($min[1], $field, $locale);
|
||||
$custom = self::customRuleLine($field, 'min', $attribute, $locale);
|
||||
if ($custom !== null) {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
return trans('validation.min.numeric', ['attribute' => $attribute, 'min' => $min[2]], $locale);
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+?)\s+must be between ([\d.]+) and ([\d.]+)\.?$/i', $message, $between) === 1) {
|
||||
$attribute = self::attributeLabelFromEnglish($between[1], $field, $locale);
|
||||
|
||||
return trans('validation.between.numeric', [
|
||||
'attribute' => $attribute,
|
||||
'min' => $between[2],
|
||||
'max' => $between[3],
|
||||
], $locale);
|
||||
}
|
||||
|
||||
$compactTails = [
|
||||
'must be a number' => 'validation.numeric',
|
||||
'must be an integer' => 'validation.integer',
|
||||
'must be a string' => 'validation.string',
|
||||
'must be a boolean' => 'validation.boolean',
|
||||
'must be an array' => 'validation.array',
|
||||
'is required' => 'validation.required',
|
||||
];
|
||||
|
||||
foreach ($compactTails as $suffix => $ruleKey) {
|
||||
$pattern = '/^(.+?)\s+'.preg_quote($suffix, '/').'\.?$/i';
|
||||
if (preg_match($pattern, $message, $match) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attribute = self::attributeLabelFromEnglish($match[1], $field, $locale);
|
||||
$line = trans($ruleKey, ['attribute' => $attribute], $locale);
|
||||
|
||||
return $line !== $ruleKey ? $line : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function attributeLabelFromEnglish(string $englishName, string $field, string $locale): string
|
||||
{
|
||||
$normalized = strtolower(trim($englishName));
|
||||
|
||||
52
app/Support/CreditAmountScale.php
Normal file
52
app/Support/CreditAmountScale.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Currency;
|
||||
use App\Services\LotterySettings;
|
||||
|
||||
/**
|
||||
* 信用占成盘额度(代理/玩家授信、已用)在库内按「主货币整数」存储;
|
||||
* 彩票下注、钱包 API 使用最小货币单位(minor)。本类负责二者换算。
|
||||
*/
|
||||
final class CreditAmountScale
|
||||
{
|
||||
public static function minorUnitFactor(string $currencyCode): int
|
||||
{
|
||||
$code = strtoupper(trim($currencyCode));
|
||||
if ($code === '') {
|
||||
return (int) max(1, 10 ** LotterySettings::currencyDisplayDecimals());
|
||||
}
|
||||
|
||||
$currency = Currency::query()->where('code', $code)->first();
|
||||
$decimals = $currency !== null
|
||||
? (int) $currency->decimal_places
|
||||
: LotterySettings::currencyDisplayDecimals();
|
||||
|
||||
return (int) max(1, 10 ** max(0, min(12, $decimals)));
|
||||
}
|
||||
|
||||
public static function majorToMinor(int $major, string $currencyCode): int
|
||||
{
|
||||
$major = max(0, $major);
|
||||
|
||||
return $major * self::minorUnitFactor($currencyCode);
|
||||
}
|
||||
|
||||
/** 最小单位 → 主货币整数(四舍五入)。 */
|
||||
public static function minorToMajor(int $minor, string $currencyCode): int
|
||||
{
|
||||
$factor = self::minorUnitFactor($currencyCode);
|
||||
if ($factor <= 1) {
|
||||
return $minor;
|
||||
}
|
||||
|
||||
if ($minor >= 0) {
|
||||
return intdiv($minor + intdiv($factor, 2), $factor);
|
||||
}
|
||||
|
||||
return -intdiv(-$minor + intdiv($factor, 2), $factor);
|
||||
}
|
||||
}
|
||||
54
app/Support/PlatformSystemRoles.php
Normal file
54
app/Support/PlatformSystemRoles.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
|
||||
/** 平台角色管理仅维护的两个内置系统角色。 */
|
||||
final class PlatformSystemRoles
|
||||
{
|
||||
public const SLUG_SUPER_ADMIN = AdminRole::ROLE_SUPER_ADMIN;
|
||||
|
||||
public const SLUG_AGENT = 'agent';
|
||||
|
||||
/** @return list<string> */
|
||||
public static function fixedSlugs(): array
|
||||
{
|
||||
return [self::SLUG_SUPER_ADMIN, self::SLUG_AGENT];
|
||||
}
|
||||
|
||||
public static function isFixedSlug(string $slug): bool
|
||||
{
|
||||
return in_array($slug, self::fixedSlugs(), true);
|
||||
}
|
||||
|
||||
/** 超级管理员:平台内置,同步当前目录中的全部 `prd.*`。 */
|
||||
public static function ensureSuperAdminRole(): AdminRole
|
||||
{
|
||||
$role = AdminRole::query()->updateOrCreate(
|
||||
[
|
||||
'slug' => self::SLUG_SUPER_ADMIN,
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
],
|
||||
[
|
||||
'code' => self::SLUG_SUPER_ADMIN,
|
||||
'name' => '超级管理员',
|
||||
'description' => '平台内置角色,拥有全部权限',
|
||||
'status' => 1,
|
||||
'is_system' => true,
|
||||
'sort_order' => 10,
|
||||
'owner_agent_id' => null,
|
||||
'delegated_from_role_id' => null,
|
||||
],
|
||||
);
|
||||
$role->syncAllActiveMenuActions();
|
||||
|
||||
return $role->fresh() ?? $role;
|
||||
}
|
||||
|
||||
public static function ensureAll(): void
|
||||
{
|
||||
self::ensureSuperAdminRole();
|
||||
AgentDefaultRolePermissions::ensurePlatformAgentRole();
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AgentProfile;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 玩家 API 统一 JSON 形状(列表行 / 详情)。 */
|
||||
final class PlayerApiPresenter
|
||||
@@ -28,11 +31,23 @@ final class PlayerApiPresenter
|
||||
? $player->agentNode
|
||||
: ($player->agent_node_id ? $player->agentNode()->first() : null);
|
||||
|
||||
$usesCredit = PlayerFundingMode::usesCredit($player);
|
||||
$credit = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
|
||||
$creditLimit = $credit !== null ? (int) $credit->credit_limit : ($usesCredit ? 0 : null);
|
||||
$usedCredit = $credit !== null ? (int) $credit->used_credit : ($usesCredit ? 0 : null);
|
||||
$availableCredit = $credit !== null
|
||||
? max(0, (int) $credit->credit_limit - (int) $credit->used_credit - (int) $credit->frozen_credit)
|
||||
: ($usesCredit ? 0 : null);
|
||||
|
||||
[$rebateRate, $rebateInherited] = self::resolveListRebate($player, $agent);
|
||||
|
||||
return [
|
||||
'id' => (int) $player->id,
|
||||
...AgentNodeApiPresenter::embed($agent),
|
||||
'site_code' => $player->site_code,
|
||||
'site_player_id' => $player->site_player_id,
|
||||
'auth_source' => $player->auth_source,
|
||||
'funding_mode' => $player->funding_mode,
|
||||
'username' => $player->username,
|
||||
'nickname' => $player->nickname,
|
||||
'default_currency' => $player->default_currency,
|
||||
@@ -40,6 +55,47 @@ final class PlayerApiPresenter
|
||||
'last_login_at' => $player->last_login_at?->toIso8601String(),
|
||||
'created_at' => $player->created_at?->toIso8601String(),
|
||||
'wallets' => $walletRows,
|
||||
'uses_credit' => $usesCredit,
|
||||
'credit_limit' => $creditLimit,
|
||||
'used_credit' => $usedCredit,
|
||||
'available_credit' => $availableCredit,
|
||||
'rebate_rate' => $rebateRate,
|
||||
'rebate_inherited' => $rebateInherited,
|
||||
'risk_tags' => $player->risk_tags ?? [],
|
||||
'rebate_profiles' => DB::table('player_rebate_profiles')
|
||||
->where('player_id', $player->id)
|
||||
->orderBy('game_type')
|
||||
->get()
|
||||
->map(static fn (object $row): array => [
|
||||
'game_type' => (string) $row->game_type,
|
||||
'rebate_rate' => (float) $row->rebate_rate,
|
||||
'extra_rebate_rate' => (float) $row->extra_rebate_rate,
|
||||
'inherit_from_agent' => (bool) $row->inherit_from_agent,
|
||||
])
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?float, 1: bool} rebate rate (ratio) and whether inherited from agent
|
||||
*/
|
||||
private static function resolveListRebate(Player $player, ?\App\Models\AgentNode $agent): array
|
||||
{
|
||||
$row = DB::table('player_rebate_profiles')
|
||||
->where('player_id', $player->id)
|
||||
->where('game_type', '*')
|
||||
->first();
|
||||
|
||||
if ($row !== null && ! (bool) $row->inherit_from_agent) {
|
||||
return [(float) $row->rebate_rate, false];
|
||||
}
|
||||
|
||||
if ($agent !== null) {
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
|
||||
|
||||
return [(float) ($profile?->default_player_rebate ?? 0), true];
|
||||
}
|
||||
|
||||
return [null, false];
|
||||
}
|
||||
}
|
||||
|
||||
19
app/Support/PlayerAuthSource.php
Normal file
19
app/Support/PlayerAuthSource.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/** 玩家登录来源(与 funding_mode 配合,见双模式玩家改造计划)。 */
|
||||
final class PlayerAuthSource
|
||||
{
|
||||
public const MAIN_SITE_SSO = 'main_site_sso';
|
||||
|
||||
public const LOTTERY_NATIVE = 'lottery_native';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [self::MAIN_SITE_SSO, self::LOTTERY_NATIVE];
|
||||
}
|
||||
}
|
||||
34
app/Support/PlayerFundingMode.php
Normal file
34
app/Support/PlayerFundingMode.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Player;
|
||||
|
||||
/** 玩家资金模式:钱包(主站划转)或信用(代理授信)。 */
|
||||
final class PlayerFundingMode
|
||||
{
|
||||
public const WALLET = 'wallet';
|
||||
|
||||
public const CREDIT = 'credit';
|
||||
|
||||
public static function usesCredit(Player $player): bool
|
||||
{
|
||||
$mode = (string) ($player->funding_mode ?? '');
|
||||
|
||||
if ($mode === self::CREDIT) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($mode === self::WALLET) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (string) ($player->auth_source ?? '') === PlayerAuthSource::LOTTERY_NATIVE
|
||||
&& CreditLineMode::isEnabledForSiteCode((string) $player->site_code);
|
||||
}
|
||||
|
||||
public static function usesWallet(Player $player): bool
|
||||
{
|
||||
return ! self::usesCredit($player);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->api(prepend: [
|
||||
NegotiateLotteryLocale::class,
|
||||
]);
|
||||
$middleware->redirectGuestsTo(static function (Request $request): ?string {
|
||||
if ($request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/login';
|
||||
});
|
||||
$middleware->convertEmptyStringsToNull([
|
||||
static fn (Request $request): bool => $request->is('api/v1/admin/settings')
|
||||
|| $request->is('api/v1/admin/settings/*'),
|
||||
@@ -192,6 +199,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->everyMinute()
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
$schedule->command('settlement:mark-overdue-bills --days=7')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
/** @see docs/01-界面文档.md §2.1 `draw.countdown` */
|
||||
if (config('lottery.realtime_hall_countdown', true)) {
|
||||
$schedule->command('lottery:hall-countdown')
|
||||
|
||||
9
config/agent_line_defaults.php
Normal file
9
config/agent_line_defaults.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'credit_limit' => (int) env('AGENT_LINE_DEFAULT_CREDIT_LIMIT', 0),
|
||||
'total_share_rate' => (float) env('AGENT_LINE_DEFAULT_TOTAL_SHARE_RATE', 100),
|
||||
'rebate_limit' => (float) env('AGENT_LINE_DEFAULT_REBATE_LIMIT', 0.005),
|
||||
'default_player_rebate' => (float) env('AGENT_LINE_DEFAULT_PLAYER_REBATE', 0.005),
|
||||
'settlement_cycle' => env('AGENT_LINE_DEFAULT_SETTLEMENT_CYCLE', 'weekly'),
|
||||
];
|
||||
5
config/agent_settlement.php
Normal file
5
config/agent_settlement.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'allow_demo_close' => (bool) env('AGENT_SETTLEMENT_ALLOW_DEMO_CLOSE', false),
|
||||
];
|
||||
@@ -72,6 +72,14 @@ return [
|
||||
'aes' => [
|
||||
'key_base64' => env('LOTTERY_PLAYER_TOKEN_AES_KEY'),
|
||||
],
|
||||
'native' => [
|
||||
'secret' => env('LOTTERY_NATIVE_JWT_SECRET', env('MAIN_SITE_SSO_JWT_SECRET', '')),
|
||||
'ttl_seconds' => max(300, min(86400, (int) env('LOTTERY_NATIVE_JWT_TTL_SECONDS', 28800))),
|
||||
'claim_player_id' => 'player_id',
|
||||
'claim_auth_source' => 'auth_source',
|
||||
'max_login_attempts' => max(3, (int) env('LOTTERY_NATIVE_LOGIN_MAX_ATTEMPTS', 8)),
|
||||
'lock_minutes' => max(1, (int) env('LOTTERY_NATIVE_LOGIN_LOCK_MINUTES', 15)),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -16,6 +16,10 @@ return new class extends Migration
|
||||
private const RESOURCE_CODE_PREFIXES = [
|
||||
'admin.settlement-bills.',
|
||||
'admin.settlement-periods.',
|
||||
'admin.settlement-payments.',
|
||||
'admin.settlement-adjustments.',
|
||||
'admin.settlement-reports.',
|
||||
'admin.credit-ledger.',
|
||||
'admin.agent-lines.',
|
||||
'admin.agent-nodes.profile.',
|
||||
];
|
||||
|
||||
@@ -18,10 +18,15 @@ return new class extends Migration
|
||||
'can_create_player' => true,
|
||||
]);
|
||||
|
||||
$nodeService = app(\App\Services\Agent\AgentNodeService::class);
|
||||
\App\Models\AgentNode::query()->each(static function (\App\Models\AgentNode $node) use ($nodeService): void {
|
||||
$nodeService->syncPrimaryOwnerRoleFromProfile($node);
|
||||
});
|
||||
\App\Support\AgentDefaultRolePermissions::ensurePlatformAgentRole();
|
||||
\App\Models\AdminUser::query()
|
||||
->whereIn('id', \Illuminate\Support\Facades\DB::table('admin_user_agents')->pluck('admin_user_id'))
|
||||
->each(static function (\App\Models\AdminUser $user): void {
|
||||
$agentNodeId = $user->primaryAgentNodeId();
|
||||
if ($agentNodeId !== null) {
|
||||
$user->syncPrimaryPlatformAgentRole($agentNodeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ticket_items', function (Blueprint $table): void {
|
||||
$table->foreignId('agent_node_id')->nullable()->after('player_id')->constrained('agent_nodes')->nullOnDelete();
|
||||
$table->json('share_snapshot')->nullable()->after('rule_snapshot_json');
|
||||
$table->decimal('agent_rebate_rate_snapshot', 8, 4)->nullable()->after('share_snapshot');
|
||||
$table->timestamp('agent_settled_at')->nullable()->after('settled_at');
|
||||
$table->foreignId('agent_settlement_reversal_of_id')->nullable()->after('agent_settled_at')
|
||||
->constrained('ticket_items')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::create('share_ledger', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_item_id')->constrained('ticket_items')->cascadeOnDelete();
|
||||
$table->foreignId('player_id')->constrained('players')->cascadeOnDelete();
|
||||
$table->foreignId('agent_node_id')->nullable()->constrained('agent_nodes')->nullOnDelete();
|
||||
$table->json('agent_path')->nullable();
|
||||
$table->json('share_snapshot')->nullable();
|
||||
$table->bigInteger('game_win_loss')->default(0);
|
||||
$table->bigInteger('basic_rebate')->default(0);
|
||||
$table->bigInteger('shared_net_win_loss')->default(0);
|
||||
$table->json('allocations_json')->nullable();
|
||||
$table->foreignId('settlement_period_id')->nullable()->constrained('settlement_periods')->nullOnDelete();
|
||||
$table->unsignedBigInteger('reversal_of_id')->nullable();
|
||||
$table->timestamp('settled_at');
|
||||
$table->timestamps();
|
||||
$table->index(['settled_at', 'player_id']);
|
||||
$table->index(['settlement_period_id']);
|
||||
});
|
||||
|
||||
Schema::table('share_ledger', function (Blueprint $table): void {
|
||||
$table->foreign('reversal_of_id')->references('id')->on('share_ledger')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('rebate_records', function (Blueprint $table): void {
|
||||
$table->foreignId('ticket_item_id')->nullable()->after('player_id')->constrained('ticket_items')->nullOnDelete();
|
||||
$table->foreignId('reversal_of_id')->nullable()->after('ticket_item_id')->constrained('rebate_records')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('settlement_bills', function (Blueprint $table): void {
|
||||
$table->timestamp('locked_at')->nullable()->after('confirmed_at');
|
||||
$table->foreignId('reversed_bill_id')->nullable()->after('locked_at')->constrained('settlement_bills')->nullOnDelete();
|
||||
$table->json('meta_json')->nullable()->after('reversed_bill_id');
|
||||
});
|
||||
|
||||
Schema::create('settlement_adjustments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('settlement_period_id')->nullable()->constrained('settlement_periods')->nullOnDelete();
|
||||
$table->foreignId('original_bill_id')->nullable()->constrained('settlement_bills')->nullOnDelete();
|
||||
$table->string('adjustment_type', 32);
|
||||
$table->bigInteger('amount');
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->foreignId('created_by')->nullable()->constrained('admin_users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settlement_adjustments');
|
||||
|
||||
Schema::table('settlement_bills', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('reversed_bill_id');
|
||||
$table->dropColumn(['locked_at', 'meta_json']);
|
||||
});
|
||||
|
||||
Schema::table('rebate_records', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('reversal_of_id');
|
||||
$table->dropConstrainedForeignId('ticket_item_id');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('share_ledger');
|
||||
|
||||
Schema::table('ticket_items', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('agent_settlement_reversal_of_id');
|
||||
$table->dropConstrainedForeignId('agent_node_id');
|
||||
$table->dropColumn(['share_snapshot', 'agent_rebate_rate_snapshot', 'agent_settled_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->string('auth_source', 16)->default('main_site_sso')->after('site_player_id');
|
||||
$table->string('funding_mode', 16)->default('wallet')->after('auth_source');
|
||||
$table->string('password_hash', 255)->nullable()->after('username');
|
||||
$table->unsignedSmallInteger('login_failed_count')->default(0)->after('last_login_at');
|
||||
$table->timestamp('login_locked_until')->nullable()->after('login_failed_count');
|
||||
});
|
||||
|
||||
DB::table('players')->update([
|
||||
'auth_source' => 'main_site_sso',
|
||||
'funding_mode' => 'wallet',
|
||||
]);
|
||||
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->index(['site_code', 'auth_source', 'username'], 'idx_players_site_auth_username');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->dropIndex('idx_players_site_auth_username');
|
||||
$table->dropColumn([
|
||||
'auth_source',
|
||||
'funding_mode',
|
||||
'password_hash',
|
||||
'login_failed_count',
|
||||
'login_locked_until',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('payment_records', function (Blueprint $table): void {
|
||||
$table->text('proof')->nullable()->after('method');
|
||||
$table->string('remark', 255)->nullable()->after('proof');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('payment_records', function (Blueprint $table): void {
|
||||
$table->dropColumn(['proof', 'remark']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AgentNode;
|
||||
use App\Support\AgentDefaultRolePermissions;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
AgentNode::query()->each(static function (AgentNode $node): void {
|
||||
$role = AdminRole::query()
|
||||
->where('owner_agent_id', $node->id)
|
||||
->where('slug', 'agent_owner_'.$node->id)
|
||||
->first();
|
||||
|
||||
if ($role === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$role->syncLegacyPermissionSlugs(AgentDefaultRolePermissions::ownerSlugsForNode($node));
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 权限包为产品策略,回滚不恢复旧 slug 集合。
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Support\AgentDefaultRolePermissions;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$platformRole = AgentDefaultRolePermissions::ensurePlatformAgentRole();
|
||||
|
||||
AgentNode::query()->each(static function (AgentNode $node): void {
|
||||
$ownerRole = AdminRole::query()
|
||||
->where('owner_agent_id', $node->id)
|
||||
->where('slug', 'agent_owner_'.$node->id)
|
||||
->first();
|
||||
|
||||
if ($ownerRole !== null) {
|
||||
$ownerRole->syncLegacyPermissionSlugs(AgentDefaultRolePermissions::ownerSlugsForNode($node));
|
||||
}
|
||||
});
|
||||
|
||||
$bindings = DB::table('admin_user_agents')->get(['admin_user_id', 'agent_node_id']);
|
||||
foreach ($bindings as $binding) {
|
||||
$adminUserId = (int) $binding->admin_user_id;
|
||||
$agentNodeId = (int) $binding->agent_node_id;
|
||||
$user = AdminUser::query()->find($adminUserId);
|
||||
if ($user === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$agentRoleIds = DB::table('admin_user_agent_roles')
|
||||
->where('admin_user_id', $adminUserId)
|
||||
->where('agent_node_id', $agentNodeId)
|
||||
->pluck('role_id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($agentRoleIds === []) {
|
||||
$ownerId = (int) (AdminRole::query()
|
||||
->where('owner_agent_id', $agentNodeId)
|
||||
->where('slug', 'agent_owner_'.$agentNodeId)
|
||||
->value('id') ?? 0);
|
||||
if ($ownerId > 0) {
|
||||
$agentRoleIds = [$ownerId];
|
||||
}
|
||||
}
|
||||
|
||||
if ($agentRoleIds !== []) {
|
||||
$user->syncAgentRoleIds($agentNodeId, $agentRoleIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 不回滚权限与 pivot,避免经营账号失权。
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('settlement_bills', function (Blueprint $table): void {
|
||||
$table->bigInteger('platform_rounding_adjustment')->default(0)->after('adjustment_amount');
|
||||
});
|
||||
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->json('risk_tags')->nullable()->after('status');
|
||||
});
|
||||
|
||||
Schema::table('agent_nodes', function (Blueprint $table): void {
|
||||
$table->json('risk_tags')->nullable()->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('agent_nodes', function (Blueprint $table): void {
|
||||
$table->dropColumn('risk_tags');
|
||||
});
|
||||
|
||||
Schema::table('players', function (Blueprint $table): void {
|
||||
$table->dropColumn('risk_tags');
|
||||
});
|
||||
|
||||
Schema::table('settlement_bills', function (Blueprint $table): void {
|
||||
$table->dropColumn('platform_rounding_adjustment');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AgentDefaultRolePermissions;
|
||||
use App\Support\AgentPlatformRole;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
AgentDefaultRolePermissions::ensurePlatformAgentRole();
|
||||
$platformRoleId = AgentPlatformRole::id();
|
||||
|
||||
foreach (DB::table('admin_user_agents')->get(['admin_user_id', 'agent_node_id']) as $binding) {
|
||||
$user = AdminUser::query()->find((int) $binding->admin_user_id);
|
||||
if ($user === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user->syncPrimaryPlatformAgentRole((int) $binding->agent_node_id);
|
||||
}
|
||||
|
||||
$ownerRoleIds = AdminRole::query()
|
||||
->where('scope_type', AdminRole::SCOPE_AGENT)
|
||||
->where('slug', 'like', 'agent_owner_%')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if ($ownerRoleIds !== []) {
|
||||
DB::table('admin_user_agent_roles')->whereIn('role_id', $ownerRoleIds)->delete();
|
||||
DB::table('admin_user_site_roles')->whereIn('role_id', $ownerRoleIds)->delete();
|
||||
DB::table('admin_role_menu_actions')->whereIn('role_id', $ownerRoleIds)->delete();
|
||||
AdminRole::query()->whereIn('id', $ownerRoleIds)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 不回滚:避免经营账号失权。
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Support\PlatformSystemRoles;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
PlatformSystemRoles::ensureAll();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 不回滚内置角色与权限,避免平台/代理账号失权。
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
|
||||
/**
|
||||
* 信用流水 API 注册到 admin_api_resources(已有库增量同步,避免 500 api_resource_not_configured)。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const RESOURCE_CODES = [
|
||||
'admin.credit-ledger.index',
|
||||
'admin.settlement-payments.index',
|
||||
'admin.settlement-adjustments.index',
|
||||
'admin.settlement-reports.summary',
|
||||
'admin.settlement-reports.show',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
|
||||
$byCode = collect(AdminAuthorizationRegistry::resources())->keyBy('code');
|
||||
|
||||
foreach (self::RESOURCE_CODES as $code) {
|
||||
$resource = $byCode->get($code);
|
||||
if (! is_array($resource)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resourceId = DB::table('admin_api_resources')
|
||||
->where('code', $resource['code'])
|
||||
->value('id');
|
||||
|
||||
$payload = [
|
||||
'module_code' => $resource['module_code'],
|
||||
'name' => $resource['name'],
|
||||
'http_method' => $resource['http_method'],
|
||||
'uri_pattern' => $resource['uri_pattern'],
|
||||
'route_name' => $resource['route_name'],
|
||||
'auth_mode' => $resource['auth_mode'],
|
||||
'is_audit_required' => $resource['is_audit_required'],
|
||||
'status' => 1,
|
||||
'meta_json' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($resourceId === null) {
|
||||
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
|
||||
'code' => $resource['code'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
DB::table('admin_api_resources')
|
||||
->where('id', (int) $resourceId)
|
||||
->update($payload);
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')
|
||||
->where('api_resource_id', (int) $resourceId)
|
||||
->delete();
|
||||
|
||||
foreach ($resource['permission_codes'] as $permissionCode) {
|
||||
$menuActionId = $menuActionIds[$permissionCode] ?? null;
|
||||
if ($menuActionId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')->insert([
|
||||
'api_resource_id' => (int) $resourceId,
|
||||
'menu_action_id' => (int) $menuActionId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (self::RESOURCE_CODES as $code) {
|
||||
$resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id');
|
||||
if ($resourceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
|
||||
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2,98 +2,27 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Support\AdminAgentPermissionMenuActionSync;
|
||||
use App\Support\AdminDrawPermissionMenuActionSync;
|
||||
use App\Support\AdminPermissionBridge;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
|
||||
/**
|
||||
* 后台 RBAC:与 {@see AdminUser::ROLE_SUPER_ADMIN} 及 `config/admin_permissions.php` 对齐。
|
||||
* 后台 RBAC:平台固定角色 super_admin / agent。
|
||||
*
|
||||
* 演示账号 **admin** / **123456**(仅限非 production)。
|
||||
*/
|
||||
final class AdminRbacAndUserSeeder extends Seeder
|
||||
{
|
||||
/** @param list<string> $legacySlugs */
|
||||
private function syncRolePermissions(AdminRole $role, array $legacySlugs): void
|
||||
{
|
||||
$role->syncLegacyPermissionSlugs($legacySlugs);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function allCatalogSlugs(): array
|
||||
{
|
||||
return AdminPermissionBridge::allLegacySlugs();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
AdminAgentPermissionMenuActionSync::syncMissing();
|
||||
AdminDrawPermissionMenuActionSync::syncMissing();
|
||||
|
||||
$super = AdminRole::query()->updateOrCreate(
|
||||
['slug' => AdminUser::ROLE_SUPER_ADMIN],
|
||||
['code' => AdminUser::ROLE_SUPER_ADMIN, 'name' => '超级管理员'],
|
||||
);
|
||||
$this->syncRolePermissions($super, $this->allCatalogSlugs());
|
||||
PlatformSystemRoles::ensureAll();
|
||||
|
||||
$risk = AdminRole::query()->updateOrCreate(
|
||||
['slug' => 'risk_operator'],
|
||||
['code' => 'risk_operator', 'name' => '风控运营员'],
|
||||
);
|
||||
$this->syncRolePermissions($risk, [
|
||||
'prd.dashboard.view',
|
||||
'prd.play_switch.manage',
|
||||
'prd.odds.manage',
|
||||
'prd.risk_cap.manage',
|
||||
'prd.rebate.manage',
|
||||
'prd.jackpot.manage',
|
||||
'prd.draw_result.manage',
|
||||
'prd.risk.view',
|
||||
'prd.risk.manage',
|
||||
'prd.payout.review',
|
||||
'prd.tickets.view',
|
||||
'prd.wallet_reconcile.view',
|
||||
'prd.audit.view',
|
||||
'prd.player_freeze.manage',
|
||||
'prd.report.view',
|
||||
'prd.report.export',
|
||||
]);
|
||||
|
||||
$finance = AdminRole::query()->updateOrCreate(
|
||||
['slug' => 'finance'],
|
||||
['code' => 'finance', 'name' => '财务/对账员'],
|
||||
);
|
||||
$this->syncRolePermissions($finance, [
|
||||
'prd.dashboard.view',
|
||||
'prd.users.view_finance',
|
||||
'prd.risk_cap.view',
|
||||
'prd.rebate.view',
|
||||
'prd.jackpot.view',
|
||||
'prd.draw_result.view',
|
||||
'prd.payout.view',
|
||||
'prd.tickets.view',
|
||||
'prd.wallet_reconcile.manage',
|
||||
'prd.wallet_adjust.manage',
|
||||
'prd.audit.view',
|
||||
'prd.report.view',
|
||||
'prd.report.export',
|
||||
]);
|
||||
|
||||
$cs = AdminRole::query()->updateOrCreate(
|
||||
['slug' => 'customer_service'],
|
||||
['code' => 'customer_service', 'name' => '客服人员'],
|
||||
);
|
||||
$this->syncRolePermissions($cs, [
|
||||
'prd.dashboard.view',
|
||||
'prd.users.view_cs',
|
||||
'prd.tickets.view',
|
||||
'prd.draw_result.view',
|
||||
'prd.wallet_reconcile.view_cs',
|
||||
'prd.report.view',
|
||||
]);
|
||||
$super = PlatformSystemRoles::ensureSuperAdminRole();
|
||||
|
||||
$username = 'admin';
|
||||
AdminUser::query()->updateOrCreate(
|
||||
|
||||
@@ -46,3 +46,42 @@ php artisan lottery:admin-auth-audit # 仅体检:受保护路由是
|
||||
|---------|--------|
|
||||
| `prd.audit.all` / `prd.audit.self` / `prd.audit.finance` | `prd.audit.view` |
|
||||
| `prd.report.all` / `prd.report.risk` / `prd.report.finance` / `prd.report.player` | `prd.report.view` |
|
||||
|
||||
## 按站点开通后台(数据隔离)
|
||||
|
||||
所有人登录**同一套** lotteryadmin(`POST /api/v1/admin/auth/login`)。登录后 API 按账号绑定自动过滤站点数据,**无需**为每个站点单独部署后台域名。
|
||||
|
||||
| 给谁 | 账号形态 | 数据范围来源 | 推荐入口 |
|
||||
|------|----------|--------------|----------|
|
||||
| 整条代理线负责人 | **代理经营账号**(`admin_user_agents` 主绑定) | `agent_nodes.admin_site_id` + 代理子树 `path` | 超管:**代理线路 → 开通线路** |
|
||||
| 平台侧站点运营 | **平台账号**(无代理绑定) | `admin_user_site_roles.site_id` | 超管:**系统 → 平台账号**,创建时选择 `admin_site_id` 与角色 |
|
||||
|
||||
**菜单权限**(能进哪些页)由角色的 `admin_role_menu_actions` / 反推 `prd.*` 决定;**数据范围**(能看哪些站点的行)由 `admin_user_site_roles.site_id`(平台)或代理绑定(线路)决定,二者独立。
|
||||
|
||||
### 路径 A:开通线路(代理主账号)
|
||||
|
||||
1. 侧栏 **代理线路 → 开通线路**(`prd.agent-line.provision`)。
|
||||
2. 填写站点 `code`、名称、线路主 **用户名 / 密码**。
|
||||
3. 后端一次性创建:`admin_sites`、根 `agent_nodes`、`admin_users`、`admin_user_agents`、平台代理经营角色。
|
||||
4. 将账号密码交给对方;对方用同一 lotteryadmin 地址登录。
|
||||
5. 登录后 `auth/me.agent` 含 `admin_site_id`、`site_code`;侧栏隐藏「开通线路」等平台-only 菜单;列表经 `AdminScopePolicy` 收敛到该站点 + 代理子树。
|
||||
|
||||
**子账号**:在代理树下 **代理节点 → 管理员**(`POST /api/v1/admin/agent-nodes/{id}/admin-users`),站点不变、子树更窄。勿在「系统 → 平台账号」为线路主建号。
|
||||
|
||||
### 路径 B:平台运营账号(单站)
|
||||
|
||||
1. 平台 **角色管理** 仅有两个内置角色:**超级管理员**(自动拥有全部 `prd.*`,随 `lottery:admin-auth-sync` 补齐)与 **代理**(经营主账号默认模板,可在此调整 `prd.*`)。若需更细的平台运营分工,请使用不同平台账号并绑定 **代理** 角色后按需收窄权限;勿授予 `prd.agent-line.provision`、全站接入密钥类权限。
|
||||
2. **系统 → 平台账号 → 新建**:填写账号信息,**选择目标站点**(`admin_site_id`),勾选上一步角色。
|
||||
3. 对方登录后仅见绑定站点数据;`auth/me.accessible_sites` 列出可访问站点(单站时一项)。
|
||||
|
||||
改角色绑定时须带上同一 `admin_site_id`(`PUT /api/v1/admin/admin-users/{id}/roles`),仅替换该站点上的角色 pivot,不影响其他站点绑定。
|
||||
|
||||
### 运行时过滤(已实现)
|
||||
|
||||
- 可访问站点:`AdminUser::accessibleAdminSiteIds()`(超管 `null` = 不限)。
|
||||
- 查询:`AdminSiteScope` + `AdminScopePolicy`(`site_scope ∩ agent_subtree_scope`)。
|
||||
- 操作者授权站点:`AdminIntegrationSiteAccess::canAccess()`(创建/改绑平台用户时校验)。
|
||||
|
||||
### 临时手工绑站(迁移前)
|
||||
|
||||
若 UI 未就绪,超管建用户后可在库表 `admin_user_site_roles` 插入 `(admin_user_id, site_id, role_id)`,参考 `tests/Feature/AdminIntegrationSiteApiTest.php`。
|
||||
|
||||
@@ -20,6 +20,9 @@ return [
|
||||
'player_wallet_balance_blocks_delete' => 'Player wallet still has balance. Clear it before deletion.',
|
||||
'player_has_tickets_blocks_delete' => 'Player has ticket records and cannot be deleted.',
|
||||
'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.',
|
||||
'platform_roles_fixed' => 'Only the built-in Super Admin and Agent platform roles are supported; creating new roles is disabled.',
|
||||
'role_super_admin_permissions_fixed' => 'Super Admin always has full permissions. Run lottery:admin-auth-sync after the permission catalog changes.',
|
||||
'role_super_admin_metadata_fixed' => 'Super Admin is a built-in role; name and status cannot be changed.',
|
||||
'role_builtin_cannot_delete' => 'Built-in roles cannot be deleted.',
|
||||
'role_has_users_cannot_delete' => 'This role still has assigned admins and cannot be deleted.',
|
||||
'agent_root_delete_denied' => 'Root agent nodes cannot be deleted.',
|
||||
|
||||
@@ -12,4 +12,7 @@ return [
|
||||
'8003' => 'Player not registered', // 库中无对应玩家
|
||||
'8004' => 'SSO secret not configured', // 未配置 MAIN_SITE_SSO_JWT_SECRET(通常返回 503)
|
||||
'8005' => 'Account suspended or login disabled',
|
||||
'8006' => 'Invalid username or password',
|
||||
'8007' => 'Too many failed attempts. Try again later',
|
||||
'8008' => 'Please sign in through the main site',
|
||||
];
|
||||
|
||||
@@ -59,4 +59,14 @@ return [
|
||||
'items.*.odds_value' => 'odds',
|
||||
'items.*.display_name' => 'display name',
|
||||
'report_type' => 'report type',
|
||||
'total_share_rate' => 'share rate',
|
||||
'credit_limit' => 'credit limit',
|
||||
'rebate_limit' => 'rebate ceiling',
|
||||
'default_player_rebate' => 'default player rebate',
|
||||
'rebate_rate' => 'rebate rate',
|
||||
'extra_rebate_rate' => 'extra rebate rate',
|
||||
'settlement_cycle' => 'settlement cycle',
|
||||
'can_grant_extra_rebate' => 'allow extra rebate',
|
||||
'can_create_child_agent' => 'allow child agents',
|
||||
'can_create_player' => 'allow players',
|
||||
];
|
||||
|
||||
@@ -15,6 +15,7 @@ return [
|
||||
'1008' => 'Invalid amount; enter a positive integer in minor units',
|
||||
'1009' => 'Main wallet operation failed; please try again later',
|
||||
'1010' => 'Do not reuse an idempotency key with different transfer parameters',
|
||||
'1011' => 'Wallet transfers are not available for credit-line players',
|
||||
'2001' => 'The current draw is already closed',
|
||||
'2002' => 'This play is closed',
|
||||
'2003' => 'Insufficient balance. Please transfer in before betting',
|
||||
|
||||
@@ -20,6 +20,9 @@ return [
|
||||
'player_wallet_balance_blocks_delete' => 'खेलाडी वालेटमा ब्यालेन्स छ, मेटाउनु अघि खाली गर्नुहोस्।',
|
||||
'player_has_tickets_blocks_delete' => 'खेलाडीसँग टिकट रेकर्ड छ, मेटाउन मिल्दैन।',
|
||||
'role_cannot_delete_super_admin' => 'सुपर एडमिन भूमिका मेटाउन मिल्दैन।',
|
||||
'platform_roles_fixed' => 'प्लेटफर्ममा केवल सुपर एडमिन र एजेन्ट भूमिका छन्; नयाँ भूमिका थप्न मिल्दैन।',
|
||||
'role_super_admin_permissions_fixed' => 'सुपर एडमिनसँग सबै अनुमति हुन्छ; क्याटलग परिवर्तनपछि lottery:admin-auth-sync चलाउनुहोस्।',
|
||||
'role_super_admin_metadata_fixed' => 'सुपर एडमिन बिल्ट-इन भूमिका हो; नाम वा स्थिति परिवर्तन गर्न मिल्दैन।',
|
||||
'role_builtin_cannot_delete' => 'बिल्ट-इन भूमिका मेटाउन मिल्दैन।',
|
||||
'role_has_users_cannot_delete' => 'यो भूमिकामा अझै एडमिन छ, मेटाउन मिल्दैन।',
|
||||
'agent_root_delete_denied' => 'रुट एजेन्ट नोड मेटाउन मिल्दैन।',
|
||||
|
||||
@@ -12,7 +12,7 @@ return [
|
||||
'site_rotate_denied' => '无权操作该站点。',
|
||||
'site_update_denied' => '无权修改该站点。',
|
||||
'site_player_access_denied' => '无权访问该站点下的玩家。',
|
||||
'integration_site_store_deprecated' => '请使用「开通代理线路」创建新站点,不再支持单独创建接入站点。',
|
||||
'integration_site_store_deprecated' => '请先在「平台配置 → 接入站点」创建站点,再在「代理配置 → 创建一级代理」绑定一级代理。',
|
||||
'player_create_site_forbidden' => '无权在该站点下创建玩家。',
|
||||
'player_create_agent_required' => '创建玩家须归属代理节点:请选择有效主站(须已配置代理根节点),或由代理账号操作。',
|
||||
'player_create_agent_forbidden' => '无权将玩家归属到该代理节点。',
|
||||
@@ -21,6 +21,9 @@ return [
|
||||
'player_wallet_balance_blocks_delete' => '该玩家钱包仍有余额,请先清空后再删除。',
|
||||
'player_has_tickets_blocks_delete' => '该玩家存在注单记录,无法删除。',
|
||||
'role_cannot_delete_super_admin' => '不能删除超级管理员角色。',
|
||||
'platform_roles_fixed' => '平台仅保留「超级管理员」与「代理」两个内置角色,不支持新增。',
|
||||
'role_super_admin_permissions_fixed' => '超级管理员拥有全部权限,请在权限目录变更后执行 lottery:admin-auth-sync。',
|
||||
'role_super_admin_metadata_fixed' => '超级管理员为内置角色,不支持修改名称或状态。',
|
||||
'role_builtin_cannot_delete' => '系统内置角色不允许删除。',
|
||||
'role_has_users_cannot_delete' => '该角色下仍有关联管理员,不能删除。',
|
||||
'agent_root_delete_denied' => '根节点不允许删除。',
|
||||
|
||||
@@ -9,4 +9,7 @@ return [
|
||||
'8003' => '玩家未建档',
|
||||
'8004' => '未配置 SSO 密钥',
|
||||
'8005' => '账号已冻结或暂时无法登录',
|
||||
'8006' => '账号或密码错误',
|
||||
'8007' => '登录失败次数过多,请稍后再试',
|
||||
'8008' => '请使用主站登录进入彩票',
|
||||
];
|
||||
|
||||
@@ -159,4 +159,14 @@ return [
|
||||
'supports_multi_number' => '是否支持多号',
|
||||
'reserved_rule_json' => '预留规则',
|
||||
'extra_config_json' => '扩展配置',
|
||||
'total_share_rate' => '占成比例',
|
||||
'credit_limit' => '授信额度',
|
||||
'rebate_limit' => '回水上限',
|
||||
'default_player_rebate' => '默认玩家回水',
|
||||
'rebate_rate' => '回水比例',
|
||||
'extra_rebate_rate' => '额外回水比例',
|
||||
'settlement_cycle' => '结算周期',
|
||||
'can_grant_extra_rebate' => '允许额外回水',
|
||||
'can_create_child_agent' => '允许创建下级代理',
|
||||
'can_create_player' => '允许创建玩家',
|
||||
];
|
||||
|
||||
@@ -15,12 +15,15 @@ return [
|
||||
'permission_exceeds_actor' => '下列权限超出您可分配的范围::detail',
|
||||
'permission_catalog_incomplete' => '权限目录不完整,缺少::detail。请联系管理员执行 migrate 与 admin-auth-sync。',
|
||||
'exceeds_parent' => '占成比例不能超过上级代理。',
|
||||
'exceeds_available' => '授信额度超出上级可下发额度。',
|
||||
'exceeds_available' => '超出代理可下发额度:请提高该代理授信,或减少其他下级/玩家已占用的额度。',
|
||||
'agent_profile_required' => '该代理尚未配置占成与授信,请先在「占成与授信」保存代理档案。',
|
||||
'exceeds_limit' => '默认玩家回水不能超过回水上限。',
|
||||
'invalid_range' => '占成比例必须在 0–100 之间。',
|
||||
'below_allocated' => '授信额度不能低于已下发给下级的额度。',
|
||||
'below_allocated' => '代理授信额度不能低于已下发给下级代理与玩家的总额。',
|
||||
'below_player_used' => '玩家授信额度不能低于该玩家已占用(含冻结)的额度。',
|
||||
'parent_cannot_delegate' => '上级未开放该能力,无法下放。',
|
||||
'cannot_create_child_agent' => '当前账号无权创建下级代理。',
|
||||
'cannot_create_player' => '当前账号无权创建玩家。',
|
||||
'primary_account_missing' => '该代理尚未绑定登录账号,无法修改登录名。',
|
||||
'site_root_exists' => '该接入站点已绑定一级代理,请选择其他站点。',
|
||||
];
|
||||
|
||||
@@ -57,4 +57,24 @@ return [
|
||||
'role_ids' => [
|
||||
'required' => '请选择角色。',
|
||||
],
|
||||
'rebate_rate' => [
|
||||
'max' => '回水比例不能超过 1(100% 记为 1)。',
|
||||
'min' => '回水比例不能小于 0。',
|
||||
],
|
||||
'extra_rebate_rate' => [
|
||||
'max' => '额外回水比例不能超过 1(100% 记为 1)。',
|
||||
'min' => '额外回水比例不能小于 0。',
|
||||
],
|
||||
'rebate_limit' => [
|
||||
'max' => '回水上限不能超过 1(100% 记为 1)。',
|
||||
'min' => '回水上限不能小于 0。',
|
||||
],
|
||||
'default_player_rebate' => [
|
||||
'max' => '默认玩家回水不能超过 1(100% 记为 1)。',
|
||||
'min' => '默认玩家回水不能小于 0。',
|
||||
],
|
||||
'total_share_rate' => [
|
||||
'max' => '占成比例不能超过 100。',
|
||||
'min' => '占成比例不能小于 0。',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -14,12 +14,13 @@ return [
|
||||
'1008' => '金额无效,请输入正整数(最小货币单位)',
|
||||
'1009' => '主站钱包处理失败,请稍后重试',
|
||||
'1010' => '请勿重复使用幂等键发起不同金额的转账',
|
||||
'1011' => '信用盘玩家不支持主站钱包划转',
|
||||
'2001' => '当前期已封盘,暂不可下注',
|
||||
'2002' => '玩法已关闭',
|
||||
'2003' => '余额不足,请先转入后再下注',
|
||||
'2004' => '号码格式不正确',
|
||||
'2005' => '玩法参数不完整或不合法',
|
||||
'2006' => '当前期号不可下注',
|
||||
'2006' => '期号无效或已切换,请刷新大厅后重试',
|
||||
'2007' => '该玩法暂不支持下注',
|
||||
'2008' => '赔率或玩法配置已变更,请重新预览后再提交',
|
||||
'2009' => '该订单已退款或不可重复提交,请关闭预览后重新下注',
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AdminCreditLedgerIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementAdjustmentIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillAdjustmentController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillBadDebtWriteOffController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPaymentIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillConfirmController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillIndexController;
|
||||
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\AgentSettlementPeriodIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportShowController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('admin.api-resource')
|
||||
->group(function (): void {
|
||||
Route::get('settlement-periods', AgentSettlementPeriodIndexController::class)
|
||||
->name('api.v1.admin.settlement-periods.index');
|
||||
Route::post('settlement-periods', AgentSettlementPeriodStoreController::class)
|
||||
->name('api.v1.admin.settlement-periods.store');
|
||||
Route::post('settlement-periods/{settlement_period}/close', AgentSettlementPeriodCloseController::class)
|
||||
->name('api.v1.admin.settlement-periods.close');
|
||||
Route::get('credit-ledger', AdminCreditLedgerIndexController::class)
|
||||
->name('api.v1.admin.credit-ledger.index');
|
||||
Route::get('settlement-bills', AgentSettlementBillIndexController::class)
|
||||
->name('api.v1.admin.settlement-bills.index');
|
||||
Route::get('settlement-payments', AgentSettlementPaymentIndexController::class)
|
||||
->name('api.v1.admin.settlement-payments.index');
|
||||
Route::get('settlement-adjustments', AgentSettlementAdjustmentIndexController::class)
|
||||
->name('api.v1.admin.settlement-adjustments.index');
|
||||
Route::get('settlement-bills/{settlement_bill}', AgentSettlementBillShowController::class)
|
||||
->name('api.v1.admin.settlement-bills.show');
|
||||
Route::post('settlement-bills/{settlement_bill}/confirm', AgentSettlementBillConfirmController::class)
|
||||
->name('api.v1.admin.settlement-bills.confirm');
|
||||
Route::post('settlement-bills/{settlement_bill}/payments', AgentSettlementBillPaymentController::class)
|
||||
->name('api.v1.admin.settlement-bills.payments');
|
||||
Route::post('settlement-bills/{settlement_bill}/adjustments', AgentSettlementBillAdjustmentController::class)
|
||||
->name('api.v1.admin.settlement-bills.adjustments');
|
||||
Route::post('settlement-bills/{settlement_bill}/bad-debt-write-off', AgentSettlementBillBadDebtWriteOffController::class)
|
||||
->name('api.v1.admin.settlement-bills.bad-debt-write-off');
|
||||
Route::get('settlement-reports/summary', AgentSettlementReportIndexController::class)
|
||||
->name('api.v1.admin.settlement-reports.summary');
|
||||
Route::get('settlement-reports', AgentSettlementReportShowController::class)
|
||||
->name('api.v1.admin.settlement-reports.show');
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteUpdateCont
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteRotateSecretsController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteExportController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteSecretsController;
|
||||
|
||||
Route::middleware('admin.api-resource')
|
||||
->group(function (): void {
|
||||
@@ -25,4 +26,6 @@ Route::middleware('admin.api-resource')
|
||||
->name('api.v1.admin.integration-sites.connectivity-test');
|
||||
Route::get('integration-sites/{admin_site}/export', AdminIntegrationSiteExportController::class)
|
||||
->name('api.v1.admin.integration-sites.export');
|
||||
Route::get('integration-sites/{admin_site}/secrets', AdminIntegrationSiteSecretsController::class)
|
||||
->name('api.v1.admin.integration-sites.secrets');
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Controllers\Api\V1\Currency\CurrencyIndexController;
|
||||
use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController;
|
||||
use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController;
|
||||
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
|
||||
use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController;
|
||||
use App\Http\Controllers\Api\V1\Setting\SettingIndexController;
|
||||
use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController;
|
||||
|
||||
@@ -40,6 +41,7 @@ Route::prefix('player')
|
||||
->name('api.v1.player.')
|
||||
->group(function (): void {
|
||||
Route::get('ping', PlayerPingController::class)->name('ping');
|
||||
Route::post('auth/login', PlayerAuthLoginController::class)->name('auth.login');
|
||||
});
|
||||
|
||||
// 系统公共配置(如前端规则等)
|
||||
|
||||
67
tests/Feature/AdminAgentDashboardOverviewTest.php
Normal file
67
tests/Feature/AdminAgentDashboardOverviewTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Support\AgentPlatformRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('agent dashboard returns agent overview for operator with dashboard permission', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'super_dash_agent',
|
||||
'name' => 'Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$branch = $service->createChild($super, [
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'dash-branch',
|
||||
'name' => 'Dash Branch',
|
||||
'can_create_player' => true,
|
||||
]);
|
||||
|
||||
$operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first();
|
||||
if ($operator === null) {
|
||||
$operator = AdminUser::query()
|
||||
->whereIn('id', DB::table('admin_user_agents')->where('agent_node_id', $branch->id)->pluck('admin_user_id'))
|
||||
->first();
|
||||
}
|
||||
expect($operator)->not->toBeNull();
|
||||
|
||||
$platformRoleId = AgentPlatformRole::id();
|
||||
$boundRoleId = (int) DB::table('admin_user_agent_roles')
|
||||
->where('admin_user_id', $operator->id)
|
||||
->where('agent_node_id', $branch->id)
|
||||
->value('role_id');
|
||||
|
||||
expect($boundRoleId)->toBe($platformRoleId);
|
||||
|
||||
$slugs = DB::table('admin_role_menu_actions as rma')
|
||||
->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id')
|
||||
->where('rma.role_id', $platformRoleId)
|
||||
->pluck('ma.permission_code')
|
||||
->all();
|
||||
|
||||
expect(in_array('dashboard.view', $slugs, true))->toBeTrue();
|
||||
|
||||
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/dashboard')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.agent_overview.agent_node_id', $branch->id)
|
||||
->assertJsonPath('data.agent_overview.agent_code', 'dash-branch');
|
||||
});
|
||||
@@ -11,7 +11,7 @@ beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('super admin can provision agent line with aligned root code', function (): void {
|
||||
test('super admin can provision root agent on existing integration site', function (): void {
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'line_super',
|
||||
'name' => 'Line Super',
|
||||
@@ -23,21 +23,29 @@ test('super admin can provision agent line with aligned root code', function ():
|
||||
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'line-alpha',
|
||||
'name' => 'Line Alpha Site',
|
||||
'status' => 1,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.code', 'line-alpha');
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/agent-lines', [
|
||||
'site_code' => 'line-alpha',
|
||||
'code' => 'line-alpha',
|
||||
'name' => 'Line Alpha',
|
||||
'username' => 'line_alpha_owner',
|
||||
'password' => 'secret-strong',
|
||||
'currency_code' => 'NPR',
|
||||
'status' => 1,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.code', 'line-alpha')
|
||||
->assertJsonPath('data.agent_node.code', 'line-alpha')
|
||||
->assertJsonPath('data.line_root.site_code', 'line-alpha')
|
||||
->assertJsonPath('data.secrets.sso_jwt_secret', fn ($v) => is_string($v) && $v !== '')
|
||||
->assertJsonPath('data.secrets.wallet_api_key', fn ($v) => is_string($v) && $v !== '');
|
||||
->assertJsonMissingPath('data.secrets');
|
||||
|
||||
$siteId = (int) DB::table('admin_sites')->where('code', 'line-alpha')->value('id');
|
||||
expect($siteId)->toBeGreaterThan(0);
|
||||
@@ -55,7 +63,48 @@ test('super admin can provision agent line with aligned root code', function ():
|
||||
)->toBe(1);
|
||||
});
|
||||
|
||||
test('non super admin cannot create integration site directly', function (): void {
|
||||
test('agent line provision rejects site that already has root', function (): void {
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'line_super2',
|
||||
'name' => 'Line Super 2',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'line-beta',
|
||||
'name' => 'Line Beta Site',
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/agent-lines', [
|
||||
'site_code' => 'line-beta',
|
||||
'code' => 'line-beta',
|
||||
'name' => 'Line Beta',
|
||||
'username' => 'line_beta_owner',
|
||||
'password' => 'secret-strong',
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/agent-lines', [
|
||||
'site_code' => 'line-beta',
|
||||
'code' => 'line-beta-2',
|
||||
'name' => 'Line Beta 2',
|
||||
'username' => 'line_beta_owner2',
|
||||
'password' => 'secret-strong',
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('data.errors.site_code.0', 'site_root_exists');
|
||||
});
|
||||
|
||||
test('integration manager with site.manage can create integration site', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'line_ops',
|
||||
@@ -98,8 +147,9 @@ test('non super admin cannot create integration site directly', function (): voi
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'blocked-site',
|
||||
'name' => 'Blocked',
|
||||
'code' => 'ops-site',
|
||||
'name' => 'Ops Site',
|
||||
])
|
||||
->assertForbidden();
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.code', 'ops-site');
|
||||
});
|
||||
|
||||
@@ -161,6 +161,43 @@ test('agent profile update normalizes empty settlement cycle', function (): void
|
||||
->assertJsonPath('data.settlement_cycle', 'weekly');
|
||||
});
|
||||
|
||||
test('bound agent cannot update own profile share and credit', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
$service = app(\App\Services\Agent\AgentNodeService::class);
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'self_profile_super',
|
||||
'name' => 'Self Profile Super',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$agentNode = $service->createChild($super, agentChildPayload([
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'self-profile-agent',
|
||||
'name' => 'Self Profile Agent',
|
||||
'username' => 'self_profile_agent',
|
||||
'total_share_rate' => 20,
|
||||
'credit_limit' => 4000,
|
||||
]));
|
||||
|
||||
$agentUser = AdminUser::query()->where('username', 'self_profile_agent')->firstOrFail();
|
||||
bindAdminUserToAgent($agentUser, $agentNode->id);
|
||||
$agentUser->syncPrimaryPlatformAgentRole($agentNode->id);
|
||||
|
||||
$token = $agentUser->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/agent-nodes/'.$agentNode->id.'/profile', [
|
||||
'total_share_rate' => 99,
|
||||
'credit_limit' => 999_999,
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('agent profile update rejects default rebate above limit', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
129
tests/Feature/AdminAgentProfileCapabilityPermissionTest.php
Normal file
129
tests/Feature/AdminAgentProfileCapabilityPermissionTest.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Support\AdminAuthProfile;
|
||||
use App\Support\AgentPlatformRole;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:agent-roles-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('agent profile switches strip create player and child manage from effective permissions', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
$child = AgentNode::query()->create([
|
||||
'admin_site_id' => $siteId,
|
||||
'parent_id' => $rootId,
|
||||
'path' => '/',
|
||||
'depth' => 1,
|
||||
'code' => 'cap-child',
|
||||
'name' => 'Cap Child',
|
||||
'status' => 1,
|
||||
]);
|
||||
$child->path = "/{$rootId}/{$child->id}/";
|
||||
$child->save();
|
||||
|
||||
AgentProfile::query()->create([
|
||||
'agent_node_id' => $child->id,
|
||||
'total_share_rate' => 10,
|
||||
'credit_limit' => 0,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 0,
|
||||
'default_player_rebate' => 0,
|
||||
'settlement_cycle' => 'weekly',
|
||||
'can_grant_extra_rebate' => false,
|
||||
'can_create_child_agent' => false,
|
||||
'can_create_player' => false,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'cap_child_agent',
|
||||
'name' => 'Cap Child Agent',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => $child->id,
|
||||
'is_primary' => true,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
$admin->syncPrimaryPlatformAgentRole($child->id);
|
||||
|
||||
$fresh = $admin->fresh();
|
||||
$profile = AdminAuthProfile::fromAdmin($fresh);
|
||||
$perms = $profile['permissions'];
|
||||
|
||||
expect($perms)->toContain('prd.agent.view')
|
||||
->not->toContain('prd.agent.manage')
|
||||
->not->toContain('prd.users.manage');
|
||||
|
||||
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeFalse();
|
||||
expect($fresh->hasPermissionCode('service.players.manage'))->toBeFalse();
|
||||
expect($profile['agent']['can_create_child_agent'])->toBeFalse();
|
||||
expect($profile['agent']['can_create_player'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('agent profile switches on grant create capabilities even when platform agent role omits manage', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
$child = AgentNode::query()->create([
|
||||
'admin_site_id' => $siteId,
|
||||
'parent_id' => $rootId,
|
||||
'path' => '/',
|
||||
'depth' => 1,
|
||||
'code' => 'cap-child-on',
|
||||
'name' => 'Cap Child On',
|
||||
'status' => 1,
|
||||
]);
|
||||
$child->path = "/{$rootId}/{$child->id}/";
|
||||
$child->save();
|
||||
|
||||
AgentProfile::query()->create([
|
||||
'agent_node_id' => $child->id,
|
||||
'total_share_rate' => 10,
|
||||
'credit_limit' => 0,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 0,
|
||||
'default_player_rebate' => 0,
|
||||
'settlement_cycle' => 'weekly',
|
||||
'can_grant_extra_rebate' => false,
|
||||
'can_create_child_agent' => true,
|
||||
'can_create_player' => true,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'cap_child_on_agent',
|
||||
'name' => 'Cap Child On Agent',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_agents')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'agent_node_id' => $child->id,
|
||||
'is_primary' => true,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
$admin->syncPrimaryPlatformAgentRole($child->id);
|
||||
|
||||
$fresh = $admin->fresh();
|
||||
|
||||
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue();
|
||||
expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue();
|
||||
expect($fresh->adminPermissionSlugs())->toContain('prd.agent.manage')
|
||||
->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage');
|
||||
});
|
||||
119
tests/Feature/AdminCreditLedgerIndexTest.php
Normal file
119
tests/Feature/AdminCreditLedgerIndexTest.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('admin credit ledger index returns credit player ledger rows', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$siteId = (int) $site->id;
|
||||
$siteCode = (string) $site->code;
|
||||
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => now()->subDays(3),
|
||||
'period_end' => now()->addDay(),
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'site_player_id' => 'native:credit-ledger-admin',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_admin_flow',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -500,
|
||||
'reason' => 'bet_hold',
|
||||
'ref_type' => 'bet',
|
||||
'ref_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'credit_ledger_super',
|
||||
'name' => 'Credit Ledger',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.ledger_source', 'settlement_ledger')
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.entry_kind', 'credit')
|
||||
->assertJsonPath('data.items.0.player_id', $player->id)
|
||||
->assertJsonPath('data.items.0.biz_type', 'bet_hold')
|
||||
->assertJsonPath('data.items.0.ledger_source', 'credit_ledger')
|
||||
->assertJsonPath('data.items.0.funding_mode', PlayerFundingMode::CREDIT)
|
||||
->assertJsonPath('data.items.0.available_actions', ['view_player']);
|
||||
});
|
||||
|
||||
test('settlement periods include pipeline credit and share counts', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$siteId = (int) $site->id;
|
||||
$siteCode = (string) $site->code;
|
||||
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => now()->subDay(),
|
||||
'period_end' => now()->addDay(),
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'site_player_id' => 'native:pipeline-1',
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'pipe_user',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -100,
|
||||
'reason' => 'bet_hold',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'pipeline_super',
|
||||
'name' => 'Pipeline',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/settlement-periods?admin_site_id='.$siteId)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.id', $periodId)
|
||||
->assertJsonPath('data.items.0.pipeline.credit_ledger_count', 1)
|
||||
->assertJsonPath('data.items.0.pipeline.share_ledger_count', 0);
|
||||
});
|
||||
175
tests/Feature/AdminDrawViewOnlyAuthorizationTest.php
Normal file
175
tests/Feature/AdminDrawViewOnlyAuthorizationTest.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Support\AdminAuthProfile;
|
||||
use App\Support\AdminPermissionBridge;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
function drawViewOnlyToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_view_only_admin',
|
||||
'name' => 'Draw View',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'draw_view_only_role',
|
||||
'name' => 'Draw view only role',
|
||||
]);
|
||||
$role->syncLegacyPermissionSlugs(['prd.draw_result.view']);
|
||||
|
||||
$siteId = AdminUser::defaultAdminSiteId();
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
|
||||
]);
|
||||
|
||||
return $admin->fresh()->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
function drawViewOnlyFixtureDraw(): Draw
|
||||
{
|
||||
return Draw::query()->create([
|
||||
'draw_no' => '20260604-099',
|
||||
'business_date' => '2026-06-04',
|
||||
'sequence_no' => 99,
|
||||
'status' => DrawStatus::Settled->value,
|
||||
'start_time' => now()->subHours(3),
|
||||
'close_time' => now()->subHours(2),
|
||||
'draw_time' => now()->subHours(1),
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
test('partial draw review codes do not infer manage slug', function (): void {
|
||||
$granted = AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes(['draw.review.publish']);
|
||||
|
||||
expect($granted)->not->toContain('prd.draw_result.manage');
|
||||
});
|
||||
|
||||
test('draw view only admin profile excludes manage and cannot store draw', function (): void {
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_view_only_profile',
|
||||
'name' => 'Draw View',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'draw_view_only_role_profile',
|
||||
'name' => 'Draw view only role',
|
||||
]);
|
||||
$role->syncLegacyPermissionSlugs(['prd.draw_result.view']);
|
||||
|
||||
$siteId = AdminUser::defaultAdminSiteId();
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
|
||||
]);
|
||||
|
||||
$profile = AdminAuthProfile::fromAdmin($admin->fresh());
|
||||
|
||||
expect($profile['permissions'])->toContain('prd.draw_result.view')
|
||||
->not->toContain('prd.draw_result.manage');
|
||||
|
||||
$token = $admin->fresh()->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/draws', [
|
||||
'admin_site_id' => $siteId,
|
||||
'draw_no' => 'test-view-only-001',
|
||||
'start_time' => now()->toIso8601String(),
|
||||
'close_time' => now()->addHour()->toIso8601String(),
|
||||
'draw_time' => now()->addHours(2)->toIso8601String(),
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('draw view only list and show omit finance and operational fields', function (): void {
|
||||
$token = drawViewOnlyToken();
|
||||
|
||||
$draw = drawViewOnlyFixtureDraw();
|
||||
|
||||
$listPayload = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/draws')
|
||||
->assertOk()
|
||||
->json('data');
|
||||
|
||||
$listRow = collect($listPayload['items'])->firstWhere('id', $draw->id);
|
||||
|
||||
expect($listRow)->not->toBeNull()
|
||||
->and($listRow)->not->toHaveKey('total_bet_minor')
|
||||
->and($listRow)->not->toHaveKey('result_source')
|
||||
->and($listPayload['capabilities']['can_manage_draw_results'])->toBeFalse()
|
||||
->and($listPayload['capabilities']['can_view_draw_finance'])->toBeFalse();
|
||||
|
||||
$show = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/draws/'.$draw->id)
|
||||
->assertOk()
|
||||
->json('data');
|
||||
|
||||
expect($show)->not->toHaveKeys([
|
||||
'result_source',
|
||||
'current_result_version',
|
||||
'settle_version',
|
||||
'is_reopened',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
->and($show['result_batch_counts'])->not->toHaveKey('pending_review')
|
||||
->and($show['result_batch_counts'])->not->toHaveKey('total')
|
||||
->and($show['capabilities']['can_manage_draw_results'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('draw view only cannot read finance summary and result batches hide ops metadata', function (): void {
|
||||
$token = drawViewOnlyToken();
|
||||
$draw = drawViewOnlyFixtureDraw();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/draws/'.$draw->id.'/finance-summary')
|
||||
->assertForbidden();
|
||||
|
||||
$payload = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/draws/'.$draw->id.'/result-batches')
|
||||
->assertOk()
|
||||
->json('data');
|
||||
|
||||
expect($payload['capabilities']['can_manage_draw_results'])->toBeFalse();
|
||||
|
||||
foreach ($payload['batches'] as $batch) {
|
||||
expect($batch)->not->toHaveKeys([
|
||||
'source_type',
|
||||
'rng_seed_hash',
|
||||
'created_by',
|
||||
'confirmed_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
}
|
||||
|
||||
$hasPending = DB::table('draw_result_batches')
|
||||
->where('draw_id', $draw->id)
|
||||
->where('status', 'pending_review')
|
||||
->exists();
|
||||
|
||||
if ($hasPending) {
|
||||
$statuses = collect($payload['batches'])->pluck('status')->unique()->all();
|
||||
expect($statuses)->not->toContain('pending_review');
|
||||
}
|
||||
});
|
||||
@@ -59,6 +59,34 @@ test('super admin can create integration site and receive secrets once', functio
|
||||
expect(AuditLog::query()->where('module_code', 'integration')->where('action_code', 'create')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('super admin can reveal integration site secrets for copy', function (): void {
|
||||
$token = integrationAdminToken();
|
||||
|
||||
$create = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/integration-sites', [
|
||||
'code' => 'partner-secrets',
|
||||
'name' => 'Partner Secrets',
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$id = (int) $create->json('data.id');
|
||||
$plainSso = (string) $create->json('data.secrets.sso_jwt_secret');
|
||||
$plainWallet = (string) $create->json('data.secrets.wallet_api_key');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/integration-sites/'.$id.'/secrets')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.sso_jwt_secret', $plainSso)
|
||||
->assertJsonPath('data.wallet_api_key', $plainWallet);
|
||||
|
||||
expect(
|
||||
AuditLog::query()
|
||||
->where('module_code', 'integration')
|
||||
->where('action_code', 'reveal_secrets')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
test('integration site code cannot be changed on update', function (): void {
|
||||
$token = integrationAdminToken();
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AgentProfile;
|
||||
use App\Models\Player;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -263,3 +266,58 @@ test('admin can update player default currency and validation rejects unknown co
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('admin can set player credit limit without clobbering used credit', function (): void {
|
||||
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
AgentProfile::query()->updateOrCreate(
|
||||
['agent_node_id' => $rootId],
|
||||
[
|
||||
'total_share_rate' => 60,
|
||||
'credit_limit' => 50_000,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 0.01,
|
||||
'default_player_rebate' => 0.005,
|
||||
'settlement_cycle' => 'weekly',
|
||||
],
|
||||
);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'credit-limit-1',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_limit_user',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 120,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$token = playerManageAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/players/'.$player->id, [
|
||||
'credit_limit' => 2000,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.credit_limit', 2000)
|
||||
->assertJsonPath('data.available_credit', 1880);
|
||||
|
||||
$this->assertDatabaseHas('player_credit_accounts', [
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 2000,
|
||||
'used_credit' => 120,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -120,6 +120,7 @@ test('admin can sync user roles for default site', function (): void {
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
|
||||
'admin_site_id' => AdminUser::defaultAdminSiteId(),
|
||||
'role_slugs' => ['role_sync_b', 'role_sync_a'],
|
||||
])
|
||||
->assertOk()
|
||||
@@ -247,7 +248,6 @@ test('permission catalog groups permissions by admin navigation order', function
|
||||
'rules_odds',
|
||||
'jackpot',
|
||||
'risk_cap',
|
||||
'integration',
|
||||
'currencies',
|
||||
'admin_users',
|
||||
'admin_roles',
|
||||
@@ -257,12 +257,12 @@ test('permission catalog groups permissions by admin navigation order', function
|
||||
]);
|
||||
expect($groups[1]['key'])->toBe('agents');
|
||||
expect($groups[2]['key'])->toBe('draws');
|
||||
expect($groups[15]['label'])->toBe('管理列表');
|
||||
expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
|
||||
expect($groups[16]['label'])->toBe('角色管理');
|
||||
expect(array_column($groups[16]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
|
||||
|
||||
$groupsByKey = collect($groups)->keyBy('key');
|
||||
expect($groupsByKey['admin_users']['label'])->toBe('管理列表');
|
||||
expect(array_column($groupsByKey['admin_users']['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
|
||||
expect($groupsByKey['admin_roles']['label'])->toBe('角色管理');
|
||||
expect(array_column($groupsByKey['admin_roles']['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
|
||||
expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([
|
||||
'prd.tickets.view',
|
||||
]);
|
||||
@@ -280,7 +280,7 @@ test('permission catalog groups permissions by admin navigation order', function
|
||||
]);
|
||||
});
|
||||
|
||||
test('admin can repair role permissions from the full catalog after role creation', function (): void {
|
||||
test('admin can adjust platform agent role permissions from the catalog', function (): void {
|
||||
$token = makeAdminWithPermissions('role_permission_repairer', ['prd.admin_user.manage', 'prd.admin_role.manage']);
|
||||
|
||||
$catalog = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
@@ -300,39 +300,37 @@ test('admin can repair role permissions from the full catalog after role creatio
|
||||
->toContain('prd.report.view')
|
||||
->toContain('prd.wallet_reconcile.manage');
|
||||
|
||||
$role = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/admin-roles', [
|
||||
'slug' => 'repairable_role',
|
||||
'name' => 'Repairable Role',
|
||||
'permission_slugs' => [],
|
||||
])
|
||||
$agentRole = collect($this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/admin-roles')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.permission_slugs', [])
|
||||
->json('data');
|
||||
->json('data.items'))
|
||||
->firstWhere('slug', 'agent');
|
||||
|
||||
expect($agentRole)->not->toBeNull();
|
||||
|
||||
$repairResponse = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [
|
||||
->putJson('/api/v1/admin/admin-roles/'.$agentRole['id'].'/permissions', [
|
||||
'permission_slugs' => ['prd.report.view', 'prd.wallet_reconcile.manage'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.slug', 'repairable_role');
|
||||
->assertJsonPath('data.slug', 'agent');
|
||||
|
||||
expect($repairResponse->json('data.permission_slugs'))
|
||||
->toContain('prd.report.view', 'prd.wallet_reconcile.manage');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [
|
||||
->putJson('/api/v1/admin/admin-roles/'.$agentRole['id'].'/permissions', [
|
||||
'permission_slugs' => ['prd.admin_role.manage'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.permission_slugs', ['prd.admin_role.manage']);
|
||||
|
||||
$persistedPermissions = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
$persistedRole = collect($this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/admin-roles')
|
||||
->assertOk()
|
||||
->json('data.items');
|
||||
->json('data.items'))
|
||||
->firstWhere('slug', 'agent');
|
||||
|
||||
$persistedRole = collect($persistedPermissions)->firstWhere('slug', 'repairable_role');
|
||||
expect($persistedRole['permission_slugs'])->toBe(['prd.admin_role.manage']);
|
||||
});
|
||||
|
||||
@@ -348,6 +346,7 @@ test('admin can create update and delete users with crud rules', function (): vo
|
||||
'email' => 'newuser@example.com',
|
||||
'password' => 'secret-long',
|
||||
'status' => 0,
|
||||
'admin_site_id' => AdminUser::defaultAdminSiteId(),
|
||||
'role_slugs' => ['crud_new_user_role'],
|
||||
])
|
||||
->assertOk()
|
||||
@@ -364,6 +363,7 @@ test('admin can create update and delete users with crud rules', function (): vo
|
||||
'nickname' => 'dup',
|
||||
'email' => null,
|
||||
'password' => 'secret-long',
|
||||
'admin_site_id' => AdminUser::defaultAdminSiteId(),
|
||||
'role_slugs' => [$crudRole->slug],
|
||||
])
|
||||
->assertStatus(422)
|
||||
@@ -407,6 +407,7 @@ test('admin user create requires at least one role slug', function (): void {
|
||||
'nickname' => 'NR',
|
||||
'email' => null,
|
||||
'password' => 'secret-long',
|
||||
'admin_site_id' => AdminUser::defaultAdminSiteId(),
|
||||
'role_slugs' => [],
|
||||
])
|
||||
->assertStatus(422)
|
||||
|
||||
243
tests/Feature/AdminUserSiteRoleBindingTest.php
Normal file
243
tests/Feature/AdminUserSiteRoleBindingTest.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminSite;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use App\Support\AdminSiteScope;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function siteRoleBindingAdmin(string $username, array $permissionSlugs, ?int $boundSiteId = null): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => $username,
|
||||
'name' => 'Tester',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'role_'.$username,
|
||||
'name' => 'Role '.$username,
|
||||
]);
|
||||
$role->syncLegacyPermissionSlugs($permissionSlugs);
|
||||
|
||||
$siteId = $boundSiteId ?? AdminUser::defaultAdminSiteId();
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
'admin_user_id' => $admin->id,
|
||||
'site_id' => $siteId,
|
||||
'role_id' => $role->id,
|
||||
'granted_at' => now(),
|
||||
]);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('platform user created with admin_site_id only sees that site players', function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
|
||||
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
|
||||
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
|
||||
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
|
||||
|
||||
Player::query()->create([
|
||||
'site_code' => 'site-a',
|
||||
'site_player_id' => 'pa-bind-1',
|
||||
'username' => 'pa_bind_1',
|
||||
'nickname' => 'PA',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
Player::query()->create([
|
||||
'site_code' => 'site-b',
|
||||
'site_player_id' => 'pb-bind-1',
|
||||
'username' => 'pb_bind_1',
|
||||
'nickname' => 'PB',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$opsRole = AdminRole::query()->create([
|
||||
'slug' => 'site_b_ops',
|
||||
'name' => 'Site B Ops',
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
]);
|
||||
$opsRole->syncLegacyPermissionSlugs(['prd.users.view_finance', 'prd.admin_user.manage']);
|
||||
|
||||
$creator = AdminUser::query()->create([
|
||||
'username' => 'super_creator',
|
||||
'name' => 'Creator',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($creator);
|
||||
$token = $creator->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/admin-users', [
|
||||
'username' => 'site_b_ops_user',
|
||||
'nickname' => 'Site B Ops User',
|
||||
'email' => null,
|
||||
'password' => 'secret-long',
|
||||
'admin_site_id' => $siteBId,
|
||||
'role_slugs' => ['site_b_ops'],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.site_bindings.0.site_id', $siteBId)
|
||||
->assertJsonPath('data.site_bindings.0.site_code', 'site-b');
|
||||
|
||||
$created = AdminUser::query()->where('username', 'site_b_ops_user')->firstOrFail();
|
||||
expect($created->isSuperAdmin())->toBeFalse();
|
||||
expect($created->accessibleAdminSiteIds())->toEqual([$siteBId]);
|
||||
expect(AdminSiteScope::accessibleSiteCodes($created))->toBe(['site-b']);
|
||||
|
||||
$scopedQuery = Player::query();
|
||||
AdminSiteScope::applyToPlayerQuery($scopedQuery, $created);
|
||||
expect($scopedQuery->pluck('site_code')->unique()->values()->all())->toBe(['site-b']);
|
||||
|
||||
$createdToken = $created->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
expect($creator->id)->not->toBe($created->id);
|
||||
|
||||
$boundRoleSlugs = DB::table('admin_user_site_roles as usr')
|
||||
->join('admin_roles as r', 'r.id', '=', 'usr.role_id')
|
||||
->where('usr.admin_user_id', $created->id)
|
||||
->orderBy('r.slug')
|
||||
->pluck('r.slug')
|
||||
->all();
|
||||
expect($boundRoleSlugs)->toBe(['site_b_ops']);
|
||||
expect($created->fresh()->isSuperAdmin())->toBeFalse();
|
||||
|
||||
app('auth')->forgetGuards();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$createdToken)
|
||||
->getJson('/api/v1/admin/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.admin.id', $created->id)
|
||||
->assertJsonPath('data.admin.username', 'site_b_ops_user')
|
||||
->assertJsonCount(1, 'data.admin.accessible_sites');
|
||||
|
||||
$codes = collect(
|
||||
$this->withHeader('Authorization', 'Bearer '.$createdToken)
|
||||
->getJson('/api/v1/admin/players')
|
||||
->assertOk()
|
||||
->json('data.items'),
|
||||
)->pluck('site_code')->unique()->values()->all();
|
||||
|
||||
expect($codes)->toBe(['site-b']);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$createdToken)
|
||||
->getJson('/api/v1/admin/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.admin.accessible_sites.0.code', 'site-b')
|
||||
->assertJsonPath('data.admin.agent', null);
|
||||
});
|
||||
|
||||
test('scoped operator cannot assign roles on site outside their binding', function (): void {
|
||||
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
|
||||
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
|
||||
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
|
||||
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
|
||||
|
||||
$target = AdminUser::query()->create([
|
||||
'username' => 'bind_target',
|
||||
'name' => 'Target',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
AdminRole::query()->create([
|
||||
'slug' => 'scoped_assign_role',
|
||||
'name' => 'Scoped Assign',
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
]);
|
||||
|
||||
$token = siteRoleBindingAdmin('site_a_only_mgr', ['prd.admin_user.manage'], $siteAId);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
|
||||
'admin_site_id' => $siteBId,
|
||||
'role_slugs' => ['scoped_assign_role'],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
test('role sync replaces roles only for requested site', function (): void {
|
||||
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
|
||||
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
|
||||
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
|
||||
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
|
||||
|
||||
$rA = AdminRole::query()->create([
|
||||
'slug' => 'multi_a',
|
||||
'name' => 'A',
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
]);
|
||||
$rB = AdminRole::query()->create([
|
||||
'slug' => 'multi_b',
|
||||
'name' => 'B',
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
]);
|
||||
|
||||
$target = AdminUser::query()->create([
|
||||
'username' => 'multi_site_user',
|
||||
'name' => 'Multi',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('admin_user_site_roles')->insert([
|
||||
['admin_user_id' => $target->id, 'site_id' => $siteAId, 'role_id' => $rA->id, 'granted_at' => now()],
|
||||
['admin_user_id' => $target->id, 'site_id' => $siteBId, 'role_id' => $rB->id, 'granted_at' => now()],
|
||||
]);
|
||||
|
||||
$actor = AdminUser::query()->create([
|
||||
'username' => 'multi_sync_actor',
|
||||
'name' => 'Actor',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($actor);
|
||||
$token = $actor->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$rC = AdminRole::query()->create([
|
||||
'slug' => 'multi_c',
|
||||
'name' => 'C',
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
|
||||
'admin_site_id' => $siteAId,
|
||||
'role_slugs' => ['multi_c'],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$siteARoles = DB::table('admin_user_site_roles')
|
||||
->where('admin_user_id', $target->id)
|
||||
->where('site_id', $siteAId)
|
||||
->pluck('role_id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->all();
|
||||
$siteBRoles = DB::table('admin_user_site_roles')
|
||||
->where('admin_user_id', $target->id)
|
||||
->where('site_id', $siteBId)
|
||||
->pluck('role_id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
expect($siteARoles)->toBe([(int) $rC->id]);
|
||||
expect($siteBRoles)->toBe([(int) $rB->id]);
|
||||
});
|
||||
171
tests/Feature/AgentCreditAllocationTest.php
Normal file
171
tests/Feature/AgentCreditAllocationTest.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Services\Agent\AgentProfileService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createAgentLineForAllocation(string $code, int $creditLimit): AgentNode
|
||||
{
|
||||
$siteId = (int) DB::table('admin_sites')->insertGetId([
|
||||
'code' => $code,
|
||||
'name' => $code,
|
||||
'is_default' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$rootId = (int) DB::table('agent_nodes')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'parent_id' => null,
|
||||
'depth' => 0,
|
||||
'path' => '/'.$code,
|
||||
'code' => $code,
|
||||
'name' => 'Root',
|
||||
'status' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
AgentProfile::query()->create([
|
||||
'agent_node_id' => $rootId,
|
||||
'total_share_rate' => 60,
|
||||
'credit_limit' => $creditLimit,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 0.01,
|
||||
'default_player_rebate' => 0.005,
|
||||
'settlement_cycle' => 'weekly',
|
||||
]);
|
||||
|
||||
return AgentNode::query()->findOrFail($rootId);
|
||||
}
|
||||
|
||||
test('player credit account syncs agent allocated credit', function (): void {
|
||||
$root = createAgentLineForAllocation('line-alloc', 10000);
|
||||
$service = app(AgentProfileService::class);
|
||||
|
||||
$playerId = (int) DB::table('players')->insertGetId([
|
||||
'site_code' => 'line-alloc',
|
||||
'agent_node_id' => $root->id,
|
||||
'site_player_id' => 'p-alloc-1',
|
||||
'username' => 'alloc1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $playerId,
|
||||
'credit_limit' => 2000,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$service->refreshAllocatedCredit($root);
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $root->id)->first();
|
||||
expect((int) $profile->allocated_credit)->toBe(2000);
|
||||
expect($service->present($profile)['available_credit'])->toBe(8000);
|
||||
});
|
||||
|
||||
test('player credit allocation exceeds available throws', function (): void {
|
||||
$root = createAgentLineForAllocation('line-over', 5000);
|
||||
$service = app(AgentProfileService::class);
|
||||
|
||||
expect(fn () => $service->assertMayIncreasePlayerCredit($root, 6000))
|
||||
->toThrow(\Illuminate\Validation\ValidationException::class);
|
||||
});
|
||||
|
||||
test('win loss does not change agent allocated credit', function (): void {
|
||||
$root = createAgentLineForAllocation('line-hold', 10000);
|
||||
|
||||
$playerId = (int) DB::table('players')->insertGetId([
|
||||
'site_code' => 'line-hold',
|
||||
'agent_node_id' => $root->id,
|
||||
'site_player_id' => 'p-hold-1',
|
||||
'username' => 'hold1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $playerId,
|
||||
'credit_limit' => 2000,
|
||||
'used_credit' => 200,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
app(AgentProfileService::class)->refreshAllocatedCredit($root);
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $root->id)->first();
|
||||
expect((int) $profile->allocated_credit)->toBe(2000);
|
||||
});
|
||||
|
||||
test('raising player credit limit succeeds when agent allocated credit includes other subordinates', function (): void {
|
||||
$root = createAgentLineForAllocation('line-player-raise', 10000);
|
||||
$service = app(AgentProfileService::class);
|
||||
|
||||
$playerId = (int) DB::table('players')->insertGetId([
|
||||
'site_code' => 'line-player-raise',
|
||||
'agent_node_id' => $root->id,
|
||||
'site_player_id' => 'p-other',
|
||||
'username' => 'other',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $playerId,
|
||||
'credit_limit' => 3000,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$service->refreshAllocatedCredit($root);
|
||||
|
||||
$newPlayerId = (int) DB::table('players')->insertGetId([
|
||||
'site_code' => 'line-player-raise',
|
||||
'agent_node_id' => $root->id,
|
||||
'site_player_id' => 'p-raise-1',
|
||||
'username' => 'raise1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $newPlayerId,
|
||||
'credit_limit' => 0,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$service->adjustPlayerCreditAllocation($root, 0, 2000);
|
||||
DB::table('player_credit_accounts')->where('player_id', $newPlayerId)->update(['credit_limit' => 2000]);
|
||||
$service->refreshAllocatedCredit($root);
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $root->id)->first();
|
||||
expect((int) $profile->allocated_credit)->toBe(5000);
|
||||
});
|
||||
72
tests/Feature/AgentOverdueCreatePlayerTest.php
Normal file
72
tests/Feature/AgentOverdueCreatePlayerTest.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\AgentNode;
|
||||
use App\Services\Agent\AgentNodeService;
|
||||
use App\Services\Agent\AgentProfileService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('agent with overdue bill cannot create player', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'od_super',
|
||||
'name' => 'OD',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$agent = app(AgentNodeService::class)->createChild($super, agentChildPayload([
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'OD1',
|
||||
'name' => 'OD1',
|
||||
'username' => 'od_agent_user',
|
||||
'total_share_rate' => 50,
|
||||
'credit_limit' => 10000,
|
||||
'can_create_player' => true,
|
||||
]));
|
||||
|
||||
$admin = AdminUser::query()->where('username', 'od_agent_user')->first();
|
||||
expect($admin)->not->toBeNull();
|
||||
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => now()->subWeek(),
|
||||
'period_end' => now()->subDay(),
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('settlement_bills')->insert([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'agent',
|
||||
'owner_type' => 'agent',
|
||||
'owner_id' => $agent->id,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => $rootId,
|
||||
'gross_win_loss' => 0,
|
||||
'rebate_amount' => 0,
|
||||
'adjustment_amount' => 0,
|
||||
'net_amount' => 500,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => 500,
|
||||
'status' => 'overdue',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
expect(fn () => app(AgentProfileService::class)->assertActorMayCreatePlayer($admin))
|
||||
->toThrow(\Illuminate\Validation\ValidationException::class);
|
||||
});
|
||||
212
tests/Feature/AgentPeriodCloseE2eTest.php
Normal file
212
tests/Feature/AgentPeriodCloseE2eTest.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Models\Player;
|
||||
use App\Services\Agent\AgentNodeService;
|
||||
use App\Services\AgentSettlement\AgentSettlementPeriodCloseService;
|
||||
use App\Support\Settlement\DesignDocExample12;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
test('period close from share ledger matches design doc example 12', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
$service = app(AgentNodeService::class);
|
||||
$super = \App\Models\AdminUser::query()->create([
|
||||
'username' => 'e2e_super',
|
||||
'name' => 'E2E',
|
||||
'email' => null,
|
||||
'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($super);
|
||||
|
||||
$a = $service->createChild($super, agentChildPayload([
|
||||
'parent_id' => $rootId,
|
||||
'code' => 'A',
|
||||
'name' => 'A',
|
||||
'username' => 'e2e_a',
|
||||
'total_share_rate' => 60,
|
||||
'credit_limit' => 500000,
|
||||
]));
|
||||
$b = $service->createChild($super, agentChildPayload([
|
||||
'parent_id' => $a->id,
|
||||
'code' => 'B',
|
||||
'name' => 'B',
|
||||
'username' => 'e2e_b',
|
||||
'total_share_rate' => 40,
|
||||
'credit_limit' => 200000,
|
||||
]));
|
||||
$c = $service->createChild($super, agentChildPayload([
|
||||
'parent_id' => $b->id,
|
||||
'code' => 'C',
|
||||
'name' => 'C',
|
||||
'username' => 'e2e_c',
|
||||
'total_share_rate' => 25,
|
||||
'credit_limit' => 100000,
|
||||
]));
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $c->id,
|
||||
'site_player_id' => 'e2e-p1',
|
||||
'username' => 'e2eplayer',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$draw = \App\Models\Draw::query()->create([
|
||||
'draw_no' => 'E2E-AG-001',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 99,
|
||||
'status' => \App\Lottery\DrawStatus::Open->value,
|
||||
'start_time' => null,
|
||||
'close_time' => null,
|
||||
'draw_time' => null,
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORD-E2E-AG-1',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 10000,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 10000,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'confirmed',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$ticketItemId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => 'T-E2E-AG-1',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'original_number' => null,
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'big',
|
||||
'dimension' => 2,
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => null,
|
||||
'unit_bet_amount' => 10000,
|
||||
'total_bet_amount' => 10000,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 10000,
|
||||
'odds_snapshot_json' => null,
|
||||
'rule_snapshot_json' => null,
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 0,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'settled_lose',
|
||||
'fail_reason_code' => null,
|
||||
'fail_reason_text' => null,
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$settledAt = now();
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => $settledAt->copy()->subDay(),
|
||||
'period_end' => $settledAt->copy()->addDay(),
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $ticketItemId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $c->id,
|
||||
'agent_path' => json_encode([$a->id, $b->id, $c->id]),
|
||||
'share_snapshot' => json_encode([
|
||||
'total_shares' => ['C' => 25, 'B' => 40, 'A' => 60],
|
||||
'actual_shares' => ['C' => 25, 'B' => 15, 'A' => 20, 'platform' => 40],
|
||||
'chain_codes' => ['C', 'B', 'A'],
|
||||
'agent_path' => [$a->id, $b->id, $c->id],
|
||||
]),
|
||||
'game_win_loss' => DesignDocExample12::GAME_WIN_LOSS,
|
||||
'basic_rebate' => DesignDocExample12::BASIC_REBATE,
|
||||
'shared_net_win_loss' => DesignDocExample12::SHARED_NET_WIN_LOSS,
|
||||
'allocations_json' => json_encode([]),
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
|
||||
DB::table('rebate_records')->insert([
|
||||
[
|
||||
'player_id' => $player->id,
|
||||
'ticket_item_id' => $ticketItemId,
|
||||
'game_type' => '*',
|
||||
'valid_bet_amount' => 10000,
|
||||
'rebate_rate' => 0.005,
|
||||
'rebate_amount' => DesignDocExample12::BASIC_REBATE,
|
||||
'rebate_type' => 'basic',
|
||||
'owner_agent_id' => $c->id,
|
||||
'status' => 'accrued',
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
],
|
||||
[
|
||||
'player_id' => $player->id,
|
||||
'ticket_item_id' => $ticketItemId,
|
||||
'game_type' => '*',
|
||||
'valid_bet_amount' => 10000,
|
||||
'rebate_rate' => 0.002,
|
||||
'rebate_amount' => DesignDocExample12::EXTRA_REBATE_BY_C,
|
||||
'rebate_type' => 'extra',
|
||||
'owner_agent_id' => $c->id,
|
||||
'status' => 'accrued',
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
],
|
||||
]);
|
||||
|
||||
$close = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
|
||||
|
||||
$playerBill = DB::table('settlement_bills')
|
||||
->where('settlement_period_id', $periodId)
|
||||
->where('bill_type', 'player')
|
||||
->where('owner_id', $player->id)
|
||||
->first();
|
||||
|
||||
expect($playerBill)->not->toBeNull();
|
||||
expect((int) $playerBill->net_amount)->toBe((int) DesignDocExample12::PLAYER_NET_SETTLEMENT);
|
||||
|
||||
$edgeCtoB = DB::table('settlement_bills')
|
||||
->where('settlement_period_id', $periodId)
|
||||
->where('meta_json', 'like', '%C_to_B%')
|
||||
->value('net_amount');
|
||||
expect((int) $edgeCtoB)->toBe((int) round(DesignDocExample12::TIER_C_TO_B));
|
||||
|
||||
expect($close['rebate_dispatched'])->toBe(2);
|
||||
expect($close['rebate_allocations'])->toBeGreaterThan(0);
|
||||
|
||||
expect(DB::table('rebate_records')->where('status', 'in_bill')->count())->toBe(2);
|
||||
expect(DB::table('rebate_allocations')->where('settlement_bill_id', $playerBill->id)->count())
|
||||
->toBeGreaterThan(0);
|
||||
});
|
||||
90
tests/Feature/AgentSettlementBadDebtTest.php
Normal file
90
tests/Feature/AgentSettlementBadDebtTest.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('admin can write off player bill bad debt and complete period when all settled', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$agentId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => (int) $site->id,
|
||||
'period_start' => now()->subDays(7),
|
||||
'period_end' => now(),
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => $agentId,
|
||||
'site_player_id' => 'bd-p1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'bduser',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$billId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => $agentId,
|
||||
'gross_win_loss' => 10000,
|
||||
'rebate_amount' => 0,
|
||||
'adjustment_amount' => 0,
|
||||
'platform_rounding_adjustment' => 0,
|
||||
'net_amount' => 10000,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => 10000,
|
||||
'status' => 'overdue',
|
||||
'confirmed_at' => now(),
|
||||
'locked_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'bad_debt_super',
|
||||
'name' => 'Bad Debt',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/settlement-bills/'.$billId.'/bad-debt-write-off', [
|
||||
'reason' => 'uncollectible',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.original_bill_id', $billId);
|
||||
|
||||
$this->assertDatabaseHas('settlement_bills', [
|
||||
'id' => $billId,
|
||||
'status' => 'settled',
|
||||
'unpaid_amount' => 0,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('settlement_adjustments', [
|
||||
'original_bill_id' => $billId,
|
||||
'adjustment_type' => 'bad_debt',
|
||||
'amount' => 10000,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('settlement_periods', [
|
||||
'id' => $periodId,
|
||||
'status' => 'completed',
|
||||
]);
|
||||
});
|
||||
46
tests/Feature/AgentSettlementBillAdjustmentTest.php
Normal file
46
tests/Feature/AgentSettlementBillAdjustmentTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('locked bill can receive adjustment bill', function (): void {
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => (int) DB::table('admin_sites')->where('is_default', true)->value('id'),
|
||||
'period_start' => now()->subDay(),
|
||||
'period_end' => now()->addDay(),
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$billId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => 1,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => 1,
|
||||
'gross_win_loss' => 1000,
|
||||
'rebate_amount' => 50,
|
||||
'adjustment_amount' => 0,
|
||||
'net_amount' => 930,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => 930,
|
||||
'status' => 'confirmed',
|
||||
'locked_at' => now(),
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$newId = app(\App\Services\AgentSettlement\AgentSettlementBillAdjustmentService::class)
|
||||
->createAdjustment($billId, -30, 'adjustment', 'correction', 0);
|
||||
|
||||
$adjustment = DB::table('settlement_bills')->where('id', $newId)->first();
|
||||
expect($adjustment)->not->toBeNull();
|
||||
expect((string) $adjustment->bill_type)->toBe('adjustment');
|
||||
expect((int) $adjustment->reversed_bill_id)->toBe($billId);
|
||||
expect((int) $adjustment->net_amount)->toBe(-30);
|
||||
});
|
||||
84
tests/Feature/AgentSettlementListsApiTest.php
Normal file
84
tests/Feature/AgentSettlementListsApiTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('settlement payments and adjustments index return items', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => now()->subWeek(),
|
||||
'period_end' => now(),
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$billId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => 1,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => 1,
|
||||
'net_amount' => 1000,
|
||||
'unpaid_amount' => 0,
|
||||
'paid_amount' => 1000,
|
||||
'status' => 'settled',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('payment_records')->insert([
|
||||
'settlement_bill_id' => $billId,
|
||||
'payer_type' => 'player',
|
||||
'payer_id' => 1,
|
||||
'payee_type' => 'agent',
|
||||
'payee_id' => 1,
|
||||
'amount' => 1000,
|
||||
'method' => 'cash',
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('settlement_adjustments')->insert([
|
||||
'settlement_period_id' => $periodId,
|
||||
'original_bill_id' => $billId,
|
||||
'adjustment_type' => 'adjustment',
|
||||
'amount' => 100,
|
||||
'reason' => 'test',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'lists_super',
|
||||
'name' => 'Lists',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/settlement-payments?admin_site_id='.$siteId)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.settlement_bill_id', $billId);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/settlement-adjustments?admin_site_id='.$siteId)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.original_bill_id', $billId);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/settlement-bills?admin_site_id='.$siteId.'&bill_type=player')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.bill_type', 'player');
|
||||
});
|
||||
76
tests/Feature/AgentSettlementPeriodSummaryTest.php
Normal file
76
tests/Feature/AgentSettlementPeriodSummaryTest.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Services\AgentSettlement\AgentSettlementPeriodSummaryService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('settlement periods index includes bill summary per period', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => now()->subWeek(),
|
||||
'period_end' => now(),
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('settlement_bills')->insert([
|
||||
[
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => 1,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => 1,
|
||||
'net_amount' => 1000,
|
||||
'unpaid_amount' => 1000,
|
||||
'paid_amount' => 0,
|
||||
'status' => 'pending_confirm',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'agent',
|
||||
'owner_type' => 'agent',
|
||||
'owner_id' => 1,
|
||||
'counterparty_type' => 'platform',
|
||||
'counterparty_id' => 0,
|
||||
'net_amount' => 5000,
|
||||
'unpaid_amount' => 5000,
|
||||
'paid_amount' => 0,
|
||||
'status' => 'confirmed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'period_summary_admin',
|
||||
'name' => 'Summary',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/settlement-periods?admin_site_id='.$siteId)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.summary.player_bills', 1)
|
||||
->assertJsonPath('data.items.0.summary.agent_bills', 1)
|
||||
->assertJsonPath('data.items.0.summary.pending_confirm', 1)
|
||||
->assertJsonPath('data.items.0.summary.awaiting_payment', 1)
|
||||
->assertJsonPath('data.items.0.summary.total_unpaid', 6000);
|
||||
|
||||
$service = app(AgentSettlementPeriodSummaryService::class);
|
||||
$summaries = $service->summariesForPeriodIds([$periodId]);
|
||||
expect($summaries[$periodId]['player_bills'])->toBe(1);
|
||||
expect($summaries[$periodId]['agent_bills'])->toBe(1);
|
||||
});
|
||||
66
tests/Feature/BetShareSnapshotImmutabilityTest.php
Normal file
66
tests/Feature/BetShareSnapshotImmutabilityTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AgentProfile;
|
||||
use App\Services\Agent\AgentProfileService;
|
||||
use App\Services\AgentSettlement\BetSettlementSnapshotBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('share snapshot uses profile at build time not after change', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->insertGetId([
|
||||
'code' => 'snap-line',
|
||||
'name' => 'snap',
|
||||
'is_default' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$rootId = (int) DB::table('agent_nodes')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'parent_id' => null,
|
||||
'depth' => 0,
|
||||
'path' => '/snap-line',
|
||||
'code' => 'snap-line',
|
||||
'name' => 'Root',
|
||||
'status' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
AgentProfile::query()->create([
|
||||
'agent_node_id' => $rootId,
|
||||
'total_share_rate' => 25,
|
||||
'credit_limit' => 10000,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 0.01,
|
||||
'default_player_rebate' => 0.005,
|
||||
'settlement_cycle' => 'weekly',
|
||||
]);
|
||||
|
||||
$playerId = (int) DB::table('players')->insertGetId([
|
||||
'site_code' => 'snap-line',
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'snap-p1',
|
||||
'username' => 'snap1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = \App\Models\Player::query()->findOrFail($playerId);
|
||||
$builder = app(BetSettlementSnapshotBuilder::class);
|
||||
$first = $builder->buildForPlayer($player);
|
||||
|
||||
AgentProfile::query()->where('agent_node_id', $rootId)->update(['total_share_rate' => 50]);
|
||||
|
||||
$stored = json_encode($first['total_shares']);
|
||||
$second = $builder->buildForPlayer($player->fresh());
|
||||
|
||||
expect($stored)->toContain('"snap-line":25');
|
||||
expect($second['total_shares']['snap-line'])->toBe(50.0);
|
||||
});
|
||||
92
tests/Feature/CreditHoldSettlementNoDoubleTest.php
Normal file
92
tests/Feature/CreditHoldSettlementNoDoubleTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('settled loss does not double count bet hold', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
|
||||
'site_player_id' => 'hold-p1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'hold1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 5000,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$credit = app(PlayerCreditService::class);
|
||||
$credit->assertMayPlaceBet($player, 200);
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(2);
|
||||
|
||||
$drawId = (int) \App\Models\Draw::query()->create([
|
||||
'draw_no' => 'HOLD-DRAW',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => \App\Lottery\DrawStatus::Open->value,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
])->id;
|
||||
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORD-HOLD-1',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 200,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 200,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'placed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$item = \App\Models\TicketItem::query()->create([
|
||||
'ticket_no' => 'T-HOLD-1',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'direct',
|
||||
'dimension' => '4d',
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => 'single',
|
||||
'unit_bet_amount' => 200,
|
||||
'total_bet_amount' => 200,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 200,
|
||||
'odds_snapshot_json' => '{}',
|
||||
'rule_snapshot_json' => '{}',
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 0,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'settled_lose',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
]);
|
||||
$item->setRelation('player', $player);
|
||||
|
||||
app(AgentGameSettlementRecorder::class)->recordForTicketItem($item, 0, 'settled_lose');
|
||||
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(2);
|
||||
});
|
||||
62
tests/Feature/CreditLineBetHoldTest.php
Normal file
62
tests/Feature/CreditLineBetHoldTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('credit line hold does not change wallet balance', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
|
||||
if (! is_array($extra)) {
|
||||
$extra = [];
|
||||
}
|
||||
$extra['credit_line_mode'] = true;
|
||||
DB::table('admin_sites')->where('id', $site->id)->update([
|
||||
'extra_json' => json_encode($extra),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
|
||||
'site_player_id' => 'cl-p1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'cl1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 50000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 10000,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$walletBefore = (int) PlayerWallet::query()->where('player_id', $player->id)->value('balance');
|
||||
|
||||
app(PlayerCreditService::class)->assertMayPlaceBet($player, 500);
|
||||
|
||||
$walletAfter = (int) PlayerWallet::query()->where('player_id', $player->id)->value('balance');
|
||||
expect($walletAfter)->toBe($walletBefore);
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(5);
|
||||
expect(DB::table('credit_ledger')->where('reason', 'bet_hold')->where('owner_id', $player->id)->exists())->toBeTrue();
|
||||
});
|
||||
59
tests/Feature/CreditWalletLogsTest.php
Normal file
59
tests/Feature/CreditWalletLogsTest.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
test('credit player wallet logs reads credit_ledger not wallet_txns', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:logs-1',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_logs',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 200,
|
||||
'used_credit' => 10,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -1000,
|
||||
'reason' => 'bet_hold',
|
||||
'ref_type' => 'bet',
|
||||
'ref_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=1&size=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.ledger_source', 'credit_ledger')
|
||||
->assertJsonPath('data.funding_mode', PlayerFundingMode::CREDIT)
|
||||
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE)
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.type', 'bet')
|
||||
->assertJsonPath('data.items.0.biz_type', 'bet_hold')
|
||||
->assertJsonPath('data.items.0.ledger_source', 'credit_ledger');
|
||||
});
|
||||
126
tests/Feature/GameSettlementReversalTest.php
Normal file
126
tests/Feature/GameSettlementReversalTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Services\AgentSettlement\GameSettlementReversalService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('reversal zeroes share ledger net and marks rebates reversed', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
|
||||
if (! is_array($extra)) {
|
||||
$extra = [];
|
||||
}
|
||||
$extra['credit_line_mode'] = true;
|
||||
DB::table('admin_sites')->where('id', $site->id)->update([
|
||||
'extra_json' => json_encode($extra),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$siteCode = (string) $site->code;
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
|
||||
'site_player_id' => 'rev-p1',
|
||||
'username' => 'rev1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$drawId = (int) \App\Models\Draw::query()->create([
|
||||
'draw_no' => 'REV-DRAW-1',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => \App\Lottery\DrawStatus::Open->value,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
])->id;
|
||||
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORD-REV-1',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'placed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$itemId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => 'T-REV-1',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'direct',
|
||||
'dimension' => '4d',
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => 'single',
|
||||
'unit_bet_amount' => 100,
|
||||
'total_bet_amount' => 100,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 100,
|
||||
'odds_snapshot_json' => '{}',
|
||||
'rule_snapshot_json' => '{}',
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 0,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'settled',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'agent_node_id' => $player->agent_node_id,
|
||||
'share_snapshot' => '{}',
|
||||
'agent_settled_at' => now(),
|
||||
'settled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$ledgerId = (int) DB::table('share_ledger')->insertGetId([
|
||||
'ticket_item_id' => $itemId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $player->agent_node_id,
|
||||
'agent_path' => '[]',
|
||||
'share_snapshot' => '{}',
|
||||
'game_win_loss' => -1000,
|
||||
'basic_rebate' => 50,
|
||||
'shared_net_win_loss' => -950,
|
||||
'allocations_json' => '[]',
|
||||
'settled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$rebateId = (int) DB::table('rebate_records')->insertGetId([
|
||||
'player_id' => $player->id,
|
||||
'ticket_item_id' => $itemId,
|
||||
'game_type' => '*',
|
||||
'valid_bet_amount' => 1000,
|
||||
'rebate_rate' => 0.005,
|
||||
'rebate_amount' => 50,
|
||||
'rebate_type' => 'basic',
|
||||
'owner_agent_id' => $player->agent_node_id,
|
||||
'status' => 'accrued',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$item = TicketItem::query()->findOrFail($itemId);
|
||||
app(GameSettlementReversalService::class)->reverseTicketItem($item);
|
||||
|
||||
$sum = (int) DB::table('share_ledger')->where('ticket_item_id', $itemId)->sum('shared_net_win_loss');
|
||||
expect($sum)->toBe(0);
|
||||
expect((string) DB::table('rebate_records')->where('id', $rebateId)->value('status'))->toBe('reversed');
|
||||
expect(DB::table('share_ledger')->where('reversal_of_id', $ledgerId)->exists())->toBeTrue();
|
||||
});
|
||||
100
tests/Feature/PlatformSystemRolesTest.php
Normal file
100
tests/Feature/PlatformSystemRolesTest.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
});
|
||||
|
||||
function platformRolesApiToken(string $username): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => $username,
|
||||
'name' => 'Tester',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('platform role index only lists fixed super_admin and agent roles', function (): void {
|
||||
AdminRole::query()->create([
|
||||
'slug' => 'legacy_custom_ops',
|
||||
'code' => 'legacy_custom_ops',
|
||||
'name' => 'Legacy Ops',
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
'status' => 1,
|
||||
'is_system' => false,
|
||||
'sort_order' => 99,
|
||||
]);
|
||||
|
||||
PlatformSystemRoles::ensureAll();
|
||||
|
||||
$token = platformRolesApiToken('platform_role_index');
|
||||
|
||||
$slugs = collect($this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/admin-roles')
|
||||
->assertOk()
|
||||
->json('data.items'))
|
||||
->pluck('slug')
|
||||
->all();
|
||||
|
||||
expect($slugs)->toBe(['super_admin', 'agent']);
|
||||
});
|
||||
|
||||
test('platform roles cannot be created and super_admin permissions are full catalog', function (): void {
|
||||
PlatformSystemRoles::ensureAll();
|
||||
|
||||
$token = platformRolesApiToken('platform_role_guard');
|
||||
$menuActionCount = (int) DB::table('admin_menu_actions')->where('status', 1)->count();
|
||||
|
||||
$super = AdminRole::query()->where('slug', 'super_admin')->firstOrFail();
|
||||
expect($super->is_system)->toBeTrue();
|
||||
expect((int) DB::table('admin_role_menu_actions')->where('role_id', $super->id)->count())
|
||||
->toBe($menuActionCount);
|
||||
expect($super->legacyPermissionSlugs())->not->toBeEmpty();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/admin-roles', [
|
||||
'slug' => 'new_ops',
|
||||
'name' => 'New Ops',
|
||||
])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-roles/'.$super->id.'/permissions', [
|
||||
'permission_slugs' => ['prd.dashboard.view'],
|
||||
])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/admin-roles/'.$super->id, [
|
||||
'name' => 'Renamed Super',
|
||||
])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->deleteJson('/api/v1/admin/admin-roles/'.$super->id)
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('admin-auth-sync grants super_admin the full permission catalog', function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
$super = AdminRole::query()->where('slug', 'super_admin')->firstOrFail();
|
||||
|
||||
$menuActionCount = (int) DB::table('admin_menu_actions')->where('status', 1)->count();
|
||||
|
||||
expect((int) DB::table('admin_role_menu_actions')->where('role_id', $super->id)->count())
|
||||
->toBe($menuActionCount);
|
||||
});
|
||||
139
tests/Feature/PlayerNativeAuthTest.php
Normal file
139
tests/Feature/PlayerNativeAuthTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
config([
|
||||
'lottery.player_auth.native.secret' => 'test-native-jwt-secret-32bytes!!',
|
||||
'lottery.player_auth.native.ttl_seconds' => 3600,
|
||||
'lottery.main_site.wallet_api_url' => null,
|
||||
]);
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
test('native player can login and access me', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native:test-1',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'agentplayer1',
|
||||
'password_hash' => Hash::make('secret-pass'),
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 50000,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$login = $this->postJson('/api/v1/player/auth/login', [
|
||||
'site_code' => $site->code,
|
||||
'username' => 'agentplayer1',
|
||||
'password' => 'secret-pass',
|
||||
]);
|
||||
|
||||
$login->assertOk();
|
||||
$token = (string) $login->json('data.access_token');
|
||||
expect($token)->not->toBe('');
|
||||
|
||||
$me = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/player/me');
|
||||
|
||||
$me->assertOk()
|
||||
->assertJsonPath('data.id', $player->id)
|
||||
->assertJsonPath('data.funding_mode', PlayerFundingMode::CREDIT)
|
||||
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE);
|
||||
});
|
||||
|
||||
test('credit player wallet transfer in is rejected', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'native:test-2',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'agentplayer2',
|
||||
'password_hash' => Hash::make('secret-pass'),
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$auth = app(\App\Services\Player\PlayerNativeAuthService::class);
|
||||
$token = $auth->issueToken($player);
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/wallet/transfer-in', [
|
||||
'amount' => 1000,
|
||||
'idempotent_key' => 'native-ti-1',
|
||||
'currency' => 'NPR',
|
||||
]);
|
||||
|
||||
$response->assertJsonPath('code', 1011);
|
||||
});
|
||||
|
||||
test('sso wallet player balance does not use credit when site credit mode on', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
|
||||
if (! is_array($extra)) {
|
||||
$extra = [];
|
||||
}
|
||||
$extra['credit_line_mode'] = true;
|
||||
DB::table('admin_sites')->where('id', $site->id)->update([
|
||||
'extra_json' => json_encode($extra),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'site_player_id' => 'sso-wallet-1',
|
||||
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
|
||||
'funding_mode' => PlayerFundingMode::WALLET,
|
||||
'username' => 'ssouser',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
\App\Models\PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 12000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/balance?currency=NPR');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.credit_line_mode', false)
|
||||
->assertJsonPath('data.funding_mode', PlayerFundingMode::WALLET)
|
||||
->assertJsonPath('data.available_balance', 12000);
|
||||
});
|
||||
42
tests/Feature/SettlementBillLockTest.php
Normal file
42
tests/Feature/SettlementBillLockTest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Services\AgentSettlement\AgentSettlementBillGuard;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('confirmed bill net amount cannot be mutated via guard', function (): void {
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => (int) DB::table('admin_sites')->where('is_default', true)->value('id'),
|
||||
'period_start' => now()->subDay(),
|
||||
'period_end' => now()->addDay(),
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$billId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => 1,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => 1,
|
||||
'gross_win_loss' => 1000,
|
||||
'rebate_amount' => 50,
|
||||
'adjustment_amount' => 0,
|
||||
'net_amount' => 930,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => 930,
|
||||
'status' => 'pending_confirm',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$guard = app(AgentSettlementBillGuard::class);
|
||||
$guard->markConfirmed($billId);
|
||||
|
||||
expect(fn () => $guard->assertNetAmountMutable($billId))
|
||||
->toThrow(\Illuminate\Validation\ValidationException::class);
|
||||
});
|
||||
72
tests/Feature/SettlementOverdueFreezeTest.php
Normal file
72
tests/Feature/SettlementOverdueFreezeTest.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('overdue player cannot place bet on credit line', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
|
||||
if (! is_array($extra)) {
|
||||
$extra = [];
|
||||
}
|
||||
$extra['credit_line_mode'] = true;
|
||||
DB::table('admin_sites')->where('id', $site->id)->update([
|
||||
'extra_json' => json_encode($extra),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
|
||||
'site_player_id' => 'od-p1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'od1',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 10000,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => (int) $site->id,
|
||||
'period_start' => now()->subWeek(),
|
||||
'period_end' => now()->subDay(),
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('settlement_bills')->insert([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => $player->agent_node_id,
|
||||
'gross_win_loss' => 1000,
|
||||
'rebate_amount' => 0,
|
||||
'adjustment_amount' => 0,
|
||||
'net_amount' => 1000,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => 1000,
|
||||
'status' => 'overdue',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
expect(fn () => app(PlayerCreditService::class)->assertMayPlaceBet($player, 100))
|
||||
->toThrow(\Illuminate\Validation\ValidationException::class);
|
||||
});
|
||||
46
tests/Feature/WalletBalanceCreditPlayerTest.php
Normal file
46
tests/Feature/WalletBalanceCreditPlayerTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
});
|
||||
|
||||
test('credit player wallet balance returns minor units matching admin credit limit', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => (string) $site->code,
|
||||
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
|
||||
'site_player_id' => 'credit-bal-1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'creditbal',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 200,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/balance?currency=NPR')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.credit_line_mode', true)
|
||||
->assertJsonPath('data.available_balance', 20000)
|
||||
->assertJsonPath('data.credit_limit', 20000)
|
||||
->assertJsonPath('data.available_balance_formatted', '200.00');
|
||||
});
|
||||
37
tests/Unit/AgentDefaultRolePermissionsTest.php
Normal file
37
tests/Unit/AgentDefaultRolePermissionsTest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AgentProfile;
|
||||
use App\Support\AgentDefaultRolePermissions;
|
||||
|
||||
test('base owner slugs include dashboard and settlement view but not wallet reconcile', function (): void {
|
||||
$slugs = AgentDefaultRolePermissions::baseSlugs();
|
||||
|
||||
expect($slugs)
|
||||
->toContain('prd.dashboard.view')
|
||||
->toContain('prd.settlement.agent.view')
|
||||
->not->toContain('prd.wallet_reconcile.view')
|
||||
->not->toContain('prd.wallet_reconcile.view_cs');
|
||||
});
|
||||
|
||||
test('line root owner slugs include agent management packages', function (): void {
|
||||
$slugs = AgentDefaultRolePermissions::lineRootOwnerSlugs();
|
||||
|
||||
expect($slugs)
|
||||
->toContain('prd.agent.manage')
|
||||
->toContain('prd.settlement.agent.manage')
|
||||
->toContain('prd.agent.role.manage');
|
||||
});
|
||||
|
||||
test('owner slugs from profile add manage slugs when capabilities enabled', function (): void {
|
||||
$profile = new AgentProfile([
|
||||
'can_create_child_agent' => true,
|
||||
'can_create_player' => false,
|
||||
]);
|
||||
|
||||
$slugs = AgentDefaultRolePermissions::ownerSlugsFromProfile($profile);
|
||||
|
||||
expect($slugs)
|
||||
->toContain('prd.agent.manage')
|
||||
->toContain('prd.agent.profile.manage')
|
||||
->not->toContain('prd.users.manage');
|
||||
});
|
||||
@@ -50,3 +50,21 @@ test('normalizes exact draw items message in zh', function (): void {
|
||||
|
||||
expect($errors['items'][0])->toContain('23');
|
||||
});
|
||||
|
||||
test('normalizes compact english max for rebate rate in zh', function (): void {
|
||||
$errors = ApiValidationErrors::normalize(
|
||||
['rebate_rate' => ['rebate rate must not be greater than 1.']],
|
||||
'zh',
|
||||
);
|
||||
|
||||
expect($errors['rebate_rate'][0])->toBe('回水比例不能超过 1(100% 记为 1)。');
|
||||
});
|
||||
|
||||
test('normalizes compact english max for rebate limit in zh', function (): void {
|
||||
$errors = ApiValidationErrors::normalize(
|
||||
['rebate_limit' => ['rebate limit must not be greater than 1.']],
|
||||
'zh',
|
||||
);
|
||||
|
||||
expect($errors['rebate_limit'][0])->toBe('回水上限不能超过 1(100% 记为 1)。');
|
||||
});
|
||||
|
||||
18
tests/Unit/CreditAmountScaleTest.php
Normal file
18
tests/Unit/CreditAmountScaleTest.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Support\CreditAmountScale;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
});
|
||||
|
||||
test('major and minor convert for two decimal currency', function (): void {
|
||||
expect(CreditAmountScale::majorToMinor(200, 'NPR'))->toBe(20000);
|
||||
expect(CreditAmountScale::minorToMajor(20000, 'NPR'))->toBe(200);
|
||||
expect(CreditAmountScale::minorToMajor(250, 'NPR'))->toBe(3);
|
||||
expect(CreditAmountScale::minorToMajor(200, 'NPR'))->toBe(2);
|
||||
});
|
||||
Reference in New Issue
Block a user