feat: 拆分开奖与结算审核流程,新增手动结果录入、重开和派彩审批接口
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Draw;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultItem;
|
||||
@@ -46,6 +47,151 @@ test('draw planner fills buffer rows with ordered draw_no', function (): void {
|
||||
expect($drawNos)->toEqual($sorted);
|
||||
});
|
||||
|
||||
test('admin can batch generate draw schedule buffer', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:00:00', 'UTC'));
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_plan_admin',
|
||||
'name' => 'Draw Plan Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/v1/admin/draws/generate-plan')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.buffer_target', 3);
|
||||
|
||||
expect(Draw::query()->count())->toBeGreaterThanOrEqual(3);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can manually close open draw', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:10:00', 'UTC'));
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-120',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 120,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->copy()->subMinute(),
|
||||
'close_time' => now()->copy()->addMinutes(10),
|
||||
'draw_time' => now()->copy()->addMinutes(15),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_close_admin',
|
||||
'name' => 'Draw Close Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/manual-close")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', DrawStatus::Closing->value);
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Closing->value);
|
||||
expect($draw->close_time?->timestamp)->toBe(now()->timestamp);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can cancel draw before results exist', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:15:00', 'UTC'));
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-121',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 121,
|
||||
'status' => DrawStatus::Pending->value,
|
||||
'start_time' => now()->copy()->addMinute(),
|
||||
'close_time' => now()->copy()->addMinutes(10),
|
||||
'draw_time' => now()->copy()->addMinutes(15),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_cancel_admin',
|
||||
'name' => 'Draw Cancel Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/cancel")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', DrawStatus::Cancelled->value);
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Cancelled->value);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can manually trigger rng for closed draw', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:20:00', 'UTC'));
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-122',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 122,
|
||||
'status' => DrawStatus::Closed->value,
|
||||
'start_time' => now()->copy()->subMinutes(20),
|
||||
'close_time' => now()->copy()->subMinutes(5),
|
||||
'draw_time' => now()->copy()->subMinute(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_rng_admin',
|
||||
'name' => 'Draw Rng Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/rng")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', DrawStatus::Review->value)
|
||||
->assertJsonPath('data.batch.source_type', 'rng')
|
||||
->assertJsonPath('data.batch.items_count', 23);
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Review->value);
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('draw tick moves open draw to closing when close_time passed before draw_time', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:00:00', 'UTC'));
|
||||
|
||||
@@ -167,6 +313,191 @@ test('draw tick rng awaits manual publish when review enabled', function (): voi
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can create manual result batch with 23 numbers for review', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:20:00', 'UTC'));
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-220',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 220,
|
||||
'status' => DrawStatus::Closed->value,
|
||||
'start_time' => now()->copy()->subMinutes(20),
|
||||
'close_time' => now()->copy()->subMinutes(2),
|
||||
'draw_time' => now()->copy()->subMinute(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'manual_draw_admin',
|
||||
'name' => 'Manual Draw Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$items = [];
|
||||
foreach (array_values(App\Services\Draw\DrawPrizeLayout::slots()) as $i => $slot) {
|
||||
$items[] = [
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT),
|
||||
];
|
||||
}
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-220')
|
||||
->assertJsonPath('data.status', DrawStatus::Review->value)
|
||||
->assertJsonPath('data.batch.status', DrawResultBatchStatus::PendingReview->value)
|
||||
->assertJsonPath('data.batch.source_type', 'manual')
|
||||
->assertJsonPath('data.batch.items_count', 23);
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Review->value);
|
||||
expect($draw->result_source)->toBe('manual');
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->where('source_type', 'manual')->count())->toBe(1);
|
||||
expect(DrawResultItem::query()->where('draw_id', $draw->id)->count())->toBe(23);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can reopen cooldown draw for a replacement result batch', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:30:00', 'UTC'));
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-230',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 230,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => now()->copy()->subMinutes(20),
|
||||
'close_time' => now()->copy()->subMinutes(3),
|
||||
'draw_time' => now()->copy()->subMinutes(2),
|
||||
'cooling_end_time' => now()->copy()->addMinutes(10),
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'rng_seed_hash' => hash('sha256', 'seed'),
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
foreach (App\Services\Draw\DrawPrizeLayout::slots() as $i => $slot) {
|
||||
$number = str_pad((string) ($i + 100), 4, '0', STR_PAD_LEFT);
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $number,
|
||||
'suffix_3d' => substr($number, -3),
|
||||
'suffix_2d' => substr($number, -2),
|
||||
'head_digit' => (int) substr($number, 0, 1),
|
||||
'tail_digit' => (int) substr($number, 3, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'reopen_draw_admin',
|
||||
'name' => 'Reopen Draw Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/reopen", ['reason' => 'wrong result'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-230')
|
||||
->assertJsonPath('data.status', DrawStatus::Closed->value)
|
||||
->assertJsonPath('data.is_reopened', true)
|
||||
->assertJsonPath('data.current_result_version', 1);
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Closed->value);
|
||||
expect($draw->is_reopened)->toBeTrue();
|
||||
expect($draw->cooling_end_time)->toBeNull();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('non super admin cannot reopen cooldown draw', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:35:00', 'UTC'));
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-231',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 231,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => now()->copy()->subMinutes(20),
|
||||
'close_time' => now()->copy()->subMinutes(3),
|
||||
'draw_time' => now()->copy()->subMinutes(2),
|
||||
'cooling_end_time' => now()->copy()->addMinutes(10),
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => 'draw_manager_test',
|
||||
'name' => 'Draw Manager Test',
|
||||
]);
|
||||
$ids = DB::table('admin_menu_actions')
|
||||
->whereIn('permission_code', App\Support\AdminPermissionBridge::menuActionCodesForLegacy('prd.draw_result.manage'))
|
||||
->where('status', 1)
|
||||
->pluck('id');
|
||||
foreach ($ids as $mid) {
|
||||
DB::table('admin_role_menu_actions')->insert([
|
||||
'role_id' => $role->id,
|
||||
'menu_action_id' => (int) $mid,
|
||||
]);
|
||||
}
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_manager_only',
|
||||
'name' => 'Draw Manager Only',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
$admin->roles()->sync([
|
||||
(int) $role->id => [
|
||||
'site_id' => AdminUser::defaultAdminSiteId(),
|
||||
'granted_at' => now(),
|
||||
],
|
||||
]);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/reopen")
|
||||
->assertStatus(403);
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Cooldown->value);
|
||||
expect($draw->is_reopened)->toBeFalse();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('cooldown expiry tick moves draw to settling', function (): void {
|
||||
config([
|
||||
'lottery.draw.require_manual_review' => false,
|
||||
@@ -201,9 +532,9 @@ test('cooldown expiry tick moves draw to settling', function (): void {
|
||||
app(DrawTickService::class)->tick(now()->utc());
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Settled->value);
|
||||
expect($draw->status)->toBe(DrawStatus::Settling->value);
|
||||
expect((int) $draw->settle_version)->toBe(1);
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', 'completed')->count())->toBe(1);
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', 'pending_review')->count())->toBe(1);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -231,10 +562,10 @@ test('GET draw current returns open draw with seconds to close', function (): vo
|
||||
|
||||
$this->getJson('/api/v1/draw/current')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-300')
|
||||
->assertJsonPath('data.status', DrawStatus::Open->value)
|
||||
->assertJsonPath('data.seconds_to_close', 60 * 60 - 30)
|
||||
->assertJsonPath('data.seconds_to_draw', 3600);
|
||||
->assertJsonPath('data.data.draw_no', '20260509-300')
|
||||
->assertJsonPath('data.data.status', DrawStatus::Open->value)
|
||||
->assertJsonPath('data.data.seconds_to_close', 60 * 60 - 30)
|
||||
->assertJsonPath('data.data.seconds_to_draw', 3600);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -261,10 +592,10 @@ test('GET draw current exposes closing when row is open in DB but close_time has
|
||||
|
||||
$this->getJson('/api/v1/draw/current')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-310')
|
||||
->assertJsonPath('data.status', DrawStatus::Closing->value)
|
||||
->assertJsonPath('data.seconds_to_close', 0)
|
||||
->assertJsonPath('data.seconds_to_draw', 20);
|
||||
->assertJsonPath('data.data.draw_no', '20260509-310')
|
||||
->assertJsonPath('data.data.status', DrawStatus::Closing->value)
|
||||
->assertJsonPath('data.data.seconds_to_close', 0)
|
||||
->assertJsonPath('data.data.seconds_to_draw', 20);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -291,10 +622,10 @@ test('GET draw current exposes closed when row is open in DB but draw_time has p
|
||||
|
||||
$this->getJson('/api/v1/draw/current')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_no', '20260509-311')
|
||||
->assertJsonPath('data.status', DrawStatus::Closed->value)
|
||||
->assertJsonPath('data.seconds_to_close', 0)
|
||||
->assertJsonPath('data.seconds_to_draw', 0);
|
||||
->assertJsonPath('data.data.draw_no', '20260509-311')
|
||||
->assertJsonPath('data.data.status', DrawStatus::Closed->value)
|
||||
->assertJsonPath('data.data.seconds_to_close', 0)
|
||||
->assertJsonPath('data.data.seconds_to_draw', 0);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -343,8 +674,8 @@ test('GET draw current includes result_items when cooldown', function (): void {
|
||||
|
||||
$this->getJson('/api/v1/draw/current')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', DrawStatus::Cooldown->value)
|
||||
->assertJsonPath('data.result_items.0.number_4d', '1234');
|
||||
->assertJsonPath('data.data.status', DrawStatus::Cooldown->value)
|
||||
->assertJsonPath('data.data.result_items.0.number_4d', '1234');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user