feat: 增强代理和玩家管理功能
- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。 - 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。 - 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。 - 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。 - 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
This commit is contained in:
151
app/Services/AgentSettlement/AgentGameSettlementRecorder.php
Normal file
151
app/Services/AgentSettlement/AgentGameSettlementRecorder.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Models\TicketItem;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 彩票注单游戏结算侧入账:快照、占成流水、回水计提、玩家已用额度。
|
||||
*/
|
||||
final class AgentGameSettlementRecorder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BetSettlementSnapshotBuilder $snapshotBuilder,
|
||||
private readonly ShareSettlementCalculator $calculator,
|
||||
private readonly PlayerCreditService $playerCreditService,
|
||||
) {}
|
||||
|
||||
public function shouldRecord(TicketItem $item): bool
|
||||
{
|
||||
$player = $item->player;
|
||||
if ($player === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PlayerFundingMode::usesCredit($player)
|
||||
&& (int) ($player->agent_node_id ?? 0) > 0;
|
||||
}
|
||||
|
||||
public function recordForTicketItem(TicketItem $item, int $netWin, string $terminalStatus): void
|
||||
{
|
||||
if (! $this->shouldRecord($item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$player = $item->player;
|
||||
if ($player === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$gameType = trim((string) ($item->play_code ?? '')) ?: '*';
|
||||
$snapshot = $this->snapshotBuilder->buildForPlayer($player, $gameType);
|
||||
|
||||
$gameWinLoss = $this->platformWinLoss($item, $netWin, $terminalStatus);
|
||||
$validBet = (int) $item->total_bet_amount;
|
||||
$basicRebate = (int) round($validBet * $snapshot['rebate_rate']);
|
||||
$extraRebate = (int) round($validBet * $snapshot['extra_rebate_rate']);
|
||||
|
||||
$extraByCode = [];
|
||||
if ($extraRebate > 0 && $snapshot['chain_codes'] !== []) {
|
||||
$leaf = $snapshot['chain_codes'][0];
|
||||
$extraByCode[$leaf] = $extraRebate;
|
||||
}
|
||||
|
||||
$result = $this->calculator->calculate(
|
||||
sharedNetWinLoss: 0,
|
||||
totalSharesByCode: $snapshot['total_shares'],
|
||||
extraRebateByCode: $extraByCode,
|
||||
gameWinLoss: $gameWinLoss,
|
||||
basicRebate: $basicRebate,
|
||||
chainFromPlayer: $snapshot['chain_codes'],
|
||||
);
|
||||
|
||||
$settledAt = now();
|
||||
|
||||
DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate): void {
|
||||
$item->forceFill([
|
||||
'agent_node_id' => $snapshot['agent_node_id'],
|
||||
'share_snapshot' => [
|
||||
'total_shares' => $snapshot['total_shares'],
|
||||
'actual_shares' => $snapshot['actual_shares'],
|
||||
'chain_codes' => $snapshot['chain_codes'],
|
||||
],
|
||||
'agent_rebate_rate_snapshot' => $snapshot['rebate_rate'],
|
||||
'agent_settled_at' => $settledAt,
|
||||
])->save();
|
||||
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $item->id,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $snapshot['agent_node_id'],
|
||||
'agent_path' => json_encode($snapshot['agent_path']),
|
||||
'share_snapshot' => json_encode($snapshot),
|
||||
'game_win_loss' => (int) round($gameWinLoss),
|
||||
'basic_rebate' => $basicRebate,
|
||||
'shared_net_win_loss' => (int) round($result->sharedNetWinLoss),
|
||||
'allocations_json' => json_encode($result->finalProfits),
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
|
||||
if ($basicRebate > 0) {
|
||||
DB::table('rebate_records')->insert([
|
||||
'player_id' => $player->id,
|
||||
'ticket_item_id' => $item->id,
|
||||
'game_type' => $gameType,
|
||||
'valid_bet_amount' => $validBet,
|
||||
'rebate_rate' => $snapshot['rebate_rate'],
|
||||
'rebate_amount' => $basicRebate,
|
||||
'rebate_type' => 'basic',
|
||||
'owner_agent_id' => $snapshot['agent_node_id'],
|
||||
'status' => 'accrued',
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($extraRebate > 0) {
|
||||
DB::table('rebate_records')->insert([
|
||||
'player_id' => $player->id,
|
||||
'ticket_item_id' => $item->id,
|
||||
'game_type' => $gameType,
|
||||
'valid_bet_amount' => $validBet,
|
||||
'rebate_rate' => $snapshot['extra_rebate_rate'],
|
||||
'rebate_amount' => $extraRebate,
|
||||
'rebate_type' => 'extra',
|
||||
'owner_agent_id' => $snapshot['agent_node_id'],
|
||||
'status' => 'accrued',
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
}
|
||||
|
||||
$holdAmount = (int) $item->actual_deduct_amount;
|
||||
if ($holdAmount > 0) {
|
||||
$this->playerCreditService->releaseBetHold($player, $holdAmount, $item->id);
|
||||
}
|
||||
|
||||
if ($gameWinLoss > 0) {
|
||||
$this->playerCreditService->applySettledLoss($player, (int) round($gameWinLoss), $item->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function platformWinLoss(TicketItem $item, int $netWin, string $terminalStatus): float
|
||||
{
|
||||
if ($terminalStatus === 'settled_lose') {
|
||||
return (float) max(0, (int) $item->actual_deduct_amount);
|
||||
}
|
||||
|
||||
if ($netWin > 0) {
|
||||
return -1 * (float) $netWin;
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
232
app/Services/AgentSettlement/AgentPeriodAggregator.php
Normal file
232
app/Services/AgentSettlement/AgentPeriodAggregator.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentPeriodAggregator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShareSettlementCalculator $calculator,
|
||||
private readonly BetSettlementSnapshotBuilder $snapshotBuilder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* players: array<int, array<string, mixed>>,
|
||||
* agent_edges: array<string, int>,
|
||||
* agent_subtrees: array<int, array<string, mixed>>,
|
||||
* platform_share_profit: int,
|
||||
* }
|
||||
*/
|
||||
public function aggregate(int $adminSiteId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
|
||||
$codeToId = AgentNode::query()
|
||||
->where('admin_site_id', $adminSiteId)
|
||||
->pluck('id', 'code')
|
||||
->mapWithKeys(fn ($id, $code): array => [(string) $code => (int) $id])
|
||||
->all();
|
||||
|
||||
$rows = DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->select([
|
||||
'sl.player_id',
|
||||
'sl.ticket_item_id',
|
||||
'sl.agent_node_id',
|
||||
'sl.share_snapshot',
|
||||
'sl.game_win_loss',
|
||||
'sl.basic_rebate',
|
||||
])
|
||||
->orderBy('sl.id')
|
||||
->get();
|
||||
|
||||
$players = [];
|
||||
$agentEdges = [];
|
||||
$agentSubtrees = [];
|
||||
$platformShareProfit = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$playerId = (int) $row->player_id;
|
||||
$snapshot = $this->resolveSnapshotFromLedgerRow($row);
|
||||
if ($snapshot === null) {
|
||||
$player = Player::query()->find($playerId);
|
||||
if ($player === null) {
|
||||
continue;
|
||||
}
|
||||
$built = $this->snapshotBuilder->buildForPlayer($player);
|
||||
$snapshot = [
|
||||
'total_shares' => $built['total_shares'],
|
||||
'chain_codes' => $built['chain_codes'],
|
||||
];
|
||||
}
|
||||
|
||||
$gameWinLoss = (int) $row->game_win_loss;
|
||||
$basicRebate = (int) $row->basic_rebate;
|
||||
$extraRebate = $this->extraRebateForTicketItem(
|
||||
(int) $row->ticket_item_id,
|
||||
$periodStart,
|
||||
$periodEnd,
|
||||
);
|
||||
|
||||
$extraByCode = [];
|
||||
if ($extraRebate > 0 && $snapshot['chain_codes'] !== []) {
|
||||
$extraByCode[$snapshot['chain_codes'][0]] = $extraRebate;
|
||||
}
|
||||
|
||||
$result = $this->calculator->calculate(
|
||||
sharedNetWinLoss: 0,
|
||||
totalSharesByCode: $snapshot['total_shares'],
|
||||
extraRebateByCode: $extraByCode,
|
||||
gameWinLoss: $gameWinLoss,
|
||||
basicRebate: $basicRebate,
|
||||
chainFromPlayer: $snapshot['chain_codes'],
|
||||
);
|
||||
|
||||
$net = (int) round($result->playerNetSettlement);
|
||||
|
||||
if (! isset($players[$playerId])) {
|
||||
$players[$playerId] = [
|
||||
'agent_node_id' => (int) $row->agent_node_id,
|
||||
'game_win_loss' => 0,
|
||||
'basic_rebate' => 0,
|
||||
'extra_rebate' => 0,
|
||||
'net_amount' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$players[$playerId]['game_win_loss'] += $gameWinLoss;
|
||||
$players[$playerId]['basic_rebate'] += $basicRebate;
|
||||
$players[$playerId]['extra_rebate'] += $extraRebate;
|
||||
$players[$playerId]['net_amount'] += $net;
|
||||
|
||||
foreach ($result->tierSettlements as $edge => $amount) {
|
||||
$agentEdges[$edge] = ($agentEdges[$edge] ?? 0) + (int) round($amount);
|
||||
}
|
||||
|
||||
$platformShareProfit += (int) round($result->finalProfits['platform'] ?? 0);
|
||||
|
||||
$pathIds = $this->resolveAgentPathIds($row, $snapshot, $codeToId);
|
||||
foreach ($pathIds as $agentId) {
|
||||
if (! isset($agentSubtrees[$agentId])) {
|
||||
$agentSubtrees[$agentId] = [
|
||||
'gross_win_loss' => 0,
|
||||
'basic_rebate' => 0,
|
||||
'extra_rebate' => 0,
|
||||
'share_profit' => 0,
|
||||
'player_count' => 0,
|
||||
'_players_seen' => [],
|
||||
];
|
||||
}
|
||||
$agentSubtrees[$agentId]['gross_win_loss'] += $gameWinLoss;
|
||||
$agentSubtrees[$agentId]['basic_rebate'] += $basicRebate;
|
||||
$agentSubtrees[$agentId]['extra_rebate'] += $extraRebate;
|
||||
if (! in_array($playerId, $agentSubtrees[$agentId]['_players_seen'], true)) {
|
||||
$agentSubtrees[$agentId]['_players_seen'][] = $playerId;
|
||||
$agentSubtrees[$agentId]['player_count']++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($snapshot['chain_codes'] as $code) {
|
||||
$agentId = $codeToId[$code] ?? 0;
|
||||
if ($agentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$profit = (int) round($result->finalProfits[$code] ?? 0);
|
||||
$agentSubtrees[$agentId]['share_profit'] = ($agentSubtrees[$agentId]['share_profit'] ?? 0) + $profit;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($agentSubtrees as $id => $subtree) {
|
||||
unset($agentSubtrees[$id]['_players_seen']);
|
||||
}
|
||||
|
||||
return [
|
||||
'players' => $players,
|
||||
'agent_edges' => $agentEdges,
|
||||
'agent_subtrees' => $agentSubtrees,
|
||||
'platform_share_profit' => $platformShareProfit,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{chain_codes: list<string>, total_shares: array<string, float>} $snapshot
|
||||
* @param array<string, int> $codeToId
|
||||
* @return list<int>
|
||||
*/
|
||||
private function resolveAgentPathIds(object $row, array $snapshot, array $codeToId): array
|
||||
{
|
||||
$raw = $row->share_snapshot ?? null;
|
||||
if ($raw !== null && $raw !== '') {
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (is_array($decoded) && is_array($decoded['agent_path'] ?? null)) {
|
||||
return array_values(array_map(intval(...), $decoded['agent_path']));
|
||||
}
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($snapshot['chain_codes'] as $code) {
|
||||
$id = $codeToId[$code] ?? 0;
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public function siteIdForPeriod(int $periodId): int
|
||||
{
|
||||
return (int) DB::table('settlement_periods')->where('id', $periodId)->value('admin_site_id');
|
||||
}
|
||||
|
||||
private function extraRebateForTicketItem(int $ticketItemId, string $periodStart, string $periodEnd): int
|
||||
{
|
||||
if ($ticketItemId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) DB::table('rebate_records')
|
||||
->where('ticket_item_id', $ticketItemId)
|
||||
->where('rebate_type', 'extra')
|
||||
->whereIn('status', ['accrued', 'reversed'])
|
||||
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||
->sum('rebate_amount');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total_shares: array<string, float>, chain_codes: list<string>}|null
|
||||
*/
|
||||
private function resolveSnapshotFromLedgerRow(object $row): ?array
|
||||
{
|
||||
$raw = $row->share_snapshot ?? null;
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$totalShares = $decoded['total_shares'] ?? null;
|
||||
$chainCodes = $decoded['chain_codes'] ?? null;
|
||||
if (! is_array($totalShares) || ! is_array($chainCodes) || $chainCodes === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shares = [];
|
||||
foreach ($totalShares as $code => $rate) {
|
||||
$shares[(string) $code] = (float) $rate;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_shares' => $shares,
|
||||
'chain_codes' => array_values(array_map(strval(...), $chainCodes)),
|
||||
];
|
||||
}
|
||||
}
|
||||
125
app/Services/AgentSettlement/AgentSettlementBadDebtService.php
Normal file
125
app/Services/AgentSettlement/AgentSettlementBadDebtService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** 坏账核销:原账单保留,记 bad_debt 调整与归档单(§2、§21.1)。 */
|
||||
final class AgentSettlementBadDebtService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
|
||||
) {}
|
||||
|
||||
public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int
|
||||
{
|
||||
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
|
||||
if ($original === null) {
|
||||
throw new \InvalidArgumentException('bill_not_found');
|
||||
}
|
||||
|
||||
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'period' => ['completed'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['not_eligible'],
|
||||
]);
|
||||
}
|
||||
|
||||
$unpaid = (int) $original->unpaid_amount;
|
||||
if ($unpaid <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['no_unpaid'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['not_eligible'],
|
||||
]);
|
||||
}
|
||||
|
||||
return (int) DB::transaction(function () use ($original, $originalBillId, $unpaid, $reason, $adminUserId): int {
|
||||
$now = now();
|
||||
$periodId = (int) $original->settlement_period_id;
|
||||
|
||||
$archiveBillId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'bad_debt',
|
||||
'owner_type' => (string) $original->owner_type,
|
||||
'owner_id' => (int) $original->owner_id,
|
||||
'counterparty_type' => (string) $original->counterparty_type,
|
||||
'counterparty_id' => (int) $original->counterparty_id,
|
||||
'gross_win_loss' => 0,
|
||||
'rebate_amount' => 0,
|
||||
'adjustment_amount' => -$unpaid,
|
||||
'platform_rounding_adjustment' => 0,
|
||||
'net_amount' => 0,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => 0,
|
||||
'status' => 'settled',
|
||||
'reversed_bill_id' => $originalBillId,
|
||||
'meta_json' => json_encode([
|
||||
'original_bill_id' => $originalBillId,
|
||||
'written_off_amount' => $unpaid,
|
||||
'original_net_amount' => (int) $original->net_amount,
|
||||
]),
|
||||
'locked_at' => $now,
|
||||
'confirmed_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('settlement_adjustments')->insert([
|
||||
'settlement_period_id' => $periodId,
|
||||
'original_bill_id' => $originalBillId,
|
||||
'adjustment_type' => 'bad_debt',
|
||||
'amount' => $unpaid,
|
||||
'reason' => $reason,
|
||||
'created_by' => $adminUserId > 0 ? $adminUserId : null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('settlement_bills')->where('id', $originalBillId)->update([
|
||||
'unpaid_amount' => 0,
|
||||
'status' => 'settled',
|
||||
'meta_json' => json_encode(array_merge(
|
||||
$this->decodeMeta($original->meta_json),
|
||||
[
|
||||
'bad_debt_bill_id' => $archiveBillId,
|
||||
'written_off_amount' => $unpaid,
|
||||
],
|
||||
)),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->periodCompletion->syncIfReady($periodId);
|
||||
|
||||
return $archiveBillId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeMeta(mixed $metaJson): array
|
||||
{
|
||||
if ($metaJson === null || $metaJson === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($metaJson)) {
|
||||
return $metaJson;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $metaJson, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* 已锁定账单的补差/冲正单(§21.1、§21.2)。
|
||||
*/
|
||||
final class AgentSettlementBillAdjustmentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentSettlementBillGuard $billGuard,
|
||||
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
|
||||
) {}
|
||||
|
||||
public function createAdjustment(
|
||||
int $originalBillId,
|
||||
int $amount,
|
||||
string $adjustmentType,
|
||||
?string $reason,
|
||||
int $adminUserId,
|
||||
): int {
|
||||
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
|
||||
if ($original === null) {
|
||||
throw new \InvalidArgumentException('bill_not_found');
|
||||
}
|
||||
|
||||
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'period' => ['completed'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'settled', 'overdue'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['not_locked'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($amount === 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => ['zero'],
|
||||
]);
|
||||
}
|
||||
|
||||
$type = in_array($adjustmentType, ['adjustment', 'reversal'], true)
|
||||
? $adjustmentType
|
||||
: 'adjustment';
|
||||
|
||||
return (int) DB::transaction(function () use ($original, $amount, $type, $reason, $adminUserId): int {
|
||||
$now = now();
|
||||
$newBillId = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => (int) $original->settlement_period_id,
|
||||
'bill_type' => $type,
|
||||
'owner_type' => (string) $original->owner_type,
|
||||
'owner_id' => (int) $original->owner_id,
|
||||
'counterparty_type' => (string) $original->counterparty_type,
|
||||
'counterparty_id' => (int) $original->counterparty_id,
|
||||
'gross_win_loss' => 0,
|
||||
'rebate_amount' => 0,
|
||||
'adjustment_amount' => $amount,
|
||||
'platform_rounding_adjustment' => 0,
|
||||
'net_amount' => $amount,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => abs($amount),
|
||||
'status' => 'pending_confirm',
|
||||
'reversed_bill_id' => (int) $original->id,
|
||||
'meta_json' => json_encode([
|
||||
'original_bill_id' => (int) $original->id,
|
||||
'original_net_amount' => (int) $original->net_amount,
|
||||
]),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('settlement_adjustments')->insert([
|
||||
'settlement_period_id' => (int) $original->settlement_period_id,
|
||||
'original_bill_id' => (int) $original->id,
|
||||
'adjustment_type' => $type,
|
||||
'amount' => $amount,
|
||||
'reason' => $reason,
|
||||
'created_by' => $adminUserId > 0 ? $adminUserId : null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return $newBillId;
|
||||
});
|
||||
}
|
||||
}
|
||||
51
app/Services/AgentSettlement/AgentSettlementBillGuard.php
Normal file
51
app/Services/AgentSettlement/AgentSettlementBillGuard.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class AgentSettlementBillGuard
|
||||
{
|
||||
private const LOCKED_STATUSES = ['confirmed', 'partial_paid', 'settled', 'overdue', 'reversed'];
|
||||
|
||||
public function __construct(
|
||||
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
|
||||
) {}
|
||||
|
||||
public function assertPeriodMutable(int $billId): void
|
||||
{
|
||||
$periodId = (int) DB::table('settlement_bills')->where('id', $billId)->value('settlement_period_id');
|
||||
if ($this->periodCompletion->isPeriodReadOnly($periodId)) {
|
||||
throw ValidationException::withMessages([
|
||||
'period' => ['completed'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function assertNetAmountMutable(int $billId): void
|
||||
{
|
||||
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
|
||||
if ($bill === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array((string) $bill->status, self::LOCKED_STATUSES, true) || $bill->locked_at !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['locked'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function markConfirmed(int $billId): void
|
||||
{
|
||||
$this->assertPeriodMutable($billId);
|
||||
|
||||
DB::table('settlement_bills')->where('id', $billId)->update([
|
||||
'status' => 'confirmed',
|
||||
'locked_at' => now(),
|
||||
'confirmed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,20 @@
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Support\Settlement\DesignDocExample12;
|
||||
use App\Models\AgentNode;
|
||||
use App\Services\Agent\AgentCreditAllocatedSyncService;
|
||||
use App\Support\AgentSettlementProductionGuard;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AgentSettlementPeriodCloseService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShareSettlementCalculator $calculator,
|
||||
private readonly AgentPeriodAggregator $aggregator,
|
||||
private readonly SettlementBillGenerator $billGenerator,
|
||||
private readonly PeriodCloseRebateService $periodCloseRebate,
|
||||
private readonly UnsettledTicketPeriodWarning $unsettledWarning,
|
||||
private readonly PlatformRoundingAdjuster $platformRounding,
|
||||
private readonly AgentCreditAllocatedSyncService $allocatedSync,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -16,51 +23,74 @@ final class AgentSettlementPeriodCloseService
|
||||
*/
|
||||
public function closePeriod(int $periodId): array
|
||||
{
|
||||
AgentSettlementProductionGuard::assertProductionCloseAllowed();
|
||||
|
||||
$period = DB::table('settlement_periods')->where('id', $periodId)->first();
|
||||
if ($period === null) {
|
||||
throw new \InvalidArgumentException('period_not_found');
|
||||
}
|
||||
|
||||
$result = $this->calculator->calculate(
|
||||
sharedNetWinLoss: DesignDocExample12::SHARED_NET_WIN_LOSS,
|
||||
totalSharesByCode: [
|
||||
'A' => DesignDocExample12::TOTAL_SHARE_A,
|
||||
'B' => DesignDocExample12::TOTAL_SHARE_B,
|
||||
'C' => DesignDocExample12::TOTAL_SHARE_C,
|
||||
],
|
||||
extraRebateByCode: ['C' => DesignDocExample12::EXTRA_REBATE_BY_C],
|
||||
gameWinLoss: DesignDocExample12::GAME_WIN_LOSS,
|
||||
basicRebate: DesignDocExample12::BASIC_REBATE,
|
||||
chainFromPlayer: ['C', 'B', 'A'],
|
||||
if ((string) $period->status === 'closed') {
|
||||
throw new \InvalidArgumentException('period_already_closed');
|
||||
}
|
||||
|
||||
$adminSiteId = (int) $period->admin_site_id;
|
||||
$aggregate = $this->aggregator->aggregate(
|
||||
$adminSiteId,
|
||||
(string) $period->period_start,
|
||||
(string) $period->period_end,
|
||||
);
|
||||
|
||||
$playerBillId = DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => 0,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => 0,
|
||||
'gross_win_loss' => DesignDocExample12::GAME_WIN_LOSS,
|
||||
'rebate_amount' => DesignDocExample12::BASIC_REBATE + DesignDocExample12::EXTRA_REBATE_BY_C,
|
||||
'adjustment_amount' => 0,
|
||||
'net_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT,
|
||||
'status' => 'pending',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
if ($aggregate['players'] === []) {
|
||||
throw new \InvalidArgumentException('period_no_ledger_rows');
|
||||
}
|
||||
|
||||
$billIds = $this->billGenerator->generate($periodId, $adminSiteId, $aggregate);
|
||||
|
||||
$roundingDiff = $this->platformRounding->apply($periodId, $aggregate);
|
||||
|
||||
$rebateStats = $this->periodCloseRebate->dispatchAndAllocate(
|
||||
$periodId,
|
||||
(string) $period->period_start,
|
||||
(string) $period->period_end,
|
||||
);
|
||||
|
||||
$unsettled = $this->unsettledWarning->countForSite(
|
||||
$adminSiteId,
|
||||
(string) $period->period_start,
|
||||
(string) $period->period_end,
|
||||
);
|
||||
|
||||
DB::table('settlement_periods')->where('id', $periodId)->update([
|
||||
'status' => 'closed',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('share_ledger')
|
||||
->whereBetween('settled_at', [$period->period_start, $period->period_end])
|
||||
->update(['settlement_period_id' => $periodId]);
|
||||
|
||||
$this->reconcileAllocatedCreditForSite($adminSiteId);
|
||||
|
||||
return [
|
||||
'period_id' => $periodId,
|
||||
'settlement' => $result,
|
||||
'player_bill_id' => $playerBillId,
|
||||
'bill_ids' => $billIds,
|
||||
'player_count' => count($aggregate['players']),
|
||||
'agent_edges' => $aggregate['agent_edges'],
|
||||
'rebate_dispatched' => $rebateStats['dispatched'],
|
||||
'rebate_allocations' => $rebateStats['allocations'],
|
||||
'unsettled_ticket_count' => $unsettled['count'],
|
||||
'unsettled_ticket_sample' => $unsettled['ticket_item_ids'],
|
||||
'platform_rounding_adjustment' => $roundingDiff,
|
||||
];
|
||||
}
|
||||
|
||||
/** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */
|
||||
private function reconcileAllocatedCreditForSite(int $adminSiteId): void
|
||||
{
|
||||
$nodes = AgentNode::query()->where('admin_site_id', $adminSiteId)->get();
|
||||
foreach ($nodes as $node) {
|
||||
$this->allocatedSync->syncForAgent($node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 账期全部账单结清或核销后标记为 completed(§4)。 */
|
||||
final class AgentSettlementPeriodCompletionService
|
||||
{
|
||||
public function syncIfReady(int $periodId): void
|
||||
{
|
||||
if ($periodId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$period = DB::table('settlement_periods')->where('id', $periodId)->first();
|
||||
if ($period === null || (string) $period->status !== 'closed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasOpenSettlementWork($periodId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('settlement_periods')
|
||||
->where('id', $periodId)
|
||||
->update([
|
||||
'status' => 'completed',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function isPeriodReadOnly(int $periodId): bool
|
||||
{
|
||||
if ($periodId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = DB::table('settlement_periods')->where('id', $periodId)->value('status');
|
||||
|
||||
return $status !== null && (string) $status === 'completed';
|
||||
}
|
||||
|
||||
private function hasOpenSettlementWork(int $periodId): bool
|
||||
{
|
||||
return DB::table('settlement_bills')
|
||||
->where('settlement_period_id', $periodId)
|
||||
->whereNotIn('bill_type', ['bad_debt'])
|
||||
->where(function ($query): void {
|
||||
$query->where('status', 'pending_confirm')
|
||||
->orWhere(function ($inner): void {
|
||||
$inner->whereIn('status', ['confirmed', 'partial_paid', 'overdue'])
|
||||
->where('unpaid_amount', '>', 0);
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 账期窗口内信用流水与占成流水笔数(关账前诊断)。 */
|
||||
final class AgentSettlementPeriodPipelineService
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, object> $periods settlement_periods 行,须含 id、period_start、period_end、admin_site_id
|
||||
* @return array<int, array{credit_ledger_count: int, share_ledger_count: int}>
|
||||
*/
|
||||
public function countsForPeriods(Collection $periods): array
|
||||
{
|
||||
if ($periods->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$siteIds = $periods->pluck('admin_site_id')->map(static fn ($id): int => (int) $id)->unique()->all();
|
||||
$siteCodes = DB::table('admin_sites')
|
||||
->whereIn('id', $siteIds)
|
||||
->pluck('code', 'id');
|
||||
|
||||
$out = [];
|
||||
foreach ($periods as $period) {
|
||||
$periodId = (int) $period->id;
|
||||
$siteCode = (string) ($siteCodes[(int) $period->admin_site_id] ?? '');
|
||||
if ($siteCode === '') {
|
||||
$out[$periodId] = ['credit_ledger_count' => 0, 'share_ledger_count' => 0];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = Carbon::parse($period->period_start)->startOfDay();
|
||||
$end = Carbon::parse($period->period_end)->endOfDay();
|
||||
|
||||
$creditCount = (int) DB::table('credit_ledger as cl')
|
||||
->join('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'cl.owner_id')
|
||||
->where('cl.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->where('p.funding_mode', PlayerFundingMode::CREDIT)
|
||||
->whereBetween('cl.created_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
$shareCount = (int) DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$start, $end])
|
||||
->count();
|
||||
|
||||
$out[$periodId] = [
|
||||
'credit_ledger_count' => $creditCount,
|
||||
'share_ledger_count' => $shareCount,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 账期维度账单笔数与待办汇总(结算中心看板)。 */
|
||||
final class AgentSettlementPeriodSummaryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentSettlementPeriodPipelineService $pipelineService,
|
||||
) {}
|
||||
/**
|
||||
* @param list<int> $periodIds
|
||||
* @return array<int, array<string, int>>
|
||||
*/
|
||||
public function summariesForPeriodIds(array $periodIds): array
|
||||
{
|
||||
if ($periodIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('settlement_bills')
|
||||
->whereIn('settlement_period_id', $periodIds)
|
||||
->groupBy('settlement_period_id')
|
||||
->selectRaw('settlement_period_id')
|
||||
->selectRaw("SUM(CASE WHEN bill_type = 'player' THEN 1 ELSE 0 END) as player_bills")
|
||||
->selectRaw("SUM(CASE WHEN bill_type = 'agent' THEN 1 ELSE 0 END) as agent_bills")
|
||||
->selectRaw("SUM(CASE WHEN bill_type IN ('adjustment', 'reversal') THEN 1 ELSE 0 END) as adjustment_bills")
|
||||
->selectRaw("SUM(CASE WHEN status = 'pending_confirm' THEN 1 ELSE 0 END) as pending_confirm")
|
||||
->selectRaw("SUM(CASE WHEN status IN ('confirmed', 'partial_paid', 'overdue') AND unpaid_amount > 0 THEN 1 ELSE 0 END) as awaiting_payment")
|
||||
->selectRaw("SUM(CASE WHEN status = 'settled' THEN 1 ELSE 0 END) as settled")
|
||||
->selectRaw('COALESCE(SUM(unpaid_amount), 0) as total_unpaid')
|
||||
->get();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$periodId = (int) $row->settlement_period_id;
|
||||
$out[$periodId] = [
|
||||
'player_bills' => (int) $row->player_bills,
|
||||
'agent_bills' => (int) $row->agent_bills,
|
||||
'adjustment_bills' => (int) $row->adjustment_bills,
|
||||
'pending_confirm' => (int) $row->pending_confirm,
|
||||
'awaiting_payment' => (int) $row->awaiting_payment,
|
||||
'settled' => (int) $row->settled,
|
||||
'total_unpaid' => (int) $row->total_unpaid,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $periods
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function attachToPeriodRows(Collection $periods): array
|
||||
{
|
||||
$ids = $periods->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||
$summaries = $this->summariesForPeriodIds($ids);
|
||||
$pipelines = $this->pipelineService->countsForPeriods($periods);
|
||||
$empty = [
|
||||
'player_bills' => 0,
|
||||
'agent_bills' => 0,
|
||||
'adjustment_bills' => 0,
|
||||
'pending_confirm' => 0,
|
||||
'awaiting_payment' => 0,
|
||||
'settled' => 0,
|
||||
'total_unpaid' => 0,
|
||||
];
|
||||
$emptyPipeline = ['credit_ledger_count' => 0, 'share_ledger_count' => 0];
|
||||
|
||||
$items = [];
|
||||
foreach ($periods as $period) {
|
||||
$row = (array) $period;
|
||||
$row['summary'] = $summaries[(int) $period->id] ?? $empty;
|
||||
$row['pipeline'] = $pipelines[(int) $period->id] ?? $emptyPipeline;
|
||||
$items[] = $row;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** §21.12 信用占成盘报表最低集。 */
|
||||
final class AgentSettlementReportQueryService
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function summary(AdminUser $admin, int $periodId = 0): array
|
||||
{
|
||||
$query = DB::table('settlement_bills');
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin);
|
||||
if ($periodId > 0) {
|
||||
$query->where('settlement_period_id', $periodId);
|
||||
}
|
||||
$rows = $query->get();
|
||||
|
||||
return [
|
||||
'bill_count' => $rows->count(),
|
||||
'total_net' => (int) $rows->sum('net_amount'),
|
||||
'total_unpaid' => (int) $rows->sum('unpaid_amount'),
|
||||
'overdue_count' => $rows->where('status', 'overdue')->count(),
|
||||
'platform_rounding_total' => (int) $rows->sum('platform_rounding_adjustment'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function playerWinLoss(AdminUser $admin, int $periodId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, $periodId);
|
||||
|
||||
return DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->leftJoin('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->groupBy('sl.player_id', 'p.username', 'p.agent_node_id', 'ti.play_code')
|
||||
->selectRaw('sl.player_id, p.username, p.agent_node_id, COALESCE(ti.play_code, ?) as game_type', ['*'])
|
||||
->selectRaw('SUM(sl.game_win_loss) as game_win_loss')
|
||||
->selectRaw('SUM(sl.basic_rebate) as basic_rebate')
|
||||
->orderByDesc('game_win_loss')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'player_id' => (int) $r->player_id,
|
||||
'username' => (string) ($r->username ?? ''),
|
||||
'agent_node_id' => (int) $r->agent_node_id,
|
||||
'game_type' => (string) $r->game_type,
|
||||
'game_win_loss' => (int) $r->game_win_loss,
|
||||
'basic_rebate' => (int) $r->basic_rebate,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function agentShare(AdminUser $admin, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, 0);
|
||||
|
||||
return DB::table('share_ledger as sl')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->groupBy('sl.agent_node_id')
|
||||
->selectRaw('sl.agent_node_id, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as entry_count')
|
||||
->orderByDesc('game_win_loss')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'agent_node_id' => (int) $r->agent_node_id,
|
||||
'game_win_loss' => (int) $r->game_win_loss,
|
||||
'basic_rebate' => (int) $r->basic_rebate,
|
||||
'entry_count' => (int) $r->entry_count,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rebate(AdminUser $admin, int $periodId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, $periodId);
|
||||
|
||||
$base = DB::table('rebate_records as rr')
|
||||
->join('players as p', 'p.id', '=', 'rr.player_id')
|
||||
->where('p.site_code', $siteCode);
|
||||
|
||||
$accrued = (clone $base)->where('rr.status', 'accrued')->sum('rr.rebate_amount');
|
||||
$inBill = (clone $base)->where('rr.status', 'in_bill')->sum('rr.rebate_amount');
|
||||
$settled = (clone $base)->where('rr.status', 'settled')->sum('rr.rebate_amount');
|
||||
$allocated = (int) DB::table('rebate_allocations as ra')
|
||||
->join('rebate_records as rr', 'rr.id', '=', 'ra.rebate_record_id')
|
||||
->join('players as p', 'p.id', '=', 'rr.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->when($periodId > 0, fn (Builder $q) => $q->where('rr.settlement_period_id', $periodId))
|
||||
->sum('ra.allocated_amount');
|
||||
|
||||
return [
|
||||
'accrued_total' => (int) $accrued,
|
||||
'in_bill_total' => (int) $inBill,
|
||||
'settled_total' => (int) $settled,
|
||||
'allocated_total' => $allocated,
|
||||
'by_type' => DB::table('rebate_records as rr')
|
||||
->join('players as p', 'p.id', '=', 'rr.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('rr.created_at', [$periodStart, $periodEnd])
|
||||
->groupBy('rr.rebate_type', 'rr.status')
|
||||
->selectRaw('rr.rebate_type, rr.status, SUM(rr.rebate_amount) as total, COUNT(*) as cnt')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'rebate_type' => (string) $r->rebate_type,
|
||||
'status' => (string) $r->status,
|
||||
'total' => (int) $r->total,
|
||||
'count' => (int) $r->cnt,
|
||||
])
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{agents: list<array<string, mixed>>, players: list<array<string, mixed>>}
|
||||
*/
|
||||
public function credit(AdminUser $admin): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, 0);
|
||||
|
||||
$agents = DB::table('agent_profiles as ap')
|
||||
->join('agent_nodes as an', 'an.id', '=', 'ap.agent_node_id')
|
||||
->join('admin_sites as s', 's.id', '=', 'an.admin_site_id')
|
||||
->where('s.code', $siteCode)
|
||||
->selectRaw('ap.agent_node_id, an.code, an.name, ap.credit_limit, ap.allocated_credit, (ap.credit_limit - ap.allocated_credit) as available_credit')
|
||||
->orderBy('an.depth')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'agent_node_id' => (int) $r->agent_node_id,
|
||||
'code' => (string) $r->code,
|
||||
'name' => (string) $r->name,
|
||||
'credit_limit' => (int) $r->credit_limit,
|
||||
'allocated_credit' => (int) $r->allocated_credit,
|
||||
'available_credit' => (int) $r->available_credit,
|
||||
])
|
||||
->all();
|
||||
|
||||
$players = DB::table('player_credit_accounts as pc')
|
||||
->join('players as p', 'p.id', '=', 'pc.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->selectRaw('pc.player_id, p.username, pc.credit_limit, pc.used_credit, pc.frozen_credit, (pc.credit_limit - pc.used_credit - pc.frozen_credit) as available_credit')
|
||||
->orderByDesc('pc.used_credit')
|
||||
->limit(500)
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'player_id' => (int) $r->player_id,
|
||||
'username' => (string) ($r->username ?? ''),
|
||||
'credit_limit' => (int) $r->credit_limit,
|
||||
'used_credit' => (int) $r->used_credit,
|
||||
'frozen_credit' => (int) $r->frozen_credit,
|
||||
'available_credit' => max(0, (int) $r->available_credit),
|
||||
])
|
||||
->all();
|
||||
|
||||
return ['agents' => $agents, 'players' => $players];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function unpaidBills(AdminUser $admin, int $periodId = 0): array
|
||||
{
|
||||
$query = DB::table('settlement_bills as sb')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->where('sb.unpaid_amount', '>', 0)
|
||||
->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']);
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
|
||||
if ($periodId > 0) {
|
||||
$query->where('sb.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
return $query
|
||||
->select([
|
||||
'sb.id',
|
||||
'sb.bill_type',
|
||||
'sb.owner_type',
|
||||
'sb.owner_id',
|
||||
'sb.counterparty_type',
|
||||
'sb.counterparty_id',
|
||||
'sb.net_amount',
|
||||
'sb.unpaid_amount',
|
||||
'sb.status',
|
||||
'sp.period_start',
|
||||
'sp.period_end',
|
||||
])
|
||||
->orderByDesc('sb.unpaid_amount')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'bill_id' => (int) $r->id,
|
||||
'bill_type' => (string) $r->bill_type,
|
||||
'owner_type' => (string) $r->owner_type,
|
||||
'owner_id' => (int) $r->owner_id,
|
||||
'counterparty_type' => (string) $r->counterparty_type,
|
||||
'counterparty_id' => (int) $r->counterparty_id,
|
||||
'net_amount' => (int) $r->net_amount,
|
||||
'unpaid_amount' => (int) $r->unpaid_amount,
|
||||
'status' => (string) $r->status,
|
||||
'period_start' => (string) $r->period_start,
|
||||
'period_end' => (string) $r->period_end,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function overdue(AdminUser $admin): array
|
||||
{
|
||||
$query = DB::table('settlement_bills as sb')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->where('sb.status', 'overdue')
|
||||
->where('sb.unpaid_amount', '>', 0);
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
|
||||
|
||||
return $query
|
||||
->select([
|
||||
'sb.id',
|
||||
'sb.bill_type',
|
||||
'sb.owner_type',
|
||||
'sb.owner_id',
|
||||
'sb.unpaid_amount',
|
||||
'sb.updated_at',
|
||||
'sp.period_end',
|
||||
])
|
||||
->orderBy('sb.updated_at')
|
||||
->get()
|
||||
->map(static function (object $r): array {
|
||||
$days = Carbon::parse((string) $r->updated_at)->diffInDays(now());
|
||||
|
||||
return [
|
||||
'bill_id' => (int) $r->id,
|
||||
'bill_type' => (string) $r->bill_type,
|
||||
'owner_type' => (string) $r->owner_type,
|
||||
'owner_id' => (int) $r->owner_id,
|
||||
'unpaid_amount' => (int) $r->unpaid_amount,
|
||||
'overdue_days' => $days,
|
||||
'period_end' => (string) $r->period_end,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function platformPnl(AdminUser $admin, int $periodId): array
|
||||
{
|
||||
$query = DB::table('settlement_bills as sb')
|
||||
->where('sb.settlement_period_id', $periodId)
|
||||
->where(function (Builder $q): void {
|
||||
$q->where('sb.counterparty_type', 'platform')
|
||||
->orWhere('sb.owner_type', 'platform');
|
||||
});
|
||||
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
|
||||
$rows = $query->get();
|
||||
|
||||
return [
|
||||
'platform_bill_net' => (int) $rows->sum('net_amount'),
|
||||
'platform_rounding_adjustment' => (int) $rows->sum('platform_rounding_adjustment'),
|
||||
'share_profit_meta' => (int) $rows->sum(fn (object $r): int => (int) (json_decode((string) ($r->meta_json ?? '{}'), true)['platform_share_profit'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function drawPeriod(AdminUser $admin, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = $this->siteCodeForAdmin($admin, 0);
|
||||
|
||||
return DB::table('share_ledger as sl')
|
||||
->join('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
|
||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||
->join('draws as d', 'd.id', '=', 'ti.draw_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->whereNull('sl.reversal_of_id')
|
||||
->groupBy('ti.draw_id', 'd.draw_no')
|
||||
->selectRaw('ti.draw_id, d.draw_no, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as ticket_count')
|
||||
->orderBy('d.draw_no')
|
||||
->get()
|
||||
->map(static fn (object $r): array => [
|
||||
'draw_id' => (int) $r->draw_id,
|
||||
'draw_no' => (string) $r->draw_no,
|
||||
'game_win_loss' => (int) $r->game_win_loss,
|
||||
'basic_rebate' => (int) $r->basic_rebate,
|
||||
'ticket_count' => (int) $r->ticket_count,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function siteCodeForAdmin(AdminUser $admin, int $periodId): string
|
||||
{
|
||||
if ($periodId > 0) {
|
||||
$siteId = (int) DB::table('settlement_periods')->where('id', $periodId)->value('admin_site_id');
|
||||
|
||||
return (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
}
|
||||
|
||||
$siteId = (int) ($admin->admin_site_id ?? 0);
|
||||
if ($siteId > 0) {
|
||||
return (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
}
|
||||
|
||||
return (string) DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||
}
|
||||
}
|
||||
116
app/Services/AgentSettlement/BetSettlementSnapshotBuilder.php
Normal file
116
app/Services/AgentSettlement/BetSettlementSnapshotBuilder.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use App\Models\AgentProfile;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class BetSettlementSnapshotBuilder
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* agent_node_id: int,
|
||||
* agent_path: list<int>,
|
||||
* chain_codes: list<string>,
|
||||
* total_shares: array<string, float>,
|
||||
* actual_shares: array<string, float>,
|
||||
* rebate_rate: float,
|
||||
* extra_rebate_rate: float,
|
||||
* }
|
||||
*/
|
||||
public function buildForPlayer(Player $player, string $gameType = '*'): array
|
||||
{
|
||||
$agentNodeId = (int) $player->agent_node_id;
|
||||
if ($agentNodeId <= 0) {
|
||||
throw new \InvalidArgumentException('player_missing_agent');
|
||||
}
|
||||
|
||||
$pathIds = [];
|
||||
$chainCodes = [];
|
||||
$totalShares = [];
|
||||
$nodeId = $agentNodeId;
|
||||
|
||||
while ($nodeId > 0) {
|
||||
$node = AgentNode::query()->find($nodeId);
|
||||
if ($node === null) {
|
||||
break;
|
||||
}
|
||||
array_unshift($pathIds, (int) $node->id);
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
|
||||
$code = (string) $node->code;
|
||||
$chainCodes[] = $code;
|
||||
$totalShares[$code] = (float) ($profile?->total_share_rate ?? 0);
|
||||
$nodeId = (int) ($node->parent_id ?? 0);
|
||||
}
|
||||
|
||||
$orderedBottomUp = $chainCodes;
|
||||
$actual = $this->resolveActualShares($totalShares, $orderedBottomUp);
|
||||
|
||||
$rebate = $this->resolvePlayerRebateRate((int) $player->id, $agentNodeId, $gameType);
|
||||
|
||||
return [
|
||||
'agent_node_id' => $agentNodeId,
|
||||
'agent_path' => $pathIds,
|
||||
'chain_codes' => $orderedBottomUp,
|
||||
'total_shares' => $totalShares,
|
||||
'actual_shares' => $actual,
|
||||
'rebate_rate' => $rebate['rebate_rate'],
|
||||
'extra_rebate_rate' => $rebate['extra_rebate_rate'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, float> $totalShares
|
||||
* @param list<string> $orderedBottomUp
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private function resolveActualShares(array $totalShares, array $orderedBottomUp): array
|
||||
{
|
||||
$actual = [];
|
||||
$prev = 0.0;
|
||||
foreach ($orderedBottomUp as $code) {
|
||||
$total = (float) ($totalShares[$code] ?? 0);
|
||||
$actual[$code] = max(0, $total - $prev);
|
||||
$prev = $total;
|
||||
}
|
||||
$actual['platform'] = max(0, 100 - $prev);
|
||||
|
||||
return $actual;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rebate_rate: float, extra_rebate_rate: float}
|
||||
*/
|
||||
private function resolvePlayerRebateRate(int $playerId, int $agentNodeId, string $gameType = '*'): array
|
||||
{
|
||||
$gameType = trim($gameType) !== '' ? trim($gameType) : '*';
|
||||
|
||||
$row = DB::table('player_rebate_profiles')
|
||||
->where('player_id', $playerId)
|
||||
->where('game_type', $gameType)
|
||||
->first();
|
||||
|
||||
if ($row === null && $gameType !== '*') {
|
||||
$row = DB::table('player_rebate_profiles')
|
||||
->where('player_id', $playerId)
|
||||
->where('game_type', '*')
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($row !== null && ! (bool) $row->inherit_from_agent) {
|
||||
return [
|
||||
'rebate_rate' => (float) $row->rebate_rate,
|
||||
'extra_rebate_rate' => (float) $row->extra_rebate_rate,
|
||||
];
|
||||
}
|
||||
|
||||
$profile = AgentProfile::query()->where('agent_node_id', $agentNodeId)->first();
|
||||
|
||||
return [
|
||||
'rebate_rate' => (float) ($profile?->default_player_rebate ?? 0),
|
||||
'extra_rebate_rate' => 0.0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Support\CreditAmountScale;
|
||||
use App\Models\TicketItem;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class GameSettlementReversalService
|
||||
{
|
||||
public function reverseTicketItem(TicketItem $item): void
|
||||
{
|
||||
$ledger = DB::table('share_ledger')->where('ticket_item_id', $item->id)->whereNull('reversal_of_id')->first();
|
||||
if ($ledger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settledAt = now();
|
||||
|
||||
DB::transaction(function () use ($item, $ledger, $settledAt): void {
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $item->id,
|
||||
'player_id' => $ledger->player_id,
|
||||
'agent_node_id' => $ledger->agent_node_id,
|
||||
'agent_path' => $ledger->agent_path,
|
||||
'share_snapshot' => $ledger->share_snapshot,
|
||||
'game_win_loss' => -1 * (int) $ledger->game_win_loss,
|
||||
'basic_rebate' => -1 * (int) $ledger->basic_rebate,
|
||||
'shared_net_win_loss' => -1 * (int) $ledger->shared_net_win_loss,
|
||||
'allocations_json' => $ledger->allocations_json,
|
||||
'reversal_of_id' => $ledger->id,
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
|
||||
$rebates = DB::table('rebate_records')
|
||||
->where('ticket_item_id', $item->id)
|
||||
->where('status', 'accrued')
|
||||
->get();
|
||||
|
||||
foreach ($rebates as $rebate) {
|
||||
DB::table('rebate_records')->insert([
|
||||
'player_id' => $rebate->player_id,
|
||||
'ticket_item_id' => $item->id,
|
||||
'game_type' => $rebate->game_type,
|
||||
'valid_bet_amount' => $rebate->valid_bet_amount,
|
||||
'rebate_rate' => $rebate->rebate_rate,
|
||||
'rebate_amount' => -1 * (int) $rebate->rebate_amount,
|
||||
'rebate_type' => $rebate->rebate_type,
|
||||
'owner_agent_id' => $rebate->owner_agent_id,
|
||||
'status' => 'reversed',
|
||||
'reversal_of_id' => $rebate->id,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
DB::table('rebate_records')->where('id', $rebate->id)->update(['status' => 'reversed']);
|
||||
}
|
||||
|
||||
$player = Player::query()->find((int) $ledger->player_id);
|
||||
if ($player !== null && PlayerFundingMode::usesCredit($player) && (int) $ledger->game_win_loss > 0) {
|
||||
$playerId = (int) $ledger->player_id;
|
||||
$row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first();
|
||||
if ($row !== null) {
|
||||
$deltaMinor = (int) $ledger->game_win_loss;
|
||||
$deltaMajor = CreditAmountScale::minorToMajor(
|
||||
$deltaMinor,
|
||||
(string) $player->default_currency,
|
||||
);
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $playerId)
|
||||
->update([
|
||||
'used_credit' => max(0, (int) $row->used_credit - $deltaMajor),
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
239
app/Services/AgentSettlement/PeriodCloseRebateService.php
Normal file
239
app/Services/AgentSettlement/PeriodCloseRebateService.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 账期关账:回水计提 → in_bill,并写入 rebate_allocations(§7、§9.3)。
|
||||
*/
|
||||
final class PeriodCloseRebateService
|
||||
{
|
||||
/**
|
||||
* @return array{dispatched: int, allocations: int}
|
||||
*/
|
||||
public function dispatchAndAllocate(
|
||||
int $periodId,
|
||||
string $periodStart,
|
||||
string $periodEnd,
|
||||
): array {
|
||||
$rebateIds = $this->dispatchAccruedToPeriod($periodId, $periodStart, $periodEnd);
|
||||
$allocationCount = $this->buildAllocations($periodId, $rebateIds);
|
||||
|
||||
return [
|
||||
'dispatched' => count($rebateIds),
|
||||
'allocations' => $allocationCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function dispatchAccruedToPeriod(int $periodId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$ids = DB::table('rebate_records as rr')
|
||||
->join('ticket_items as ti', 'ti.id', '=', 'rr.ticket_item_id')
|
||||
->join('share_ledger as sl', function ($join): void {
|
||||
$join->on('sl.ticket_item_id', '=', 'ti.id')
|
||||
->whereNull('sl.reversal_of_id');
|
||||
})
|
||||
->where('rr.status', 'accrued')
|
||||
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
|
||||
->pluck('rr.id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
DB::table('rebate_records')
|
||||
->whereIn('id', $ids)
|
||||
->update([
|
||||
'settlement_period_id' => $periodId,
|
||||
'status' => 'in_bill',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $rebateIds
|
||||
*/
|
||||
private function buildAllocations(int $periodId, array $rebateIds): int
|
||||
{
|
||||
if ($rebateIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$playerBills = DB::table('settlement_bills')
|
||||
->where('settlement_period_id', $periodId)
|
||||
->where('bill_type', 'player')
|
||||
->get()
|
||||
->keyBy('owner_id');
|
||||
|
||||
$count = 0;
|
||||
$rebates = DB::table('rebate_records')
|
||||
->whereIn('id', $rebateIds)
|
||||
->where('status', 'in_bill')
|
||||
->get();
|
||||
|
||||
foreach ($rebates as $rebate) {
|
||||
$playerId = (int) $rebate->player_id;
|
||||
$bill = $playerBills->get($playerId);
|
||||
$billId = $bill !== null ? (int) $bill->id : null;
|
||||
|
||||
if ((string) $rebate->rebate_type === 'extra') {
|
||||
$count += $this->insertExtraAllocation($rebate, $billId);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$count += $this->insertBasicShareAllocations($rebate, $billId);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function insertExtraAllocation(object $rebate, ?int $billId): int
|
||||
{
|
||||
$agentId = (int) ($rebate->owner_agent_id ?? 0);
|
||||
if ($agentId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
DB::table('rebate_allocations')->insert([
|
||||
'rebate_record_id' => (int) $rebate->id,
|
||||
'settlement_bill_id' => $billId,
|
||||
'participant_type' => 'agent',
|
||||
'participant_id' => $agentId,
|
||||
'actual_share_rate' => 0,
|
||||
'allocated_amount' => (int) $rebate->rebate_amount,
|
||||
'allocation_rule' => 'owner',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function insertBasicShareAllocations(object $rebate, ?int $billId): int
|
||||
{
|
||||
$ticketItemId = (int) ($rebate->ticket_item_id ?? 0);
|
||||
if ($ticketItemId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ledger = DB::table('share_ledger')
|
||||
->where('ticket_item_id', $ticketItemId)
|
||||
->whereNull('reversal_of_id')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($ledger === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$snapshot = $this->decodeSnapshot($ledger->share_snapshot);
|
||||
if ($snapshot === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$amount = (int) $rebate->rebate_amount;
|
||||
if ($amount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$shares = $snapshot['actual_shares'];
|
||||
$rows = [];
|
||||
$allocatedSum = 0;
|
||||
$participants = [];
|
||||
|
||||
foreach ($shares as $code => $rate) {
|
||||
if ($code === 'platform') {
|
||||
$participants[] = ['type' => 'platform', 'id' => 0, 'rate' => (float) $rate];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$node = AgentNode::query()->where('code', (string) $code)->first();
|
||||
if ($node === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participants[] = ['type' => 'agent', 'id' => (int) $node->id, 'rate' => (float) $rate];
|
||||
}
|
||||
|
||||
foreach ($participants as $index => $p) {
|
||||
$isLast = $index === count($participants) - 1;
|
||||
$slice = $isLast
|
||||
? $amount - $allocatedSum
|
||||
: (int) round($amount * ($p['rate'] / 100), 0, PHP_ROUND_HALF_UP);
|
||||
$allocatedSum += $slice;
|
||||
|
||||
$rows[] = [
|
||||
'rebate_record_id' => (int) $rebate->id,
|
||||
'settlement_bill_id' => $billId,
|
||||
'participant_type' => (string) $p['type'],
|
||||
'participant_id' => (int) $p['id'],
|
||||
'actual_share_rate' => $p['rate'],
|
||||
'allocated_amount' => $slice,
|
||||
'allocation_rule' => 'share',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
DB::table('rebate_allocations')->insert($rows);
|
||||
}
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{actual_shares: array<string, float>}|null
|
||||
*/
|
||||
private function decodeSnapshot(mixed $raw): ?array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$actual = $decoded['actual_shares'] ?? null;
|
||||
if (! is_array($actual) || $actual === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shares = [];
|
||||
foreach ($actual as $code => $rate) {
|
||||
$shares[(string) $code] = (float) $rate;
|
||||
}
|
||||
|
||||
return ['actual_shares' => $shares];
|
||||
}
|
||||
|
||||
public function markRebatesSettledForBill(int $billId): void
|
||||
{
|
||||
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
|
||||
if ($bill === null || (string) $bill->bill_type !== 'player') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('rebate_records')
|
||||
->where('player_id', (int) $bill->owner_id)
|
||||
->where('settlement_period_id', (int) $bill->settlement_period_id)
|
||||
->where('status', 'in_bill')
|
||||
->update([
|
||||
'status' => 'settled',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
app/Services/AgentSettlement/PlatformRoundingAdjuster.php
Normal file
72
app/Services/AgentSettlement/PlatformRoundingAdjuster.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 账期汇总尾差归平台(§21.8)。
|
||||
*/
|
||||
final class PlatformRoundingAdjuster
|
||||
{
|
||||
/**
|
||||
* @param array{players: array<int, array<string, mixed>>, agent_edges: array<string, int>, agent_subtrees: array<int, array<string, mixed>>} $aggregate
|
||||
*/
|
||||
public function apply(int $periodId, array $aggregate): int
|
||||
{
|
||||
$playerNet = 0;
|
||||
foreach ($aggregate['players'] as $row) {
|
||||
$playerNet += (int) $row['net_amount'];
|
||||
}
|
||||
|
||||
$shareProfitTotal = 0;
|
||||
foreach ($aggregate['agent_subtrees'] as $subtree) {
|
||||
$shareProfitTotal += (int) ($subtree['share_profit'] ?? 0);
|
||||
}
|
||||
$shareProfitTotal += (int) ($aggregate['platform_share_profit'] ?? 0);
|
||||
|
||||
$diff = $playerNet - $shareProfitTotal;
|
||||
if ($diff === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$platformBill = DB::table('settlement_bills')
|
||||
->where('settlement_period_id', $periodId)
|
||||
->where('bill_type', 'agent')
|
||||
->where('counterparty_type', 'platform')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if ($platformBill === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$net = (int) $platformBill->net_amount + $diff;
|
||||
DB::table('settlement_bills')->where('id', (int) $platformBill->id)->update([
|
||||
'platform_rounding_adjustment' => $diff,
|
||||
'net_amount' => $net,
|
||||
'unpaid_amount' => abs($net),
|
||||
'meta_json' => json_encode(array_merge(
|
||||
$this->decodeMeta($platformBill->meta_json),
|
||||
['platform_rounding_adjustment' => $diff],
|
||||
)),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeMeta(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
123
app/Services/AgentSettlement/SettlementBillGenerator.php
Normal file
123
app/Services/AgentSettlement/SettlementBillGenerator.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AgentNode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SettlementBillGenerator
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* players: array<int, array<string, mixed>>,
|
||||
* agent_edges: array<string, int>,
|
||||
* agent_subtrees: array<int, array<string, mixed>>,
|
||||
* platform_share_profit?: int,
|
||||
* } $aggregate
|
||||
* @return list<int> bill ids
|
||||
*/
|
||||
public function generate(int $periodId, int $adminSiteId, array $aggregate): array
|
||||
{
|
||||
$billIds = [];
|
||||
$now = now();
|
||||
$subtrees = $aggregate['agent_subtrees'] ?? [];
|
||||
|
||||
foreach ($aggregate['players'] as $playerId => $row) {
|
||||
$net = (int) $row['net_amount'];
|
||||
$billIds[] = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'player',
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $playerId,
|
||||
'counterparty_type' => 'agent',
|
||||
'counterparty_id' => (int) $row['agent_node_id'],
|
||||
'gross_win_loss' => (int) $row['game_win_loss'],
|
||||
'rebate_amount' => (int) $row['basic_rebate'] + (int) $row['extra_rebate'],
|
||||
'adjustment_amount' => 0,
|
||||
'platform_rounding_adjustment' => 0,
|
||||
'net_amount' => $net,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => abs($net),
|
||||
'status' => 'pending_confirm',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($aggregate['agent_edges'] as $edge => $amount) {
|
||||
if ($amount === 0 || str_starts_with($edge, 'P_to_')) {
|
||||
continue;
|
||||
}
|
||||
$parsed = $this->parseEdge($edge);
|
||||
if ($parsed === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$fromType, $fromId, $toType, $toId] = $parsed;
|
||||
$subtree = $subtrees[$fromId] ?? null;
|
||||
|
||||
$billIds[] = (int) DB::table('settlement_bills')->insertGetId([
|
||||
'settlement_period_id' => $periodId,
|
||||
'bill_type' => 'agent',
|
||||
'owner_type' => $fromType,
|
||||
'owner_id' => $fromId,
|
||||
'counterparty_type' => $toType,
|
||||
'counterparty_id' => $toId,
|
||||
'gross_win_loss' => (int) ($subtree['gross_win_loss'] ?? 0),
|
||||
'rebate_amount' => (int) ($subtree['basic_rebate'] ?? 0) + (int) ($subtree['extra_rebate'] ?? 0),
|
||||
'adjustment_amount' => 0,
|
||||
'platform_rounding_adjustment' => 0,
|
||||
'net_amount' => $amount,
|
||||
'paid_amount' => 0,
|
||||
'unpaid_amount' => $amount,
|
||||
'status' => 'pending_confirm',
|
||||
'meta_json' => json_encode([
|
||||
'edge' => $edge,
|
||||
'share_profit' => (int) ($subtree['share_profit'] ?? 0),
|
||||
'player_count' => (int) ($subtree['player_count'] ?? 0),
|
||||
'platform_share_profit' => $toType === 'platform' ? (int) ($aggregate['platform_share_profit'] ?? 0) : null,
|
||||
]),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $billIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int, 2: string, 3: int}|null
|
||||
*/
|
||||
private function parseEdge(string $edge): ?array
|
||||
{
|
||||
if (preg_match('/^P_to_(.+)$/', $edge, $m)) {
|
||||
$agent = AgentNode::query()->where('code', $m[1])->first();
|
||||
if ($agent === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['agent', (int) $agent->id, 'player', 0];
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+)_to_platform$/', $edge, $m)) {
|
||||
$agent = AgentNode::query()->where('code', $m[1])->first();
|
||||
if ($agent === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['agent', (int) $agent->id, 'platform', 0];
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+)_to_(.+)$/', $edge, $m)) {
|
||||
$from = AgentNode::query()->where('code', $m[1])->first();
|
||||
$to = AgentNode::query()->where('code', $m[2])->first();
|
||||
if ($from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['agent', (int) $from->id, 'agent', (int) $to->id];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
598
app/Services/AgentSettlement/SettlementCenterLedgerService.php
Normal file
598
app/Services/AgentSettlement/SettlementCenterLedgerService.php
Normal file
@@ -0,0 +1,598 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AdminDataScope;
|
||||
use App\Support\AdminAgentSettlementScope;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** 结算中心统一账务流水(credit_ledger + 收付 + 调账)。 */
|
||||
final class SettlementCenterLedgerService
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* items: list<array<string, mixed>>,
|
||||
* total: int,
|
||||
* page: int,
|
||||
* per_page: int,
|
||||
* ledger_source: string,
|
||||
* }
|
||||
*/
|
||||
public function listUnified(
|
||||
AdminUser $admin,
|
||||
string $siteCode,
|
||||
int $page,
|
||||
int $perPage,
|
||||
SettlementLedgerListFilters $filters = new SettlementLedgerListFilters,
|
||||
): array {
|
||||
$periodId = $filters->settlementPeriodId;
|
||||
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
|
||||
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
|
||||
|
||||
$items = [];
|
||||
$includeCredit = $this->includeEntryKind($filters, 'credit');
|
||||
$includePayment = $this->includeEntryKind($filters, 'payment');
|
||||
$includeAdjustment = $this->includeEntryKind($filters, 'adjustment');
|
||||
|
||||
if ($includeCredit) {
|
||||
$creditRows = $this->fetchCreditRows($admin, $siteCode, $range, $filters->playerId);
|
||||
foreach ($creditRows as $row) {
|
||||
$pid = (int) $row->player_id;
|
||||
$bill = $playerBills[$pid] ?? null;
|
||||
$items[] = $this->formatCreditEntry($row, $bill);
|
||||
}
|
||||
}
|
||||
if ($includePayment) {
|
||||
foreach ($this->fetchPaymentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
|
||||
$items[] = $this->formatPaymentEntry($row);
|
||||
}
|
||||
}
|
||||
if ($includeAdjustment) {
|
||||
foreach ($this->fetchAdjustmentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
|
||||
if ($filters->badDebtOnly && (string) $row->adjustment_type !== 'bad_debt') {
|
||||
continue;
|
||||
}
|
||||
$items[] = $this->formatAdjustmentEntry($row);
|
||||
}
|
||||
}
|
||||
|
||||
$items = $this->applyFilters($items, $filters);
|
||||
|
||||
usort($items, static function (array $a, array $b): int {
|
||||
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
|
||||
});
|
||||
|
||||
$total = count($items);
|
||||
$offset = max(0, ($page - 1) * $perPage);
|
||||
$pageItems = array_slice($items, $offset, $perPage);
|
||||
|
||||
return [
|
||||
'items' => array_values($pageItems),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'ledger_source' => 'settlement_ledger',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Carbon|null, 1: Carbon|null}
|
||||
*/
|
||||
private function includeEntryKind(SettlementLedgerListFilters $filters, string $kind): bool
|
||||
{
|
||||
if ($filters->badDebtOnly) {
|
||||
return $kind === 'adjustment';
|
||||
}
|
||||
|
||||
$selected = $filters->entryKind;
|
||||
if ($selected === null || $selected === '' || $selected === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $selected === $kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function applyFilters(array $items, SettlementLedgerListFilters $filters): array
|
||||
{
|
||||
return array_values(array_filter($items, function (array $row) use ($filters): bool {
|
||||
if ($filters->badDebtOnly) {
|
||||
if (($row['entry_kind'] ?? '') !== 'adjustment' || ($row['biz_type'] ?? '') !== 'bad_debt') {
|
||||
return false;
|
||||
}
|
||||
} elseif ($filters->entryKind === 'adjustment') {
|
||||
if (($row['entry_kind'] ?? '') === 'adjustment' && ($row['biz_type'] ?? '') === 'bad_debt') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($filters->txnNo !== null) {
|
||||
$needle = strtolower($filters->txnNo);
|
||||
$hay = strtolower((string) ($row['txn_no'] ?? ''));
|
||||
if (! str_contains($hay, $needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($filters->playerAccount !== null) {
|
||||
$needle = strtolower($filters->playerAccount);
|
||||
$haystack = strtolower(implode(' ', array_filter([
|
||||
(string) ($row['username'] ?? ''),
|
||||
(string) ($row['nickname'] ?? ''),
|
||||
(string) ($row['site_player_id'] ?? ''),
|
||||
])));
|
||||
if (! str_contains($haystack, $needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($filters->bizType !== null && ($row['biz_type'] ?? '') !== $filters->bizType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($filters->billStatus !== null && ($row['bill_status'] ?? '') !== $filters->billStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($filters->actionableOnly) {
|
||||
$actions = $row['available_actions'] ?? [];
|
||||
$operational = array_filter(
|
||||
$actions,
|
||||
static fn (string $a): bool => ! in_array($a, ['view_player', 'view_bill'], true),
|
||||
);
|
||||
if ($operational === []) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
private function resolveCreatedRange(
|
||||
?int $settlementPeriodId,
|
||||
?string $createdFrom,
|
||||
?string $createdTo,
|
||||
): ?array {
|
||||
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
|
||||
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
|
||||
if ($period === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
Carbon::parse($period->period_start)->startOfDay(),
|
||||
Carbon::parse($period->period_end)->endOfDay(),
|
||||
];
|
||||
}
|
||||
|
||||
$from = $createdFrom !== null && $createdFrom !== ''
|
||||
? Carbon::parse($createdFrom)->startOfDay()
|
||||
: null;
|
||||
$to = $createdTo !== null && $createdTo !== ''
|
||||
? Carbon::parse($createdTo)->endOfDay()
|
||||
: null;
|
||||
|
||||
if ($from === null && $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
$from ?? Carbon::parse('1970-01-01')->startOfDay(),
|
||||
$to ?? Carbon::now()->endOfDay(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array
|
||||
{
|
||||
$query = DB::table('settlement_bills as sb')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->join('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'sb.owner_id')->where('sb.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->where('sb.bill_type', 'player')
|
||||
->select([
|
||||
'sb.id',
|
||||
'sb.owner_id as player_id',
|
||||
'sb.status',
|
||||
'sb.bill_type',
|
||||
'sb.net_amount',
|
||||
'sb.unpaid_amount',
|
||||
'sb.paid_amount',
|
||||
'sb.settlement_period_id',
|
||||
])
|
||||
->orderByDesc('sb.id');
|
||||
|
||||
if ($periodId !== null && $periodId > 0) {
|
||||
abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403);
|
||||
$query->where('sb.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
$map = [];
|
||||
foreach ($query->limit(500)->get() as $bill) {
|
||||
$pid = (int) $bill->player_id;
|
||||
if (! isset($map[$pid])) {
|
||||
$map[$pid] = $bill;
|
||||
|
||||
continue;
|
||||
}
|
||||
$existing = $map[$pid];
|
||||
if ((string) $bill->status === 'pending_confirm') {
|
||||
$map[$pid] = $bill;
|
||||
} elseif ((string) $existing->status !== 'pending_confirm'
|
||||
&& (int) $bill->unpaid_amount > 0
|
||||
&& (int) $existing->unpaid_amount <= 0) {
|
||||
$map[$pid] = $bill;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: Carbon, 1: Carbon}|null $range
|
||||
* @return list<object>
|
||||
*/
|
||||
private function fetchCreditRows(
|
||||
AdminUser $admin,
|
||||
string $siteCode,
|
||||
?array $range,
|
||||
?int $playerId,
|
||||
): array {
|
||||
$query = DB::table('credit_ledger as cl')
|
||||
->join('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'cl.owner_id')
|
||||
->where('cl.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->where('p.funding_mode', PlayerFundingMode::CREDIT)
|
||||
->select([
|
||||
'cl.id',
|
||||
'cl.amount',
|
||||
'cl.reason',
|
||||
'cl.ref_type',
|
||||
'cl.ref_id',
|
||||
'cl.created_at',
|
||||
'p.id as player_id',
|
||||
'p.site_code',
|
||||
'p.site_player_id',
|
||||
'p.username',
|
||||
'p.nickname',
|
||||
'p.funding_mode',
|
||||
'p.auth_source',
|
||||
'p.default_currency',
|
||||
])
|
||||
->orderByDesc('cl.id');
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
if ($playerId !== null && $playerId > 0) {
|
||||
$query->where('p.id', $playerId);
|
||||
}
|
||||
|
||||
if ($range !== null) {
|
||||
$query->whereBetween('cl.created_at', $range);
|
||||
}
|
||||
|
||||
return $query->limit(500)->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<object>
|
||||
*/
|
||||
private function fetchPaymentRows(
|
||||
AdminUser $admin,
|
||||
string $siteCode,
|
||||
?int $periodId,
|
||||
?int $playerId,
|
||||
): array {
|
||||
$query = DB::table('payment_records as pr')
|
||||
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
|
||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||
->join('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'sb.owner_id')
|
||||
->where('sb.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->select([
|
||||
'pr.id',
|
||||
'pr.amount',
|
||||
'pr.method',
|
||||
'pr.status',
|
||||
'pr.created_at',
|
||||
'pr.settlement_bill_id',
|
||||
'sb.status as bill_status',
|
||||
'sb.bill_type',
|
||||
'sb.unpaid_amount',
|
||||
'p.id as player_id',
|
||||
'p.site_player_id',
|
||||
'p.username',
|
||||
'p.nickname',
|
||||
'p.auth_source',
|
||||
'p.funding_mode',
|
||||
'p.default_currency',
|
||||
])
|
||||
->orderByDesc('pr.id');
|
||||
|
||||
if ($periodId !== null && $periodId > 0) {
|
||||
$query->where('sb.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
if ($playerId !== null && $playerId > 0) {
|
||||
$query->where('p.id', $playerId);
|
||||
}
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds !== null) {
|
||||
if ($siteIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->whereIn('sp.admin_site_id', $siteIds);
|
||||
}
|
||||
|
||||
return $query->limit(300)->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<object>
|
||||
*/
|
||||
private function fetchAdjustmentRows(
|
||||
AdminUser $admin,
|
||||
string $siteCode,
|
||||
?int $periodId,
|
||||
?int $playerId,
|
||||
): array {
|
||||
$query = DB::table('settlement_adjustments as sa')
|
||||
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
|
||||
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
|
||||
->leftJoin('players as p', function ($join): void {
|
||||
$join->on('p.id', '=', 'sb.owner_id')
|
||||
->where('sb.owner_type', '=', 'player');
|
||||
})
|
||||
->where('p.site_code', $siteCode)
|
||||
->select([
|
||||
'sa.id',
|
||||
'sa.amount',
|
||||
'sa.adjustment_type',
|
||||
'sa.reason',
|
||||
'sa.created_at',
|
||||
'sa.original_bill_id as settlement_bill_id',
|
||||
'sb.status as bill_status',
|
||||
'sb.bill_type',
|
||||
'sb.unpaid_amount',
|
||||
'p.id as player_id',
|
||||
'p.site_player_id',
|
||||
'p.username',
|
||||
'p.nickname',
|
||||
'p.auth_source',
|
||||
'p.funding_mode',
|
||||
'p.default_currency',
|
||||
])
|
||||
->orderByDesc('sa.id');
|
||||
|
||||
if ($periodId !== null && $periodId > 0) {
|
||||
$query->where('sa.settlement_period_id', $periodId);
|
||||
}
|
||||
|
||||
if ($playerId !== null && $playerId > 0) {
|
||||
$query->where('p.id', $playerId);
|
||||
}
|
||||
|
||||
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
|
||||
|
||||
$siteIds = $admin->accessibleAdminSiteIds();
|
||||
if ($siteIds !== null) {
|
||||
if ($siteIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query->whereIn('sp.admin_site_id', $siteIds);
|
||||
}
|
||||
|
||||
return $query->limit(300)->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatCreditEntry(object $row, ?object $bill): array
|
||||
{
|
||||
$amount = (int) $row->amount;
|
||||
$billId = $bill !== null ? (int) $bill->id : null;
|
||||
|
||||
return $this->baseRow(
|
||||
entryKind: 'credit',
|
||||
entryId: (int) $row->id,
|
||||
txnPrefix: 'CL',
|
||||
playerId: (int) $row->player_id,
|
||||
row: $row,
|
||||
bizType: (string) $row->reason,
|
||||
signedAmount: $amount,
|
||||
createdAt: $row->created_at,
|
||||
ledgerSource: 'credit_ledger',
|
||||
settlementBillId: $billId,
|
||||
billStatus: $bill !== null ? (string) $bill->status : null,
|
||||
billType: $bill !== null ? (string) $bill->bill_type : null,
|
||||
billUnpaid: $bill !== null ? (int) $bill->unpaid_amount : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatPaymentEntry(object $row): array
|
||||
{
|
||||
$amount = (int) $row->amount;
|
||||
|
||||
return $this->baseRow(
|
||||
entryKind: 'payment',
|
||||
entryId: (int) $row->id,
|
||||
txnPrefix: 'PAY',
|
||||
playerId: (int) $row->player_id,
|
||||
row: $row,
|
||||
bizType: 'payment_record',
|
||||
signedAmount: $amount,
|
||||
createdAt: $row->created_at,
|
||||
ledgerSource: 'payment_record',
|
||||
settlementBillId: (int) $row->settlement_bill_id,
|
||||
billStatus: (string) ($row->bill_status ?? ''),
|
||||
billType: (string) ($row->bill_type ?? ''),
|
||||
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
|
||||
refLabel: 'bill#'.$row->settlement_bill_id.($row->method ? ' · '.$row->method : ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatAdjustmentEntry(object $row): array
|
||||
{
|
||||
$amount = (int) $row->amount;
|
||||
$type = (string) $row->adjustment_type;
|
||||
|
||||
return $this->baseRow(
|
||||
entryKind: 'adjustment',
|
||||
entryId: (int) $row->id,
|
||||
txnPrefix: 'ADJ',
|
||||
playerId: (int) $row->player_id,
|
||||
row: $row,
|
||||
bizType: $type,
|
||||
signedAmount: $amount,
|
||||
createdAt: $row->created_at,
|
||||
ledgerSource: 'settlement_adjustment',
|
||||
settlementBillId: (int) $row->settlement_bill_id,
|
||||
billStatus: (string) ($row->bill_status ?? ''),
|
||||
billType: (string) ($row->bill_type ?? ''),
|
||||
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
|
||||
refLabel: $row->reason !== null && $row->reason !== ''
|
||||
? (string) $row->reason
|
||||
: 'bill#'.$row->settlement_bill_id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function baseRow(
|
||||
string $entryKind,
|
||||
int $entryId,
|
||||
string $txnPrefix,
|
||||
int $playerId,
|
||||
object $row,
|
||||
string $bizType,
|
||||
int $signedAmount,
|
||||
mixed $createdAt,
|
||||
string $ledgerSource,
|
||||
?int $settlementBillId,
|
||||
?string $billStatus,
|
||||
?string $billType,
|
||||
?int $billUnpaid,
|
||||
?string $refLabel = null,
|
||||
): array {
|
||||
$amountAbs = abs($signedAmount);
|
||||
$currency = (string) ($row->default_currency ?? '');
|
||||
|
||||
return [
|
||||
'entry_kind' => $entryKind,
|
||||
'id' => $entryId,
|
||||
'row_key' => $entryKind.'-'.$entryId,
|
||||
'txn_no' => $txnPrefix.'-'.$entryId,
|
||||
'player_id' => $playerId,
|
||||
'site_code' => $row->site_code ?? null,
|
||||
'site_player_id' => $row->site_player_id ?? null,
|
||||
'username' => $row->username ?? null,
|
||||
'nickname' => $row->nickname ?? null,
|
||||
'biz_type' => $bizType,
|
||||
'biz_no' => $refLabel ?? $this->creditRefLabel($row),
|
||||
'direction' => $signedAmount >= 0 ? 1 : 2,
|
||||
'amount' => $amountAbs,
|
||||
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
|
||||
'signed_amount' => $signedAmount,
|
||||
'currency_code' => $currency,
|
||||
'status' => 'posted',
|
||||
'created_at' => $createdAt !== null ? Carbon::parse($createdAt)->toIso8601String() : null,
|
||||
'ledger_source' => $ledgerSource,
|
||||
'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
|
||||
'auth_source' => $row->auth_source ?? null,
|
||||
'settlement_bill_id' => $settlementBillId,
|
||||
'bill_status' => $billStatus,
|
||||
'bill_type' => $billType,
|
||||
'bill_unpaid_amount' => $billUnpaid,
|
||||
'available_actions' => $this->resolveActions(
|
||||
$entryKind,
|
||||
$settlementBillId,
|
||||
$billStatus,
|
||||
$billType,
|
||||
$billUnpaid,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveActions(
|
||||
string $entryKind,
|
||||
?int $billId,
|
||||
?string $billStatus,
|
||||
?string $billType,
|
||||
?int $billUnpaid,
|
||||
): array {
|
||||
$actions = ['view_player'];
|
||||
|
||||
if ($billId === null || $billId <= 0) {
|
||||
return $actions;
|
||||
}
|
||||
|
||||
$actions[] = 'view_bill';
|
||||
|
||||
if ($billStatus === 'pending_confirm') {
|
||||
$actions[] = 'confirm';
|
||||
}
|
||||
|
||||
if ($billStatus !== null
|
||||
&& in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
|
||||
&& ($billUnpaid ?? 0) > 0) {
|
||||
$actions[] = 'payment';
|
||||
}
|
||||
|
||||
if ($billStatus !== null
|
||||
&& in_array($billStatus, ['confirmed', 'partial_paid', 'settled', 'overdue'], true)
|
||||
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
|
||||
$actions[] = 'adjustment';
|
||||
$actions[] = 'reversal';
|
||||
}
|
||||
|
||||
if ($billStatus !== null
|
||||
&& in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
|
||||
&& ($billUnpaid ?? 0) > 0
|
||||
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
|
||||
$actions[] = 'bad_debt';
|
||||
}
|
||||
|
||||
return array_values(array_unique($actions));
|
||||
}
|
||||
|
||||
private function creditRefLabel(object $row): ?string
|
||||
{
|
||||
if (! isset($row->ref_type) || $row->ref_type === null || $row->ref_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $row->ref_type.'#'.$row->ref_id;
|
||||
}
|
||||
}
|
||||
54
app/Services/AgentSettlement/SettlementLedgerListFilters.php
Normal file
54
app/Services/AgentSettlement/SettlementLedgerListFilters.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
/** 结算中心账务流水列表筛选。 */
|
||||
final class SettlementLedgerListFilters
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?int $settlementPeriodId = null,
|
||||
public readonly ?int $playerId = null,
|
||||
public readonly ?string $entryKind = null,
|
||||
public readonly ?string $txnNo = null,
|
||||
public readonly ?string $playerAccount = null,
|
||||
public readonly ?string $bizType = null,
|
||||
public readonly ?string $billStatus = null,
|
||||
public readonly bool $actionableOnly = false,
|
||||
public readonly ?string $createdFrom = null,
|
||||
public readonly ?string $createdTo = null,
|
||||
public readonly bool $badDebtOnly = false,
|
||||
) {}
|
||||
|
||||
public static function fromQuery(array $query): self
|
||||
{
|
||||
$playerId = (int) ($query['player_id'] ?? 0);
|
||||
|
||||
return new self(
|
||||
settlementPeriodId: self::positiveInt($query['settlement_period_id'] ?? null),
|
||||
playerId: $playerId > 0 ? $playerId : null,
|
||||
entryKind: self::nonEmptyString($query['entry_kind'] ?? null),
|
||||
txnNo: self::nonEmptyString($query['txn_no'] ?? null),
|
||||
playerAccount: self::nonEmptyString($query['player_account'] ?? null),
|
||||
bizType: self::nonEmptyString($query['reason'] ?? $query['biz_type'] ?? null),
|
||||
billStatus: self::nonEmptyString($query['bill_status'] ?? null),
|
||||
actionableOnly: filter_var($query['actionable_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
createdFrom: self::nonEmptyString($query['created_from'] ?? null),
|
||||
createdTo: self::nonEmptyString($query['created_to'] ?? null),
|
||||
badDebtOnly: filter_var($query['bad_debt_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
);
|
||||
}
|
||||
|
||||
private static function positiveInt(mixed $value): ?int
|
||||
{
|
||||
$id = (int) $value;
|
||||
|
||||
return $id > 0 ? $id : null;
|
||||
}
|
||||
|
||||
private static function nonEmptyString(mixed $value): ?string
|
||||
{
|
||||
$s = trim((string) $value);
|
||||
|
||||
return $s !== '' ? $s : null;
|
||||
}
|
||||
}
|
||||
81
app/Services/AgentSettlement/SettlementPaymentService.php
Normal file
81
app/Services/AgentSettlement/SettlementPaymentService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SettlementPaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentSettlementBillGuard $billGuard,
|
||||
private readonly PlayerCreditService $playerCreditService,
|
||||
private readonly PeriodCloseRebateService $periodCloseRebate,
|
||||
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
|
||||
) {}
|
||||
|
||||
public function confirmBill(int $billId): void
|
||||
{
|
||||
$this->billGuard->markConfirmed($billId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{method?: string|null, proof?: string|null, remark?: string|null} $meta
|
||||
*/
|
||||
public function recordPayment(int $billId, int $amount, int $adminUserId, array $meta = []): void
|
||||
{
|
||||
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
|
||||
if ($bill === null) {
|
||||
throw new \InvalidArgumentException('bill_not_found');
|
||||
}
|
||||
|
||||
$this->billGuard->assertPeriodMutable($billId);
|
||||
|
||||
$amount = min($amount, (int) $bill->unpaid_amount);
|
||||
if ($amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('payment_records')->insert([
|
||||
'settlement_bill_id' => $billId,
|
||||
'payer_type' => (string) $bill->owner_type,
|
||||
'payer_id' => (int) $bill->owner_id,
|
||||
'payee_type' => (string) $bill->counterparty_type,
|
||||
'payee_id' => (int) $bill->counterparty_id,
|
||||
'amount' => $amount,
|
||||
'method' => $meta['method'] ?? null,
|
||||
'proof' => $meta['proof'] ?? null,
|
||||
'remark' => $meta['remark'] ?? null,
|
||||
'status' => 'confirmed',
|
||||
'created_by' => $adminUserId,
|
||||
'confirmed_by' => $adminUserId,
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$newPaid = (int) $bill->paid_amount + $amount;
|
||||
$newUnpaid = max(0, (int) $bill->unpaid_amount - $amount);
|
||||
$status = $newUnpaid === 0 ? 'settled' : 'partial_paid';
|
||||
|
||||
DB::table('settlement_bills')->where('id', $billId)->update([
|
||||
'paid_amount' => $newPaid,
|
||||
'unpaid_amount' => $newUnpaid,
|
||||
'status' => $status,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
|
||||
$player = Player::query()->find((int) $bill->owner_id);
|
||||
if ($player !== null) {
|
||||
$this->playerCreditService->releaseFromSettlement($player, $amount, $billId);
|
||||
if ($status === 'settled') {
|
||||
$this->periodCloseRebate->markRebatesSettledForBill($billId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->periodCompletion->syncIfReady((int) $bill->settlement_period_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class UnsettledTicketPeriodWarning
|
||||
{
|
||||
/**
|
||||
* @return array{count: int, ticket_item_ids: list<int>}
|
||||
*/
|
||||
public function countForSite(int $adminSiteId, string $periodStart, string $periodEnd): array
|
||||
{
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
|
||||
|
||||
$rows = DB::table('ticket_items as ti')
|
||||
->join('players as p', 'p.id', '=', 'ti.player_id')
|
||||
->where('p.site_code', $siteCode)
|
||||
->whereIn('ti.status', ['pending_draw', 'pending_confirm', 'pending_payout'])
|
||||
->whereBetween('ti.created_at', [$periodStart, $periodEnd])
|
||||
->pluck('ti.id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
return [
|
||||
'count' => count($rows),
|
||||
'ticket_item_ids' => array_slice($rows, 0, 20),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user