feat: 添加结算功能,更新 TicketItem 模型以支持最新结算详情,增强 DrawTickService 以自动处理结算,更新 TicketWalletService 以支持派彩入账,扩展 API 路由以管理结算批次和奖池
This commit is contained in:
45
tests/Feature/AdminSettlementJackpotApiTest.php
Normal file
45
tests/Feature/AdminSettlementJackpotApiTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\JackpotPool;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function mintSettlementAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'settlement_admin',
|
||||
'name' => 'Settlement QA',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('admin settlement batches index is authenticated', function (): void {
|
||||
$this->getJson('/api/v1/admin/settlement-batches')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('admin jackpot pools index returns rows', function (): void {
|
||||
JackpotPool::query()->create([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 100,
|
||||
'contribution_rate' => '0.01',
|
||||
'trigger_threshold' => 1000,
|
||||
'payout_rate' => '0.5',
|
||||
'force_trigger_draw_gap' => 10,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
'last_trigger_draw_id' => null,
|
||||
]);
|
||||
|
||||
$token = mintSettlementAdminToken();
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/jackpot/pools')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.currency_code', 'NPR');
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Events\DrawCountdownBroadcast;
|
||||
use App\Events\DrawStatusChangeBroadcast;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Events\DrawCountdownBroadcast;
|
||||
use App\Events\DrawStatusChangeBroadcast;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Services\Draw\DrawPlannerService;
|
||||
use App\Services\Draw\DrawTickService;
|
||||
use Carbon\Carbon;
|
||||
@@ -199,7 +200,9 @@ test('cooldown expiry tick moves draw to settling', function (): void {
|
||||
app(DrawTickService::class)->tick(now()->utc());
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Settling->value);
|
||||
expect($draw->status)->toBe(DrawStatus::Settled->value);
|
||||
expect((int) $draw->settle_version)->toBe(1);
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', 'completed')->count())->toBe(1);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
141
tests/Feature/JackpotPlacementSettlementTest.php
Normal file
141
tests/Feature/JackpotPlacementSettlementTest.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\JackpotContribution;
|
||||
use App\Models\JackpotPayoutLog;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Database\Seeders\OperationalConfigV1Seeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(PlayTypeSeeder::class);
|
||||
$this->seed(OperationalConfigV1Seeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void {
|
||||
JackpotPool::query()->create([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 0,
|
||||
'contribution_rate' => '0.1000',
|
||||
'trigger_threshold' => 1,
|
||||
'payout_rate' => '1.0000',
|
||||
'force_trigger_draw_gap' => 0,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
'last_trigger_draw_id' => null,
|
||||
]);
|
||||
|
||||
$uniq = bin2hex(random_bytes(4));
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'jp-p-'.$uniq,
|
||||
'username' => 'jp_'.$uniq,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 5_000_000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260511-901',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 901,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(2),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
'draw_time' => now()->addMinutes(6),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260511-901',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'jp-trace-1',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect(JackpotContribution::query()->count())->toBe(1);
|
||||
$poolAfterBet = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
expect((int) $poolAfterBet->current_amount)->toBe(1_000);
|
||||
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'rng_seed_hash' => 'test',
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
foreach (DrawPrizeLayout::slots() as $slot) {
|
||||
$num = $slot['prize_type'] === 'first' ? '1234' : '5678';
|
||||
$suffix3 = substr($num, -3);
|
||||
$suffix2 = substr($num, -2);
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $num,
|
||||
'suffix_3d' => $suffix3,
|
||||
'suffix_2d' => $suffix2,
|
||||
'head_digit' => (int) substr($num, 0, 1),
|
||||
'tail_digit' => (int) substr($num, 3, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
$ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh());
|
||||
expect($ran)->toBeTrue();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect((int) $item->win_amount)->toBe(250_000);
|
||||
expect((int) $item->jackpot_win_amount)->toBe(1_000);
|
||||
|
||||
$poolAfterSettle = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
expect((int) $poolAfterSettle->current_amount)->toBe(0);
|
||||
expect(JackpotPayoutLog::query()->count())->toBe(1);
|
||||
|
||||
$order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail();
|
||||
expect($order->status)->toBe('settled');
|
||||
});
|
||||
24
tests/Feature/SettlementMatcherRegistryCompletenessTest.php
Normal file
24
tests/Feature/SettlementMatcherRegistryCompletenessTest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PlayType;
|
||||
use App\Services\Settlement\Matchers\NoopSettlementMatcher;
|
||||
use App\Services\Settlement\SettlementMatcherRegistry;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(fn () => $this->seed(PlayTypeSeeder::class));
|
||||
|
||||
test('every play_types.play_code maps to a non-noop settlement matcher', function (): void {
|
||||
$reg = app(SettlementMatcherRegistry::class);
|
||||
foreach (PlayType::query()->orderBy('play_code')->pluck('play_code') as $code) {
|
||||
$matcher = $reg->for((string) $code);
|
||||
expect($matcher)->not->toBeInstanceOf(NoopSettlementMatcher::class);
|
||||
}
|
||||
});
|
||||
|
||||
test('half_box reuses the same matcher instance as big spread', function (): void {
|
||||
$reg = app(SettlementMatcherRegistry::class);
|
||||
expect($reg->for('half_box'))->toBe($reg->for('big'));
|
||||
});
|
||||
131
tests/Feature/SettlementOrchestratorTest.php
Normal file
131
tests/Feature/SettlementOrchestratorTest.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Database\Seeders\OperationalConfigV1Seeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(PlayTypeSeeder::class);
|
||||
$this->seed(OperationalConfigV1Seeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
test('settlement pays big winner and marks ticket settled', function (): void {
|
||||
$uniq = bin2hex(random_bytes(4));
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'settle-p-'.$uniq,
|
||||
'username' => 'sp_'.$uniq,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 5_000_000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260511-900',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 900,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(2),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
'draw_time' => now()->addMinutes(6),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260511-900',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'settle-trace-1',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'rng_seed_hash' => 'test',
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
foreach (DrawPrizeLayout::slots() as $slot) {
|
||||
$num = $slot['prize_type'] === 'first' ? '1234' : '5678';
|
||||
$suffix3 = substr($num, -3);
|
||||
$suffix2 = substr($num, -2);
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $num,
|
||||
'suffix_3d' => $suffix3,
|
||||
'suffix_2d' => $suffix2,
|
||||
'head_digit' => (int) substr($num, 0, 1),
|
||||
'tail_digit' => (int) substr($num, 3, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
$ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh());
|
||||
expect($ran)->toBeTrue();
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Settled->value);
|
||||
expect((int) $draw->settle_version)->toBe(1);
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect($item->status)->toBe('settled_win');
|
||||
expect((int) $item->win_amount)->toBe(250_000);
|
||||
|
||||
$order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail();
|
||||
expect($order->status)->toBe('settled');
|
||||
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(5_000_000 - (int) $item->actual_deduct_amount + 250_000);
|
||||
|
||||
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1);
|
||||
});
|
||||
681
tests/Feature/SettlementPhase145AcceptanceTest.php
Normal file
681
tests/Feature/SettlementPhase145AcceptanceTest.php
Normal file
@@ -0,0 +1,681 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* §14.5 测试任务 / §14.6 完成标准:玩法命中、未中奖、派彩入彩票钱包、下单失败资金回滚、Jackpot 蓄水与非头奖结算、玩家端可见状态。
|
||||
*/
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\JackpotContribution;
|
||||
use App\Models\JackpotPayoutLog;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\RiskPool;
|
||||
use App\Models\TicketCombination;
|
||||
use App\Models\TicketItem;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\TicketSettlementDetail;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use App\Support\OddsStandardScopes;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Database\Seeders\OperationalConfigV1Seeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(PlayTypeSeeder::class);
|
||||
$this->seed(OperationalConfigV1Seeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
function p145_player(int $balance = 5_000_000): Player
|
||||
{
|
||||
$uniq = bin2hex(random_bytes(4));
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'p145-'.$uniq,
|
||||
'username' => 'p145_'.$uniq,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => $balance,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
return $player;
|
||||
}
|
||||
|
||||
/** 路由 `draw_no` 约束为 `YYYYMMDD-NNN`(序数三位)。 */
|
||||
function p145_next_draw_no(): string
|
||||
{
|
||||
static $i = 0;
|
||||
$i++;
|
||||
|
||||
return sprintf('20260511-%03d', 400 + ($i % 500));
|
||||
}
|
||||
|
||||
function p145_draw(string $drawNo, int $sequenceNo): Draw
|
||||
{
|
||||
return Draw::query()->create([
|
||||
'draw_no' => $drawNo,
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => $sequenceNo,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(2),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
'draw_time' => now()->addMinutes(6),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Closure(string $prizeType, int $prizeIndex): string $numberFor
|
||||
*/
|
||||
function p145_publish_board(Draw $draw, Closure $numberFor): void
|
||||
{
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'rng_seed_hash' => 'p145',
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
foreach (DrawPrizeLayout::slots() as $slot) {
|
||||
$num = $numberFor($slot['prize_type'], (int) $slot['prize_index']);
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $num,
|
||||
'suffix_3d' => substr($num, -3),
|
||||
'suffix_2d' => substr($num, -2),
|
||||
'head_digit' => (int) substr($num, 0, 1),
|
||||
'tail_digit' => (int) substr($num, 3, 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 23 格不含 8888,用于 Big 未中奖。 */
|
||||
function p145_board_without_8888(string $prizeType, int $prizeIndex): string
|
||||
{
|
||||
return match ($prizeType) {
|
||||
'first' => '1111',
|
||||
'second' => '2222',
|
||||
'third' => '3333',
|
||||
'starter' => sprintf('41%02d', $prizeIndex),
|
||||
'consolation' => sprintf('52%02d', $prizeIndex),
|
||||
};
|
||||
}
|
||||
|
||||
test('§14.5 big no-hit settles lose wallet unchanged except bet and no settle_payout txn', function (): void {
|
||||
$player = p145_player();
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-lose-1',
|
||||
'lines' => [
|
||||
['number' => '8888', 'play_code' => 'big', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$deduct = (int) $item->actual_deduct_amount;
|
||||
|
||||
p145_publish_board($draw, fn (string $t, int $i): string => p145_board_without_8888($t, $i));
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
|
||||
$item->refresh();
|
||||
expect($item->status)->toBe('settled_lose')
|
||||
->and((int) $item->win_amount)->toBe(0)
|
||||
->and((int) $item->jackpot_win_amount)->toBe(0);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(5_000_000 - $deduct);
|
||||
|
||||
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0);
|
||||
expect(TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->count())->toBe(1);
|
||||
|
||||
$ticketNo = $item->ticket_no;
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items/'.$ticketNo)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', 'settled_lose')
|
||||
->assertJsonPath('data.win_amount', 0);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/draws/'.$drawNo.'/my-match')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.has_bets', true)
|
||||
->assertJsonPath('data.hit_numbers_4d', [])
|
||||
->assertJsonPath('data.total_win_minor', 0);
|
||||
});
|
||||
|
||||
test('§14.5 small hits second tier only', function (): void {
|
||||
$player = p145_player();
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-small-1',
|
||||
'lines' => [
|
||||
['number' => '8888', 'play_code' => 'small', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$deduct = (int) $item->actual_deduct_amount;
|
||||
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['second'] / 10_000);
|
||||
|
||||
p145_publish_board($draw, function (string $t, int $i): string {
|
||||
return match ($t) {
|
||||
'first' => '1001',
|
||||
'second' => '8888',
|
||||
'third' => '2002',
|
||||
'starter' => sprintf('30%02d', $i),
|
||||
'consolation' => sprintf('40%02d', $i),
|
||||
};
|
||||
});
|
||||
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
|
||||
$item->refresh();
|
||||
expect($item->status)->toBe('settled_win')
|
||||
->and((int) $item->win_amount)->toBe($expectedWin);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(5_000_000 - $deduct + $expectedWin);
|
||||
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', function (): void {
|
||||
$cases = [
|
||||
[
|
||||
'play' => 'pos_4b',
|
||||
'number' => '7777',
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '1111',
|
||||
'second' => '7777',
|
||||
'third' => '3333',
|
||||
'starter' => sprintf('51%02d', $i),
|
||||
'consolation' => sprintf('62%02d', $i),
|
||||
},
|
||||
'scope' => 'second',
|
||||
],
|
||||
[
|
||||
'play' => 'pos_3a',
|
||||
'number' => '234',
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '1234',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'first',
|
||||
],
|
||||
[
|
||||
'play' => 'pos_2a',
|
||||
'number' => '34',
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '1234',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'first',
|
||||
],
|
||||
[
|
||||
'play' => 'pos_4e',
|
||||
'number' => '7777',
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '1111',
|
||||
'second' => '2222',
|
||||
'third' => '3333',
|
||||
'starter' => sprintf('51%02d', $i),
|
||||
'consolation' => $i === 4 ? '7777' : sprintf('62%02d', $i),
|
||||
},
|
||||
'scope' => 'consolation',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($cases as $case) {
|
||||
$player = p145_player();
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-'.$case['play'].'-'.uniqid('', true),
|
||||
'lines' => [
|
||||
['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$deduct = (int) $item->actual_deduct_amount;
|
||||
$odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']];
|
||||
$perComboWin = (int) floor(10_000 * $odds / 10_000);
|
||||
$comboCount = (int) $item->combination_count;
|
||||
$expectedWin = match ($case['play']) {
|
||||
'pos_3a', 'pos_2a' => $perComboWin * $comboCount,
|
||||
default => $perComboWin,
|
||||
};
|
||||
|
||||
p145_publish_board($draw, $case['board']);
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
|
||||
$item->refresh();
|
||||
expect($item->status)->toBe('settled_win', $case['play'])
|
||||
->and((int) $item->win_amount)->toBe($expectedWin, $case['play']);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(5_000_000 - $deduct + $expectedWin, $case['play']);
|
||||
}
|
||||
});
|
||||
|
||||
test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void {
|
||||
JackpotPool::query()->create([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 0,
|
||||
'contribution_rate' => '0.1000',
|
||||
'trigger_threshold' => 1,
|
||||
'payout_rate' => '1.0000',
|
||||
'force_trigger_draw_gap' => 0,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
'last_trigger_draw_id' => null,
|
||||
]);
|
||||
|
||||
$player = p145_player();
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-jp-keep',
|
||||
'lines' => [
|
||||
['number' => '8888', 'play_code' => 'small', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect(JackpotContribution::query()->count())->toBe(1);
|
||||
$poolAfterBet = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
expect((int) $poolAfterBet->current_amount)->toBe(1_000);
|
||||
|
||||
p145_publish_board($draw, function (string $t, int $i): string {
|
||||
return match ($t) {
|
||||
'first' => '1001',
|
||||
'second' => '8888',
|
||||
'third' => '2002',
|
||||
'starter' => sprintf('30%02d', $i),
|
||||
'consolation' => sprintf('40%02d', $i),
|
||||
};
|
||||
});
|
||||
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
|
||||
expect(JackpotPayoutLog::query()->count())->toBe(0);
|
||||
$poolAfter = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||
expect((int) $poolAfter->current_amount)->toBe(1_000);
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect((int) $item->jackpot_win_amount)->toBe(0);
|
||||
});
|
||||
|
||||
test('§14.5 placement rollback returns stake when mid-order risk acquire fails (退本)', function (): void {
|
||||
$player = p145_player(500_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
RiskPool::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'normalized_number' => '1234',
|
||||
'total_cap_amount' => 5000,
|
||||
'locked_amount' => 0,
|
||||
'remaining_amount' => 5000,
|
||||
'sold_out_status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-rollback',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
|
||||
],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value);
|
||||
|
||||
expect(TicketOrder::query()->count())->toBe(0);
|
||||
expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(500_000);
|
||||
});
|
||||
|
||||
/**
|
||||
* 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。
|
||||
* `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。
|
||||
*/
|
||||
test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants settle win', function (): void {
|
||||
$cases = [
|
||||
[
|
||||
'play' => 'straight',
|
||||
'line' => ['number' => '8881', 'play_code' => 'straight', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '8881' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'roll',
|
||||
'line' => ['number' => 'R234', 'play_code' => 'roll', 'amount' => 100],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '5234' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'box',
|
||||
'line' => ['number' => '1357', 'play_code' => 'box', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '7135' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'ibox',
|
||||
'line' => ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '1212' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'mbox',
|
||||
'line' => ['number' => '2468', 'play_code' => 'mbox', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '8642' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'head',
|
||||
'line' => ['number' => '6', 'play_code' => 'head', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '6781' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'tail',
|
||||
'line' => ['number' => '2', 'play_code' => 'tail', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '2342' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'odd',
|
||||
'line' => ['number' => '1', 'play_code' => 'odd', 'amount' => 10_000, 'dimension' => 'D4'],
|
||||
'first_combo_board' => true,
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'even',
|
||||
'line' => ['number' => '0', 'play_code' => 'even', 'amount' => 10_000, 'dimension' => 'D4'],
|
||||
'first_combo_board' => true,
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'digit_big',
|
||||
'line' => ['number' => '9', 'play_code' => 'digit_big', 'amount' => 10_000, 'dimension' => 'D4', 'digit_slot' => 2],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '1299' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'digit_small',
|
||||
'line' => ['number' => '1', 'play_code' => 'digit_small', 'amount' => 10_000, 'dimension' => 'D4', 'digit_slot' => 1],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '3142' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'pos_4a',
|
||||
'line' => ['number' => '6006', 'play_code' => 'pos_4a', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '6006' : p145_board_without_8888($t, $i),
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'pos_4c',
|
||||
'line' => ['number' => '4004', 'play_code' => 'pos_4c', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'third' => '4004',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'third',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'pos_4d',
|
||||
'line' => ['number' => '5555', 'play_code' => 'pos_4d', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'starter' => $i === 3 ? '5555' : sprintf('71%02d', $i),
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'starter',
|
||||
'comboMultiplier' => 1,
|
||||
],
|
||||
[
|
||||
'play' => 'pos_3b',
|
||||
'line' => ['number' => '949', 'play_code' => 'pos_3b', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'second' => '2949',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'second',
|
||||
'comboMultiplier' => 10,
|
||||
],
|
||||
[
|
||||
'play' => 'pos_3c',
|
||||
'line' => ['number' => '678', 'play_code' => 'pos_3c', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'third' => '9678',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'third',
|
||||
'comboMultiplier' => 10,
|
||||
],
|
||||
[
|
||||
'play' => 'pos_3abc',
|
||||
'line' => ['number' => '567', 'play_code' => 'pos_3abc', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '4567',
|
||||
'second' => '8123',
|
||||
'third' => '9234',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 10,
|
||||
],
|
||||
[
|
||||
'play' => 'pos_2b',
|
||||
'line' => ['number' => '56', 'play_code' => 'pos_2b', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '1111',
|
||||
'second' => '7856',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'second',
|
||||
'comboMultiplier' => 100,
|
||||
],
|
||||
[
|
||||
'play' => 'pos_2c',
|
||||
'line' => ['number' => '30', 'play_code' => 'pos_2c', 'amount' => 10_000],
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '9999',
|
||||
'second' => '8888',
|
||||
'third' => '7830',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'third',
|
||||
'comboMultiplier' => 100,
|
||||
],
|
||||
[
|
||||
'play' => 'pos_2abc',
|
||||
'line' => ['number' => '99', 'play_code' => 'pos_2abc', 'amount' => 100],
|
||||
'board' => fn (string $t, int $i): string => match ($t) {
|
||||
'first' => '8899',
|
||||
'second' => '2299',
|
||||
'third' => '1199',
|
||||
default => p145_board_without_8888($t, $i),
|
||||
},
|
||||
'scope' => 'first',
|
||||
'comboMultiplier' => 100,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($cases as $case) {
|
||||
$player = p145_player(80_000_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-all-'.$case['play'].'-'.uniqid('', true),
|
||||
'lines' => [$case['line']],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$deduct = (int) $item->actual_deduct_amount;
|
||||
$odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']];
|
||||
$unitOnTicket = (int) $item->unit_bet_amount;
|
||||
$perComboWin = (int) floor($unitOnTicket * $odds / 10_000);
|
||||
$expectedWin = $perComboWin * (int) $case['comboMultiplier'];
|
||||
|
||||
$board = $case['board'] ?? null;
|
||||
if ($case['first_combo_board'] ?? false) {
|
||||
$target = (string) TicketCombination::query()
|
||||
->where('ticket_item_id', $item->id)
|
||||
->orderBy('combination_no')
|
||||
->value('number_4d');
|
||||
$board = fn (string $t, int $i): string => $t === 'first' ? $target : p145_board_without_8888($t, $i);
|
||||
}
|
||||
expect($board)->toBeInstanceOf(Closure::class, $case['play']);
|
||||
|
||||
p145_publish_board($draw, $board);
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
|
||||
|
||||
$item->refresh();
|
||||
expect($item->status)->toBe('settled_win', $case['play'])
|
||||
->and((int) $item->win_amount)->toBe($expectedWin, $case['play']);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin, $case['play']);
|
||||
}
|
||||
});
|
||||
|
||||
test('§14.6 ticket detail shows settlement tier after win', function (): void {
|
||||
$player = p145_player();
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-detail',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$ticketNo = TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no');
|
||||
expect($ticketNo)->not->toBeEmpty();
|
||||
|
||||
p145_publish_board($draw, function (string $t, int $i): string {
|
||||
$num = $t === 'first' ? '1234' : '5678';
|
||||
|
||||
return $num;
|
||||
});
|
||||
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh());
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items/'.$ticketNo)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', 'settled_win')
|
||||
->assertJsonPath('data.settlement.matched_prize_tier', 'first');
|
||||
});
|
||||
203
tests/Feature/TicketItemsApiTest.php
Normal file
203
tests/Feature/TicketItemsApiTest.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\Draw;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\Player;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Database\Seeders\OperationalConfigV1Seeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(CurrencySeeder::class);
|
||||
$this->seed(PlayTypeSeeder::class);
|
||||
$this->seed(OperationalConfigV1Seeder::class);
|
||||
$this->seed(LotterySettingsSeeder::class);
|
||||
});
|
||||
|
||||
test('jackpot summary is public', function (): void {
|
||||
JackpotPool::query()->create([
|
||||
'currency_code' => 'NPR',
|
||||
'current_amount' => 1_234_000,
|
||||
'contribution_rate' => '0.0100',
|
||||
'trigger_threshold' => 0,
|
||||
'payout_rate' => '0.5000',
|
||||
'force_trigger_draw_gap' => 0,
|
||||
'min_bet_amount' => 0,
|
||||
'status' => 1,
|
||||
'last_trigger_draw_id' => null,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/jackpot/summary?currency_code=NPR')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.enabled', true)
|
||||
->assertJsonPath('data.current_amount_minor', 1_234_000);
|
||||
});
|
||||
|
||||
test('ticket items index returns placed ticket for player', function (): void {
|
||||
$uniq = bin2hex(random_bytes(4));
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'items-p-'.$uniq,
|
||||
'username' => 'ti_'.$uniq,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 5_000_000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260511-777',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 777,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(2),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
'draw_time' => now()->addMinutes(6),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260511-777',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'items-trace-1',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.draw_no', '20260511-777')
|
||||
->assertJsonPath('data.items.0.play_code', 'big');
|
||||
|
||||
$ticketNo = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items')
|
||||
->json('data.items.0.ticket_no');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items/'.$ticketNo)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.ticket_no', $ticketNo)
|
||||
->assertJsonPath('data.combinations.0.number_4d', '1234');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-777'))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-000'))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 0);
|
||||
});
|
||||
|
||||
test('my-match returns hit numbers when draw published', function (): void {
|
||||
$uniq = bin2hex(random_bytes(4));
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'match-p-'.$uniq,
|
||||
'username' => 'tm_'.$uniq,
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
PlayerWallet::query()->create([
|
||||
'player_id' => $player->id,
|
||||
'wallet_type' => 'lottery',
|
||||
'currency_code' => 'NPR',
|
||||
'balance' => 5_000_000,
|
||||
'frozen_balance' => 0,
|
||||
'status' => 0,
|
||||
'version' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260511-778',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 778,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(2),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
'draw_time' => now()->addMinutes(6),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260511-778',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'match-trace-1',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'rng_seed_hash' => 'test',
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
foreach (DrawPrizeLayout::slots() as $slot) {
|
||||
$num = $slot['prize_type'] === 'first' ? '1234' : '5678';
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $num,
|
||||
'suffix_3d' => substr($num, -3),
|
||||
'suffix_2d' => substr($num, -2),
|
||||
'head_digit' => (int) substr($num, 0, 1),
|
||||
'tail_digit' => (int) substr($num, 3, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$draw->forceFill([
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/draws/20260511-778/my-match')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.has_bets', true)
|
||||
->assertJsonPath('data.hit_numbers_4d', ['1234']);
|
||||
});
|
||||
Reference in New Issue
Block a user