From d4779660c839b64291722f0f3b0feb43bc35e03e Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 9 Jul 2026 15:48:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(draw):=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E7=8E=A9=E6=B3=95provider=E5=8F=8A=E6=9C=AC=E5=9C=B0=E5=8C=96?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E6=97=B6=E5=8C=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Draw相关控制器添加provider_code和provider_name字段支持 - 允许手动录入开奖批次时指定provider_code - RNG开奖批次生成支持多provider,分别生成对应批次数据 - DrawPublishService和DrawManualResultService中支持基于provider_code管理开奖版本和发布流程 - DrawResultViewService调整,支持按provider汇总及筛选开奖结果 - Settlement处理逻辑调整,按provider区分待结算票据及批次,分开结算 - 结算期间管理相关服务支持使用站点本地结算时区计算开账建议和日期处理 - 增加AdminSite的settlement_timezone字段及相关请求验证和数据存取支持 - 优化AdminSettlementPeriod相关代码,防止存在未结清票据时关账 - Ticket明细返回接口添加结算时区信息,提升用户时间体验 - 多处查询排序和版本号处理改进,保证多provider数据正确顺序与一致性 --- .../AdminDrawResultBatchesIndexController.php | 1 + .../DrawManualResultBatchStoreController.php | 9 +- .../Draw/DrawResultBatchPublishController.php | 4 +- .../V1/Admin/Draw/DrawRngRunController.php | 18 + .../Api/V1/Player/MeController.php | 2 + .../V1/Ticket/TicketItemShowController.php | 4 +- .../AdminIntegrationSiteStoreRequest.php | 1 + .../AdminIntegrationSiteUpdateRequest.php | 1 + .../DrawManualResultBatchStoreRequest.php | 1 + app/Models/AdminSite.php | 1 + app/Models/DrawResultBatch.php | 2 + .../AgentSettlementPeriodCloseService.php | 10 +- .../AgentSettlementPeriodOpenService.php | 3 + .../SettlementPeriodOpenHintsService.php | 70 ++-- app/Services/Draw/DrawManualResultService.php | 25 +- app/Services/Draw/DrawPublishService.php | 13 +- app/Services/Draw/DrawResultViewService.php | 84 +++-- app/Services/Draw/DrawRngRunner.php | 110 ++++-- .../Integration/IntegrationSiteService.php | 2 + .../Settlement/SettlementOrchestrator.php | 315 +++++++++--------- app/Support/AdminDrawApiPresenter.php | 2 + app/Support/AdminIntegrationSitePresenter.php | 2 + app/Support/AgentSettlementPeriodWindow.php | 15 +- app/Support/AgentSettlementSiteTimezone.php | 48 +++ bootstrap/app.php | 33 +- config/lottery.php | 6 +- ...00_add_provider_to_draw_result_batches.php | 42 +++ ...add_settlement_timezone_to_admin_sites.php | 34 ++ lang/en/validation_business.php | 1 + lang/ne/validation_business.php | 1 + lang/zh/validation_business.php | 1 + .../AgentSettlementPeriodCloseP0FixesTest.php | 48 +++ .../AgentSettlementPeriodOpenHintsTest.php | 112 ++++++- tests/Feature/DrawPipelineTest.php | 160 ++++++++- tests/Feature/DrawResultsApiTest.php | 59 ++++ tests/Feature/PlayerFoundationTest.php | 2 + tests/Feature/RngSeedAuditTest.php | 7 +- tests/Feature/SettlementOrchestratorTest.php | 143 ++++++++ 38 files changed, 1121 insertions(+), 271 deletions(-) create mode 100644 app/Support/AgentSettlementSiteTimezone.php create mode 100644 database/migrations/2026_07_09_180000_add_provider_to_draw_result_batches.php create mode 100644 database/migrations/2026_07_09_190000_add_settlement_timezone_to_admin_sites.php diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php index 819a0ed..3e6b89b 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawResultBatchesIndexController.php @@ -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) { diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualResultBatchStoreController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualResultBatchStoreController.php index 99a32cf..5d730d8 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualResultBatchStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualResultBatchStoreController.php @@ -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, diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawResultBatchPublishController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawResultBatchPublishController.php index f46614d..d7016de 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/DrawResultBatchPublishController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawResultBatchPublishController.php @@ -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, ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawRngRunController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawRngRunController.php index ea4f231..0bcde70 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/DrawRngRunController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawRngRunController.php @@ -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(), ]); } } diff --git a/app/Http/Controllers/Api/V1/Player/MeController.php b/app/Http/Controllers/Api/V1/Player/MeController.php index e4ea557..f28d25d 100644 --- a/app/Http/Controllers/Api/V1/Player/MeController.php +++ b/app/Http/Controllers/Api/V1/Player/MeController.php @@ -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(), diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php index 58e6444..7c8b6c8 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php @@ -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; diff --git a/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php b/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php index 9cec67e..59a7af3 100644 --- a/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php +++ b/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php @@ -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'], diff --git a/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php b/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php index ca39941..fd7b470 100644 --- a/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminIntegrationSiteUpdateRequest.php @@ -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'], diff --git a/app/Http/Requests/Admin/DrawManualResultBatchStoreRequest.php b/app/Http/Requests/Admin/DrawManualResultBatchStoreRequest.php index 89bbfba..dcffb5e 100644 --- a/app/Http/Requests/Admin/DrawManualResultBatchStoreRequest.php +++ b/app/Http/Requests/Admin/DrawManualResultBatchStoreRequest.php @@ -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'], diff --git a/app/Models/AdminSite.php b/app/Models/AdminSite.php index 44dd9d6..e03d16f 100644 --- a/app/Models/AdminSite.php +++ b/app/Models/AdminSite.php @@ -17,6 +17,7 @@ final class AdminSite extends Model 'code', 'name', 'currency_code', + 'settlement_timezone', 'status', 'is_default', 'extra_json', diff --git a/app/Models/DrawResultBatch.php b/app/Models/DrawResultBatch.php index d679d5b..ba1f1ea 100644 --- a/app/Models/DrawResultBatch.php +++ b/app/Models/DrawResultBatch.php @@ -14,6 +14,8 @@ final class DrawResultBatch extends Model protected $fillable = [ 'draw_id', + 'provider_code', + 'provider_name', 'result_version', 'source_type', 'rng_seed_hash', diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php index 45d2e88..13b86d8 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php @@ -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(), diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php b/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php index f9dd310..33216f5 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodOpenService.php @@ -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') diff --git a/app/Services/AgentSettlement/SettlementPeriodOpenHintsService.php b/app/Services/AgentSettlement/SettlementPeriodOpenHintsService.php index c9889db..fd47517 100644 --- a/app/Services/AgentSettlement/SettlementPeriodOpenHintsService.php +++ b/app/Services/AgentSettlement/SettlementPeriodOpenHintsService.php @@ -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, * pending_activity_dates: list, + * orphan_activity_dates: list, * unpaid_bill_dates: list * } */ 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 $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 站点本地日历 `Y-m-d`(东八区,与后台开账日期选择一致) */ - private function expandPeriodToUtcDays(string $periodStart, string $periodEnd): array + /** @return list 站点本地日历 `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, pending_activity_dates: list, unpaid_bill_dates: list} */ + /** @return array{suggested_start: string, suggested_end: string, settlement_timezone: string, occupied_period_dates: list, pending_activity_dates: list, orphan_activity_dates: list, unpaid_bill_dates: list} */ 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' => [], ]; } diff --git a/app/Services/Draw/DrawManualResultService.php b/app/Services/Draw/DrawManualResultService.php index 6e68551..f067170 100644 --- a/app/Services/Draw/DrawManualResultService.php +++ b/app/Services/Draw/DrawManualResultService.php @@ -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 $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, diff --git a/app/Services/Draw/DrawPublishService.php b/app/Services/Draw/DrawPublishService.php index 40d443a..1e63e61 100644 --- a/app/Services/Draw/DrawPublishService.php +++ b/app/Services/Draw/DrawPublishService.php @@ -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'); } diff --git a/app/Services/Draw/DrawResultViewService.php b/app/Services/Draw/DrawResultViewService.php index dfe86e0..f548841 100644 --- a/app/Services/Draw/DrawResultViewService.php +++ b/app/Services/Draw/DrawResultViewService.php @@ -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|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(), + ]; + } } diff --git a/app/Services/Draw/DrawRngRunner.php b/app/Services/Draw/DrawRngRunner.php index 04174a7..000844a 100644 --- a/app/Services/Draw/DrawRngRunner.php +++ b/app/Services/Draw/DrawRngRunner.php @@ -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 + */ + 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', + ]]); + } } diff --git a/app/Services/Integration/IntegrationSiteService.php b/app/Services/Integration/IntegrationSiteService.php index 23d80e8..89f5923 100644 --- a/app/Services/Integration/IntegrationSiteService.php +++ b/app/Services/Integration/IntegrationSiteService.php @@ -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']) diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index 4f98671..fd192ad 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -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 $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 $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'], diff --git a/app/Support/AdminDrawApiPresenter.php b/app/Support/AdminDrawApiPresenter.php index 9f288fc..ed64dbf 100644 --- a/app/Support/AdminDrawApiPresenter.php +++ b/app/Support/AdminDrawApiPresenter.php @@ -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(), diff --git a/app/Support/AdminIntegrationSitePresenter.php b/app/Support/AdminIntegrationSitePresenter.php index f45f73d..d62fefb 100644 --- a/app/Support/AdminIntegrationSitePresenter.php +++ b/app/Support/AdminIntegrationSitePresenter.php @@ -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), diff --git a/app/Support/AgentSettlementPeriodWindow.php b/app/Support/AgentSettlementPeriodWindow.php index e3bc4fb..b806d72 100644 --- a/app/Support/AgentSettlementPeriodWindow.php +++ b/app/Support/AgentSettlementPeriodWindow.php @@ -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)) { diff --git a/app/Support/AgentSettlementSiteTimezone.php b/app/Support/AgentSettlementSiteTimezone.php new file mode 100644 index 0000000..ba6688b --- /dev/null +++ b/app/Support/AgentSettlementSiteTimezone.php @@ -0,0 +1,48 @@ +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'; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c02c308..2e205c6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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(); diff --git a/config/lottery.php b/config/lottery.php index 971de9b..1626c3c 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -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)), ]; diff --git a/database/migrations/2026_07_09_180000_add_provider_to_draw_result_batches.php b/database/migrations/2026_07_09_180000_add_provider_to_draw_result_batches.php new file mode 100644 index 0000000..112bd21 --- /dev/null +++ b/database/migrations/2026_07_09_180000_add_provider_to_draw_result_batches.php @@ -0,0 +1,42 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_07_09_190000_add_settlement_timezone_to_admin_sites.php b/database/migrations/2026_07_09_190000_add_settlement_timezone_to_admin_sites.php new file mode 100644 index 0000000..adab4b1 --- /dev/null +++ b/database/migrations/2026_07_09_190000_add_settlement_timezone_to_admin_sites.php @@ -0,0 +1,34 @@ +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'); + }); + } +}; diff --git a/lang/en/validation_business.php b/lang/en/validation_business.php index c98a696..0e21ede 100644 --- a/lang/en/validation_business.php +++ b/lang/en/validation_business.php @@ -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.', diff --git a/lang/ne/validation_business.php b/lang/ne/validation_business.php index 1a6129e..c336030 100644 --- a/lang/ne/validation_business.php +++ b/lang/ne/validation_business.php @@ -40,6 +40,7 @@ return [ 'period_overlaps_existing' => 'यो अवधि अवस्थित अवधिसँग ओभरल्याप हुन्छ। सुरु र अन्त्य मिति मिलाउनुहोस्।', 'period_not_found' => 'सेटलमेन्ट अवधि फेला परेन वा पहुँच छैन।', 'period_already_closed' => 'यो अवधि पहिले नै बन्द भइसकेको छ।', + 'period_has_unsettled_tickets' => 'यस अवधिमा अझै नसेटल टिकटहरू छन्। बन्द गर्नु अघि ड्र सेटलमेन्ट पूरा गर्नुहोस्।', 'share_snapshot_missing' => 'केही लेजर पङ्क्तिहरूमा शेयर स्न्यापसटहरू हरारहेका छन्। पहिले ड्र सेटलमेन्ट पूरा गर्नुहोस्।', 'completed' => 'यो सेटलमेन्ट अवधि बन्द भयो; बिल र भुक्तानी परिवर्तन गर्न सकिँदैन।', 'locked' => 'यो बिल जम्मा गरिएको छ र परिमार्जन गर्न सकिँदैन।', diff --git a/lang/zh/validation_business.php b/lang/zh/validation_business.php index cd3f952..57bb5c0 100644 --- a/lang/zh/validation_business.php +++ b/lang/zh/validation_business.php @@ -41,6 +41,7 @@ return [ 'period_overlaps_existing' => '账期时间与已有账期重叠,请调整起止日期。', 'period_not_found' => '账期不存在或无权访问。', 'period_already_closed' => '该账期已关账,请勿重复操作。', + 'period_has_unsettled_tickets' => '账期内仍有未结算注单,需先完成开奖结算后才能关账。', 'share_snapshot_missing' => '账期内存在缺少占成快照的流水,无法关账。请先完成开奖结算或联系技术支持。', 'completed' => '该账期已关账,无法再修改账单或登记收付。', 'locked' => '账单已锁定,无法修改金额或状态。', diff --git a/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php b/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php index a4a75da..b107a72 100644 --- a/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php +++ b/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php @@ -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'); diff --git a/tests/Feature/AgentSettlementPeriodOpenHintsTest.php b/tests/Feature/AgentSettlementPeriodOpenHintsTest.php index 8e356fa..e8f5322 100644 --- a/tests/Feature/AgentSettlementPeriodOpenHintsTest.php +++ b/tests/Feature/AgentSettlementPeriodOpenHintsTest.php @@ -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'); +}); diff --git a/tests/Feature/DrawPipelineTest.php b/tests/Feature/DrawPipelineTest.php index 57cc7b1..b04d78e 100644 --- a/tests/Feature/DrawPipelineTest.php +++ b/tests/Feature/DrawPipelineTest.php @@ -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(); }); diff --git a/tests/Feature/DrawResultsApiTest.php b/tests/Feature/DrawResultsApiTest.php index 483aaa9..e6bd8b8 100644 --- a/tests/Feature/DrawResultsApiTest.php +++ b/tests/Feature/DrawResultsApiTest.php @@ -1,6 +1,7 @@ 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'); +}); diff --git a/tests/Feature/PlayerFoundationTest.php b/tests/Feature/PlayerFoundationTest.php index bcdb32b..d7a4ff5 100644 --- a/tests/Feature/PlayerFoundationTest.php +++ b/tests/Feature/PlayerFoundationTest.php @@ -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', ], diff --git a/tests/Feature/RngSeedAuditTest.php b/tests/Feature/RngSeedAuditTest.php index a7feb36..3b95fd7 100644 --- a/tests/Feature/RngSeedAuditTest.php +++ b/tests/Feature/RngSeedAuditTest.php @@ -1,6 +1,7 @@ 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() diff --git a/tests/Feature/SettlementOrchestratorTest.php b/tests/Feature/SettlementOrchestratorTest.php index ad42009..5f4cecf 100644 --- a/tests/Feature/SettlementOrchestratorTest.php +++ b/tests/Feature/SettlementOrchestratorTest.php @@ -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([