feat: 更新玩法配置管理,简化字段并增强功能
- 将玩法相关的显示名称字段统一为 `display_name`,移除多语言字段。 - 在 `PlayTypePatchController` 中新增即时切换玩法开关的功能,并推送大厅更新。 - 优化多个控制器和服务中的权限检查与数据处理逻辑,提升代码可读性与维护性。
This commit is contained in:
@@ -14,6 +14,7 @@ use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\OddsItem;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
@@ -413,18 +414,238 @@ 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 {
|
||||
$cases = [
|
||||
[
|
||||
'play' => 'pos_3abc',
|
||||
'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);
|
||||
$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' => 'module6-multi-tier-'.$case['play'].'-'.$case['expected_tier'].'-'.uniqid('', true),
|
||||
'lines' => [
|
||||
['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$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']);
|
||||
}
|
||||
});
|
||||
|
||||
test('module 6 ibox sums payout across combinations hitting different prize tiers', function (): void {
|
||||
$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' => 'module6-ibox-multi-tier',
|
||||
'lines' => [
|
||||
['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$deduct = (int) $item->actual_deduct_amount;
|
||||
expect($deduct)->toBe(600)
|
||||
->and((int) $item->combination_count)->toBe(6);
|
||||
|
||||
$unitWinFirst = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
|
||||
$unitWinStarter = (int) floor(100 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['starter'] / 10_000);
|
||||
$expectedWin = $unitWinFirst + $unitWinStarter;
|
||||
|
||||
p145_publish_board($draw, function (string $t, int $i): string {
|
||||
return match ($t) {
|
||||
'first' => '1212',
|
||||
'starter' => $i === 0 ? '2121' : sprintf('71%02d', $i),
|
||||
default => 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();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$item->refresh();
|
||||
$detail = TicketSettlementDetail::query()->where('ticket_item_id', $item->id)->firstOrFail();
|
||||
$matchLines = is_array($detail->match_detail_json)
|
||||
? ($detail->match_detail_json['lines'] ?? [])
|
||||
: [];
|
||||
|
||||
expect($item->status)->toBe('settled_win')
|
||||
->and((int) $item->win_amount)->toBe($expectedWin)
|
||||
->and($detail->matched_prize_tier)->toBe('first')
|
||||
->and(count($matchLines))->toBe(2);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(80_000_000 - $deduct + $expectedWin);
|
||||
});
|
||||
|
||||
test('module 6 mbox remainder deducts floored total and settles win on per-combination unit amount', function (): void {
|
||||
$player = p145_player(80_000_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
$rawAmount = 10_001;
|
||||
$comboCount = 24;
|
||||
$unitBet = intdiv($rawAmount, $comboCount);
|
||||
$expectedDeduct = $unitBet * $comboCount;
|
||||
$expectedRemainder = $rawAmount - $expectedDeduct;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'module6-mbox-remainder-win',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
|
||||
|
||||
expect((int) $item->combination_count)->toBe($comboCount)
|
||||
->and((int) $item->unit_bet_amount)->toBe($unitBet)
|
||||
->and((int) $item->total_bet_amount)->toBe($expectedDeduct)
|
||||
->and((int) $item->actual_deduct_amount)->toBe($expectedDeduct)
|
||||
->and($ruleSnapshot['rounding_refund_amount'] ?? null)->toBe($expectedRemainder);
|
||||
|
||||
$expectedWin = (int) floor($unitBet * OddsStandardScopes::PRESET_ODDS_BY_SCOPE['first'] / 10_000);
|
||||
|
||||
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : 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();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$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(80_000_000 - $expectedDeduct + $expectedWin);
|
||||
});
|
||||
|
||||
test('module 6 mbox remainder is not refunded on losing settlement', function (): void {
|
||||
$player = p145_player(80_000_000);
|
||||
$drawNo = p145_next_draw_no();
|
||||
$draw = p145_draw($drawNo, random_int(1, 99_999));
|
||||
$rawAmount = 10_001;
|
||||
$expectedDeduct = intdiv($rawAmount, 24) * 24;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'module6-mbox-remainder-lose',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'mbox', 'amount' => $rawAmount],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect((int) $item->actual_deduct_amount)->toBe($expectedDeduct);
|
||||
|
||||
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();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$item->refresh();
|
||||
expect($item->status)->toBe('settled_lose')
|
||||
->and((int) $item->win_amount)->toBe(0);
|
||||
|
||||
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||
expect((int) $wallet->balance)->toBe(80_000_000 - $expectedDeduct);
|
||||
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0);
|
||||
});
|
||||
|
||||
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,
|
||||
]);
|
||||
JackpotPool::query()->updateOrCreate(
|
||||
['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();
|
||||
@@ -515,6 +736,117 @@ test('§14.5 placement partial failure only deducts successful lines when mid-or
|
||||
expect((int) $pool->locked_amount)->toBe(3000);
|
||||
});
|
||||
|
||||
test('§14.5 settlement uses odds snapshot even if odds config changes after placement', function (): void {
|
||||
$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-odds-snapshot-1',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 10_000],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$snapshotOdds = collect(is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : [])
|
||||
->firstWhere('prize_scope', 'first');
|
||||
expect($snapshotOdds)->not->toBeNull();
|
||||
|
||||
// 修改当前赔率配置:如果结算错误使用“实时配置”,这里会导致派奖金额变化。
|
||||
OddsItem::query()
|
||||
->where('play_code', 'big')
|
||||
->where('prize_scope', 'first')
|
||||
->where('currency_code', 'NPR')
|
||||
->update(['odds_value' => 10_000]);
|
||||
|
||||
p145_publish_board($draw, fn (string $t, int $i): string => $t === 'first' ? '1234' : 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();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$item->refresh();
|
||||
$expectedWinBySnapshot = (int) floor(10_000 * ((int) $snapshotOdds['odds_value'] / 10_000));
|
||||
expect($item->status)->toBe('settled_win')
|
||||
->and((int) $item->win_amount)->toBe($expectedWinBySnapshot);
|
||||
});
|
||||
|
||||
test('§14.5 settlement releases risk pool locks after payout (win and lose)', function (): void {
|
||||
$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-risk-release-1',
|
||||
'lines' => [
|
||||
['number' => '8888', 'play_code' => 'big', 'amount' => 120],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '8888')->firstOrFail();
|
||||
$cap = (int) $pool->total_cap_amount;
|
||||
expect((int) $pool->locked_amount)->toBeGreaterThan(0)
|
||||
->and((int) $pool->remaining_amount)->toBeLessThan($cap);
|
||||
|
||||
// 先走未中奖结算,验证释放。
|
||||
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();
|
||||
p145_approve_and_payout($draw);
|
||||
|
||||
$poolAfterLose = $pool->fresh();
|
||||
expect((int) $poolAfterLose->locked_amount)->toBe(0)
|
||||
->and((int) $poolAfterLose->remaining_amount)->toBe($cap);
|
||||
|
||||
// 再开一盘中奖结算,验证同样释放。
|
||||
$drawNo2 = p145_next_draw_no();
|
||||
$draw2 = p145_draw($drawNo2, random_int(1, 99_999));
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => $drawNo2,
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'p145-risk-release-2',
|
||||
'lines' => [
|
||||
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
|
||||
],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$pool2 = RiskPool::query()->where('draw_id', $draw2->id)->where('normalized_number', '1234')->firstOrFail();
|
||||
$cap2 = (int) $pool2->total_cap_amount;
|
||||
expect((int) $pool2->locked_amount)->toBeGreaterThan(0);
|
||||
|
||||
p145_publish_board($draw2, fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i));
|
||||
$draw2->forceFill([
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'current_result_version' => 1,
|
||||
])->save();
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw2->fresh()))->toBeTrue();
|
||||
p145_approve_and_payout($draw2);
|
||||
|
||||
$poolAfterWin = $pool2->fresh();
|
||||
expect((int) $poolAfterWin->locked_amount)->toBe(0)
|
||||
->and((int) $poolAfterWin->remaining_amount)->toBe($cap2);
|
||||
});
|
||||
|
||||
/**
|
||||
* 覆盖 {@see PlayTypeSeeder} 中已启用且注册匹配器的玩法(不含 `half_box`:种子为禁用)。
|
||||
* `odd` / `even`:头奖取该注项首条展开组合号码,避免与 `expandOddEven` 枚举顺序硬编码耦合。
|
||||
|
||||
Reference in New Issue
Block a user