feat(draw): 支持多玩法provider及本地化结算时区功能
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

- Draw相关控制器添加provider_code和provider_name字段支持
- 允许手动录入开奖批次时指定provider_code
- RNG开奖批次生成支持多provider,分别生成对应批次数据
- DrawPublishService和DrawManualResultService中支持基于provider_code管理开奖版本和发布流程
- DrawResultViewService调整,支持按provider汇总及筛选开奖结果
- Settlement处理逻辑调整,按provider区分待结算票据及批次,分开结算
- 结算期间管理相关服务支持使用站点本地结算时区计算开账建议和日期处理
- 增加AdminSite的settlement_timezone字段及相关请求验证和数据存取支持
- 优化AdminSettlementPeriod相关代码,防止存在未结清票据时关账
- Ticket明细返回接口添加结算时区信息,提升用户时间体验
- 多处查询排序和版本号处理改进,保证多provider数据正确顺序与一致性
This commit is contained in:
2026-07-09 15:48:21 +08:00
parent c6c10f1397
commit d4779660c8
38 changed files with 1121 additions and 271 deletions

View File

@@ -28,6 +28,7 @@ final class AdminDrawResultBatchesIndexController extends Controller
->with(['items' => function ($q): void {
$q->orderBy('prize_type')->orderBy('prize_index');
}])
->orderBy('provider_code')
->orderByDesc('result_version');
if (! $manage) {

View File

@@ -31,7 +31,12 @@ final class DrawManualResultBatchStoreController extends Controller
}
try {
$batch = $this->service->createPendingBatch($draw, $admin, $request->validated('items'));
$batch = $this->service->createPendingBatch(
$draw,
$admin,
$request->validated('items'),
$request->validated('provider_code'),
);
} catch (\RuntimeException $e) {
return ApiMessage::runtimeErrorResponse($request, $e);
}
@@ -43,6 +48,8 @@ final class DrawManualResultBatchStoreController extends Controller
'status' => $draw->status,
'batch' => [
'id' => (int) $batch->id,
'provider_code' => $batch->provider_code,
'provider_name' => $batch->provider_name,
'result_version' => (int) $batch->result_version,
'source_type' => $batch->source_type,
'status' => $batch->status,

View File

@@ -54,7 +54,9 @@ final class DrawResultBatchPublishController extends Controller
return ApiResponse::success([
'draw_no' => $draw->draw_no,
'status' => $draw->status,
'result_version' => (int) $draw->current_result_version,
'provider_code' => $batch->provider_code,
'provider_name' => $batch->provider_name,
'result_version' => (int) $batch->result_version,
]);
}
}

View File

