feat(draw): 支持多玩法provider及本地化结算时区功能
- 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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -17,6 +17,7 @@ final class AdminSite extends Model
|
||||
'code',
|
||||
'name',
|
||||
'currency_code',
|
||||
'settlement_timezone',
|
||||
'status',
|
||||
'is_default',
|
||||
'extra_json',
|
||||
|
||||
@@ -14,6 +14,8 @@ final class DrawResultBatch extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'draw_id',
|
||||
'provider_code',
|
||||
'provider_name',
|
||||
'result_version',
|
||||
'source_type',
|
||||
'rng_seed_hash',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
48
app/Support/AgentSettlementSiteTimezone.php
Normal file
48
app/Support/AgentSettlementSiteTimezone.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -240,29 +240,32 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
});
|
||||
})
|
||||
->withSchedule(function (Schedule $schedule): void {
|
||||
$withSingleServerLock = static function ($event) {
|
||||
if (app()->environment('production')) {
|
||||
$event->onOneServer();
|
||||
}
|
||||
|
||||
return $event;
|
||||
};
|
||||
|
||||
/** 开奖时刻后尽快跑 RNG/冷静期,避免大厅在 0:00 卡住最多 1 分钟 */
|
||||
$schedule->command('lottery:draw-tick')
|
||||
$withSingleServerLock($schedule->command('lottery:draw-tick')
|
||||
->everyTenSeconds()
|
||||
->withoutOverlapping(expiresAt: 10)
|
||||
->onOneServer();
|
||||
$schedule->command('lottery:wallet-transfer-reconcile --lookback-hours=24 --stale-minutes=15 --limit=1000')
|
||||
->withoutOverlapping(expiresAt: 10));
|
||||
$withSingleServerLock($schedule->command('lottery:wallet-transfer-reconcile --lookback-hours=24 --stale-minutes=15 --limit=1000')
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
$schedule->command('lottery:ticket-pending-confirm-reconcile --stale-minutes=5 --limit=500')
|
||||
->withoutOverlapping());
|
||||
$withSingleServerLock($schedule->command('lottery:ticket-pending-confirm-reconcile --stale-minutes=5 --limit=500')
|
||||
->everyMinute()
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
$schedule->command('settlement:mark-overdue-bills --days=7')
|
||||
->withoutOverlapping());
|
||||
$withSingleServerLock($schedule->command('settlement:mark-overdue-bills --days=7')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping()
|
||||
->onOneServer();
|
||||
->withoutOverlapping());
|
||||
/** @see docs/01-界面文档.md §2.1 `draw.countdown` */
|
||||
if (config('lottery.realtime_hall_countdown', true)) {
|
||||
$schedule->command('lottery:hall-countdown')
|
||||
$withSingleServerLock($schedule->command('lottery:hall-countdown')
|
||||
->everySecond()
|
||||
->withoutOverlapping(expiresAt: 5)
|
||||
->onOneServer();
|
||||
->withoutOverlapping(expiresAt: 5));
|
||||
}
|
||||
})
|
||||
->create();
|
||||
|
||||
@@ -16,6 +16,10 @@ return [
|
||||
|
||||
'default_currency' => env('LOTTERY_DEFAULT_CURRENCY', 'NPR'),
|
||||
|
||||
'settlement' => [
|
||||
'default_timezone' => env('LOTTERY_SETTLEMENT_TIMEZONE', env('APP_TIMEZONE', 'UTC')),
|
||||
],
|
||||
|
||||
/*
|
||||
| lottery_settings 表读缓存 TTL(秒)。调小可更快看到后台改值,调大减 DB 压力。
|
||||
*/
|
||||
@@ -127,6 +131,6 @@ return [
|
||||
'draw_tick_stage_warn_threshold_ms' => max(50, (int) env('LOTTERY_DRAW_TICK_STAGE_WARN_THRESHOLD_MS', 500)),
|
||||
'draw_tick_settle_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_SETTLE_LIMIT', 3)),
|
||||
'draw_tick_finalize_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_FINALIZE_LIMIT', 5)),
|
||||
'draw_tick_rng_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_RNG_LIMIT', 3)),
|
||||
'draw_tick_rng_limit' => max(1, (int) env('LOTTERY_DRAW_TICK_RNG_LIMIT', 10)),
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BetProvider;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('draw_result_batches', function (Blueprint $table): void {
|
||||
$table->dropUnique('uk_draw_result_batches_draw_version');
|
||||
});
|
||||
|
||||
Schema::table('draw_result_batches', function (Blueprint $table): void {
|
||||
$table->string('provider_code', 32)->default(BetProvider::DEFAULT_CODE)->after('draw_id');
|
||||
$table->string('provider_name', 64)->default('Singapore')->after('provider_code');
|
||||
$table->unique(['draw_id', 'provider_code', 'result_version'], 'uk_draw_result_batches_draw_provider_version');
|
||||
$table->index(['draw_id', 'provider_code', 'status'], 'idx_draw_result_batches_draw_provider_status');
|
||||
});
|
||||
|
||||
DB::table('draw_result_batches')->update([
|
||||
'provider_code' => BetProvider::DEFAULT_CODE,
|
||||
'provider_name' => 'Singapore',
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('draw_result_batches', function (Blueprint $table): void {
|
||||
$table->dropUnique('uk_draw_result_batches_draw_provider_version');
|
||||
$table->dropIndex('idx_draw_result_batches_draw_provider_status');
|
||||
$table->dropColumn(['provider_code', 'provider_name']);
|
||||
});
|
||||
|
||||
Schema::table('draw_result_batches', function (Blueprint $table): void {
|
||||
$table->unique(['draw_id', 'result_version'], 'uk_draw_result_batches_draw_version');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('admin_sites', function (Blueprint $table): void {
|
||||
$table->string('settlement_timezone', 64)
|
||||
->default('UTC')
|
||||
->after('currency_code');
|
||||
});
|
||||
|
||||
$defaultTimezone = (string) config('lottery.settlement.default_timezone', config('app.timezone', 'UTC'));
|
||||
if (! in_array($defaultTimezone, timezone_identifiers_list(), true)) {
|
||||
$defaultTimezone = 'UTC';
|
||||
}
|
||||
|
||||
DB::table('admin_sites')->update([
|
||||
'settlement_timezone' => $defaultTimezone,
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('admin_sites', function (Blueprint $table): void {
|
||||
$table->dropColumn('settlement_timezone');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -40,6 +40,7 @@ return [
|
||||
'period_overlaps_existing' => 'This period overlaps an existing one. Adjust the start and end dates.',
|
||||
'period_not_found' => 'Settlement period not found or not accessible.',
|
||||
'period_already_closed' => 'This period is already closed.',
|
||||
'period_has_unsettled_tickets' => 'This period still has unsettled tickets. Complete draw settlement before closing.',
|
||||
'share_snapshot_missing' => 'Some ledger rows are missing share snapshots. Complete draw settlement first.',
|
||||
'completed' => 'This settlement period is closed; bills and payments cannot be changed.',
|
||||
'locked' => 'This bill is locked and cannot be modified.',
|
||||
|
||||
@@ -40,6 +40,7 @@ return [
|
||||
'period_overlaps_existing' => 'यो अवधि अवस्थित अवधिसँग ओभरल्याप हुन्छ। सुरु र अन्त्य मिति मिलाउनुहोस्।',
|
||||
'period_not_found' => 'सेटलमेन्ट अवधि फेला परेन वा पहुँच छैन।',
|
||||
'period_already_closed' => 'यो अवधि पहिले नै बन्द भइसकेको छ।',
|
||||
'period_has_unsettled_tickets' => 'यस अवधिमा अझै नसेटल टिकटहरू छन्। बन्द गर्नु अघि ड्र सेटलमेन्ट पूरा गर्नुहोस्।',
|
||||
'share_snapshot_missing' => 'केही लेजर पङ्क्तिहरूमा शेयर स्न्यापसटहरू हरारहेका छन्। पहिले ड्र सेटलमेन्ट पूरा गर्नुहोस्।',
|
||||
'completed' => 'यो सेटलमेन्ट अवधि बन्द भयो; बिल र भुक्तानी परिवर्तन गर्न सकिँदैन।',
|
||||
'locked' => 'यो बिल जम्मा गरिएको छ र परिमार्जन गर्न सकिँदैन।',
|
||||
|
||||
@@ -41,6 +41,7 @@ return [
|
||||
'period_overlaps_existing' => '账期时间与已有账期重叠,请调整起止日期。',
|
||||
'period_not_found' => '账期不存在或无权访问。',
|
||||
'period_already_closed' => '该账期已关账,请勿重复操作。',
|
||||
'period_has_unsettled_tickets' => '账期内仍有未结算注单,需先完成开奖结算后才能关账。',
|
||||
'share_snapshot_missing' => '账期内存在缺少占成快照的流水,无法关账。请先完成开奖结算或联系技术支持。',
|
||||
'completed' => '该账期已关账,无法再修改账单或登记收付。',
|
||||
'locked' => '账单已锁定,无法修改金额或状态。',
|
||||
|
||||
@@ -397,6 +397,54 @@ test('period close succeeds with no share ledger rows in window', function (): v
|
||||
->toBe(0);
|
||||
});
|
||||
|
||||
test('period close fails when tickets in the window are still unsettled', function (): void {
|
||||
['site_id' => $siteId, 'site_code' => $siteCode, 'root_id' => $rootId] = createSiteWithRoot('unsettled-close');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'p-unsettled-close',
|
||||
'username' => 'unsettled_close',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'funding_mode' => 'credit',
|
||||
'auth_source' => 'lottery_native',
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
$ticketItemId = createTicketItemForPlayer($player, 'UNSETTLED-CLOSE');
|
||||
DB::table('ticket_items')->where('id', $ticketItemId)->update([
|
||||
'status' => 'pending_draw',
|
||||
'settled_at' => null,
|
||||
'agent_settled_at' => null,
|
||||
'created_at' => '2026-06-03 12:00:00',
|
||||
'updated_at' => '2026-06-03 12:00:00',
|
||||
]);
|
||||
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => '2026-06-01 00:00:00',
|
||||
'period_end' => '2026-06-07 23:59:59',
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$caught = null;
|
||||
try {
|
||||
app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$caught = $e;
|
||||
}
|
||||
|
||||
expect($caught)->toBeInstanceOf(\Illuminate\Validation\ValidationException::class);
|
||||
expect($caught?->errors()['period'][0] ?? null)->toBe('period_has_unsettled_tickets');
|
||||
expect((string) DB::table('settlement_periods')->where('id', $periodId)->value('status'))
|
||||
->toBe('open');
|
||||
expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count())
|
||||
->toBe(0);
|
||||
});
|
||||
|
||||
test('period close does not dispatch rebates from other sites', function (): void {
|
||||
$siteA = createSiteWithRoot('rebate-site-a');
|
||||
$siteB = createSiteWithRoot('rebate-site-b');
|
||||
|
||||
@@ -190,8 +190,116 @@ test('settlement period open hints does not suggest range overlapping occupied p
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/v1/admin/settlement-periods/open-hints?admin_site_id='.$siteId)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.suggested_start', '')
|
||||
->assertJsonPath('data.suggested_end', '')
|
||||
->assertJsonPath('data.suggested_start', '2026-07-01')
|
||||
->assertJsonPath('data.suggested_end', '2026-07-08')
|
||||
->assertJsonFragment(['2026-06-01'])
|
||||
->assertJsonFragment(['2026-06-30']);
|
||||
});
|
||||
|
||||
test('settlement period open hints uses site timezone for orphan activity dates', function (): void {
|
||||
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
|
||||
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
|
||||
|
||||
DB::table('admin_sites')->where('id', $siteId)->update([
|
||||
'settlement_timezone' => 'Asia/Kathmandu',
|
||||
]);
|
||||
|
||||
DB::table('settlement_periods')->insert([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => '2026-07-08 18:15:00',
|
||||
'period_end' => '2026-07-09 18:14:59',
|
||||
'status' => 'closed',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'hints-orphan-player',
|
||||
'username' => 'hints_orphan_player',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => 'DRAW-HINTS-ORPHAN',
|
||||
'business_date' => '2026-07-09',
|
||||
'sequence_no' => 1,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'current_result_version' => 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORD-HINTS-ORPHAN',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 0,
|
||||
'status' => 'confirmed',
|
||||
'submit_source' => 'h5',
|
||||
'client_trace_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$itemId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => 'T-HINTS-ORPHAN',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'original_number' => null,
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'big',
|
||||
'dimension' => 2,
|
||||
'digit_slot' => null,
|
||||
'bet_mode' => null,
|
||||
'unit_bet_amount' => 100,
|
||||
'total_bet_amount' => 100,
|
||||
'rebate_rate_snapshot' => 0,
|
||||
'commission_rate_snapshot' => 0,
|
||||
'actual_deduct_amount' => 100,
|
||||
'odds_snapshot_json' => null,
|
||||
'rule_snapshot_json' => null,
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 0,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'settled_lose',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $itemId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $rootId,
|
||||
'agent_path' => json_encode([$rootId]),
|
||||
'share_snapshot' => json_encode(['total_shares' => [$siteCode => 100]]),
|
||||
'game_win_loss' => 100,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => 100,
|
||||
'allocations_json' => json_encode([]),
|
||||
'settled_at' => '2026-07-09 18:00:00',
|
||||
'settlement_period_id' => null,
|
||||
'created_at' => '2026-07-09 18:00:00',
|
||||
'updated_at' => '2026-07-09 18:00:00',
|
||||
]);
|
||||
|
||||
$hints = app(\App\Services\AgentSettlement\SettlementPeriodOpenHintsService::class)->hints($siteId);
|
||||
|
||||
expect($hints['settlement_timezone'])->toBe('Asia/Kathmandu')
|
||||
->and($hints['occupied_period_dates'])->toContain('2026-07-09')
|
||||
->and($hints['occupied_period_dates'])->not->toContain('2026-07-08')
|
||||
->and($hints['pending_activity_dates'])->toContain('2026-07-09')
|
||||
->and($hints['orphan_activity_dates'])->toContain('2026-07-09');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use Carbon\Carbon;
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\BetProvider;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\RiskPool;
|
||||
@@ -466,6 +467,60 @@ test('admin can manually trigger rng for closed draw', function (): void {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('rng creates independent result batches for every enabled provider', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 12:22:00', 'UTC'));
|
||||
|
||||
foreach ([['SG', 'Singapore', 10], ['MY', 'Malaysia', 20], ['TH', 'Thailand', 30]] as [$code, $name, $sort]) {
|
||||
BetProvider::query()->updateOrCreate(
|
||||
['code' => $code],
|
||||
['name' => $name, 'is_enabled' => true, 'sort_order' => $sort],
|
||||
);
|
||||
}
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-123',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 123,
|
||||
'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_provider_admin',
|
||||
'name' => 'Draw Rng Provider 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()
|
||||
->assertJsonCount(3, 'data.batches');
|
||||
|
||||
$batches = DrawResultBatch::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->with('items')
|
||||
->orderBy('provider_code')
|
||||
->get();
|
||||
|
||||
expect($batches->pluck('provider_code')->all())->toBe(['MY', 'SG', 'TH']);
|
||||
expect($batches->pluck('rng_seed_hash')->unique()->count())->toBe(3);
|
||||
expect($batches->map(fn (DrawResultBatch $batch): ?string => $batch->items->firstWhere('prize_type', 'first')?->number_4d)->unique()->count())->toBe(3);
|
||||
|
||||
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'));
|
||||
|
||||
@@ -500,6 +555,8 @@ test('draw tick moves open draw to closing when close_time passed before draw_ti
|
||||
test('draw tick rng publishes result when manual review disabled', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => false]);
|
||||
LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核');
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:05:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->subMinute();
|
||||
@@ -527,9 +584,11 @@ test('draw tick rng publishes result when manual review disabled', function ():
|
||||
expect($draw->current_result_version)->toBe(1);
|
||||
expect($draw->cooling_end_time)->not->toBeNull();
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
expect($batch->status)->toBe(DrawResultBatchStatus::Published->value);
|
||||
expect($batch->items()->count())->toBe(23);
|
||||
$batches = DrawResultBatch::query()->where('draw_id', $draw->id)->orderBy('provider_code')->get();
|
||||
expect($batches)->toHaveCount(2);
|
||||
expect($batches->pluck('provider_code')->all())->toBe(['MY', 'SG']);
|
||||
expect($batches->every(fn (DrawResultBatch $batch): bool => $batch->status === DrawResultBatchStatus::Published->value))->toBeTrue();
|
||||
expect($batches->every(fn (DrawResultBatch $batch): bool => $batch->items()->count() === 23))->toBeTrue();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -537,6 +596,8 @@ test('draw tick rng publishes result when manual review disabled', function ():
|
||||
test('draw tick rng awaits manual publish when review enabled', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
LotterySettings::put('draw.require_manual_review', true, 'draw', 'RNG 开奖后是否必须进入人工审核');
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:06:00', 'UTC'));
|
||||
|
||||
$drawTime = now()->copy()->subMinute();
|
||||
@@ -562,8 +623,9 @@ test('draw tick rng awaits manual publish when review enabled', function (): voi
|
||||
$drawRow->refresh();
|
||||
expect($drawRow->status)->toBe(DrawStatus::Review->value);
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $drawRow->id)->firstOrFail();
|
||||
expect($batch->status)->toBe(DrawResultBatchStatus::PendingReview->value);
|
||||
$batches = DrawResultBatch::query()->where('draw_id', $drawRow->id)->orderBy('provider_code')->get();
|
||||
expect($batches)->toHaveCount(2);
|
||||
expect($batches->every(fn (DrawResultBatch $batch): bool => $batch->status === DrawResultBatchStatus::PendingReview->value))->toBeTrue();
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'draw_auditor',
|
||||
@@ -575,14 +637,23 @@ test('draw tick rng awaits manual publish when review enabled', function (): voi
|
||||
grantSuperAdminRole($admin);
|
||||
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
|
||||
$sgBatch = $batches->firstWhere('provider_code', 'SG');
|
||||
$myBatch = $batches->firstWhere('provider_code', 'MY');
|
||||
expect($sgBatch)->not->toBeNull()
|
||||
->and($myBatch)->not->toBeNull();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$batch->id}/publish")
|
||||
->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$sgBatch->id}/publish")
|
||||
->assertOk();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$drawRow->id}/result-batches/{$myBatch->id}/publish")
|
||||
->assertOk();
|
||||
|
||||
$drawRow->refresh();
|
||||
$batch->refresh();
|
||||
$batches = DrawResultBatch::query()->where('draw_id', $drawRow->id)->get();
|
||||
expect($drawRow->status)->toBe(DrawStatus::Cooldown->value);
|
||||
expect($batch->status)->toBe(DrawResultBatchStatus::Published->value);
|
||||
expect($batches->every(fn (DrawResultBatch $batch): bool => $batch->status === DrawResultBatchStatus::Published->value))->toBeTrue();
|
||||
expect($drawRow->current_result_version)->toBe(1);
|
||||
expect($drawRow->cooling_end_time)->not->toBeNull();
|
||||
|
||||
@@ -644,6 +715,73 @@ test('admin can create manual result batch with 23 numbers for review', function
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can create and publish provider specific manual result batches for one draw', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:22:00', 'UTC'));
|
||||
BetProvider::query()->create(['code' => 'MY', 'name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-222',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 222,
|
||||
'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' => 'provider_manual_draw_admin',
|
||||
'name' => 'Provider 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 + 101), 4, '0', STR_PAD_LEFT),
|
||||
];
|
||||
}
|
||||
|
||||
$sgBatchId = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['provider_code' => 'SG', 'items' => $items])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.batch.provider_code', 'SG')
|
||||
->json('data.batch.id');
|
||||
|
||||
$myBatchId = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['provider_code' => 'MY', 'items' => $items])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.batch.provider_code', 'MY')
|
||||
->json('data.batch.id');
|
||||
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->where('status', DrawResultBatchStatus::PendingReview->value)->count())->toBe(2);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches/{$sgBatchId}/publish")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.provider_code', 'SG');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/result-batches/{$myBatchId}/publish")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.provider_code', 'MY');
|
||||
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->where('status', DrawResultBatchStatus::Published->value)->count())->toBe(2);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('admin can discard pending manual result batch and draw returns to closed', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:25:00', 'UTC'));
|
||||
|
||||
@@ -707,7 +845,11 @@ test('admin can discard pending manual result batch and draw returns to closed',
|
||||
});
|
||||
|
||||
test('admin can reopen cooldown draw for a replacement result batch', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => false]);
|
||||
LotterySettings::put('draw.require_manual_review', false, 'draw', 'RNG 开奖后是否必须进入人工审核');
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-09 14:30:00', 'UTC'));
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-230',
|
||||
@@ -782,7 +924,7 @@ test('admin can reopen cooldown draw for a replacement result batch', function (
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->current_result_version)->toBe(2);
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(2);
|
||||
expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(3);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\BetProvider;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
@@ -132,3 +133,61 @@ test('draw result show includes neighbor draw numbers', function (): void {
|
||||
->assertJsonPath('data.previous_draw_no', '20260509-100')
|
||||
->assertJsonPath('data.next_draw_no', '20260509-102');
|
||||
});
|
||||
|
||||
test('draw results include all published provider batches for the same draw', function (): void {
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260509-201',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 201,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'start_time' => now()->subHour(),
|
||||
'close_time' => now()->subMinutes(45),
|
||||
'draw_time' => now()->subMinutes(30),
|
||||
'cooling_end_time' => now()->addMinutes(10),
|
||||
'result_source' => 'manual',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
foreach ([['SG', 'Singapore', '1111'], ['MY', 'Malaysia', '2222']] as [$providerCode, $providerName, $firstNumber]) {
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => $providerCode,
|
||||
'provider_name' => $providerName,
|
||||
'result_version' => 1,
|
||||
'source_type' => 'manual',
|
||||
'rng_seed_hash' => null,
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => 'first',
|
||||
'prize_index' => 0,
|
||||
'number_4d' => $firstNumber,
|
||||
'suffix_3d' => substr($firstNumber, -3),
|
||||
'suffix_2d' => substr($firstNumber, -2),
|
||||
'head_digit' => (int) substr($firstNumber, 0, 1),
|
||||
'tail_digit' => (int) substr($firstNumber, 3, 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->getJson('/api/v1/draw/results/20260509-201')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.provider_code', 'SG')
|
||||
->assertJsonPath('data.results.1st', '1111')
|
||||
->assertJsonCount(2, 'data.provider_results')
|
||||
->assertJsonPath('data.provider_results.0.provider_code', 'SG')
|
||||
->assertJsonPath('data.provider_results.0.results.1st', '1111')
|
||||
->assertJsonPath('data.provider_results.1.provider_code', 'MY')
|
||||
->assertJsonPath('data.provider_results.1.results.1st', '2222');
|
||||
});
|
||||
|
||||
@@ -28,9 +28,11 @@ test('player me returns profile with dev bearer', function () {
|
||||
->assertJsonPath('data.id', $player->id)
|
||||
->assertJsonPath('data.site_player_id', 'uid-42')
|
||||
->assertJsonPath('data.username', 'alice')
|
||||
->assertJsonPath('data.settlement_timezone', 'UTC')
|
||||
->assertJsonPath('data.locale', 'zh')
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'settlement_timezone',
|
||||
'last_login_at',
|
||||
'created_at',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\BetProvider;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\DrawResultBatch;
|
||||
@@ -33,6 +34,8 @@ test('rng seed encrypt decrypt roundtrip preserves hex', function (): void {
|
||||
|
||||
test('admin rng run stores encrypted seed and passes batch audit verification', function (): void {
|
||||
config(['lottery.draw.require_manual_review' => true]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260525-rng-audit',
|
||||
@@ -63,7 +66,9 @@ test('admin rng run stores encrypted seed and passes batch audit verification',
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/rng")
|
||||
->assertOk();
|
||||
|
||||
$batch = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
$batches = DrawResultBatch::query()->where('draw_id', $draw->id)->orderBy('provider_code')->get();
|
||||
expect($batches)->toHaveCount(2);
|
||||
$batch = $batches->firstOrFail();
|
||||
|
||||
expect($batch->source_type)->toBe('rng')
|
||||
->and($batch->rng_seed_hash)->not->toBeEmpty()
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\BetProvider;
|
||||
use App\Models\WalletTxn;
|
||||
use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\TicketCombination;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
@@ -145,6 +147,147 @@ test('settlement pays big winner and marks ticket settled', function (): void {
|
||||
expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('settlement matches ticket items against their provider result batch', function (): void {
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->create(['code' => 'MY', 'name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'test',
|
||||
'site_player_id' => 'provider-settle-p',
|
||||
'username' => 'provider_settle_p',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260511-901',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 901,
|
||||
'status' => DrawStatus::Settling->value,
|
||||
'start_time' => now()->subMinutes(10),
|
||||
'close_time' => now()->subMinutes(2),
|
||||
'draw_time' => now()->subMinute(),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => 'manual',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
$order = TicketOrder::query()->create([
|
||||
'order_no' => 'provider-settle-order',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 20_000,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 20_000,
|
||||
'total_estimated_payout' => 200_000,
|
||||
'status' => 'placed',
|
||||
'submit_source' => 'test',
|
||||
'client_trace_id' => 'provider-settle-trace',
|
||||
]);
|
||||
|
||||
$snapshot = [['prize_scope' => 'first', 'odds_value' => 100_000]];
|
||||
$sgItem = TicketItem::query()->create([
|
||||
'ticket_no' => 'PROVIDER-SG-1',
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => 'SG',
|
||||
'provider_name' => 'Singapore',
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'pos_4a',
|
||||
'bet_mode' => 'unit',
|
||||
'unit_bet_amount' => 10_000,
|
||||
'total_bet_amount' => 10_000,
|
||||
'actual_deduct_amount' => 10_000,
|
||||
'odds_snapshot_json' => $snapshot,
|
||||
'rule_snapshot_json' => [],
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 100_000,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'pending_draw',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
]);
|
||||
$myItem = TicketItem::query()->create([
|
||||
'ticket_no' => 'PROVIDER-MY-1',
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => 'MY',
|
||||
'provider_name' => 'Malaysia',
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'pos_4a',
|
||||
'bet_mode' => 'unit',
|
||||
'unit_bet_amount' => 10_000,
|
||||
'total_bet_amount' => 10_000,
|
||||
'actual_deduct_amount' => 10_000,
|
||||
'odds_snapshot_json' => $snapshot,
|
||||
'rule_snapshot_json' => [],
|
||||
'combination_count' => 1,
|
||||
'estimated_max_payout' => 100_000,
|
||||
'risk_locked_amount' => 0,
|
||||
'status' => 'pending_draw',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
]);
|
||||
|
||||
foreach ([$sgItem, $myItem] as $item) {
|
||||
TicketCombination::query()->create([
|
||||
'ticket_item_id' => $item->id,
|
||||
'combination_no' => 1,
|
||||
'number_4d' => '1234',
|
||||
'bet_amount' => 10_000,
|
||||
'estimated_payout' => 100_000,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([['SG', '9999'], ['MY', '1234']] as [$providerCode, $firstNumber]) {
|
||||
$batch = DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => $providerCode,
|
||||
'provider_name' => $providerCode === 'MY' ? 'Malaysia' : 'Singapore',
|
||||
'result_version' => 1,
|
||||
'source_type' => 'manual',
|
||||
'rng_seed_hash' => null,
|
||||
'raw_seed_encrypted' => null,
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'created_by' => null,
|
||||
'confirmed_by' => null,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
foreach (DrawPrizeLayout::slots() as $slot) {
|
||||
$num = $slot['prize_type'] === 'first' ? $firstNumber : '5678';
|
||||
DrawResultItem::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $batch->id,
|
||||
'prize_type' => $slot['prize_type'],
|
||||
'prize_index' => $slot['prize_index'],
|
||||
'number_4d' => $num,
|
||||
'suffix_3d' => substr($num, -3),
|
||||
'suffix_2d' => substr($num, -2),
|
||||
'head_digit' => (int) substr($num, 0, 1),
|
||||
'tail_digit' => (int) substr($num, 3, 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||
|
||||
expect($sgItem->fresh()->status)->toBe('settled_lose');
|
||||
expect((int) $sgItem->fresh()->win_amount)->toBe(0);
|
||||
expect($myItem->fresh()->status)->toBe('pending_payout');
|
||||
expect((int) $myItem->fresh()->win_amount)->toBe(100_000);
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('admin settlement requires review before payout and can export report', function (): void {
|
||||
$uniq = bin2hex(random_bytes(4));
|
||||
$player = Player::query()->create([
|
||||
|
||||
Reference in New Issue
Block a user