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