@@ -11,6 +11,7 @@ use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\DB;
use App\Services\Draw\DrawRngRunner;
use App\Models\DrawResultBatch;
final class DrawRngRunController extends Controller
{
@@ -38,17 +39,34 @@ final class DrawRngRunController extends Controller
}
$draw->refresh();
$batches = DrawResultBatch::query()
->where('draw_id', $draw->id)
->where('result_version', (int) $batch->result_version)
->where('source_type', $batch->source_type)
->orderBy('provider_code')
->get();
return ApiResponse::success([
'draw_no' => $draw->draw_no,
'status' => $draw->status,
'batch' => [
'id' => (int) $batch->id,
'provider_code' => $batch->provider_code,
'provider_name' => $batch->provider_name,
'result_version' => (int) $batch->result_version,
'source_type' => $batch->source_type,
'status' => $batch->status,
'items_count' => $batch->items()->count(),
],
'batches' => $batches->map(fn (DrawResultBatch $row): array => [
'id' => (int) $row->id,
'provider_code' => $row->provider_code,
'provider_name' => $row->provider_name,
'result_version' => (int) $row->result_version,
'source_type' => $row->source_type,
'status' => $row->status,
'items_count' => $row->items()->count(),
])->values()->all(),
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Player;
use App\Support\ApiResponse;
use App\Support\AgentSettlementSiteTimezone;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
@@ -32,6 +33,7 @@ final class MeController extends Controller
'username' => $player->username,
'nickname' => $player->nickname,
'default_currency' => $player->default_currency,
'settlement_timezone' => AgentSettlementSiteTimezone::forSiteCode((string) $player->site_code),
'status' => $player->status,
'locale' => $request->lotteryLocale(),
'last_login_at' => $player->last_login_at?->toIso8601String(),

View File

@@ -51,7 +51,9 @@ final class TicketItemShowController extends Controller
$draw = $item->draw;
$published = $draw !== null && in_array($draw->status, DrawResultViewService::publishedDrawStatuses(), true);
$drawPayload = $published && $draw !== null ? $this->drawResultView->summarizeDraw($draw) : null;
$drawPayload = $published && $draw !== null
? $this->drawResultView->summarizeDraw($draw, null, $item->provider_code)
: null;
$detail = $item->latestSettlementDetail;
$settlementBatch = $detail?->batch;

View File

@@ -20,6 +20,7 @@ final class AdminIntegrationSiteStoreRequest extends ApiFormRequest
'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('admin_sites', 'code')],
'name' => ['required', 'string', 'max:128'],
'currency_code' => ['sometimes', 'string', 'max:16'],
'settlement_timezone' => ['sometimes', 'timezone'],
'status' => ['sometimes', 'integer', 'in:0,1'],
'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()],
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],

View File

@@ -18,6 +18,7 @@ final class AdminIntegrationSiteUpdateRequest extends ApiFormRequest
return [
'name' => ['required', 'string', 'max:128'],
'currency_code' => ['sometimes', 'string', 'max:16'],
'settlement_timezone' => ['sometimes', 'timezone'],
'status' => ['sometimes', 'integer', 'in:0,1'],
'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()],
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],

View File

@@ -16,6 +16,7 @@ final class DrawManualResultBatchStoreRequest extends ApiFormRequest
public function rules(): array
{
return [
'provider_code' => ['sometimes', 'string', 'max:32'],
'items' => ['required', 'array', 'size:23'],
'items.*.prize_type' => ['required', 'string', Rule::in(['first', 'second', 'third', 'starter', 'consolation'])],
'items.*.prize_index' => ['required', 'integer', 'min:0', 'max:9'],

View File

@@ -17,6 +17,7 @@ final class AdminSite extends Model
'code',
'name',
'currency_code',
'settlement_timezone',
'status',
'is_default',
'extra_json',

View File

@@ -14,6 +14,8 @@ final class DrawResultBatch extends Model
protected $fillable = [
'draw_id',
'provider_code',
'provider_name',
'result_version',
'source_type',
'rng_seed_hash',

View File

@@ -49,6 +49,14 @@ final class AgentSettlementPeriodCloseService
(string) $period->period_start,
(string) $period->period_end,
);
$unsettled = $this->unsettledWarning->countForSite($adminSiteId, $periodStart, $periodEnd);
if ($unsettled['count'] > 0) {
throw ValidationException::withMessages([
'period' => ['period_has_unsettled_tickets'],
]);
}
try {
$aggregate = $this->aggregator->aggregate($adminSiteId, $periodStart, $periodEnd);
} catch (\InvalidArgumentException $e) {
@@ -68,8 +76,6 @@ final class AgentSettlementPeriodCloseService
$rebateStats = $this->periodCloseRebate->dispatchAndAllocate($periodId, $adminSiteId, $periodStart, $periodEnd);
$this->releasePlayerBillRebatesFromCredit($periodId);
$unsettled = $this->unsettledWarning->countForSite($adminSiteId, $periodStart, $periodEnd);
DB::table('settlement_periods')->where('id', $periodId)->update([
'status' => 'closed',
'updated_at' => now(),

View File

@@ -3,6 +3,7 @@
namespace App\Services\AgentSettlement;
use App\Support\AgentSettlementPeriodWindow;
use App\Support\AgentSettlementSiteTimezone;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -15,9 +16,11 @@ final class AgentSettlementPeriodOpenService
public function open(array $data): object
{
$siteId = (int) $data['admin_site_id'];
$timezone = AgentSettlementSiteTimezone::forSiteId($siteId);
[$start, $end] = AgentSettlementPeriodWindow::normalizeInputBounds(
(string) $data['period_start'],
(string) $data['period_end'],
$timezone,
);
$existingSameRange = DB::table('settlement_periods')

View File

@@ -3,26 +3,33 @@
namespace App\Services\AgentSettlement;
use Carbon\Carbon;
use App\Support\AgentSettlementSiteTimezone;
use Illuminate\Support\Facades\DB;
/** 开账弹窗:建议账期与日历标记(已有账期 / 待入账 / 未结清)。 */
/** 开账弹窗:建议账期与日历标记(已有账期 / 待入账 / 未结清 / 已关账漏账)。 */
final class SettlementPeriodOpenHintsService
{
/**
* @return array{
* suggested_start: string,
* suggested_end: string,
* settlement_timezone: string,
* occupied_period_dates: list<string>,
* pending_activity_dates: list<string>,
* orphan_activity_dates: list<string>,
* unpaid_bill_dates: list<string>
* }
*/
public function hints(int $adminSiteId): array
{
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
$site = DB::table('admin_sites')->where('id', $adminSiteId)->first(['code', 'settlement_timezone']);
$siteCode = (string) ($site->code ?? '');
if ($siteCode === '') {
return $this->emptyHints();
}
$timezone = AgentSettlementSiteTimezone::isValid((string) ($site->settlement_timezone ?? ''))
? (string) $site->settlement_timezone
: AgentSettlementSiteTimezone::forSiteId($adminSiteId);
$periodRows = DB::table('settlement_periods')
->where('admin_site_id', $adminSiteId)
@@ -31,7 +38,7 @@ final class SettlementPeriodOpenHintsService
$occupiedPeriodDates = [];
foreach ($periodRows as $row) {
foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) {
foreach ($this->expandPeriodToLocalDays((string) $row->period_start, (string) $row->period_end, $timezone) as $day) {
$occupiedPeriodDates[$day] = true;
}
}
@@ -42,18 +49,25 @@ final class SettlementPeriodOpenHintsService
->orderByDesc('period_end')
->first();
$pendingActivityDates = DB::table('share_ledger as sl')
$pendingLedgerRows = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereNull('sl.settlement_period_id')
->whereNull('sl.reversal_of_id')
->selectRaw('DATE(sl.settled_at) as activity_day')
->groupBy('activity_day')
->orderBy('activity_day')
->pluck('activity_day')
->map(static fn ($day): string => (string) $day)
->values()
->all();
->orderBy('sl.settled_at')
->get(['sl.settled_at']);
$pendingActivityDates = [];
$orphanActivityDates = [];
foreach ($pendingLedgerRows as $row) {
$day = AgentSettlementSiteTimezone::localDayFromUtc($row->settled_at, $timezone);
$pendingActivityDates[$day] = true;
if (isset($occupiedPeriodDates[$day])) {
$orphanActivityDates[$day] = true;
}
}
$pendingActivityDates = array_keys($pendingActivityDates);
$orphanActivityDates = array_keys($orphanActivityDates);
$unpaidPeriodRows = DB::table('settlement_periods as sp')
->where('sp.admin_site_id', $adminSiteId)
@@ -70,18 +84,20 @@ final class SettlementPeriodOpenHintsService
$unpaidBillDates = [];
foreach ($unpaidPeriodRows as $row) {
foreach ($this->expandPeriodToUtcDays((string) $row->period_start, (string) $row->period_end) as $day) {
foreach ($this->expandPeriodToLocalDays((string) $row->period_start, (string) $row->period_end, $timezone) as $day) {
$unpaidBillDates[$day] = true;
}
}
$suggested = $this->suggestRange($lastPeriod, $pendingActivityDates, $occupiedPeriodDates);
$suggested = $this->suggestRange($lastPeriod, $pendingActivityDates, $occupiedPeriodDates, $timezone);
return [
'suggested_start' => $suggested['start'],
'suggested_end' => $suggested['end'],
'settlement_timezone' => $timezone,
'occupied_period_dates' => array_keys($occupiedPeriodDates),
'pending_activity_dates' => $pendingActivityDates,
'orphan_activity_dates' => $orphanActivityDates,
'unpaid_bill_dates' => array_keys($unpaidBillDates),
];
}
@@ -91,10 +107,15 @@ final class SettlementPeriodOpenHintsService
* @param array<string, true> $occupiedPeriodDates
* @return array{start: string, end: string}
*/
private function suggestRange(?object $lastPeriod, array $pendingActivityDates, array $occupiedPeriodDates): array
private function suggestRange(
?object $lastPeriod,
array $pendingActivityDates,
array $occupiedPeriodDates,
string $timezone,
): array
{
$lastEndDay = $lastPeriod !== null
? Carbon::parse((string) $lastPeriod->period_end)->utc()->startOfDay()
? Carbon::parse((string) $lastPeriod->period_end, 'UTC')->timezone($timezone)->startOfDay()
: null;
$freePending = array_values(array_filter(
@@ -103,8 +124,8 @@ final class SettlementPeriodOpenHintsService
));
if ($freePending !== []) {
$minDay = Carbon::parse($freePending[0])->utc()->startOfDay();
$maxDay = Carbon::parse($freePending[array_key_last($freePending)])->utc()->startOfDay();
$minDay = Carbon::parse($freePending[0])->startOfDay();
$maxDay = Carbon::parse($freePending[array_key_last($freePending)])->startOfDay();
$startDay = $lastEndDay !== null
? ($lastEndDay->copy()->addDay()->lessThanOrEqualTo($minDay) ? $lastEndDay->copy()->addDay() : $minDay)
: $minDay;
@@ -119,7 +140,7 @@ final class SettlementPeriodOpenHintsService
if ($lastEndDay !== null) {
$startDay = $lastEndDay->copy()->addDay();
$endDay = Carbon::now('UTC')->subDay()->startOfDay();
$endDay = Carbon::now($timezone)->subDay()->startOfDay();
if ($endDay->lessThan($startDay)) {
return ['start' => '', 'end' => ''];
}
@@ -169,13 +190,12 @@ final class SettlementPeriodOpenHintsService
return false;
}
/** @return list<string> 站点本地日历 `Y-m-d`(东八区,与后台开账日期选择一致) */
private function expandPeriodToUtcDays(string $periodStart, string $periodEnd): array
/** @return list<string> 站点本地日历 `Y-m-d` */
private function expandPeriodToLocalDays(string $periodStart, string $periodEnd, string $timezone): array
{
$dates = [];
$tz = 'Asia/Shanghai';
$cursor = Carbon::parse($periodStart)->timezone($tz)->startOfDay();
$end = Carbon::parse($periodEnd)->timezone($tz)->startOfDay();
$cursor = Carbon::parse($periodStart, 'UTC')->timezone($timezone)->startOfDay();
$end = Carbon::parse($periodEnd, 'UTC')->timezone($timezone)->startOfDay();
while ($cursor->lessThanOrEqualTo($end)) {
$dates[] = $cursor->format('Y-m-d');
@@ -185,14 +205,16 @@ final class SettlementPeriodOpenHintsService
return $dates;
}
/** @return array{suggested_start: string, suggested_end: string, occupied_period_dates: list<string>, pending_activity_dates: list<string>, unpaid_bill_dates: list<string>} */
/** @return array{suggested_start: string, suggested_end: string, settlement_timezone: string, occupied_period_dates: list<string>, pending_activity_dates: list<string>, orphan_activity_dates: list<string>, unpaid_bill_dates: list<string>} */
private function emptyHints(): array
{
return [
'suggested_start' => '',
'suggested_end' => '',
'settlement_timezone' => (string) config('lottery.settlement.default_timezone', 'UTC'),
'occupied_period_dates' => [],
'pending_activity_dates' => [],
'orphan_activity_dates' => [],
'unpaid_bill_dates' => [],
];
}

View File

@@ -4,6 +4,7 @@ namespace App\Services\Draw;
use App\Models\Draw;
use App\Models\AdminUser;
use App\Models\BetProvider;
use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
@@ -16,9 +17,9 @@ final class DrawManualResultService
/**
* @param list<array{prize_type: string, prize_index: int, number_4d: string}> $items
*/
public function createPendingBatch(Draw $draw, AdminUser $admin, array $items): DrawResultBatch
public function createPendingBatch(Draw $draw, AdminUser $admin, array $items, ?string $providerCode = null): DrawResultBatch
{
return DB::transaction(function () use ($draw, $admin, $items): DrawResultBatch {
return DB::transaction(function () use ($draw, $admin, $items, $providerCode): DrawResultBatch {
/** @var Draw $locked */
$locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail();
if (! in_array($locked->status, [DrawStatus::Closed->value, DrawStatus::Review->value], true)) {
@@ -28,16 +29,34 @@ final class DrawManualResultService
throw new \RuntimeException('draw_already_settled');
}
$providerCode = strtoupper(trim((string) ($providerCode ?: BetProvider::DEFAULT_CODE)));
/** @var BetProvider|null $provider */
$provider = BetProvider::query()
->where('code', $providerCode)
->where('is_enabled', true)
->first();
if ($provider === null && $providerCode !== BetProvider::DEFAULT_CODE) {
throw new \RuntimeException('bet_provider_not_found');
}
$providerName = $provider?->name ?? 'Singapore';
if (DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('provider_code', $providerCode)
->where('status', DrawResultBatchStatus::PendingReview->value)
->exists()) {
throw new \RuntimeException('draw_pending_result_batch_exists');
}
$nextVersion = max(1, (int) $locked->current_result_version + 1);
$latestVersion = (int) DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('provider_code', $providerCode)
->max('result_version');
$nextVersion = max(1, $latestVersion + 1);
$batch = DrawResultBatch::query()->create([
'draw_id' => $locked->id,
'provider_code' => $providerCode,
'provider_name' => $providerName,
'result_version' => $nextVersion,
'source_type' => DrawResultSourceType::Manual->value,
'rng_seed_hash' => null,

View File

@@ -38,6 +38,7 @@ final class DrawPublishService
DrawResultBatch::query()
->where('draw_id', $draw->id)
->where('provider_code', $lockedBatch->provider_code)
->where('id', '!=', $lockedBatch->id)
->where('status', DrawResultBatchStatus::Published->value)
->update(['status' => DrawResultBatchStatus::Rejected->value]);
@@ -80,17 +81,18 @@ final class DrawPublishService
private function applyPublishedToDraw(Draw $draw, DrawResultBatch $batch): Draw
{
$cooldownMinutes = LotterySettings::drawCooldownMinutes();
$currentResultVersion = max((int) $draw->current_result_version, (int) $batch->result_version);
if ($cooldownMinutes > 0) {
$draw->forceFill([
'status' => DrawStatus::Cooldown->value,
'current_result_version' => (int) $batch->result_version,
'current_result_version' => $currentResultVersion,
'result_source' => $batch->source_type,
'cooling_end_time' => now()->addMinutes($cooldownMinutes),
])->save();
} else {
$draw->forceFill([
'status' => DrawStatus::Settling->value,
'current_result_version' => (int) $batch->result_version,
'current_result_version' => $currentResultVersion,
'result_source' => $batch->source_type,
'cooling_end_time' => null,
])->save();
@@ -105,7 +107,12 @@ final class DrawPublishService
throw new \RuntimeException('draw_not_ready_to_publish');
}
if ((int) $batch->result_version < (int) $draw->current_result_version) {
$latestProviderVersion = (int) DrawResultBatch::query()
->where('draw_id', $draw->id)
->where('provider_code', $batch->provider_code)
->where('status', DrawResultBatchStatus::Published->value)
->max('result_version');
if ((int) $batch->result_version < $latestProviderVersion) {
throw new \RuntimeException('batch_result_version_stale');
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Draw;
use App\Models\BetProvider;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
@@ -76,7 +77,7 @@ final class DrawResultViewService
*
* @return array<string, mixed>|null
*/
public function summarizeDraw(Draw $draw, ?string $currencyCode = null): ?array
public function summarizeDraw(Draw $draw, ?string $currencyCode = null, ?string $providerCode = null): ?array
{
$currencyCode = $this->normalizeCurrencyCode($currencyCode);
$version = (int) $draw->current_result_version;
@@ -84,30 +85,38 @@ final class DrawResultViewService
return null;
}
$batch = DrawResultBatch::query()
$batches = DrawResultBatch::query()
->where('draw_id', $draw->id)
->where('result_version', $version)
->where('status', DrawResultBatchStatus::Published->value)
->first();
->orderByRaw('case when provider_code = ? then 0 else 1 end', [BetProvider::DEFAULT_CODE])
->orderBy('provider_code')
->orderByDesc('id')
->get();
if ($batch === null) {
if ($batches->isEmpty()) {
return null;
}
$items = DrawResultItem::query()
->where('result_batch_id', $batch->id)
->orderBy('prize_type')
->orderBy('prize_index')
->get([
'prize_type', 'prize_index', 'number_4d',
'suffix_3d', 'suffix_2d', 'head_digit', 'tail_digit',
]);
$providerResults = $batches
->map(fn (DrawResultBatch $batch): ?array => $this->summarizeBatch($batch))
->filter()
->values();
if ($items->isEmpty()) {
if ($providerResults->isEmpty()) {
return null;
}
$numbers = $this->numbersFromItems($items);
$normalizedProviderCode = strtoupper(trim((string) ($providerCode ?? '')));
$selected = $normalizedProviderCode === ''
? null
: $providerResults->first(
fn (array $row): bool => strtoupper((string) ($row['provider_code'] ?? '')) === $normalizedProviderCode,
);
$primary = $selected ?? $providerResults->first();
if ($primary === null) {
return null;
}
return [
'draw_id' => $draw->draw_no,
@@ -119,16 +128,11 @@ final class DrawResultViewService
'result_source' => $draw->result_source,
'jackpot_currency_code' => $currencyCode,
'jackpot' => $this->jackpotSummary->summary($currencyCode),
'results' => $numbers,
'result_items' => $items->map(fn (DrawResultItem $r) => [
'prize_type' => $r->prize_type,
'prize_index' => (int) $r->prize_index,
'number_4d' => $r->number_4d,
'suffix_3d' => $r->suffix_3d,
'suffix_2d' => $r->suffix_2d,
'head_digit' => $r->head_digit !== null ? (int) $r->head_digit : null,
'tail_digit' => $r->tail_digit !== null ? (int) $r->tail_digit : null,
])->values()->all(),
'provider_code' => $primary['provider_code'],
'provider_name' => $primary['provider_name'],
'results' => $primary['results'],
'result_items' => $primary['result_items'],
'provider_results' => $providerResults->all(),
];
}
@@ -197,4 +201,36 @@ final class DrawResultViewService
return LotterySettings::defaultCurrency();
}
private function summarizeBatch(DrawResultBatch $batch): ?array
{
$items = DrawResultItem::query()
->where('result_batch_id', $batch->id)
->orderBy('prize_type')
->orderBy('prize_index')
->get([
'prize_type', 'prize_index', 'number_4d',
'suffix_3d', 'suffix_2d', 'head_digit', 'tail_digit',
]);
if ($items->isEmpty()) {
return null;
}
return [
'provider_code' => $batch->provider_code,
'provider_name' => $batch->provider_name,
'result_version' => (int) $batch->result_version,
'results' => $this->numbersFromItems($items),
'result_items' => $items->map(fn (DrawResultItem $r) => [
'prize_type' => $r->prize_type,
'prize_index' => (int) $r->prize_index,
'number_4d' => $r->number_4d,
'suffix_3d' => $r->suffix_3d,
'suffix_2d' => $r->suffix_2d,
'head_digit' => $r->head_digit !== null ? (int) $r->head_digit : null,
'tail_digit' => $r->tail_digit !== null ? (int) $r->tail_digit : null,
])->values()->all(),
];
}
}

View File

@@ -4,10 +4,12 @@ namespace App\Services\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Models\BetProvider;
use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;
use App\Services\LotterySettings;
use App\Lottery\DrawResultSourceType;
use App\Lottery\DrawResultBatchStatus;
@@ -29,49 +31,71 @@ final class DrawRngRunner
])->save();
$manualReview = LotterySettings::drawRequireManualReview();
$seedHex = DrawRngSeedDerivation::generateSeedHex();
$rngSeedHash = DrawRngSeedDerivation::hashSeedHex($seedHex);
$rawSeedEncrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
$derivedRows = DrawRngSeedDerivation::deriveAllSlotRows($seedHex, (int) $draw->id);
$providers = $this->enabledProvidersForRng();
$batches = collect();
$nextVersion = max(1, (int) $draw->current_result_version + 1);
foreach ($providers as $provider) {
$seedHex = DrawRngSeedDerivation::generateSeedHex();
$rngSeedHash = DrawRngSeedDerivation::hashSeedHex($seedHex);
$rawSeedEncrypted = DrawRngSeedDerivation::encryptSeedHex($seedHex);
$derivedRows = DrawRngSeedDerivation::deriveAllSlotRows($seedHex, (int) $draw->id);
$versionQuery = DrawResultBatch::query()->where('draw_id', $draw->id);
if ($provider['code'] === BetProvider::DEFAULT_CODE) {
$versionQuery->where(function ($q) use ($provider): void {
$q->where('provider_code', $provider['code'])
->orWhereNull('provider_code');
});
} else {
$versionQuery->where('provider_code', $provider['code']);
}
$nextVersion = max(1, (int) $versionQuery->max('result_version') + 1);
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_version' => $nextVersion,
'source_type' => DrawResultSourceType::Rng->value,
'rng_seed_hash' => $rngSeedHash,
'raw_seed_encrypted' => $rawSeedEncrypted,
'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => $manualReview ? null : now(),
]);
foreach ($derivedRows as $row) {
DrawResultItem::query()->create([
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => $row['prize_type'],
'prize_index' => $row['prize_index'],
'number_4d' => $row['number_4d'],
'suffix_3d' => $row['suffix_3d'],
'suffix_2d' => $row['suffix_2d'],
'head_digit' => $row['head_digit'],
'tail_digit' => $row['tail_digit'],
'provider_code' => $provider['code'],
'provider_name' => $provider['name'],
'result_version' => $nextVersion,
'source_type' => DrawResultSourceType::Rng->value,
'rng_seed_hash' => $rngSeedHash,
'raw_seed_encrypted' => $rawSeedEncrypted,
'status' => $manualReview ? DrawResultBatchStatus::PendingReview->value : DrawResultBatchStatus::Published->value,
'created_by' => null,
'confirmed_by' => null,
'confirmed_at' => $manualReview ? null : now(),
]);
foreach ($derivedRows as $row) {
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => $row['prize_type'],
'prize_index' => $row['prize_index'],
'number_4d' => $row['number_4d'],
'suffix_3d' => $row['suffix_3d'],
'suffix_2d' => $row['suffix_2d'],
'head_digit' => $row['head_digit'],
'tail_digit' => $row['tail_digit'],
]);
}
$batches->push($batch);
}
/** @var DrawResultBatch $primaryBatch */
$primaryBatch = $batches->firstWhere('provider_code', BetProvider::DEFAULT_CODE) ?? $batches->first();
if ($manualReview) {
$draw->forceFill([
'status' => DrawStatus::Review->value,
'result_source' => DrawResultSourceType::Rng->value,
])->save();
} else {
$this->publisher->markPublishedInTransaction($draw->fresh(), $batch->fresh());
foreach ($batches as $batch) {
$this->publisher->markPublishedInTransaction($draw->fresh(), $batch->fresh());
}
}
return $batch->fresh();
return $primaryBatch->fresh();
}
/**
@@ -93,7 +117,7 @@ final class DrawRngRunner
->orWhereDoesntHave('resultBatches');
})
->orderBy('draw_time')
->limit((int) config('lottery.draw_tick_rng_limit', 3))
->limit((int) config('lottery.draw_tick_rng_limit', 10))
->pluck('id');
foreach ($ids as $drawId) {
@@ -120,4 +144,30 @@ final class DrawRngRunner
return ['rung' => $rung, 'errors' => $errors];
}
/**
* @return Collection<int, array{code: string, name: string}>
*/
private function enabledProvidersForRng(): Collection
{
$providers = BetProvider::query()
->where('is_enabled', true)
->orderByRaw('case when code = ? then 0 else 1 end', [BetProvider::DEFAULT_CODE])
->orderBy('sort_order')
->orderBy('id')
->get(['code', 'name'])
->map(fn (BetProvider $provider): array => [
'code' => (string) $provider->code,
'name' => (string) $provider->name,
]);
if ($providers->isNotEmpty()) {
return $providers->values();
}
return collect([[
'code' => BetProvider::DEFAULT_CODE,
'name' => 'Singapore',
]]);
}
}

View File

@@ -33,6 +33,7 @@ final class IntegrationSiteService
'code' => (string) $data['code'],
'name' => (string) $data['name'],
'currency_code' => (string) ($data['currency_code'] ?? 'NPR'),
'settlement_timezone' => (string) ($data['settlement_timezone'] ?? config('lottery.settlement.default_timezone', 'UTC')),
'status' => (int) ($data['status'] ?? 1),
'is_default' => false,
'wallet_api_url' => $this->nullableTrim($data['wallet_api_url'] ?? null),
@@ -73,6 +74,7 @@ final class IntegrationSiteService
$site->fill([
'name' => (string) $data['name'],
'currency_code' => (string) ($data['currency_code'] ?? $site->currency_code),
'settlement_timezone' => (string) ($data['settlement_timezone'] ?? $site->settlement_timezone ?? config('lottery.settlement.default_timezone', 'UTC')),
'status' => (int) ($data['status'] ?? $site->status),
'wallet_api_url' => array_key_exists('wallet_api_url', $data)
? $this->nullableTrim($data['wallet_api_url'])

View File

@@ -3,6 +3,7 @@
namespace App\Services\Settlement;
use App\Models\Draw;
use App\Models\BetProvider;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
@@ -53,59 +54,6 @@ final class SettlementOrchestrator
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$publishedBatch = DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('status', DrawResultBatchStatus::Published->value)
->where('result_version', (int) $locked->current_result_version)
->orderByDesc('id')
->first();
if ($publishedBatch === null) {
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$existingDone = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id)
->whereIn('status', [
SettlementBatchStatus::Running->value,
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
])
->first();
if ($existingDone !== null) {
$locked->forceFill([
'settle_version' => (int) $existingDone->settle_version,
])->save();
return [
'handled' => true,
'jackpot_bursts' => [],
'should_notify_status' => true,
];
}
$items = DrawResultItem::query()
->where('result_batch_id', $publishedBatch->id)
->orderBy('id')
->get();
$board = new PublishedDrawResultBoard($items);
$nextSettleVersion = (int) $locked->settle_version + 1;
$batchRow = SettlementBatch::query()->create([
'draw_id' => $locked->id,
'result_batch_id' => $publishedBatch->id,
'settle_version' => $nextSettleVersion,
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'started_at' => now(),
]);
$ticketItems = TicketItem::query()
->where('draw_id', $locked->id)
->where('status', 'pending_draw')
@@ -122,119 +70,188 @@ final class SettlementOrchestrator
]);
}
/** @var list<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $prepared */
$prepared = [];
foreach ($ticketItems as $item) {
$matcher = $this->matchers->for((string) $item->play_code);
$result = $matcher->match($item, $board, $item->combinations);
$gross = max(0, (int) $result['win_amount']);
$tier = $result['matched_prize_tier'] ?? null;
$tier = is_string($tier) ? $tier : null;
$net = $this->payoutAdjuster->adjustGrossWin($gross, $item);
$prepared[] = [
'item' => $item,
'gross_win' => $gross,
'matched_tier' => $tier,
'net_win' => $net,
'match_detail' => $result['match_detail'],
];
}
$allocations = [];
$totalJackpotPayout = 0;
$jackpotBursts = [];
$preparedByCurrency = collect($prepared)->groupBy(
fn (array $p): string => strtoupper((string) ($p['item']->order?->currency_code ?? 'NPR')),
);
foreach ($preparedByCurrency as $currency => $currencyPrepared) {
$pool = JackpotPool::query()
->where('currency_code', $currency)
->where('status', 1)
->lockForUpdate()
->first();
if ($pool === null) {
$handled = false;
$latestSettleVersion = (int) $locked->settle_version;
$ticketItemsByProvider = $ticketItems->isEmpty()
? collect([BetProvider::DEFAULT_CODE => collect()])
: $ticketItems->groupBy(
fn (TicketItem $item): string => strtoupper((string) ($item->provider_code ?: BetProvider::DEFAULT_CODE)),
);
$providerCodes = $ticketItemsByProvider->keys()->values()->all();
$publishedBatches = DrawResultBatch::query()
->where('draw_id', $locked->id)
->where('status', DrawResultBatchStatus::Published->value)
->whereIn('provider_code', $providerCodes)
->orderBy('provider_code')
->orderByDesc('result_version')
->orderByDesc('id')
->get()
->unique('provider_code')
->keyBy('provider_code');
foreach ($ticketItemsByProvider as $providerCode => $providerTicketItems) {
/** @var DrawResultBatch|null $publishedBatch */
$publishedBatch = $publishedBatches->get($providerCode);
if ($publishedBatch === null) {
continue;
}
$burstInput = collect($currencyPrepared)->map(fn (array $p): array => [
'item' => $p['item'],
'matched_tier' => $p['matched_tier'],
'gross_win' => $p['gross_win'],
]);
$burstOut = $this->jackpotBurst->allocate($locked, $pool, $burstInput);
$allocations = array_replace($allocations, $burstOut['allocations']);
$currencyPayout = (int) $burstOut['pool_payout'];
$totalJackpotPayout += $currencyPayout;
$existingDone = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id)
->whereIn('status', [
SettlementBatchStatus::Running->value,
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
])
->first();
if ($currencyPayout > 0 && is_string($burstOut['trigger'])) {
$jackpotBursts[] = [
'currency' => $currency,
'payout' => $currencyPayout,
'trigger' => $burstOut['trigger'],
'pool_after' => (int) $pool->fresh()->current_amount,
'winner_count' => count($burstOut['allocations']),
if ($existingDone !== null) {
$handled = true;
$latestSettleVersion = max($latestSettleVersion, (int) $existingDone->settle_version);
continue;
}
$items = DrawResultItem::query()
->where('result_batch_id', $publishedBatch->id)
->orderBy('id')
->get();
$board = new PublishedDrawResultBoard($items);
$nextSettleVersion = $latestSettleVersion + 1;
$latestSettleVersion = $nextSettleVersion;
$batchRow = SettlementBatch::query()->create([
'draw_id' => $locked->id,
'result_batch_id' => $publishedBatch->id,
'settle_version' => $nextSettleVersion,
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'started_at' => now(),
]);
/** @var list<array{item: TicketItem, gross_win: int, matched_tier: ?string, net_win: int, match_detail: mixed}> $prepared */
$prepared = [];
foreach ($providerTicketItems as $item) {
$matcher = $this->matchers->for((string) $item->play_code);
$result = $matcher->match($item, $board, $item->combinations);
$gross = max(0, (int) $result['win_amount']);
$tier = $result['matched_prize_tier'] ?? null;
$tier = is_string($tier) ? $tier : null;
$net = $this->payoutAdjuster->adjustGrossWin($gross, $item);
$prepared[] = [
'item' => $item,
'gross_win' => $gross,
'matched_tier' => $tier,
'net_win' => $net,
'match_detail' => $result['match_detail'],
];
}
}
$ticketCount = 0;
$winCount = 0;
$totalPayout = 0;
$allocations = [];
$totalJackpotPayout = 0;
$preparedByCurrency = collect($prepared)->groupBy(
fn (array $p): string => strtoupper((string) ($p['item']->order?->currency_code ?? 'NPR')),
);
foreach ($preparedByCurrency as $currency => $currencyPrepared) {
$pool = JackpotPool::query()
->where('currency_code', $currency)
->where('status', 1)
->lockForUpdate()
->first();
if ($pool === null) {
continue;
}
foreach ($prepared as $p) {
/** @var TicketItem $item */
$item = $p['item'];
$ticketCount++;
$net = (int) $p['net_win'];
$jackpotShare = (int) ($allocations[(int) $item->id] ?? 0);
$finalCredit = $net + $jackpotShare;
$burstInput = collect($currencyPrepared)->map(fn (array $p): array => [
'item' => $p['item'],
'matched_tier' => $p['matched_tier'],
'gross_win' => $p['gross_win'],
]);
$burstOut = $this->jackpotBurst->allocate($locked, $pool, $burstInput);
$allocations = array_replace($allocations, $burstOut['allocations']);
$currencyPayout = (int) $burstOut['pool_payout'];
$totalJackpotPayout += $currencyPayout;
TicketSettlementDetail::query()->create([
'settlement_batch_id' => $batchRow->id,
'ticket_item_id' => $item->id,
'matched_prize_tier' => $p['matched_tier'],
'win_amount' => $net,
'jackpot_allocation_amount' => $jackpotShare,
'match_detail_json' => $p['match_detail'],
]);
if ($currencyPayout > 0 && is_string($burstOut['trigger'])) {
$jackpotBursts[] = [
'first_prize_number' => $board->firstPrizeNumber4d(),
'currency' => $currency,
'payout' => $currencyPayout,
'trigger' => $burstOut['trigger'],
'pool_after' => (int) $pool->fresh()->current_amount,
'winner_count' => count($burstOut['allocations']),
];
}
}
$terminalStatus = $finalCredit > 0 ? 'pending_payout' : 'settled_lose';
$item->forceFill([
'win_amount' => $net,
'jackpot_win_amount' => $jackpotShare,
'settled_at' => null,
'status' => $terminalStatus,
$ticketCount = 0;
$winCount = 0;
$totalPayout = 0;
foreach ($prepared as $p) {
/** @var TicketItem $item */
$item = $p['item'];
$ticketCount++;
$net = (int) $p['net_win'];
$jackpotShare = (int) ($allocations[(int) $item->id] ?? 0);
$finalCredit = $net + $jackpotShare;
TicketSettlementDetail::query()->create([
'settlement_batch_id' => $batchRow->id,
'ticket_item_id' => $item->id,
'matched_prize_tier' => $p['matched_tier'],
'win_amount' => $net,
'jackpot_allocation_amount' => $jackpotShare,
'match_detail_json' => $p['match_detail'],
]);
$terminalStatus = $finalCredit > 0 ? 'pending_payout' : 'settled_lose';
$item->forceFill([
'win_amount' => $net,
'jackpot_win_amount' => $jackpotShare,
'settled_at' => null,
'status' => $terminalStatus,
])->save();
$this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus);
if ($finalCredit > 0) {
$winCount++;
}
$totalPayout += $finalCredit;
$locks = [];
foreach ($item->combinations as $c) {
$locks[] = [
'number_4d' => (string) $c->number_4d,
'amount' => (int) $c->estimated_payout,
];
}
$this->riskPool->release((int) $locked->id, $item, $locks);
}
$batchRow->forceFill([
'status' => SettlementBatchStatus::PendingReview->value,
'total_ticket_count' => $ticketCount,
'total_win_count' => $winCount,
'total_payout_amount' => $totalPayout,
'total_jackpot_payout_amount' => $totalJackpotPayout,
'finished_at' => now(),
])->save();
$this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus);
if ($finalCredit > 0) {
$winCount++;
}
$totalPayout += $finalCredit;
$locks = [];
foreach ($item->combinations as $c) {
$locks[] = [
'number_4d' => (string) $c->number_4d,
'amount' => (int) $c->estimated_payout,
];
}
$this->riskPool->release((int) $locked->id, $item, $locks);
$handled = true;
}
$batchRow->forceFill([
'status' => SettlementBatchStatus::PendingReview->value,
'total_ticket_count' => $ticketCount,
'total_win_count' => $winCount,
'total_payout_amount' => $totalPayout,
'total_jackpot_payout_amount' => $totalJackpotPayout,
'finished_at' => now(),
])->save();
if (! $handled) {
return ['handled' => false, 'jackpot_bursts' => [], 'should_notify_status' => false];
}
$locked->forceFill([
'status' => DrawStatus::Settling->value,
'settle_version' => $nextSettleVersion,
'settle_version' => $latestSettleVersion,
])->save();
return [
@@ -242,7 +259,7 @@ final class SettlementOrchestrator
'jackpot_bursts' => array_map(fn (array $burst): array => [
'draw_id' => (int) $locked->id,
'draw_no' => (string) $locked->draw_no,
'first_prize_number' => $board->firstPrizeNumber4d(),
'first_prize_number' => (string) $burst['first_prize_number'],
'currency' => (string) $burst['currency'],
'payout' => (int) $burst['payout'],
'winner_count' => (int) $burst['winner_count'],

View File

@@ -103,6 +103,8 @@ final class AdminDrawApiPresenter
$row = [
'id' => (int) $batch->id,
'provider_code' => $batch->provider_code,
'provider_name' => $batch->provider_name,
'result_version' => (int) $batch->result_version,
'status' => $batch->status,
'confirmed_at' => $batch->confirmed_at?->toIso8601String(),

View File

@@ -17,6 +17,7 @@ final class AdminIntegrationSitePresenter
'name' => (string) $site->name,
'has_line_root' => $hasLineRoot,
'currency_code' => (string) $site->currency_code,
'settlement_timezone' => (string) ($site->settlement_timezone ?? config('lottery.settlement.default_timezone', 'UTC')),
'status' => (int) $site->status,
'wallet_api_url' => $site->wallet_api_url,
'lottery_h5_base_url' => $site->lottery_h5_base_url,
@@ -96,6 +97,7 @@ final class AdminIntegrationSitePresenter
'name' => (string) $site->name,
'status' => (int) $site->status === 1 ? 'enabled' : 'disabled',
'currency_code' => (string) $site->currency_code,
'settlement_timezone' => (string) ($site->settlement_timezone ?? config('lottery.settlement.default_timezone', 'UTC')),
'lottery_h5_base_url' => $h5Base,
'wallet_api_url' => $site->wallet_api_url,
'wallet_balance_url' => $fullUrl($site->wallet_balance_path),

View File

@@ -30,12 +30,15 @@ final class AgentSettlementPeriodWindow
}
/**
* 开账 API支持 `Y-m-d` 或带时刻字符串;前者按 UTC 自然日扩界,后者按 UTC 解释。
* 开账 API支持 `Y-m-d` 或带时刻字符串;前者按站点业务时区扩界,后者按 UTC 解释。
*
* @return array{0: string, 1: string}
*/
public static function normalizeInputBounds(string $periodStart, string $periodEnd): array
{
public static function normalizeInputBounds(
string $periodStart,
string $periodEnd,
string $timezone = 'UTC',
): array {
$startRaw = trim($periodStart);
$endRaw = trim($periodEnd);
@@ -45,12 +48,14 @@ final class AgentSettlementPeriodWindow
]);
}
$tz = AgentSettlementSiteTimezone::isValid($timezone) ? $timezone : 'UTC';
$startAt = self::isDateOnly($startRaw)
? Carbon::parse($startRaw.' 00:00:00', 'UTC')
? Carbon::parse($startRaw.' 00:00:00', $tz)->utc()
: Carbon::parse($startRaw)->utc();
$endAt = self::isDateOnly($endRaw)
? Carbon::parse($endRaw.' 23:59:59', 'UTC')
? Carbon::parse($endRaw.' 23:59:59', $tz)->utc()
: Carbon::parse($endRaw)->utc();
if ($endAt->lessThan($startAt)) {

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Support;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
final class AgentSettlementSiteTimezone
{
public static function forSiteId(int $adminSiteId): string
{
return self::normalize((string) (DB::table('admin_sites')
->where('id', $adminSiteId)
->value('settlement_timezone') ?? ''));
}
public static function forSiteCode(string $siteCode): string
{
return self::normalize((string) (DB::table('admin_sites')
->where('code', $siteCode)
->value('settlement_timezone') ?? ''));
}
public static function isValid(?string $timezone): bool
{
return is_string($timezone)
&& $timezone !== ''
&& in_array($timezone, timezone_identifiers_list(), true);
}
public static function localDayFromUtc(mixed $instant, string $timezone): string
{
return Carbon::parse((string) $instant, 'UTC')
->timezone($timezone)
->toDateString();
}
private static function normalize(?string $timezone): string
{
if (self::isValid($timezone)) {
return (string) $timezone;
}
$fallback = (string) config('lottery.settlement.default_timezone', config('app.timezone', 'UTC'));
return self::isValid($fallback) ? $fallback : 'UTC';
}
}