feat: 增强抽奖管理功能,支持手动创建、更新和删除期号
- 新增 API 路由和控制器,允许管理员手动创建、更新和删除抽奖期号。 - 更新抽奖调度逻辑,确保在抽奖时间和封盘时间的管理上更加灵活。 - 添加多语言支持的错误信息,提升用户体验。 - 更新测试用例,确保新功能的正确性和稳定性。
This commit is contained in:
@@ -197,6 +197,11 @@ LOTTERY_PLAYER_AUTH_DEV_BYPASS=false
|
||||
|
||||
# 未来期缓冲条数(draw_time>now 的期数,分钟 tick 会补足);测试可 6–12,生产可 48+
|
||||
LOTTERY_DRAW_BUFFER_AHEAD=8
|
||||
# 期号时刻统一为 UTC(GMT),见 config/lottery.php lottery.draw.timezone 与 docs/01-界面文档.md;勿配置本地时区
|
||||
# 开奖间隔(分钟)、下注窗(秒)、封盘提前(秒)见 config/lottery.php,可按需覆盖:
|
||||
# LOTTERY_DRAW_INTERVAL_MINUTES=5
|
||||
# LOTTERY_DRAW_BETTING_WINDOW_SECONDS=270
|
||||
# LOTTERY_DRAW_CLOSE_BEFORE_SECONDS=30
|
||||
|
||||
# 校验主站 JWT 的算法(与签发方一致)
|
||||
LOTTERY_JWT_ALGORITHM=HS256
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Draw\DrawDestroyService;
|
||||
|
||||
final class AdminDrawBatchDestroyController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawDestroyService $service,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$drawIds = $request->input('draw_ids', []);
|
||||
|
||||
if (!is_array($drawIds) || empty($drawIds)) {
|
||||
return ApiResponse::error(trans('api.invalid_params'), ErrorCode::ClientHttpError->value, [], 400);
|
||||
}
|
||||
|
||||
$results = [
|
||||
'success' => [],
|
||||
'failed' => [],
|
||||
];
|
||||
|
||||
foreach ($drawIds as $drawId) {
|
||||
try {
|
||||
$draw = \App\Models\Draw::findOrFail($drawId);
|
||||
$this->service->destroy($draw);
|
||||
$results['success'][] = $drawId;
|
||||
} catch (\RuntimeException $e) {
|
||||
$results['failed'][] = [
|
||||
'id' => $drawId,
|
||||
'reason' => match ($e->getMessage()) {
|
||||
'draw_not_deletable' => trans('api.draw_not_deletable'),
|
||||
'draw_has_bets' => trans('api.draw_has_bets'),
|
||||
'draw_result_exists' => trans('api.draw_result_exists'),
|
||||
default => trans('api.client_error'),
|
||||
},
|
||||
];
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
$results['failed'][] = [
|
||||
'id' => $drawId,
|
||||
'reason' => trans('api.draw_not_found'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponse::success($results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Draw\DrawDestroyService;
|
||||
|
||||
final class AdminDrawDestroyController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawDestroyService $service,
|
||||
) {}
|
||||
|
||||
public function __invoke(Draw $draw): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->service->destroy($draw);
|
||||
} catch (\RuntimeException $e) {
|
||||
$message = match ($e->getMessage()) {
|
||||
'draw_not_deletable' => trans('api.draw_not_deletable'),
|
||||
'draw_has_bets' => trans('api.draw_has_bets'),
|
||||
'draw_result_exists' => trans('api.draw_result_exists'),
|
||||
default => trans('api.client_error'),
|
||||
};
|
||||
|
||||
return ApiResponse::error($message, ErrorCode::ClientHttpError->value, ['reason' => $e->getMessage()], 409);
|
||||
}
|
||||
|
||||
return ApiResponse::success(['deleted' => true]);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,14 @@ final class AdminDrawIndexController extends Controller
|
||||
/** @var LengthAwarePaginator $paginator */
|
||||
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||
|
||||
return AdminApiList::json($paginator, fn (Draw $row) => $this->row($row));
|
||||
return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row), [
|
||||
'schedule' => [
|
||||
'timezone' => (string) config('lottery.draw.timezone', 'UTC'),
|
||||
'interval_minutes' => (int) config('lottery.draw.interval_minutes', 5),
|
||||
'betting_window_seconds' => (int) config('lottery.draw.betting_window_seconds', 270),
|
||||
'close_before_draw_seconds' => (int) config('lottery.draw.close_before_draw_seconds', 30),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Draw\DrawManualCreateService;
|
||||
use App\Http\Requests\Admin\DrawStoreRequest;
|
||||
|
||||
final class AdminDrawStoreController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawManualCreateService $service,
|
||||
) {}
|
||||
|
||||
public function __invoke(DrawStoreRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$draw = $this->service->create($request->validated());
|
||||
} catch (\RuntimeException $e) {
|
||||
$message = match ($e->getMessage()) {
|
||||
'draw_no_exists' => trans('api.draw_no_exists'),
|
||||
'draw_timeline_invalid' => trans('api.draw_timeline_invalid'),
|
||||
default => trans('api.client_error'),
|
||||
};
|
||||
|
||||
return ApiResponse::error($message, ErrorCode::ClientHttpError->value, ['reason' => $e->getMessage()], 409);
|
||||
}
|
||||
|
||||
return ApiResponse::success([
|
||||
'id' => (int) $draw->id,
|
||||
'draw_no' => $draw->draw_no,
|
||||
'business_date' => (string) $draw->business_date,
|
||||
'sequence_no' => (int) $draw->sequence_no,
|
||||
'status' => $draw->status,
|
||||
'start_time' => $draw->start_time?->toIso8601String(),
|
||||
'close_time' => $draw->close_time?->toIso8601String(),
|
||||
'draw_time' => $draw->draw_time?->toIso8601String(),
|
||||
])->setStatusCode(201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\DrawStoreRequest;
|
||||
use App\Services\Draw\DrawManualUpdateService;
|
||||
|
||||
final class AdminDrawUpdateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawManualUpdateService $service,
|
||||
) {}
|
||||
|
||||
public function __invoke(DrawStoreRequest $request, Draw $draw): JsonResponse
|
||||
{
|
||||
try {
|
||||
$updated = $this->service->update($draw, $request->validated());
|
||||
} catch (\RuntimeException $e) {
|
||||
$message = match ($e->getMessage()) {
|
||||
'draw_no_exists' => trans('api.draw_no_exists'),
|
||||
'draw_timeline_invalid' => trans('api.draw_timeline_invalid'),
|
||||
'draw_not_editable' => trans('api.draw_not_editable'),
|
||||
'draw_has_bets' => trans('api.draw_has_bets'),
|
||||
'draw_result_exists' => trans('api.draw_result_exists'),
|
||||
default => trans('api.client_error'),
|
||||
};
|
||||
|
||||
return ApiResponse::error($message, ErrorCode::ClientHttpError->value, ['reason' => $e->getMessage()], 409);
|
||||
}
|
||||
|
||||
return ApiResponse::success([
|
||||
'id' => (int) $updated->id,
|
||||
'draw_no' => $updated->draw_no,
|
||||
'business_date' => (string) $updated->business_date,
|
||||
'sequence_no' => (int) $updated->sequence_no,
|
||||
'status' => $updated->status,
|
||||
'start_time' => $updated->start_time?->toIso8601String(),
|
||||
'close_time' => $updated->close_time?->toIso8601String(),
|
||||
'draw_time' => $updated->draw_time?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/Admin/DrawStoreRequest.php
Normal file
25
app/Http/Requests/Admin/DrawStoreRequest.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class DrawStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'draw_time' => ['required', 'string', 'max:32'],
|
||||
'start_time' => ['nullable', 'string', 'max:32'],
|
||||
'close_time' => ['nullable', 'string', 'max:32'],
|
||||
'draw_no' => ['nullable', 'string', 'max:32', 'regex:/^[0-9]{8}-[0-9]{3}$/'],
|
||||
'business_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'sequence_no' => ['nullable', 'integer', 'min:1', 'max:999'],
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Services/Draw/DrawDestroyService.php
Normal file
37
app/Services/Draw/DrawDestroyService.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Lottery\DrawStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 物理删除误建的未开始期号(无注单、无开奖结果)。
|
||||
*/
|
||||
final class DrawDestroyService
|
||||
{
|
||||
public function destroy(Draw $draw): void
|
||||
{
|
||||
DB::transaction(function () use ($draw): void {
|
||||
/** @var Draw $locked */
|
||||
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if ($locked->status !== DrawStatus::Pending->value) {
|
||||
throw new \RuntimeException('draw_not_deletable');
|
||||
}
|
||||
|
||||
if ($locked->resultBatches()->exists()) {
|
||||
throw new \RuntimeException('draw_result_exists');
|
||||
}
|
||||
|
||||
$betTotal = (int) TicketOrder::query()->where('draw_id', $locked->id)->sum('total_actual_deduct');
|
||||
if ($betTotal > 0) {
|
||||
throw new \RuntimeException('draw_has_bets');
|
||||
}
|
||||
|
||||
$locked->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -94,12 +94,38 @@ final class DrawHallSnapshotBuilder
|
||||
return $bettingOpen;
|
||||
}
|
||||
|
||||
$upcoming = Draw::query()
|
||||
->whereNotIn('status', [
|
||||
DrawStatus::Settled->value,
|
||||
DrawStatus::Cancelled->value,
|
||||
])
|
||||
->where(function ($q) use ($nowUtc): void {
|
||||
$q->where(function ($q2) use ($nowUtc): void {
|
||||
$q2->whereNotNull('close_time')
|
||||
->where('close_time', '>', $nowUtc);
|
||||
})->orWhere(function ($q2) use ($nowUtc): void {
|
||||
$q2->whereNull('close_time')
|
||||
->whereNotNull('draw_time')
|
||||
->where('draw_time', '>', $nowUtc);
|
||||
});
|
||||
})
|
||||
->orderBy('draw_time')
|
||||
->get();
|
||||
|
||||
foreach ($upcoming as $candidate) {
|
||||
if ($this->isStalePendingRow($candidate, $nowUtc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$chronological = Draw::query()
|
||||
->whereNotIn('status', [
|
||||
DrawStatus::Settled->value,
|
||||
DrawStatus::Cancelled->value,
|
||||
])
|
||||
->orderBy('draw_time')
|
||||
->orderByDesc('draw_time')
|
||||
->first();
|
||||
|
||||
if ($chronological !== null && $this->isCooldownExpired($chronological, $nowUtc)) {
|
||||
@@ -120,6 +146,23 @@ final class DrawHallSnapshotBuilder
|
||||
return $chronological;
|
||||
}
|
||||
|
||||
/** 调度未跑时:库内仍是 pending,但封盘/开奖时刻已过,不应再作为大厅「当期」。 */
|
||||
private function isStalePendingRow(Draw $draw, Carbon $nowUtc): bool
|
||||
{
|
||||
if ((string) $draw->status !== DrawStatus::Pending->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$closeUtc = $draw->close_time;
|
||||
if ($closeUtc instanceof Carbon && $closeUtc <= $nowUtc) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$drawUtc = $draw->draw_time;
|
||||
|
||||
return $drawUtc instanceof Carbon && $drawUtc <= $nowUtc;
|
||||
}
|
||||
|
||||
private function isCooldownExpired(Draw $draw, Carbon $nowUtc): bool
|
||||
{
|
||||
return (string) $draw->status === DrawStatus::Cooldown->value
|
||||
@@ -167,6 +210,11 @@ final class DrawHallSnapshotBuilder
|
||||
? max(0, (int) $target->draw_time->getTimestamp() - (int) $nowUtc->getTimestamp())
|
||||
: 0;
|
||||
|
||||
$startUtc = $target->start_time;
|
||||
$secsToStart = ($startUtc !== null && $startUtc > $nowUtc)
|
||||
? max(0, (int) $startUtc->getTimestamp() - (int) $nowUtc->getTimestamp())
|
||||
: 0;
|
||||
|
||||
$coolingRemain = null;
|
||||
if (
|
||||
$target->cooling_end_time instanceof Carbon
|
||||
@@ -180,7 +228,11 @@ final class DrawHallSnapshotBuilder
|
||||
|
||||
$effectiveStatus = $this->effectiveHallDisplayStatus($target, $nowUtc);
|
||||
|
||||
$scheduleTz = (string) config('lottery.draw.timezone', 'UTC');
|
||||
|
||||
$payload = [
|
||||
'schedule_timezone' => $scheduleTz,
|
||||
'schedule_now' => $nowUtc->copy()->timezone($scheduleTz)->format('Y-m-d H:i:s'),
|
||||
'draw_no' => $target->draw_no,
|
||||
'business_date' => $target->business_date instanceof Carbon
|
||||
? $target->business_date->format('Y-m-d')
|
||||
@@ -191,6 +243,7 @@ final class DrawHallSnapshotBuilder
|
||||
'close_time' => $target->close_time?->toIso8601String(),
|
||||
'draw_time' => $target->draw_time?->toIso8601String(),
|
||||
'seconds_to_close' => $secsToClose,
|
||||
'seconds_to_start' => $secsToStart,
|
||||
'seconds_to_draw' => $secsToDraw,
|
||||
'cooling_end_time' => $target->cooling_end_time?->toIso8601String(),
|
||||
'seconds_remaining_in_cooldown' => $coolingRemain,
|
||||
|
||||
105
app/Services/Draw/DrawManualCreateService.php
Normal file
105
app/Services/Draw/DrawManualCreateService.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Draw;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 管理员手动创建期号(可指定开奖/封盘/开始时间)。
|
||||
*/
|
||||
final class DrawManualCreateService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawTimelineBuilder $timeline,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* draw_time: string,
|
||||
* start_time?: string|null,
|
||||
* close_time?: string|null,
|
||||
* draw_no?: string|null,
|
||||
* business_date?: string|null,
|
||||
* sequence_no?: int|null,
|
||||
* } $input
|
||||
*/
|
||||
public function create(array $input, ?Carbon $now = null): Draw
|
||||
{
|
||||
$tz = (string) config('lottery.draw.timezone', 'UTC');
|
||||
$nowUtc = ($now ?? Carbon::now())->utc();
|
||||
|
||||
$drawLocal = $this->parseInTimezone((string) $input['draw_time'], $tz);
|
||||
$startLocal = isset($input['start_time']) && $input['start_time'] !== null && $input['start_time'] !== ''
|
||||
? $this->parseInTimezone((string) $input['start_time'], $tz)
|
||||
: null;
|
||||
$closeLocal = isset($input['close_time']) && $input['close_time'] !== null && $input['close_time'] !== ''
|
||||
? $this->parseInTimezone((string) $input['close_time'], $tz)
|
||||
: null;
|
||||
|
||||
if ($startLocal === null || $closeLocal === null) {
|
||||
$defaults = $this->timeline->windowsFromDrawLocal($drawLocal);
|
||||
$startLocal ??= $defaults['start_local'];
|
||||
$closeLocal ??= $defaults['close_local'];
|
||||
}
|
||||
|
||||
if (! $startLocal->lt($closeLocal) || ! $closeLocal->lt($drawLocal)) {
|
||||
throw new \RuntimeException('draw_timeline_invalid');
|
||||
}
|
||||
|
||||
$businessDate = isset($input['business_date']) && $input['business_date'] !== ''
|
||||
? (string) $input['business_date']
|
||||
: $drawLocal->format('Y-m-d');
|
||||
|
||||
$sequenceNo = isset($input['sequence_no']) && $input['sequence_no'] !== null
|
||||
? max(1, (int) $input['sequence_no'])
|
||||
: $this->nextSequenceForDate($businessDate);
|
||||
|
||||
$drawNo = isset($input['draw_no']) && trim((string) $input['draw_no']) !== ''
|
||||
? trim((string) $input['draw_no'])
|
||||
: $this->timeline->drawNo($businessDate, $sequenceNo);
|
||||
|
||||
if (Draw::query()->where('draw_no', $drawNo)->exists()) {
|
||||
throw new \RuntimeException('draw_no_exists');
|
||||
}
|
||||
|
||||
$built = $this->timeline->buildFromLocals($startLocal, $closeLocal, $drawLocal, $nowUtc);
|
||||
|
||||
return DB::transaction(function () use (
|
||||
$drawNo,
|
||||
$businessDate,
|
||||
$sequenceNo,
|
||||
$built,
|
||||
): Draw {
|
||||
return Draw::query()->create([
|
||||
'draw_no' => $drawNo,
|
||||
'business_date' => $businessDate,
|
||||
'sequence_no' => $sequenceNo,
|
||||
'status' => $built['status'],
|
||||
'start_time' => $built['start_utc'],
|
||||
'close_time' => $built['close_utc'],
|
||||
'draw_time' => $built['draw_utc'],
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function parseInTimezone(string $value, string $tz): Carbon
|
||||
{
|
||||
return Carbon::parse($value, $tz);
|
||||
}
|
||||
|
||||
private function nextSequenceForDate(string $businessDate): int
|
||||
{
|
||||
$max = (int) Draw::query()
|
||||
->where('business_date', $businessDate)
|
||||
->max('sequence_no');
|
||||
|
||||
return $max + 1;
|
||||
}
|
||||
}
|
||||
112
app/Services/Draw/DrawManualUpdateService.php
Normal file
112
app/Services/Draw/DrawManualUpdateService.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Draw;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Lottery\DrawStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 管理员修改未开奖期号的时间轴(仅 pending / 无注单的 open)。
|
||||
*/
|
||||
final class DrawManualUpdateService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawTimelineBuilder $timeline,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* draw_time: string,
|
||||
* start_time?: string|null,
|
||||
* close_time?: string|null,
|
||||
* draw_no?: string|null,
|
||||
* business_date?: string|null,
|
||||
* sequence_no?: int|null,
|
||||
* } $input
|
||||
*/
|
||||
public function update(Draw $draw, array $input, ?Carbon $now = null): Draw
|
||||
{
|
||||
$tz = (string) config('lottery.draw.timezone', 'UTC');
|
||||
$nowUtc = ($now ?? Carbon::now())->utc();
|
||||
|
||||
return DB::transaction(function () use ($draw, $input, $tz, $nowUtc): Draw {
|
||||
/** @var Draw $locked */
|
||||
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
|
||||
$this->assertEditable($locked);
|
||||
|
||||
$drawLocal = Carbon::parse((string) $input['draw_time'], $tz);
|
||||
$startLocal = isset($input['start_time']) && $input['start_time'] !== null && $input['start_time'] !== ''
|
||||
? Carbon::parse((string) $input['start_time'], $tz)
|
||||
: null;
|
||||
$closeLocal = isset($input['close_time']) && $input['close_time'] !== null && $input['close_time'] !== ''
|
||||
? Carbon::parse((string) $input['close_time'], $tz)
|
||||
: null;
|
||||
|
||||
if ($startLocal === null || $closeLocal === null) {
|
||||
$defaults = $this->timeline->windowsFromDrawLocal($drawLocal);
|
||||
$startLocal ??= $defaults['start_local'];
|
||||
$closeLocal ??= $defaults['close_local'];
|
||||
}
|
||||
|
||||
if (! $startLocal->lt($closeLocal) || ! $closeLocal->lt($drawLocal)) {
|
||||
throw new \RuntimeException('draw_timeline_invalid');
|
||||
}
|
||||
|
||||
$businessDate = isset($input['business_date']) && $input['business_date'] !== ''
|
||||
? (string) $input['business_date']
|
||||
: $drawLocal->format('Y-m-d');
|
||||
|
||||
$sequenceNo = isset($input['sequence_no']) && $input['sequence_no'] !== null
|
||||
? max(1, (int) $input['sequence_no'])
|
||||
: (int) $locked->sequence_no;
|
||||
|
||||
$drawNo = isset($input['draw_no']) && trim((string) $input['draw_no']) !== ''
|
||||
? trim((string) $input['draw_no'])
|
||||
: (string) $locked->draw_no;
|
||||
|
||||
if (
|
||||
Draw::query()
|
||||
->where('draw_no', $drawNo)
|
||||
->where('id', '!=', $locked->id)
|
||||
->exists()
|
||||
) {
|
||||
throw new \RuntimeException('draw_no_exists');
|
||||
}
|
||||
|
||||
$built = $this->timeline->buildFromLocals($startLocal, $closeLocal, $drawLocal, $nowUtc);
|
||||
|
||||
$locked->forceFill([
|
||||
'draw_no' => $drawNo,
|
||||
'business_date' => $businessDate,
|
||||
'sequence_no' => $sequenceNo,
|
||||
'status' => $built['status'],
|
||||
'start_time' => $built['start_utc'],
|
||||
'close_time' => $built['close_utc'],
|
||||
'draw_time' => $built['draw_utc'],
|
||||
])->save();
|
||||
|
||||
return $locked->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
private function assertEditable(Draw $draw): void
|
||||
{
|
||||
if ($draw->resultBatches()->exists()) {
|
||||
throw new \RuntimeException('draw_result_exists');
|
||||
}
|
||||
|
||||
if (! in_array($draw->status, [DrawStatus::Pending->value, DrawStatus::Open->value], true)) {
|
||||
throw new \RuntimeException('draw_not_editable');
|
||||
}
|
||||
|
||||
if ($draw->status === DrawStatus::Open->value) {
|
||||
$betTotal = (int) TicketOrder::query()->where('draw_id', $draw->id)->sum('total_actual_deduct');
|
||||
if ($betTotal > 0) {
|
||||
throw new \RuntimeException('draw_has_bets');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ use Illuminate\Database\QueryException;
|
||||
*/
|
||||
final class DrawPlannerService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrawTimelineBuilder $timeline,
|
||||
) {}
|
||||
|
||||
/** @return array{created: int, buffer_target: int, upcoming: int} */
|
||||
public function ensureBuffer(?Carbon $now = null): array
|
||||
{
|
||||
@@ -39,7 +43,7 @@ final class DrawPlannerService
|
||||
|
||||
$row = $last === null
|
||||
? $this->firstSchedule($tz, $interval, $maxSeq, $nowLocal)
|
||||
: $this->scheduleAfter($last, $tz, $interval, $maxSeq, $nowLocal);
|
||||
: $this->scheduleAfter($last, $tz, $interval, $nowLocal);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($row, $nowUtc, &$created): void {
|
||||
@@ -95,27 +99,27 @@ final class DrawPlannerService
|
||||
/**
|
||||
* @return array{business_date: string, sequence_no: int, draw_local: Carbon}
|
||||
*/
|
||||
private function scheduleAfter(Draw $last, string $tz, int $intervalMinutes, int $maxSeqPerDay, Carbon $nowLocal): array
|
||||
private function scheduleAfter(Draw $last, string $tz, int $intervalMinutes, Carbon $nowLocal): array
|
||||
{
|
||||
$day = Carbon::parse((string) $last->business_date, $tz)->startOfDay();
|
||||
$seq = (int) $last->sequence_no + 1;
|
||||
if ($seq > $maxSeqPerDay) {
|
||||
$day = $day->addDay();
|
||||
$seq = 1;
|
||||
$lastDrawLocal = $last->draw_time !== null
|
||||
? Carbon::parse($last->draw_time)->timezone($tz)
|
||||
: Carbon::parse((string) $last->business_date, $tz)
|
||||
->startOfDay()
|
||||
->addMinutes((int) $last->sequence_no * $intervalMinutes);
|
||||
|
||||
$drawLocal = $lastDrawLocal->copy()->addMinutes($intervalMinutes);
|
||||
while ($drawLocal <= $nowLocal) {
|
||||
$drawLocal->addMinutes($intervalMinutes);
|
||||
}
|
||||
|
||||
$drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes);
|
||||
while ($drawLocal <= $nowLocal) {
|
||||
$seq++;
|
||||
if ($seq > $maxSeqPerDay) {
|
||||
$day = $day->addDay();
|
||||
$seq = 1;
|
||||
}
|
||||
$drawLocal = $day->copy()->addMinutes($seq * $intervalMinutes);
|
||||
}
|
||||
$businessDate = $drawLocal->format('Y-m-d');
|
||||
$lastDay = Carbon::parse((string) $last->business_date, $tz)->format('Y-m-d');
|
||||
$seq = $businessDate === $lastDay
|
||||
? (int) $last->sequence_no + 1
|
||||
: 1;
|
||||
|
||||
return [
|
||||
'business_date' => $day->format('Y-m-d'),
|
||||
'business_date' => $businessDate,
|
||||
'sequence_no' => $seq,
|
||||
'draw_local' => $drawLocal->copy()->timezone($tz),
|
||||
];
|
||||
@@ -127,37 +131,22 @@ final class DrawPlannerService
|
||||
*/
|
||||
private function timelinePayload(array $row, Carbon $nowUtc): array
|
||||
{
|
||||
$closeBefore = (int) config('lottery.draw.close_before_draw_seconds', 30);
|
||||
$bettingWindow = (int) config('lottery.draw.betting_window_seconds', 270);
|
||||
|
||||
$drawLocal = $row['draw_local']->copy();
|
||||
|
||||
$closeLocal = $drawLocal->copy()->subSeconds($closeBefore);
|
||||
$startLocal = $closeLocal->copy()->subSeconds($bettingWindow);
|
||||
|
||||
$startUtc = $startLocal->copy()->timezone('UTC');
|
||||
$closeUtc = $closeLocal->copy()->timezone('UTC');
|
||||
$drawUtc = $drawLocal->copy()->timezone('UTC');
|
||||
|
||||
if ($nowUtc < $startUtc) {
|
||||
$status = DrawStatus::Pending->value;
|
||||
} elseif ($nowUtc < $closeUtc) {
|
||||
$status = DrawStatus::Open->value;
|
||||
} elseif ($nowUtc < $drawUtc) {
|
||||
$status = DrawStatus::Closing->value;
|
||||
} else {
|
||||
$status = DrawStatus::Closed->value;
|
||||
}
|
||||
$windows = $this->timeline->windowsFromDrawLocal($row['draw_local']->copy());
|
||||
$built = $this->timeline->buildFromLocals(
|
||||
$windows['start_local'],
|
||||
$windows['close_local'],
|
||||
$windows['draw_local'],
|
||||
$nowUtc,
|
||||
);
|
||||
|
||||
return [
|
||||
'draw_no' => str_replace('-', '', $row['business_date']).'-'.
|
||||
str_pad((string) $row['sequence_no'], 3, '0', STR_PAD_LEFT),
|
||||
'draw_no' => $this->timeline->drawNo($row['business_date'], $row['sequence_no']),
|
||||
'business_date' => $row['business_date'],
|
||||
'sequence_no' => $row['sequence_no'],
|
||||
'status' => $status,
|
||||
'start_time' => $startLocal->copy()->timezone('UTC'),
|
||||
'close_time' => $closeLocal->copy()->timezone('UTC'),
|
||||
'draw_time' => $drawLocal->copy()->timezone('UTC'),
|
||||
'status' => $built['status'],
|
||||
'start_time' => $built['start_utc'],
|
||||
'close_time' => $built['close_utc'],
|
||||
'draw_time' => $built['draw_utc'],
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
|
||||
75
app/Services/Draw/DrawTimelineBuilder.php
Normal file
75
app/Services/Draw/DrawTimelineBuilder.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Draw;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use App\Lottery\DrawStatus;
|
||||
|
||||
/**
|
||||
* 由开奖时刻推导下注窗口、封盘时刻与期号初始状态。
|
||||
*/
|
||||
final class DrawTimelineBuilder
|
||||
{
|
||||
public function closeBeforeDrawSeconds(): int
|
||||
{
|
||||
return (int) config('lottery.draw.close_before_draw_seconds', 30);
|
||||
}
|
||||
|
||||
public function bettingWindowSeconds(): int
|
||||
{
|
||||
return (int) config('lottery.draw.betting_window_seconds', 270);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{start_local: Carbon, close_local: Carbon, draw_local: Carbon}
|
||||
*/
|
||||
public function windowsFromDrawLocal(Carbon $drawLocal): array
|
||||
{
|
||||
$closeLocal = $drawLocal->copy()->subSeconds($this->closeBeforeDrawSeconds());
|
||||
$startLocal = $closeLocal->copy()->subSeconds($this->bettingWindowSeconds());
|
||||
|
||||
return [
|
||||
'start_local' => $startLocal,
|
||||
'close_local' => $closeLocal,
|
||||
'draw_local' => $drawLocal->copy(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{start_utc: Carbon, close_utc: Carbon, draw_utc: Carbon, status: string}
|
||||
*/
|
||||
public function buildFromLocals(Carbon $startLocal, Carbon $closeLocal, Carbon $drawLocal, Carbon $nowUtc): array
|
||||
{
|
||||
$startUtc = $startLocal->copy()->timezone('UTC');
|
||||
$closeUtc = $closeLocal->copy()->timezone('UTC');
|
||||
$drawUtc = $drawLocal->copy()->timezone('UTC');
|
||||
|
||||
return [
|
||||
'start_utc' => $startUtc,
|
||||
'close_utc' => $closeUtc,
|
||||
'draw_utc' => $drawUtc,
|
||||
'status' => $this->statusForTimeline($nowUtc, $startUtc, $closeUtc, $drawUtc),
|
||||
];
|
||||
}
|
||||
|
||||
public function statusForTimeline(Carbon $nowUtc, Carbon $startUtc, Carbon $closeUtc, Carbon $drawUtc): string
|
||||
{
|
||||
if ($nowUtc < $startUtc) {
|
||||
return DrawStatus::Pending->value;
|
||||
}
|
||||
if ($nowUtc < $closeUtc) {
|
||||
return DrawStatus::Open->value;
|
||||
}
|
||||
if ($nowUtc < $drawUtc) {
|
||||
return DrawStatus::Closing->value;
|
||||
}
|
||||
|
||||
return DrawStatus::Closed->value;
|
||||
}
|
||||
|
||||
public function drawNo(string $businessDate, int $sequenceNo): string
|
||||
{
|
||||
return str_replace('-', '', $businessDate).'-'.
|
||||
str_pad((string) $sequenceNo, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -398,6 +398,10 @@ final class AdminAuthorizationRegistry
|
||||
['code' => 'admin.draws.result-batches.publish', 'module_code' => 'draw', 'name' => '发布开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||
['code' => 'admin.draws.reopen', 'module_code' => 'draw', 'name' => '重开开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/reopen', 'route_name' => 'api.v1.admin.draws.reopen', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_reopen.manage']],
|
||||
['code' => 'admin.draws.generate-plan', 'module_code' => 'draw', 'name' => '生成开奖计划', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/generate-plan', 'route_name' => 'api.v1.admin.draws.generate-plan', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||
['code' => 'admin.draws.store', 'module_code' => 'draw', 'name' => '手动创建期号', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||
['code' => 'admin.draws.update', 'module_code' => 'draw', 'name' => '编辑期号计划', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||
['code' => 'admin.draws.destroy', 'module_code' => 'draw', 'name' => '删除期号', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||
['code' => 'admin.draws.batch-destroy', 'module_code' => 'draw', 'name' => '批量删除期号', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/batch-destroy', 'route_name' => 'api.v1.admin.draws.batch-destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||
['code' => 'admin.draws.manual-close', 'module_code' => 'draw', 'name' => '人工封盘', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/manual-close', 'route_name' => 'api.v1.admin.draws.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||
['code' => 'admin.draws.risk-pools.manual-close', 'module_code' => 'risk', 'name' => '人工关闭风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/manual-close', 'route_name' => 'api.v1.admin.draws.risk-pools.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']],
|
||||
['code' => 'admin.draws.risk-pools.recover', 'module_code' => 'risk', 'name' => '恢复风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/recover', 'route_name' => 'api.v1.admin.draws.risk-pools.recover', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.risk.manage']],
|
||||
|
||||
@@ -75,12 +75,12 @@ return [
|
||||
],
|
||||
|
||||
/*
|
||||
| 期号调度(GMT/业务日时区):生成计划、封盘与开奖时间点。
|
||||
| 与 PRD 「期号生成 / 封盘 / 开奖调度」链路一致;RNG 详见 DrawRngRunner。
|
||||
| 期号调度:PRD/界面文档约定为服务器时区 GMT(即 UTC)。
|
||||
| 生成计划、封盘判定、API ISO 时刻、前后台 YYYY-MM-DD HH:mm:ss 展示均按 UTC 解释,勿改为本地时区。
|
||||
*/
|
||||
'draw' => [
|
||||
/** 盘面「业务日」切分与应用展示用 */
|
||||
'timezone' => env('LOTTERY_DRAW_TIMEZONE', 'UTC'),
|
||||
/** 期号「业务日」切分与计划时刻(固定 UTC,与 {@see docs/01-界面文档.md} 一致) */
|
||||
'timezone' => 'UTC',
|
||||
/** 开奖时间间隔(分钟),整日从 00:00 起排槽 */
|
||||
'interval_minutes' => max(1, min(1440, (int) env('LOTTERY_DRAW_INTERVAL_MINUTES', 5))),
|
||||
/** 下注开放时长(秒):start_time = close_time - betting_window_seconds */
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
return [
|
||||
'validation_failed' => 'The given data was invalid.',
|
||||
'client_error' => 'This request could not be completed.',
|
||||
'draw_no_exists' => 'Draw number already exists. Use another draw number or sequence.',
|
||||
'draw_timeline_invalid' => 'Start time must be before close time, and close time must be before draw time.',
|
||||
'draw_not_editable' => 'Only pending draws, or open draws with no bets, can be edited.',
|
||||
'draw_not_deletable' => 'Only pending draws with no bets can be deleted.',
|
||||
'draw_has_bets' => 'This draw already has bets and cannot be edited or deleted.',
|
||||
'draw_result_exists' => 'This draw already has result data and cannot be edited or deleted.',
|
||||
'settlement_not_approved' => 'Settlement batch is not approved for payout (both status and review_status must be approved).',
|
||||
'not_found' => 'The requested resource was not found.',
|
||||
'too_many_requests' => 'Too many requests. Please try again later.',
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
return [
|
||||
'validation_failed' => 'दिइएको डाटा अमान्य छ।',
|
||||
'client_error' => 'यो अनुरोध पूरा गर्न सकिएन।',
|
||||
'draw_no_exists' => 'यो ड्र नम्बर पहिले नै छ। अर्को ड्र नम्बर वा क्रम प्रयोग गर्नुहोस्।',
|
||||
'draw_timeline_invalid' => 'सुरु समय बन्द समय भन्दा अघि, बन्द समय ड्र समय भन्दा अघि हुनुपर्छ।',
|
||||
'draw_not_editable' => 'केवल pending वा बेट नभएको open ड्र सम्पादन गर्न सकिन्छ।',
|
||||
'draw_not_deletable' => 'केवल pending र बेट नभएको ड्र मेटाउन सकिन्छ।',
|
||||
'draw_has_bets' => 'यस ड्रमा पहिले नै बेट छ, सम्पादन/मेटाउन मिल्दैन।',
|
||||
'draw_result_exists' => 'यस ड्रमा नतिजा छ, सम्पादन/मेटाउन मिल्दैन।',
|
||||
'settlement_not_approved' => 'सेटलमेन्ट ब्याच पेमेन्टका लागि स्वीकृत छैन (status र review_status दुवै approved हुनुपर्छ)।',
|
||||
'not_found' => 'अनुरोध गरिएको स्रोत फेला परेन।',
|
||||
'too_many_requests' => 'धेरै अनुरोधहरू। कृपया पछि प्रयास गर्नुहोस्।',
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
return [
|
||||
'validation_failed' => '请求参数校验未通过。',
|
||||
'client_error' => '请求无法完成。',
|
||||
'draw_no_exists' => '期号已存在,请更换期号或流水号。',
|
||||
'draw_timeline_invalid' => '开始时间须早于封盘时间,封盘时间须早于开奖时间。',
|
||||
'draw_not_editable' => '仅「未开始」或「可下注且无注单」的期号可编辑时间。',
|
||||
'draw_not_deletable' => '仅「未开始」且无注单的期号可删除。',
|
||||
'draw_has_bets' => '该期已有玩家注单,不能编辑或删除。',
|
||||
'draw_result_exists' => '该期已有开奖结果,不能编辑或删除。',
|
||||
'settlement_not_approved' => '结算批次尚未审核通过,无法派彩(需 status 与 review_status 均为 approved)。',
|
||||
'not_found' => '请求的资源不存在。',
|
||||
'too_many_requests' => '请求过于频繁,请稍后再试。',
|
||||
|
||||
@@ -6,6 +6,10 @@ use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\DrawReopenController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\DrawCancelController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\DrawRngRunController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawStoreController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawUpdateController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawDestroyController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawBatchDestroyController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\DrawPlanGenerateController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\DrawSettlementRunController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Draw\DrawManualCloseController;
|
||||
@@ -60,6 +64,14 @@ Route::middleware('admin.api-resource')
|
||||
->name('api.v1.admin.draws.reopen');
|
||||
Route::post('draws/generate-plan', DrawPlanGenerateController::class)
|
||||
->name('api.v1.admin.draws.generate-plan');
|
||||
Route::post('draws', AdminDrawStoreController::class)
|
||||
->name('api.v1.admin.draws.store');
|
||||
Route::put('draws/{draw}', AdminDrawUpdateController::class)
|
||||
->name('api.v1.admin.draws.update');
|
||||
Route::delete('draws/{draw}', AdminDrawDestroyController::class)
|
||||
->name('api.v1.admin.draws.destroy');
|
||||
Route::post('draws/batch-destroy', AdminDrawBatchDestroyController::class)
|
||||
->name('api.v1.admin.draws.batch-destroy');
|
||||
Route::post('draws/{draw}/manual-close', DrawManualCloseController::class)
|
||||
->name('api.v1.admin.draws.manual-close');
|
||||
Route::post('draws/{draw}/risk-pools/{number_4d}/manual-close', [AdminRiskPoolManualStatusController::class, 'close'])
|
||||
|
||||
@@ -72,6 +72,141 @@ test('admin can batch generate draw schedule buffer', function (): void {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('draw planner schedules after last draw_time not midnight slot', function (): void {
|
||||
config([
|
||||
'lottery.draw.interval_minutes' => 5,
|
||||
'lottery.draw.buffer_draws_ahead' => 2,
|
||||
]);
|
||||
|
||||
$fixed = Carbon::parse('2026-05-25 11:00:00', 'UTC');
|
||||
$lastId = Draw::query()->create([
|
||||
'draw_no' => '20260525-120',
|
||||
'business_date' => '2026-05-25',
|
||||
'sequence_no' => 120,
|
||||
'status' => DrawStatus::Settled->value,
|
||||
'start_time' => Carbon::parse('2026-05-25 11:54:30', 'UTC'),
|
||||
'close_time' => Carbon::parse('2026-05-25 11:59:30', 'UTC'),
|
||||
'draw_time' => Carbon::parse('2026-05-25 12:00:00', 'UTC'),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
])->id;
|
||||
|
||||
app(DrawPlannerService::class)->ensureBuffer($fixed);
|
||||
|
||||
$next = Draw::query()->where('id', '>', $lastId)->orderBy('draw_time')->first();
|
||||
expect($next)->not->toBeNull();
|
||||
expect($next->draw_time?->utc()->format('Y-m-d H:i:s'))->toBe('2026-05-25 12:05:00');
|
||||
expect($next->sequence_no)->toBe(121);
|
||||
});
|
||||
|
||||
test('admin can manually create draw with custom timeline', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-25 08:00:00', 'UTC'));
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_create_admin',
|
||||
'name' => 'Draw Create 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_time' => '2026-05-25 12:00:00',
|
||||
'start_time' => '2026-05-25 11:55:00',
|
||||
'close_time' => '2026-05-25 11:59:30',
|
||||
'draw_no' => '20260525-901',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.draw_no', '20260525-901')
|
||||
->assertJsonPath('data.status', DrawStatus::Pending->value);
|
||||
|
||||
$draw = Draw::query()->where('draw_no', '20260525-901')->first();
|
||||
expect($draw)->not->toBeNull();
|
||||
expect($draw->draw_time?->utc()->format('Y-m-d H:i:s'))->toBe('2026-05-25 12:00:00');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can update pending draw timeline', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-25 08:00:00', 'UTC'));
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_update_admin',
|
||||
'name' => 'Draw Update Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260525-902',
|
||||
'business_date' => '2026-05-25',
|
||||
'sequence_no' => 902,
|
||||
'status' => DrawStatus::Pending->value,
|
||||
'start_time' => Carbon::parse('2026-05-25 11:55:00', 'UTC'),
|
||||
'close_time' => Carbon::parse('2026-05-25 11:59:30', 'UTC'),
|
||||
'draw_time' => Carbon::parse('2026-05-25 12:00:00', 'UTC'),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/draws/'.$draw->id, [
|
||||
'draw_time' => '2026-05-25 13:00:00',
|
||||
'start_time' => '2026-05-25 12:55:00',
|
||||
'close_time' => '2026-05-25 12:59:30',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.draw_time', fn ($v) => str_contains((string) $v, '13:00'));
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can destroy pending draw without bets', function (): void {
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_destroy_admin',
|
||||
'name' => 'Draw Destroy Admin',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260525-903',
|
||||
'business_date' => '2026-05-25',
|
||||
'sequence_no' => 903,
|
||||
'status' => DrawStatus::Pending->value,
|
||||
'start_time' => now()->addHour(),
|
||||
'close_time' => now()->addHours(2),
|
||||
'draw_time' => now()->addHours(3),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->deleteJson('/api/v1/admin/draws/'.$draw->id)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.deleted', true);
|
||||
|
||||
expect(Draw::query()->find($draw->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('admin can manually close open draw', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:10:00', 'UTC'));
|
||||
|
||||
@@ -617,6 +752,7 @@ test('GET draw current returns open draw with seconds to close', function (): vo
|
||||
|
||||
$this->getJson('/api/v1/draw/current')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.data.schedule_timezone', 'UTC')
|
||||
->assertJsonPath('data.data.draw_no', '20260509-300')
|
||||
->assertJsonPath('data.data.status', DrawStatus::Open->value)
|
||||
->assertJsonPath('data.data.seconds_to_close', 60 * 60 - 30)
|
||||
@@ -771,6 +907,50 @@ test('lottery hall-countdown dispatches draw.countdown when using reverb connect
|
||||
);
|
||||
});
|
||||
|
||||
test('hall snapshot skips stale pending draw and picks next upcoming row', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-25 18:00:00', 'UTC'));
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260525-999',
|
||||
'business_date' => '2026-05-25',
|
||||
'sequence_no' => 999,
|
||||
'status' => DrawStatus::Pending->value,
|
||||
'start_time' => Carbon::parse('2026-05-25 17:32:00', 'UTC'),
|
||||
'close_time' => Carbon::parse('2026-05-25 17:36:30', 'UTC'),
|
||||
'draw_time' => Carbon::parse('2026-05-25 17:37:00', 'UTC'),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
Draw::query()->create([
|
||||
'draw_no' => '20260525-1006',
|
||||
'business_date' => '2026-05-25',
|
||||
'sequence_no' => 1006,
|
||||
'status' => DrawStatus::Pending->value,
|
||||
'start_time' => Carbon::parse('2026-05-25 18:07:00', 'UTC'),
|
||||
'close_time' => Carbon::parse('2026-05-25 18:11:30', 'UTC'),
|
||||
'draw_time' => Carbon::parse('2026-05-25 18:12:00', 'UTC'),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => null,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$target = app(DrawHallSnapshotBuilder::class)->resolveHallTarget(now()->utc());
|
||||
expect($target)->not->toBeNull()
|
||||
->and($target->draw_no)->toBe('20260525-1006');
|
||||
|
||||
$payload = app(DrawHallSnapshotBuilder::class)->build(now()->utc());
|
||||
expect($payload['draw_no'])->toBe('20260525-1006')
|
||||
->and($payload['seconds_to_start'])->toBeGreaterThan(0);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('hall snapshot switches to next bettable draw when cooldown ended', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-10 12:00:00', 'UTC'));
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ test('draw planner schedules five minute draw_time gaps', function (): void {
|
||||
]);
|
||||
|
||||
$fixed = Carbon::parse('2026-05-25 00:00:00', 'UTC');
|
||||
app(DrawPlannerService::class)->ensureBuffer($fixed);
|
||||
$report = app(DrawPlannerService::class)->ensureBuffer($fixed);
|
||||
expect($report['created'])->toBe(12);
|
||||
|
||||
$times = Draw::query()
|
||||
->whereNotNull('draw_time')
|
||||
|
||||
Reference in New Issue
Block a user