test(config): align current odds and settlement contracts
This commit is contained in:
@@ -40,7 +40,7 @@ final class OddsItemsReplaceController extends Controller
|
|||||||
'items.*.provider_code' => ['sometimes', 'string', 'max:32'],
|
'items.*.provider_code' => ['sometimes', 'string', 'max:32'],
|
||||||
'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')],
|
'items.*.play_code' => ['required', 'string', 'max:32', Rule::exists('play_types', 'play_code')],
|
||||||
'items.*.prize_scope' => ['required', 'string', 'max:32'],
|
'items.*.prize_scope' => ['required', 'string', 'max:32'],
|
||||||
'items.*.dimension' => ['sometimes', 'nullable', 'integer', 'in:2,3,4'],
|
'items.*.dimension' => ['sometimes', 'nullable', 'integer', 'in:2,3,4,5,6'],
|
||||||
'items.*.odds_value' => ['required', 'integer', 'min:0'],
|
'items.*.odds_value' => ['required', 'integer', 'min:0'],
|
||||||
'items.*.rebate_rate' => ['sometimes', 'numeric', 'between:0,1'],
|
'items.*.rebate_rate' => ['sometimes', 'numeric', 'between:0,1'],
|
||||||
'items.*.commission_rate' => ['sometimes', 'numeric', 'between:0,1'],
|
'items.*.commission_rate' => ['sometimes', 'numeric', 'between:0,1'],
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use App\Services\AuditLogger;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Support\OddsStandardScopes;
|
use App\Support\OddsStandardScopes;
|
||||||
use App\Lottery\ConfigVersionStatus;
|
use App\Lottery\ConfigVersionStatus;
|
||||||
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
|
|
||||||
use App\Http\Middleware\RecordAdminApiAudit;
|
use App\Http\Middleware\RecordAdminApiAudit;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use App\Services\Draw\LotteryHallRealtimeBroadcaster;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
/** 后台:赔率版本({@see odds_versions} / {@see odds_items}) */
|
/** 后台:赔率版本({@see odds_versions} / {@see odds_items}) */
|
||||||
@@ -269,8 +269,8 @@ final class OddsStreamService
|
|||||||
$errors["items.$index.currency_code"][] = '币种不可下注';
|
$errors["items.$index.currency_code"][] = '币种不可下注';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dimension !== null && ! in_array($dimension, [2, 3, 4], true)) {
|
if ($dimension !== null && ! in_array($dimension, [2, 3, 4, 5, 6], true)) {
|
||||||
$errors["items.$index.dimension"][] = '维度必须是 2、3 或 4';
|
$errors["items.$index.dimension"][] = '维度必须是 2、3、4、5 或 6';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($seenKeys[$key])) {
|
if (isset($seenKeys[$key])) {
|
||||||
@@ -278,8 +278,8 @@ final class OddsStreamService
|
|||||||
}
|
}
|
||||||
$seenKeys[$key] = true;
|
$seenKeys[$key] = true;
|
||||||
|
|
||||||
if ($oddsValue <= 0) {
|
if ($oddsValue < 0) {
|
||||||
$errors["items.$index.odds_value"][] = '赔率值必须大于 0';
|
$errors["items.$index.odds_value"][] = '赔率值不能小于 0';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($rebateRate < 0 || $rebateRate > 1) {
|
if ($rebateRate < 0 || $rebateRate > 1) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -112,7 +113,8 @@ test('agent line provision rejects site that already has root', function (): voi
|
|||||||
'password' => 'secret-strong',
|
'password' => 'secret-strong',
|
||||||
])
|
])
|
||||||
->assertStatus(422)
|
->assertStatus(422)
|
||||||
->assertJsonPath('data.errors.site_code.0', 'site_root_exists');
|
->assertJsonPath('code', ErrorCode::ValidationFailed->value)
|
||||||
|
->assertJsonStructure(['data' => ['errors' => ['site_code']]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('integration manager with site.manage can create integration site', function (): void {
|
test('integration manager with site.manage can create integration site', function (): void {
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\AdminUser;
|
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
|
use App\Models\AdminUser;
|
||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use App\Services\AgentSettlement\SettlementPeriodOpenHintsService;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(function (): void {
|
||||||
|
Carbon::setTestNow('2026-07-09 12:00:00');
|
||||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
Carbon::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
test('settlement period open hints api resource is configured after migrations', function (): void {
|
test('settlement period open hints api resource is configured after migrations', function (): void {
|
||||||
expect(
|
expect(
|
||||||
DB::table('admin_api_resources')
|
DB::table('admin_api_resources')
|
||||||
@@ -295,7 +302,7 @@ test('settlement period open hints uses site timezone for orphan activity dates'
|
|||||||
'updated_at' => '2026-07-09 18:00:00',
|
'updated_at' => '2026-07-09 18:00:00',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$hints = app(\App\Services\AgentSettlement\SettlementPeriodOpenHintsService::class)->hints($siteId);
|
$hints = app(SettlementPeriodOpenHintsService::class)->hints($siteId);
|
||||||
|
|
||||||
expect($hints['settlement_timezone'])->toBe('Asia/Kathmandu')
|
expect($hints['settlement_timezone'])->toBe('Asia/Kathmandu')
|
||||||
->and($hints['occupied_period_dates'])->toContain('2026-07-09')
|
->and($hints['occupied_period_dates'])->toContain('2026-07-09')
|
||||||
|
|||||||
@@ -11,19 +11,20 @@ use App\Models\TicketOrder;
|
|||||||
use App\Models\PlayerWallet;
|
use App\Models\PlayerWallet;
|
||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
use App\Models\DrawResultBatch;
|
use App\Models\DrawResultBatch;
|
||||||
use App\Models\JackpotPayoutLog;
|
|
||||||
use App\Models\SettlementBatch;
|
use App\Models\SettlementBatch;
|
||||||
use Illuminate\Support\Facades\Event;
|
use App\Models\JackpotPayoutLog;
|
||||||
use App\Events\JackpotBurstBroadcast;
|
|
||||||
use App\Services\Draw\DrawResultViewService;
|
|
||||||
use App\Models\JackpotContribution;
|
use App\Models\JackpotContribution;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use App\Support\OddsStandardScopes;
|
||||||
use App\Lottery\ConfigVersionStatus;
|
use App\Lottery\ConfigVersionStatus;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use App\Events\JackpotBurstBroadcast;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use App\Lottery\DrawResultBatchStatus;
|
use App\Lottery\DrawResultBatchStatus;
|
||||||
use App\Services\Draw\DrawPrizeLayout;
|
use App\Services\Draw\DrawPrizeLayout;
|
||||||
use Database\Seeders\LotterySettingsSeeder;
|
use Database\Seeders\LotterySettingsSeeder;
|
||||||
|
use App\Services\Draw\DrawResultViewService;
|
||||||
use Database\Seeders\OperationalConfigV1Seeder;
|
use Database\Seeders\OperationalConfigV1Seeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use App\Services\Settlement\SettlementOrchestrator;
|
use App\Services\Settlement\SettlementOrchestrator;
|
||||||
@@ -239,7 +240,8 @@ test('jackpot contributes on place and bursts on settle for first-prize straight
|
|||||||
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
|
app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh());
|
||||||
|
|
||||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||||
expect((int) $item->win_amount)->toBe(250_000);
|
$expectedWin = (int) floor(10_000 * OddsStandardScopes::presetOddsForPlay('straight')['first'] / 10_000);
|
||||||
|
expect((int) $item->win_amount)->toBe($expectedWin);
|
||||||
expect((int) $item->jackpot_win_amount)->toBe(1_000);
|
expect((int) $item->jackpot_win_amount)->toBe(1_000);
|
||||||
|
|
||||||
$poolAfterSettle = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
$poolAfterSettle = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ use App\Models\PlayConfigItem;
|
|||||||
use App\Models\PlayConfigVersion;
|
use App\Models\PlayConfigVersion;
|
||||||
use App\Support\OddsStandardScopes;
|
use App\Support\OddsStandardScopes;
|
||||||
use App\Lottery\ConfigVersionStatus;
|
use App\Lottery\ConfigVersionStatus;
|
||||||
use App\Services\Ticket\PlayCatalogResolver;
|
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
|
use App\Services\Ticket\PlayCatalogResolver;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@@ -59,7 +59,7 @@ test('syncMissingForVersion adds five scopes from default-only rows', function (
|
|||||||
->where('prize_scope', $scope)
|
->where('prize_scope', $scope)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
expect((int) $row->odds_value)->toBe(OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$scope]);
|
expect((int) $row->odds_value)->toBe(OddsStandardScopes::presetOddsForPlay((string) $pt->play_code)[$scope]);
|
||||||
/** @var string $rebate Eloquent `decimal:4` cast */
|
/** @var string $rebate Eloquent `decimal:4` cast */
|
||||||
$rebate = (string) $row->rebate_rate;
|
$rebate = (string) $row->rebate_rate;
|
||||||
expect($rebate)->toBe('0.0100');
|
expect($rebate)->toBe('0.0100');
|
||||||
|
|||||||
@@ -18,15 +18,15 @@ use App\Models\AdminUser;
|
|||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use App\Models\OddsVersion;
|
use App\Models\OddsVersion;
|
||||||
use App\Models\RiskCapVersion;
|
use App\Models\RiskCapVersion;
|
||||||
use App\Services\Ticket\PlayCatalogResolver;
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Events\OddsUpdateBroadcast;
|
use App\Events\OddsUpdateBroadcast;
|
||||||
use App\Events\PlayToggleBroadcast;
|
use App\Events\PlayToggleBroadcast;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use App\Lottery\ConfigVersionStatus;
|
use App\Lottery\ConfigVersionStatus;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use App\Services\Ticket\PlayCatalogResolver;
|
||||||
use Database\Seeders\OperationalConfigV1Seeder;
|
use Database\Seeders\OperationalConfigV1Seeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
@@ -55,8 +55,10 @@ function acceptanceMintAdminToken(): string
|
|||||||
function acceptanceOddsPutPayloadFromDetail(array $items): array
|
function acceptanceOddsPutPayloadFromDetail(array $items): array
|
||||||
{
|
{
|
||||||
return collect($items)->map(fn (array $r) => [
|
return collect($items)->map(fn (array $r) => [
|
||||||
|
'provider_code' => $r['provider_code'] ?? 'GLOBAL',
|
||||||
'play_code' => $r['play_code'],
|
'play_code' => $r['play_code'],
|
||||||
'prize_scope' => $r['prize_scope'],
|
'prize_scope' => $r['prize_scope'],
|
||||||
|
'dimension' => $r['dimension'] ?? null,
|
||||||
'odds_value' => (int) $r['odds_value'],
|
'odds_value' => (int) $r['odds_value'],
|
||||||
'rebate_rate' => (float) $r['rebate_rate'],
|
'rebate_rate' => (float) $r['rebate_rate'],
|
||||||
'commission_rate' => (float) $r['commission_rate'],
|
'commission_rate' => (float) $r['commission_rate'],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Lottery\ErrorCode;
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Models\LotterySetting;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
@@ -20,12 +21,12 @@ test('public settings requires allowed group', function (): void {
|
|||||||
->assertJsonPath('code', ErrorCode::ClientHttpError->value);
|
->assertJsonPath('code', ErrorCode::ClientHttpError->value);
|
||||||
|
|
||||||
$this->getJson('/api/v1/settings?group=currency')
|
$this->getJson('/api/v1/settings?group=currency')
|
||||||
->assertStatus(400)
|
->assertOk()
|
||||||
->assertJsonPath('code', ErrorCode::ClientHttpError->value);
|
->assertJsonPath('code', ErrorCode::Success->value);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('public settings returns frontend group only', function (): void {
|
test('public settings returns an allowed public group', function (): void {
|
||||||
\App\Models\LotterySetting::query()->updateOrCreate(
|
LotterySetting::query()->updateOrCreate(
|
||||||
['setting_key' => 'frontend.play_rules_html_zh'],
|
['setting_key' => 'frontend.play_rules_html_zh'],
|
||||||
[
|
[
|
||||||
'group_name' => 'frontend',
|
'group_name' => 'frontend',
|
||||||
|
|||||||
@@ -10,14 +10,23 @@ uses(RefreshDatabase::class);
|
|||||||
|
|
||||||
beforeEach(fn () => $this->seed(PlayTypeSeeder::class));
|
beforeEach(fn () => $this->seed(PlayTypeSeeder::class));
|
||||||
|
|
||||||
test('every play_types.play_code maps to a non-noop settlement matcher', function (): void {
|
test('every enabled play type maps to a non-noop settlement matcher', function (): void {
|
||||||
$reg = app(SettlementMatcherRegistry::class);
|
$reg = app(SettlementMatcherRegistry::class);
|
||||||
foreach (PlayType::query()->orderBy('play_code')->pluck('play_code') as $code) {
|
foreach (PlayType::query()->where('is_enabled', true)->orderBy('play_code')->pluck('play_code') as $code) {
|
||||||
$matcher = $reg->for((string) $code);
|
$matcher = $reg->for((string) $code);
|
||||||
expect($matcher)->not->toBeInstanceOf(NoopSettlementMatcher::class);
|
expect($matcher)->not->toBeInstanceOf(NoopSettlementMatcher::class);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('independent draw plays stay disabled until their settlement matchers exist', function (): void {
|
||||||
|
$reg = app(SettlementMatcherRegistry::class);
|
||||||
|
|
||||||
|
foreach (['five_d', 'six_d'] as $code) {
|
||||||
|
expect(PlayType::query()->where('play_code', $code)->value('is_enabled'))->toBeFalse()
|
||||||
|
->and($reg->for($code))->toBeInstanceOf(NoopSettlementMatcher::class);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('half_box reuses the same matcher instance as big spread', function (): void {
|
test('half_box reuses the same matcher instance as big spread', function (): void {
|
||||||
$reg = app(SettlementMatcherRegistry::class);
|
$reg = app(SettlementMatcherRegistry::class);
|
||||||
expect($reg->for('half_box'))->toBe($reg->for('big'));
|
expect($reg->for('half_box'))->toBe($reg->for('big'));
|
||||||
|
|||||||
@@ -6,34 +6,34 @@
|
|||||||
|
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Models\AdminUser;
|
use App\Models\OddsItem;
|
||||||
use App\Models\RiskPool;
|
use App\Models\RiskPool;
|
||||||
|
use App\Models\AdminUser;
|
||||||
use App\Models\WalletTxn;
|
use App\Models\WalletTxn;
|
||||||
use App\Lottery\ErrorCode;
|
use App\Lottery\ErrorCode;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use App\Models\JackpotPool;
|
use App\Models\JackpotPool;
|
||||||
use App\Models\TicketOrder;
|
use App\Models\TicketOrder;
|
||||||
use App\Models\OddsItem;
|
|
||||||
use App\Models\PlayerWallet;
|
use App\Models\PlayerWallet;
|
||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
use App\Models\DrawResultBatch;
|
use App\Models\DrawResultBatch;
|
||||||
use App\Models\JackpotPayoutLog;
|
|
||||||
use App\Models\SettlementBatch;
|
use App\Models\SettlementBatch;
|
||||||
|
use App\Models\JackpotPayoutLog;
|
||||||
use App\Models\TicketCombination;
|
use App\Models\TicketCombination;
|
||||||
use App\Models\JackpotContribution;
|
use App\Models\JackpotContribution;
|
||||||
use App\Support\OddsStandardScopes;
|
use App\Support\OddsStandardScopes;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use App\Lottery\DrawResultBatchStatus;
|
use App\Lottery\DrawResultBatchStatus;
|
||||||
use App\Models\TicketSettlementDetail;
|
use App\Models\TicketSettlementDetail;
|
||||||
use App\Services\Settlement\SettlementBatchWorkflowService;
|
|
||||||
use App\Services\Draw\DrawPrizeLayout;
|
use App\Services\Draw\DrawPrizeLayout;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Database\Seeders\LotterySettingsSeeder;
|
use Database\Seeders\LotterySettingsSeeder;
|
||||||
use Database\Seeders\OperationalConfigV1Seeder;
|
use Database\Seeders\OperationalConfigV1Seeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use App\Services\Settlement\SettlementOrchestrator;
|
use App\Services\Settlement\SettlementOrchestrator;
|
||||||
|
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ test('§14.5 small hits second tier only', function (): void {
|
|||||||
|
|
||||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||||
$deduct = (int) $item->actual_deduct_amount;
|
$deduct = (int) $item->actual_deduct_amount;
|
||||||
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['second'] / 10_000);
|
$expectedWin = (int) floor(10_000 * OddsStandardScopes::presetOddsForPlay('small')['second'] / 10_000);
|
||||||
|
|
||||||
p145_publish_board($draw, function (string $t, int $i): string {
|
p145_publish_board($draw, function (string $t, int $i): string {
|
||||||
return match ($t) {
|
return match ($t) {
|
||||||
@@ -321,7 +321,7 @@ test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', functio
|
|||||||
|
|
||||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||||
$deduct = (int) $item->actual_deduct_amount;
|
$deduct = (int) $item->actual_deduct_amount;
|
||||||
$odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']];
|
$odds = OddsStandardScopes::presetOddsForPlay($case['play'])[$case['scope']];
|
||||||
$perComboWin = (int) floor(10_000 * $odds / 10_000);
|
$perComboWin = (int) floor(10_000 * $odds / 10_000);
|
||||||
$expectedWin = $perComboWin;
|
$expectedWin = $perComboWin;
|
||||||
|
|
||||||
@@ -357,26 +357,6 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa
|
|||||||
'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i),
|
'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i),
|
||||||
'scope' => 'first',
|
'scope' => 'first',
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'play' => 'pos_3abc',
|
|
||||||
'number' => '567',
|
|
||||||
'board' => fn (string $t, int $i): string => match ($t) {
|
|
||||||
'first' => '4567',
|
|
||||||
default => p145_board_without_8888($t, $i),
|
|
||||||
},
|
|
||||||
'scope' => 'first',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'play' => 'pos_2abc',
|
|
||||||
'number' => '99',
|
|
||||||
'board' => fn (string $t, int $i): string => match ($t) {
|
|
||||||
'first' => '8899',
|
|
||||||
'second' => '2299',
|
|
||||||
'third' => '1199',
|
|
||||||
default => p145_board_without_8888($t, $i),
|
|
||||||
},
|
|
||||||
'scope' => 'first',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($cases as $case) {
|
foreach ($cases as $case) {
|
||||||
@@ -397,7 +377,7 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa
|
|||||||
|
|
||||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||||
expect((int) $item->combination_count)->toBeIn([10, 100]);
|
expect((int) $item->combination_count)->toBeIn([10, 100]);
|
||||||
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']] / 10_000);
|
$expectedWin = (int) floor(10_000 * OddsStandardScopes::presetOddsForPlay($case['play'])[$case['scope']] / 10_000);
|
||||||
|
|
||||||
p145_publish_board($draw, $case['board']);
|
p145_publish_board($draw, $case['board']);
|
||||||
$draw->forceFill([
|
$draw->forceFill([
|
||||||
@@ -414,77 +394,28 @@ test('module 6 suffix plays settle once per ticket item instead of once per expa
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('module 6 abc suffix plays pick best tier when multiple prize tiers share the same suffix', function (): void {
|
test('legacy abc suffix plays stay closed when absent from the active catalog', function (): void {
|
||||||
$cases = [
|
$cases = [
|
||||||
[
|
['play' => 'pos_3abc', 'number' => '234'],
|
||||||
'play' => 'pos_3abc',
|
['play' => 'pos_2abc', 'number' => '99'],
|
||||||
'number' => '234',
|
|
||||||
'board' => fn (string $t, int $i): string => match ($t) {
|
|
||||||
'first' => '1234',
|
|
||||||
'second' => '5234',
|
|
||||||
'third' => '9234',
|
|
||||||
default => p145_board_without_8888($t, $i),
|
|
||||||
},
|
|
||||||
'expected_tier' => 'first',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'play' => 'pos_3abc',
|
|
||||||
'number' => '234',
|
|
||||||
'board' => fn (string $t, int $i): string => match ($t) {
|
|
||||||
'first' => '1567',
|
|
||||||
'second' => '5234',
|
|
||||||
'third' => '8234',
|
|
||||||
default => p145_board_without_8888($t, $i),
|
|
||||||
},
|
|
||||||
'expected_tier' => 'second',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'play' => 'pos_2abc',
|
|
||||||
'number' => '99',
|
|
||||||
'board' => fn (string $t, int $i): string => match ($t) {
|
|
||||||
'first' => '8899',
|
|
||||||
'second' => '2299',
|
|
||||||
'third' => '1199',
|
|
||||||
default => p145_board_without_8888($t, $i),
|
|
||||||
},
|
|
||||||
'expected_tier' => 'first',
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($cases as $case) {
|
|
||||||
$player = p145_player(80_000_000);
|
$player = p145_player(80_000_000);
|
||||||
$drawNo = p145_next_draw_no();
|
$drawNo = p145_next_draw_no();
|
||||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
p145_draw($drawNo, random_int(1, 99_999));
|
||||||
|
|
||||||
|
foreach ($cases as $case) {
|
||||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||||
->postJson('/api/v1/ticket/place', [
|
->postJson('/api/v1/ticket/place', [
|
||||||
'draw_id' => $drawNo,
|
'draw_id' => $drawNo,
|
||||||
'currency_code' => 'NPR',
|
'currency_code' => 'NPR',
|
||||||
'client_trace_id' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true),
|
'client_trace_id' => 'module6-legacy-closed-'.$case['play'].'-'.uniqid('', true),
|
||||||
'lines' => [
|
'lines' => [
|
||||||
['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
|
['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->assertOk();
|
->assertStatus(400)
|
||||||
|
->assertJsonPath('code', ErrorCode::PlayModeClosed->value);
|
||||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
|
||||||
$expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['expected_tier']] / 10_000);
|
|
||||||
|
|
||||||
p145_publish_board($draw, $case['board']);
|
|
||||||
$draw->forceFill([
|
|
||||||
'status' => DrawStatus::Settling->value,
|
|
||||||
'current_result_version' => 1,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
|
|
||||||
p145_approve_and_payout($draw);
|
|
||||||
|
|
||||||
$item->refresh();
|
|
||||||
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
|
|
||||||
|
|
||||||
expect($item->status)->toBe('settled_win', $case['play'])
|
|
||||||
->and((int) $item->win_amount)->toBe($expectedWin, $case['play'])
|
|
||||||
->and($detail->matched_prize_tier)->toBe($case['expected_tier'], $case['play']);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -509,8 +440,8 @@ test('module 6 ibox sums payout across combinations hitting different prize tier
|
|||||||
expect($deduct)->toBe(600)
|
expect($deduct)->toBe(600)
|
||||||
->and((int) $item->combination_count)->toBe(6);
|
->and((int) $item->combination_count)->toBe(6);
|
||||||
|
|
||||||
$unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
|
$unitWinFirst = (int) floor(100 * OddsStandardScopes::presetOddsForPlay('ibox')['first'] / 10_000);
|
||||||
$unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000);
|
$unitWinStarter = (int) floor(100 * OddsStandardScopes::presetOddsForPlay('ibox')['starter'] / 10_000);
|
||||||
$expectedWin = $unitWinFirst + $unitWinStarter;
|
$expectedWin = $unitWinFirst + $unitWinStarter;
|
||||||
|
|
||||||
p145_publish_board($draw, function (string $t, int $i): string {
|
p145_publish_board($draw, function (string $t, int $i): string {
|
||||||
@@ -574,7 +505,7 @@ test('module 6 mbox remainder deducts floored total and settles win on per-combi
|
|||||||
->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct)
|
->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct)
|
||||||
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder);
|
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder);
|
||||||
|
|
||||||
$expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
|
$expectedWin = (int) floor($unitBet * OddsStandardScopes::presetOddsForPlay('mbox')['first'] / 10_000);
|
||||||
|
|
||||||
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
|
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
|
||||||
$draw->forceFill([
|
$draw->forceFill([
|
||||||
@@ -699,9 +630,9 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or
|
|||||||
RiskPool::query()->create([
|
RiskPool::query()->create([
|
||||||
'draw_id' => $draw->id,
|
'draw_id' => $draw->id,
|
||||||
'normalized_number' => '1234',
|
'normalized_number' => '1234',
|
||||||
'total_cap_amount' => 5000,
|
'total_cap_amount' => 500_000,
|
||||||
'locked_amount' => 0,
|
'locked_amount' => 0,
|
||||||
'remaining_amount' => 5000,
|
'remaining_amount' => 500_000,
|
||||||
'sold_out_status' => 0,
|
'sold_out_status' => 0,
|
||||||
'version' => 0,
|
'version' => 0,
|
||||||
]);
|
]);
|
||||||
@@ -732,8 +663,8 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or
|
|||||||
->where('draw_id', $draw->id)
|
->where('draw_id', $draw->id)
|
||||||
->where('normalized_number', '1234')
|
->where('normalized_number', '1234')
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
expect((int) $pool->remaining_amount)->toBe(2000);
|
expect((int) $pool->remaining_amount)->toBe(200_000);
|
||||||
expect((int) $pool->locked_amount)->toBe(3000);
|
expect((int) $pool->locked_amount)->toBe(300_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void {
|
test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void {
|
||||||
@@ -977,18 +908,6 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s
|
|||||||
'scope' => 'third',
|
'scope' => 'third',
|
||||||
'comboMultiplier' => 1,
|
'comboMultiplier' => 1,
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'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' => 1,
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'play' => 'pos_2b',
|
'play' => 'pos_2b',
|
||||||
'line' => ['number' => '56', 'play_code' => 'pos_2b', 'amount' => 10_000],
|
'line' => ['number' => '56', 'play_code' => 'pos_2b', 'amount' => 10_000],
|
||||||
@@ -1012,18 +931,6 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s
|
|||||||
'scope' => 'third',
|
'scope' => 'third',
|
||||||
'comboMultiplier' => 1,
|
'comboMultiplier' => 1,
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'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' => 1,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($cases as $case) {
|
foreach ($cases as $case) {
|
||||||
@@ -1042,7 +949,7 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s
|
|||||||
|
|
||||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||||
$deduct = (int) $item->actual_deduct_amount;
|
$deduct = (int) $item->actual_deduct_amount;
|
||||||
$odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']];
|
$odds = OddsStandardScopes::presetOddsForPlay($case['play'])[$case['scope']];
|
||||||
$unitOnTicket = (int) $item->unit_bet_amount;
|
$unitOnTicket = (int) $item->unit_bet_amount;
|
||||||
$perComboWin = (int) floor($unitOnTicket * $odds / 10_000);
|
$perComboWin = (int) floor($unitOnTicket * $odds / 10_000);
|
||||||
$expectedWin = $perComboWin * (int) $case['comboMultiplier'];
|
$expectedWin = $perComboWin * (int) $case['comboMultiplier'];
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ use App\Models\WalletTxn;
|
|||||||
use App\Lottery\ErrorCode;
|
use App\Lottery\ErrorCode;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
|
use App\Models\JackpotPool;
|
||||||
use App\Models\OddsVersion;
|
use App\Models\OddsVersion;
|
||||||
use App\Models\TicketOrder;
|
use App\Models\TicketOrder;
|
||||||
use App\Models\JackpotPool;
|
|
||||||
use App\Models\PlayerWallet;
|
use App\Models\PlayerWallet;
|
||||||
use App\Models\TicketCombination;
|
|
||||||
use App\Models\PlayConfigItem;
|
use App\Models\PlayConfigItem;
|
||||||
use App\Models\PlayConfigVersion;
|
use App\Models\PlayConfigVersion;
|
||||||
use App\Lottery\ConfigVersionStatus;
|
use App\Models\TicketCombination;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Lottery\ConfigVersionStatus;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
use Database\Seeders\LotterySettingsSeeder;
|
use Database\Seeders\LotterySettingsSeeder;
|
||||||
@@ -574,9 +574,9 @@ test('ticket place can return mixed success and failed risk results', function (
|
|||||||
RiskPool::query()->create([
|
RiskPool::query()->create([
|
||||||
'draw_id' => $draw->id,
|
'draw_id' => $draw->id,
|
||||||
'normalized_number' => '1234',
|
'normalized_number' => '1234',
|
||||||
'total_cap_amount' => 5000,
|
'total_cap_amount' => 500_000,
|
||||||
'locked_amount' => 0,
|
'locked_amount' => 0,
|
||||||
'remaining_amount' => 5000,
|
'remaining_amount' => 500_000,
|
||||||
'sold_out_status' => 0,
|
'sold_out_status' => 0,
|
||||||
'version' => 0,
|
'version' => 0,
|
||||||
]);
|
]);
|
||||||
@@ -709,9 +709,9 @@ test('ticket preview reports high risk warning without deducting wallet or creat
|
|||||||
RiskPool::query()->create([
|
RiskPool::query()->create([
|
||||||
'draw_id' => $draw->id,
|
'draw_id' => $draw->id,
|
||||||
'normalized_number' => '1234',
|
'normalized_number' => '1234',
|
||||||
'total_cap_amount' => 4000,
|
'total_cap_amount' => 500_000,
|
||||||
'locked_amount' => 0,
|
'locked_amount' => 0,
|
||||||
'remaining_amount' => 4000,
|
'remaining_amount' => 500_000,
|
||||||
'sold_out_status' => 0,
|
'sold_out_status' => 0,
|
||||||
'version' => 0,
|
'version' => 0,
|
||||||
]);
|
]);
|
||||||
@@ -734,7 +734,7 @@ test('ticket preview reports high risk warning without deducting wallet or creat
|
|||||||
|
|
||||||
expect((int) $wallet->balance)->toBe(200_000)
|
expect((int) $wallet->balance)->toBe(200_000)
|
||||||
->and((int) $pool->locked_amount)->toBe(0)
|
->and((int) $pool->locked_amount)->toBe(0)
|
||||||
->and((int) $pool->remaining_amount)->toBe(4000)
|
->and((int) $pool->remaining_amount)->toBe(500_000)
|
||||||
->and(TicketOrder::query()->count())->toBe(0)
|
->and(TicketOrder::query()->count())->toBe(0)
|
||||||
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
|
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
@@ -818,7 +818,7 @@ test('ticket preview rejects invalid line amount per validation rules', function
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* §10.1.2 混合成功失败:同一订单两行共享号码时,首条占用成功,第二条额度不足则该注项失败。
|
* §10.1.2 混合成功失败:同一订单两行共享号码时,首条占用成功,第二条额度不足则该注项失败。
|
||||||
* big + amount 120 → estimated_payout 3000(maxOdds 250000 / 10000 = 25)。
|
* big + amount 120 → estimated_payout 300000(maxOdds 25000000 / 10000 = 2500)。
|
||||||
*/
|
*/
|
||||||
test('ticket place records partial failed when mid-order acquire fails', function (): void {
|
test('ticket place records partial failed when mid-order acquire fails', function (): void {
|
||||||
$player = ticketPlayerWithWallet(500_000);
|
$player = ticketPlayerWithWallet(500_000);
|
||||||
@@ -827,9 +827,9 @@ test('ticket place records partial failed when mid-order acquire fails', functio
|
|||||||
RiskPool::query()->create([
|
RiskPool::query()->create([
|
||||||
'draw_id' => $draw->id,
|
'draw_id' => $draw->id,
|
||||||
'normalized_number' => '1234',
|
'normalized_number' => '1234',
|
||||||
'total_cap_amount' => 5000,
|
'total_cap_amount' => 500_000,
|
||||||
'locked_amount' => 0,
|
'locked_amount' => 0,
|
||||||
'remaining_amount' => 5000,
|
'remaining_amount' => 500_000,
|
||||||
'sold_out_status' => 0,
|
'sold_out_status' => 0,
|
||||||
'version' => 0,
|
'version' => 0,
|
||||||
]);
|
]);
|
||||||
@@ -862,8 +862,8 @@ test('ticket place records partial failed when mid-order acquire fails', functio
|
|||||||
->where('draw_id', $draw->id)
|
->where('draw_id', $draw->id)
|
||||||
->where('normalized_number', '1234')
|
->where('normalized_number', '1234')
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
expect((int) $pool->remaining_amount)->toBe(2000);
|
expect((int) $pool->remaining_amount)->toBe(200_000);
|
||||||
expect((int) $pool->locked_amount)->toBe(3000);
|
expect((int) $pool->locked_amount)->toBe(300_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** §13.5 并发下注(顺序挤出):先成功者占用额度,后一盘同一号码收到售罄。 */
|
/** §13.5 并发下注(顺序挤出):先成功者占用额度,后一盘同一号码收到售罄。 */
|
||||||
@@ -875,9 +875,9 @@ test('ticket place sold out for second player after first consumes shared pool',
|
|||||||
RiskPool::query()->create([
|
RiskPool::query()->create([
|
||||||
'draw_id' => $draw->id,
|
'draw_id' => $draw->id,
|
||||||
'normalized_number' => '1234',
|
'normalized_number' => '1234',
|
||||||
'total_cap_amount' => 5000,
|
'total_cap_amount' => 500_000,
|
||||||
'locked_amount' => 0,
|
'locked_amount' => 0,
|
||||||
'remaining_amount' => 5000,
|
'remaining_amount' => 500_000,
|
||||||
'sold_out_status' => 0,
|
'sold_out_status' => 0,
|
||||||
'version' => 0,
|
'version' => 0,
|
||||||
]);
|
]);
|
||||||
@@ -907,7 +907,7 @@ test('ticket place sold out for second player after first consumes shared pool',
|
|||||||
->where('draw_id', $draw->id)
|
->where('draw_id', $draw->id)
|
||||||
->where('normalized_number', '1234')
|
->where('normalized_number', '1234')
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
expect((int) $pool->remaining_amount)->toBe(2000);
|
expect((int) $pool->remaining_amount)->toBe(200_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ticket preview and place apply base rebate plus player add-on rebate for wallet player', function (): void {
|
test('ticket preview and place apply base rebate plus player add-on rebate for wallet player', function (): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user