feat: 切换 schema dump 基线并增强返点结算与管理校验
This commit is contained in:
@@ -38,8 +38,8 @@ final class RiskCapItemsReplaceController extends Controller
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.draw_id' => ['sometimes', 'nullable', 'integer', 'exists:draws,id'],
|
||||
'items.*.normalized_number' => ['required', 'string', 'size:4', 'regex:/^[0-9]{4}$/'],
|
||||
'items.*.cap_amount' => ['required', 'integer', 'min:0'],
|
||||
'items.*.cap_type' => ['required', 'string', 'max:16'],
|
||||
'items.*.cap_amount' => ['required', 'integer', 'min:1'],
|
||||
'items.*.cap_type' => ['required', 'string', 'in:default,per_number'],
|
||||
]);
|
||||
|
||||
$service->replaceItems($version, $data['items'], $admin);
|
||||
|
||||
@@ -50,6 +50,7 @@ final class AdminCurrencyDestroyController extends Controller
|
||||
$checks = [
|
||||
'玩家默认币种' => DB::table('players')->where('default_currency', $code),
|
||||
'玩家钱包' => DB::table('player_wallets')->where('currency_code', $code),
|
||||
'站点默认币种' => DB::table('admin_sites')->where('currency_code', $code),
|
||||
'转账单' => DB::table('transfer_orders')->where('currency_code', $code),
|
||||
'注单' => DB::table('ticket_orders')->where('currency_code', $code),
|
||||
'赔率配置' => DB::table('odds_items')->where('currency_code', $code),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Jackpot;
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\ApiMessage;
|
||||
@@ -40,11 +41,21 @@ final class AdminJackpotPoolManualBurstController extends Controller
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'draw_id' => 'required|integer|exists:draws,id',
|
||||
'draw_id' => ['required'],
|
||||
]);
|
||||
|
||||
$drawId = $this->resolveDrawId(trim((string) $data['draw_id']));
|
||||
if ($drawId === null) {
|
||||
return ApiResponse::error(
|
||||
trans('validation.exists', ['attribute' => 'draw_id'], $request->lotteryLocale()),
|
||||
ErrorCode::ClientHttpError->value,
|
||||
['draw_id' => [trans('validation.exists', ['attribute' => 'draw_id'], $request->lotteryLocale())]],
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->service->execute($pool, (int) $data['draw_id']);
|
||||
$payload = $this->service->execute($pool, $drawId);
|
||||
} catch (\RuntimeException $e) {
|
||||
return ApiResponse::error(
|
||||
ApiMessage::get($request, 'jackpot_manual_burst_failed', [
|
||||
@@ -58,4 +69,22 @@ final class AdminJackpotPoolManualBurstController extends Controller
|
||||
|
||||
return ApiResponse::success($payload);
|
||||
}
|
||||
|
||||
private function resolveDrawId(string $drawRef): ?int
|
||||
{
|
||||
if ($drawRef === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ctype_digit($drawRef)) {
|
||||
$draw = Draw::query()->whereKey((int) $drawRef)->first();
|
||||
if ($draw !== null) {
|
||||
return (int) $draw->id;
|
||||
}
|
||||
}
|
||||
|
||||
$draw = Draw::query()->where('draw_no', $drawRef)->first();
|
||||
|
||||
return $draw !== null ? (int) $draw->id : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminSiteScope;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** DELETE /api/v1/admin/players/{player} */
|
||||
final class AdminPlayerDestroyController extends Controller
|
||||
@@ -25,13 +27,36 @@ final class AdminPlayerDestroyController extends Controller
|
||||
return $denied;
|
||||
}
|
||||
|
||||
$hasWallets = Player::query()
|
||||
->whereKey($player->getKey())
|
||||
->whereHas('wallets', static fn (Builder $q) => $q->where('balance', '!=', 0))
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
$creditRow = DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->getKey())
|
||||
->first();
|
||||
|
||||
$usedCredit = (int) ($creditRow->used_credit ?? 0);
|
||||
$frozenCredit = (int) ($creditRow->frozen_credit ?? 0);
|
||||
if ($usedCredit !== 0 || $frozenCredit !== 0) {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_credit_in_use_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
} else {
|
||||
$hasWallets = Player::query()
|
||||
->whereKey($player->getKey())
|
||||
->whereHas('wallets', static fn (Builder $q) => $q->where('balance', '!=', 0))
|
||||
->exists();
|
||||
|
||||
if ($hasWallets) {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_wallet_balance_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
}
|
||||
|
||||
$hasUnpaidSettlementBills = DB::table('settlement_bills')
|
||||
->where('owner_type', 'player')
|
||||
->where('owner_id', $player->getKey())
|
||||
->whereIn('status', ['confirmed', 'partial_paid', 'overdue'])
|
||||
->where('unpaid_amount', '>', 0)
|
||||
->exists();
|
||||
|
||||
if ($hasWallets) {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_wallet_balance_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
if ($hasUnpaidSettlementBills) {
|
||||
return ApiMessage::errorResponse($request, 'admin.player_unpaid_settlement_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
|
||||
$hasTickets = TicketOrder::query()
|
||||
@@ -42,6 +67,7 @@ final class AdminPlayerDestroyController extends Controller
|
||||
return ApiMessage::errorResponse($request, 'admin.player_has_tickets_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
|
||||
}
|
||||
|
||||
DB::table('player_credit_accounts')->where('player_id', $player->getKey())->delete();
|
||||
$player->wallets()->delete();
|
||||
$player->delete();
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ final class AdminPlayerIndexController extends Controller
|
||||
|
||||
if ($keyword !== '') {
|
||||
$term = '%'.addcslashes($keyword, '%_\\').'%';
|
||||
$q->where(static function ($sub) use ($term): void {
|
||||
$q->where(static function ($sub) use ($term, $keyword): void {
|
||||
$sub->where('site_player_id', 'like', $term)
|
||||
->orWhere('username', 'like', $term)
|
||||
->orWhere('nickname', 'like', $term);
|
||||
|
||||
@@ -55,7 +55,7 @@ final class AdminPlayerStoreController extends Controller
|
||||
if ($isNative) {
|
||||
$sitePlayerId = $sitePlayerId !== ''
|
||||
? $sitePlayerId
|
||||
: 'native:'.Str::lower(Str::ulid());
|
||||
: $this->generateNativeSitePlayerId($siteCode);
|
||||
}
|
||||
|
||||
if ($sitePlayerId === '') {
|
||||
@@ -192,4 +192,20 @@ final class AdminPlayerStoreController extends Controller
|
||||
|
||||
return $rootId !== null ? (int) $rootId : null;
|
||||
}
|
||||
|
||||
private function generateNativeSitePlayerId(string $siteCode): string
|
||||
{
|
||||
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2));
|
||||
$prefix = str_pad($prefix, 2, 'P');
|
||||
|
||||
do {
|
||||
$candidate = sprintf('%s%06d', $prefix, random_int(0, 999999));
|
||||
$exists = Player::query()
|
||||
->where('site_code', $siteCode)
|
||||
->where('site_player_id', $candidate)
|
||||
->exists();
|
||||
} while ($exists);
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
|
||||
|
||||
use App\Models\TransferOrder;
|
||||
use App\Models\ReconcileJob;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ReconcileItem;
|
||||
@@ -20,6 +21,19 @@ final class ReconcileItemIndexController extends Controller
|
||||
->orderBy('id')
|
||||
->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||
|
||||
$transferNos = collect($paginator->items())
|
||||
->map(fn (ReconcileItem $item) => $item->side_a_ref)
|
||||
->filter(fn ($value) => is_string($value) && $value !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$transferStatuses = $transferNos === []
|
||||
? []
|
||||
: TransferOrder::query()
|
||||
->whereIn('transfer_no', $transferNos)
|
||||
->pluck('status', 'transfer_no')
|
||||
->all();
|
||||
|
||||
return AdminApiList::jsonWith($paginator, fn (ReconcileItem $r) => [
|
||||
'id' => (int) $r->id,
|
||||
'side_a_ref' => $r->side_a_ref,
|
||||
@@ -27,6 +41,8 @@ final class ReconcileItemIndexController extends Controller
|
||||
'difference_amount' => (int) $r->difference_amount,
|
||||
'status' => $r->status,
|
||||
'resolved_at' => $r->resolved_at?->toIso8601String(),
|
||||
'is_resolved' => $r->resolved_at !== null || in_array($transferStatuses[$r->side_a_ref ?? ''] ?? null, ['success', 'reversed', 'manually_processed'], true),
|
||||
'current_transfer_status' => $transferStatuses[$r->side_a_ref ?? ''] ?? null,
|
||||
'created_at' => $r->created_at?->toIso8601String(),
|
||||
], [
|
||||
'job_id' => (int) $reconcile_job->id,
|
||||
|
||||
@@ -14,10 +14,16 @@ final class ReportJobIndexController extends Controller
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$p = AdminApiList::readPaging($request);
|
||||
$reportType = trim((string) $request->query('report_type', ''));
|
||||
|
||||
$paginator = ReportJob::query()
|
||||
->orderByDesc('id')
|
||||
->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||
$query = ReportJob::query()
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($reportType !== '') {
|
||||
$query->where('report_type', $reportType);
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||
|
||||
return AdminApiList::json($paginator, fn (ReportJob $j) => $this->row($j));
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ final class AdminTicketItemIndexController extends Controller
|
||||
->with([
|
||||
'draw:id,draw_no,business_date',
|
||||
'order:id,order_no,currency_code,created_at',
|
||||
'player:id,site_code,site_player_id,username,nickname,agent_node_id',
|
||||
'player:id,site_code,site_player_id,username,nickname,agent_node_id,funding_mode',
|
||||
'player.agentNode:id,code,name',
|
||||
])
|
||||
->orderByDesc('ticket_items.id');
|
||||
@@ -108,6 +108,8 @@ final class AdminTicketItemIndexController extends Controller
|
||||
'site_player_id' => $row->player?->site_player_id,
|
||||
'username' => $row->player?->username,
|
||||
'nickname' => $row->player?->nickname,
|
||||
'funding_mode' => $row->player?->funding_mode,
|
||||
'uses_credit' => $row->player?->funding_mode === 'credit',
|
||||
'order_no' => $row->order?->order_no,
|
||||
'draw_no' => $row->draw?->draw_no,
|
||||
'currency_code' => $row->order?->currency_code,
|
||||
|
||||
@@ -67,7 +67,7 @@ final class WalletTransactionListController extends Controller
|
||||
|
||||
$query = WalletTxn::query()
|
||||
->with([
|
||||
'player:id,site_code,site_player_id,username,nickname,agent_node_id',
|
||||
'player:id,site_code,site_player_id,username,nickname,agent_node_id,funding_mode,auth_source',
|
||||
'player.agentNode:id,code,name',
|
||||
])
|
||||
->orderByDesc('id');
|
||||
|
||||
@@ -18,7 +18,6 @@ final class SettingIndexController extends Controller
|
||||
/** @var list<string> */
|
||||
private const PUBLIC_GROUPS = [
|
||||
'frontend',
|
||||
'currency',
|
||||
];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
|
||||
@@ -18,8 +18,11 @@ final class TicketPreviewController extends Controller
|
||||
|
||||
public function __invoke(TicketPreviewRequest $request): JsonResponse
|
||||
{
|
||||
$player = $request->lotteryPlayer();
|
||||
abort_if($player === null, 500, 'lottery_player missing');
|
||||
|
||||
try {
|
||||
$data = $this->previewService->preview($request->validated());
|
||||
$data = $this->previewService->preview($player, $request->validated());
|
||||
} catch (TicketOperationException $e) {
|
||||
return ApiResponse::error(
|
||||
LotteryMessage::wallet($request, $e->lotteryCode),
|
||||
|
||||
@@ -43,10 +43,17 @@ final class AgentGameSettlementRecorder
|
||||
|
||||
$gameType = trim((string) ($item->play_code ?? '')) ?: '*';
|
||||
$snapshot = $this->snapshotBuilder->buildForPlayer($player, $gameType);
|
||||
$baseRebateRate = $this->baseRebateRateForTicketItem($item);
|
||||
$basicRebateRate = $this->normalizeRate($baseRebateRate + (float) $snapshot['rebate_rate']);
|
||||
$shareSnapshot = [
|
||||
...$snapshot,
|
||||
'base_rebate_rate' => $baseRebateRate,
|
||||
'basic_rebate_rate' => $basicRebateRate,
|
||||
];
|
||||
|
||||
$gameWinLoss = $this->platformWinLoss($item, $netWin, $terminalStatus);
|
||||
$validBet = (int) $item->total_bet_amount;
|
||||
$basicRebate = (int) round($validBet * $snapshot['rebate_rate']);
|
||||
$basicRebate = (int) round($validBet * $basicRebateRate);
|
||||
$extraRebate = (int) round($validBet * $snapshot['extra_rebate_rate']);
|
||||
|
||||
$extraByCode = [];
|
||||
@@ -66,15 +73,11 @@ final class AgentGameSettlementRecorder
|
||||
|
||||
$settledAt = now();
|
||||
|
||||
DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate, $gameType): void {
|
||||
DB::transaction(function () use ($item, $player, $snapshot, $shareSnapshot, $basicRebateRate, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate, $gameType): 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'],
|
||||
'share_snapshot' => $shareSnapshot,
|
||||
'agent_rebate_rate_snapshot' => $basicRebateRate,
|
||||
'agent_settled_at' => $settledAt,
|
||||
])->save();
|
||||
|
||||
@@ -83,7 +86,7 @@ final class AgentGameSettlementRecorder
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $snapshot['agent_node_id'],
|
||||
'agent_path' => json_encode($snapshot['agent_path']),
|
||||
'share_snapshot' => json_encode($snapshot),
|
||||
'share_snapshot' => json_encode($shareSnapshot),
|
||||
'game_win_loss' => (int) round($gameWinLoss),
|
||||
'basic_rebate' => $basicRebate,
|
||||
'shared_net_win_loss' => (int) round($result->sharedNetWinLoss),
|
||||
@@ -99,7 +102,7 @@ final class AgentGameSettlementRecorder
|
||||
'ticket_item_id' => $item->id,
|
||||
'game_type' => $gameType,
|
||||
'valid_bet_amount' => $validBet,
|
||||
'rebate_rate' => $snapshot['rebate_rate'],
|
||||
'rebate_rate' => $basicRebateRate,
|
||||
'rebate_amount' => $basicRebate,
|
||||
'rebate_type' => 'basic',
|
||||
'owner_agent_id' => $snapshot['agent_node_id'],
|
||||
@@ -138,6 +141,38 @@ final class AgentGameSettlementRecorder
|
||||
});
|
||||
}
|
||||
|
||||
private function baseRebateRateForTicketItem(TicketItem $item): float
|
||||
{
|
||||
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
|
||||
$baseFromRule = isset($ruleSnapshot['base_rebate_rate'])
|
||||
? (float) $ruleSnapshot['base_rebate_rate']
|
||||
: null;
|
||||
|
||||
if ($baseFromRule !== null && $baseFromRule > 0) {
|
||||
return $this->normalizeRate($baseFromRule);
|
||||
}
|
||||
|
||||
$oddsSnapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : [];
|
||||
foreach ($oddsSnapshot as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists('rebate_rate', $row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $this->normalizeRate((float) $row['rebate_rate']);
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
private function normalizeRate(float $rate): float
|
||||
{
|
||||
return max(0.0, min(1.0, $rate));
|
||||
}
|
||||
|
||||
private function platformWinLoss(TicketItem $item, int $netWin, string $terminalStatus): float
|
||||
{
|
||||
if ($terminalStatus === 'settled_lose') {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Services\AgentSettlement;
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -10,6 +12,7 @@ final class AgentSettlementBadDebtService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
|
||||
private readonly PlayerCreditService $playerCreditService,
|
||||
) {}
|
||||
|
||||
public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int
|
||||
@@ -99,6 +102,13 @@ final class AgentSettlementBadDebtService
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) {
|
||||
$player = Player::query()->find((int) $original->owner_id);
|
||||
if ($player !== null) {
|
||||
$this->playerCreditService->releaseFromSettlement($player, $unpaid, $originalBillId);
|
||||
}
|
||||
}
|
||||
|
||||
$this->periodCompletion->syncIfReady($periodId);
|
||||
|
||||
return $archiveBillId;
|
||||
|
||||
@@ -56,6 +56,20 @@ final class AgentSettlementBillGuard
|
||||
public function markConfirmed(int $billId): void
|
||||
{
|
||||
$this->assertPeriodMutable($billId);
|
||||
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
|
||||
if ($bill === null) {
|
||||
throw new \InvalidArgumentException('bill_not_found');
|
||||
}
|
||||
|
||||
if ((string) $bill->status === 'confirmed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((string) $bill->status !== 'pending_confirm') {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['not_confirmable'],
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('settlement_bills')->where('id', $billId)->update([
|
||||
'status' => 'confirmed',
|
||||
|
||||
@@ -18,7 +18,9 @@ final class SettlementCenterLedgerService
|
||||
'bet_hold',
|
||||
'bet_hold_release',
|
||||
'game_settlement_loss',
|
||||
'game_settlement_win',
|
||||
'settlement_confirm',
|
||||
'settlement_payout',
|
||||
];
|
||||
|
||||
private const ADJUSTMENT_BIZ_TYPES = [
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services\AgentSettlement;
|
||||
use App\Models\Player;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class SettlementPaymentService
|
||||
{
|
||||
@@ -25,66 +26,72 @@ final class SettlementPaymentService
|
||||
*/
|
||||
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');
|
||||
}
|
||||
DB::transaction(function () use ($billId, $amount, $adminUserId, $meta): void {
|
||||
$bill = DB::table('settlement_bills')->where('id', $billId)->lockForUpdate()->first();
|
||||
if ($bill === null) {
|
||||
throw new \InvalidArgumentException('bill_not_found');
|
||||
}
|
||||
|
||||
$this->billGuard->assertPeriodMutable($billId);
|
||||
$this->billGuard->assertPayable($billId);
|
||||
$this->billGuard->assertPeriodMutable($billId);
|
||||
if (! in_array((string) $bill->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'bill' => ['not_payable'],
|
||||
]);
|
||||
}
|
||||
|
||||
$amount = min($amount, abs((int) $bill->unpaid_amount));
|
||||
if ($amount <= 0) {
|
||||
return;
|
||||
}
|
||||
$payAmount = min($amount, abs((int) $bill->unpaid_amount));
|
||||
if ($payAmount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$payerType, $payerId, $payeeType, $payeeId] = $this->resolvePayerPayee($bill);
|
||||
[$payerType, $payerId, $payeeType, $payeeId] = $this->resolvePayerPayee($bill);
|
||||
|
||||
DB::table('payment_records')->insert([
|
||||
'settlement_bill_id' => $billId,
|
||||
'payer_type' => $payerType,
|
||||
'payer_id' => $payerId,
|
||||
'payee_type' => $payeeType,
|
||||
'payee_id' => $payeeId,
|
||||
'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(),
|
||||
]);
|
||||
DB::table('payment_records')->insert([
|
||||
'settlement_bill_id' => $billId,
|
||||
'payer_type' => $payerType,
|
||||
'payer_id' => $payerId,
|
||||
'payee_type' => $payeeType,
|
||||
'payee_id' => $payeeId,
|
||||
'amount' => $payAmount,
|
||||
'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';
|
||||
$newPaid = (int) $bill->paid_amount + $payAmount;
|
||||
$newUnpaid = max(0, (int) $bill->unpaid_amount - $payAmount);
|
||||
$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(),
|
||||
]);
|
||||
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) {
|
||||
if ((int) $bill->net_amount > 0) {
|
||||
$this->playerCreditService->releaseFromSettlement($player, $amount, $billId);
|
||||
} elseif ((int) $bill->net_amount < 0) {
|
||||
$this->playerCreditService->applySettlementPayout($player, $amount, $billId);
|
||||
}
|
||||
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
|
||||
$player = Player::query()->find((int) $bill->owner_id);
|
||||
if ($player !== null) {
|
||||
if ((int) $bill->net_amount > 0) {
|
||||
$this->playerCreditService->releaseFromSettlement($player, $payAmount, $billId);
|
||||
} elseif ((int) $bill->net_amount < 0) {
|
||||
$this->playerCreditService->applySettlementPayout($player, $payAmount, $billId);
|
||||
}
|
||||
|
||||
if ($status === 'settled') {
|
||||
$this->periodCloseRebate->markRebatesSettledForBill($billId);
|
||||
if ($status === 'settled') {
|
||||
$this->periodCloseRebate->markRebatesSettledForBill($billId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->periodCompletion->syncIfReady((int) $bill->settlement_period_id);
|
||||
$this->periodCompletion->syncIfReady((int) $bill->settlement_period_id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ use App\Models\JackpotPool;
|
||||
use App\Models\JackpotContribution;
|
||||
|
||||
/**
|
||||
* 产品文档 §5.11.1:每笔有效注单按比例蓄水(在下注成功路径调用,非结算)。
|
||||
* 产品文档 §5.11.1:每笔满足门槛的有效注单按下注额比例蓄水(在下注成功路径调用,非结算)。
|
||||
*/
|
||||
final class JackpotContributionService
|
||||
{
|
||||
@@ -25,12 +25,14 @@ final class JackpotContributionService
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $item->actual_deduct_amount < (int) $pool->min_bet_amount) {
|
||||
$betAmount = (int) $item->total_bet_amount;
|
||||
|
||||
if ($betAmount < (int) $pool->min_bet_amount) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rate = (float) $pool->contribution_rate;
|
||||
$contrib = (int) floor((int) $item->actual_deduct_amount * $rate);
|
||||
$contrib = (int) floor($betAmount * $rate);
|
||||
if ($contrib <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ use Illuminate\Support\Facades\Cache;
|
||||
*/
|
||||
final class LotterySettings
|
||||
{
|
||||
private const CURRENCY_DISPLAY_DECIMALS = 2;
|
||||
private const CURRENCY_DECIMAL_SEPARATOR = '.';
|
||||
private const CURRENCY_THOUSANDS_SEPARATOR = ',';
|
||||
|
||||
public static function defaultCurrency(): string
|
||||
{
|
||||
$fallback = (string) config('lottery.default_currency', 'NPR');
|
||||
@@ -70,25 +74,17 @@ final class LotterySettings
|
||||
|
||||
public static function currencyDisplayDecimals(): int
|
||||
{
|
||||
$fallback = (int) config('lottery.ui.format.currency.decimals', 2);
|
||||
|
||||
return max(0, min(12, (int) self::get('currency.display_decimals', $fallback)));
|
||||
return self::CURRENCY_DISPLAY_DECIMALS;
|
||||
}
|
||||
|
||||
public static function currencyDecimalSeparator(): string
|
||||
{
|
||||
return (string) self::get(
|
||||
'currency.decimal_separator',
|
||||
(string) config('lottery.ui.format.currency.decimal_separator', '.')
|
||||
);
|
||||
return self::CURRENCY_DECIMAL_SEPARATOR;
|
||||
}
|
||||
|
||||
public static function currencyThousandsSeparator(): string
|
||||
{
|
||||
return (string) self::get(
|
||||
'currency.thousands_separator',
|
||||
(string) config('lottery.ui.format.currency.thousands_separator', ',')
|
||||
);
|
||||
return self::CURRENCY_THOUSANDS_SEPARATOR;
|
||||
}
|
||||
|
||||
public static function cacheTtlSeconds(): int
|
||||
|
||||
58
app/Services/Ticket/InstantRebateResolver.php
Normal file
58
app/Services/Ticket/InstantRebateResolver.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Models\AgentProfile;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class InstantRebateResolver
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* base_rebate_rate: float,
|
||||
* player_addon_rebate_rate: float,
|
||||
* final_rebate_rate: float,
|
||||
* inherited_from_agent: bool
|
||||
* }
|
||||
*/
|
||||
public function resolveForPlayer(Player $player, string $playCode, float $baseRebateRate): array
|
||||
{
|
||||
$row = DB::table('player_rebate_profiles')
|
||||
->where('player_id', $player->id)
|
||||
->where('game_type', $playCode)
|
||||
->first();
|
||||
|
||||
if ($row === null) {
|
||||
$row = DB::table('player_rebate_profiles')
|
||||
->where('player_id', $player->id)
|
||||
->where('game_type', '*')
|
||||
->first();
|
||||
}
|
||||
|
||||
$addonRate = 0.0;
|
||||
$inheritedFromAgent = false;
|
||||
|
||||
if ($row !== null && ! (bool) $row->inherit_from_agent) {
|
||||
$addonRate = (float) $row->rebate_rate + (float) $row->extra_rebate_rate;
|
||||
} else {
|
||||
$inheritedFromAgent = true;
|
||||
$profile = $player->agent_node_id
|
||||
? AgentProfile::query()->where('agent_node_id', $player->agent_node_id)->first()
|
||||
: null;
|
||||
$addonRate = (float) ($profile?->default_player_rebate ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'base_rebate_rate' => $this->normalizeRate($baseRebateRate),
|
||||
'player_addon_rebate_rate' => $this->normalizeRate($addonRate),
|
||||
'final_rebate_rate' => $this->normalizeRate($baseRebateRate + $addonRate),
|
||||
'inherited_from_agent' => $inheritedFromAgent,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeRate(float $rate): float
|
||||
{
|
||||
return max(0.0, min(1.0, $rate));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ final class TicketPlacementService
|
||||
public function __construct(
|
||||
private readonly PlayCatalogResolver $catalogResolver,
|
||||
private readonly PlayRuleEngine $ruleEngine,
|
||||
private readonly InstantRebateResolver $instantRebateResolver,
|
||||
private readonly RiskPoolService $riskPoolService,
|
||||
private readonly TicketWalletService $ticketWalletService,
|
||||
private readonly JackpotContributionService $jackpotContribution,
|
||||
@@ -532,12 +533,29 @@ final class TicketPlacementService
|
||||
*/
|
||||
private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array
|
||||
{
|
||||
if (! PlayerFundingMode::usesCredit($player)) {
|
||||
$resolved = $this->instantRebateResolver->resolveForPlayer(
|
||||
$player,
|
||||
(string) $evaluated['play_code'],
|
||||
(float) $evaluated['rebate_rate_snapshot'],
|
||||
);
|
||||
|
||||
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
|
||||
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
$evaluated['rebate_rate_snapshot'] = '0.0000';
|
||||
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
|
||||
$evaluated['rebate_rate_snapshot'] = '0.0000';
|
||||
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
|
||||
$finalRate = $resolved['final_rebate_rate'];
|
||||
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
|
||||
$evaluated['actual_deduct_amount'] = max(
|
||||
0,
|
||||
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
|
||||
);
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
namespace App\Services\Ticket;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Exceptions\TicketOperationException;
|
||||
use App\Services\Draw\DrawHallSnapshotBuilder;
|
||||
use App\Support\PlayerFundingMode;
|
||||
|
||||
final class TicketPreviewService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PlayCatalogResolver $catalogResolver,
|
||||
private readonly PlayRuleEngine $ruleEngine,
|
||||
private readonly InstantRebateResolver $instantRebateResolver,
|
||||
private readonly RiskPoolService $riskPoolService,
|
||||
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
|
||||
) {}
|
||||
@@ -20,7 +23,7 @@ final class TicketPreviewService
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function preview(array $payload): array
|
||||
public function preview(Player $player, array $payload): array
|
||||
{
|
||||
$drawNo = trim((string) ($payload['draw_id'] ?? ''));
|
||||
$draw = $drawNo === ''
|
||||
@@ -62,6 +65,7 @@ final class TicketPreviewService
|
||||
$resolved['play_config'],
|
||||
$resolved['odds_items'],
|
||||
);
|
||||
$evaluated = $this->applyPlayerInstantRebate($player, $evaluated);
|
||||
|
||||
$locks = array_map(fn (array $combo): array => [
|
||||
'number_4d' => $combo['number_4d'],
|
||||
@@ -131,4 +135,37 @@ final class TicketPreviewService
|
||||
'warnings' => $warningRows,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $evaluated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function applyPlayerInstantRebate(Player $player, array $evaluated): array
|
||||
{
|
||||
$resolved = $this->instantRebateResolver->resolveForPlayer(
|
||||
$player,
|
||||
(string) $evaluated['play_code'],
|
||||
(float) $evaluated['rebate_rate_snapshot'],
|
||||
);
|
||||
|
||||
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
|
||||
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
|
||||
|
||||
if (PlayerFundingMode::usesCredit($player)) {
|
||||
$evaluated['rebate_rate_snapshot'] = '0.0000';
|
||||
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
|
||||
$finalRate = $resolved['final_rebate_rate'];
|
||||
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
|
||||
$evaluated['actual_deduct_amount'] = max(
|
||||
0,
|
||||
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
|
||||
);
|
||||
|
||||
return $evaluated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ final class PlayerLedgerLogsService
|
||||
'bet_hold_release',
|
||||
'game_settlement_loss',
|
||||
'game_settlement_win',
|
||||
'settlement_confirm',
|
||||
'settlement_payout',
|
||||
])->limit(5000)->get()->all();
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Player;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -106,23 +108,29 @@ final class AuditLogApiPresenter
|
||||
{
|
||||
$items = collect($paginator->items());
|
||||
$resourceNames = self::loadResourceNames($items);
|
||||
$operatorLabels = self::loadOperatorLabels($items);
|
||||
|
||||
return AdminApiList::payload(
|
||||
$paginator,
|
||||
fn (AuditLog $r) => self::row($r, $resourceNames),
|
||||
fn (AuditLog $r) => self::row($r, $resourceNames, $operatorLabels),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $resourceNames
|
||||
* @param array<string, string> $operatorLabels
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function row(AuditLog $r, array $resourceNames = []): array
|
||||
public static function row(AuditLog $r, array $resourceNames = [], array $operatorLabels = []): array
|
||||
{
|
||||
$operatorDisplay = self::operatorDisplay($r, $operatorLabels);
|
||||
|
||||
return [
|
||||
'id' => (int) $r->id,
|
||||
'operator_type' => $r->operator_type,
|
||||
'operator_id' => (int) $r->operator_id,
|
||||
'operator_label' => $operatorDisplay['label'],
|
||||
'operator_subtitle' => $operatorDisplay['subtitle'],
|
||||
'module_code' => $r->module_code,
|
||||
'action_code' => $r->action_code,
|
||||
'target_type' => $r->target_type,
|
||||
@@ -138,6 +146,57 @@ final class AuditLogApiPresenter
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, AuditLog> $items
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function loadOperatorLabels(Collection $items): array
|
||||
{
|
||||
$labels = [];
|
||||
|
||||
$adminIds = $items
|
||||
->filter(fn (AuditLog $row) => $row->operator_type === 'admin' && (int) $row->operator_id > 0)
|
||||
->pluck('operator_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
if ($adminIds !== []) {
|
||||
$admins = AdminUser::query()
|
||||
->whereIn('id', $adminIds)
|
||||
->get(['id', 'username', 'name']);
|
||||
|
||||
foreach ($admins as $admin) {
|
||||
$labels['admin:'.$admin->id] = self::formatIdentityLabel(
|
||||
(string) ($admin->name ?? ''),
|
||||
(string) ($admin->username ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$playerIds = $items
|
||||
->filter(fn (AuditLog $row) => $row->operator_type === 'player' && (int) $row->operator_id > 0)
|
||||
->pluck('operator_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
if ($playerIds !== []) {
|
||||
$players = Player::query()
|
||||
->whereIn('id', $playerIds)
|
||||
->get(['id', 'username', 'nickname']);
|
||||
|
||||
foreach ($players as $player) {
|
||||
$labels['player:'.$player->id] = self::formatIdentityLabel(
|
||||
(string) ($player->nickname ?? ''),
|
||||
(string) ($player->username ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, AuditLog> $items
|
||||
* @return array<string, string>
|
||||
@@ -171,6 +230,48 @@ final class AuditLogApiPresenter
|
||||
return self::MODULE_LABELS[$code] ?? $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $operatorLabels
|
||||
*/
|
||||
private static function operatorDisplay(AuditLog $r, array $operatorLabels): array
|
||||
{
|
||||
$type = (string) ($r->operator_type ?? '');
|
||||
$id = (int) ($r->operator_id ?? 0);
|
||||
|
||||
if ($type === 'system') {
|
||||
return ['label' => '系统', 'subtitle' => null];
|
||||
}
|
||||
|
||||
$key = $type.':'.$id;
|
||||
if (isset($operatorLabels[$key]) && $operatorLabels[$key] !== '') {
|
||||
return [
|
||||
'label' => $operatorLabels[$key],
|
||||
'subtitle' => $id > 0 ? sprintf('%s #%d', $type === 'admin' ? '管理员' : ($type === 'player' ? '玩家' : $type), $id) : null,
|
||||
];
|
||||
}
|
||||
|
||||
$fallbackLabel = match ($type) {
|
||||
'admin' => $id > 0 ? '管理员 #'.$id : '管理员',
|
||||
'player' => $id > 0 ? '玩家 #'.$id : '玩家',
|
||||
'system' => '系统',
|
||||
default => $id > 0 ? sprintf('%s #%d', $type !== '' ? $type : '未知', $id) : ($type !== '' ? $type : '未知'),
|
||||
};
|
||||
|
||||
return ['label' => $fallbackLabel, 'subtitle' => null];
|
||||
}
|
||||
|
||||
private static function formatIdentityLabel(string $primary, string $secondary): string
|
||||
{
|
||||
$primary = trim($primary);
|
||||
$secondary = trim($secondary);
|
||||
|
||||
if ($primary !== '') {
|
||||
return $primary;
|
||||
}
|
||||
|
||||
return $secondary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $resourceNames
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@ use App\Services\LotterySettings;
|
||||
/**
|
||||
* 将「最小货币单位」整数格式化为展示用字符串(不改变业务数字,仅格式化)。
|
||||
*
|
||||
* 拆分位数由 {@see config('lottery.ui.format.currency.decimals')} 决定,默认 2(即 ÷100)。
|
||||
* 展示格式固定为 2 位小数、`.` 小数点、`,` 千分位;不从后台设置或环境变量读取。
|
||||
*/
|
||||
final class CurrencyFormatter
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user