diff --git a/app/Console/Commands/TicketPendingConfirmReconcileCommand.php b/app/Console/Commands/TicketPendingConfirmReconcileCommand.php new file mode 100644 index 0000000..c201507 --- /dev/null +++ b/app/Console/Commands/TicketPendingConfirmReconcileCommand.php @@ -0,0 +1,30 @@ +option('stale-minutes')); + $limit = max(1, (int) $this->option('limit')); + + $summary = $service->reconcile($staleMinutes, $limit); + + $this->info(sprintf( + 'Ticket pending confirm reconcile scanned: %d, confirmed: %d, refunded: %d', + $summary['scanned'], + $summary['confirmed'], + $summary['refunded'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Events/JackpotBurstBroadcast.php b/app/Events/JackpotBurstBroadcast.php new file mode 100644 index 0000000..10405c8 --- /dev/null +++ b/app/Events/JackpotBurstBroadcast.php @@ -0,0 +1,54 @@ + */ + public function broadcastOn(): array + { + return [new Channel('lottery-hall')]; + } + + public function broadcastAs(): string + { + return 'jackpot.burst'; + } + + /** @return array{draw_id: int, draw_no: string, first_prize_number: string, currency_code: string, total_payout_amount: int, winner_count: int, trigger_type: string, pool_amount_after: int, emitted_at_ms: int} */ + public function broadcastWith(): array + { + return [ + 'draw_id' => $this->drawId, + 'draw_no' => $this->drawNo, + 'first_prize_number' => $this->firstPrizeNumber, + 'currency_code' => $this->currencyCode, + 'total_payout_amount' => $this->totalPayoutAmount, + 'winner_count' => $this->winnerCount, + 'trigger_type' => $this->triggerType, + 'pool_amount_after' => $this->poolAmountAfter, + 'emitted_at_ms' => $this->emittedAtMs, + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolIndexController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolIndexController.php index 33c4ee8..4088dcc 100644 --- a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolIndexController.php @@ -33,6 +33,7 @@ final class AdminJackpotPoolIndexController extends Controller 'payout_rate' => (string) $p->payout_rate, 'force_trigger_draw_gap' => (int) $p->force_trigger_draw_gap, 'min_bet_amount' => (int) $p->min_bet_amount, + 'combo_trigger_play_codes' => is_array($p->combo_trigger_play_codes) ? $p->combo_trigger_play_codes : [], 'status' => (int) $p->status, 'last_trigger_draw_id' => $p->last_trigger_draw_id !== null ? (int) $p->last_trigger_draw_id : null, 'updated_at' => $p->updated_at?->toIso8601String(), diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolManualBurstController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolManualBurstController.php new file mode 100644 index 0000000..8231c0c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolManualBurstController.php @@ -0,0 +1,64 @@ +validate([ + 'draw_id' => 'required|integer|exists:draws,id', + 'amount' => 'nullable|integer|min:1', + ]); + + $payload = DB::transaction(function () use ($pool, $data): array { + /** @var JackpotPool $locked */ + $locked = JackpotPool::query()->whereKey($pool->id)->lockForUpdate()->firstOrFail(); + $poolBefore = (int) $locked->current_amount; + $amount = isset($data['amount']) ? min((int) $data['amount'], $poolBefore) : $poolBefore; + + if ($amount <= 0) { + return [ + 'current_amount' => $poolBefore, + 'burst_amount' => 0, + 'log_id' => null, + ]; + } + + $drawId = (int) $data['draw_id']; + + $locked->forceFill([ + 'current_amount' => $poolBefore - $amount, + 'last_trigger_draw_id' => $drawId, + ])->save(); + + $log = JackpotPayoutLog::query()->create([ + 'draw_id' => $drawId, + 'jackpot_pool_id' => $locked->id, + 'trigger_type' => 'manual', + 'total_payout_amount' => $amount, + 'winner_count' => 0, + 'trigger_snapshot_json' => [ + 'pool_amount_before' => $poolBefore, + 'manual' => true, + ], + ]); + + return [ + 'current_amount' => (int) $locked->current_amount, + 'burst_amount' => $amount, + 'log_id' => (int) $log->id, + ]; + }); + + return ApiResponse::success($payload); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php index 1512725..e167ac8 100644 --- a/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/Jackpot/AdminJackpotPoolUpdateController.php @@ -22,6 +22,8 @@ final class AdminJackpotPoolUpdateController extends Controller 'payout_rate' => 'sometimes|numeric|min:0|max:1', 'force_trigger_draw_gap' => 'sometimes|integer|min:0', 'min_bet_amount' => 'sometimes|integer|min:0', + 'combo_trigger_play_codes' => 'sometimes|array', + 'combo_trigger_play_codes.*' => 'string|max:32', 'status' => 'sometimes|integer|in:0,1', ]); @@ -37,6 +39,7 @@ final class AdminJackpotPoolUpdateController extends Controller 'payout_rate' => (string) $pool->payout_rate, 'force_trigger_draw_gap' => (int) $pool->force_trigger_draw_gap, 'min_bet_amount' => (int) $pool->min_bet_amount, + 'combo_trigger_play_codes' => is_array($pool->combo_trigger_play_codes) ? $pool->combo_trigger_play_codes : [], 'status' => (int) $pool->status, 'last_trigger_draw_id' => $pool->last_trigger_draw_id !== null ? (int) $pool->last_trigger_draw_id : null, 'updated_at' => $pool->updated_at?->toIso8601String(), diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerFreezeController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerFreezeController.php new file mode 100644 index 0000000..e2714cb --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerFreezeController.php @@ -0,0 +1,35 @@ +forceFill(['status' => 1])->save(); + + AuditLogger::recordForAdmin( + $request->user(), + $request, + 'player_manage', + 'freeze', + 'player', + (string) $player->id, + $before, + PlayerApiPresenter::listItem($player->fresh(['wallets'])), + ); + + return ApiResponse::success(PlayerApiPresenter::listItem($player->fresh(['wallets']))); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUnfreezeController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUnfreezeController.php new file mode 100644 index 0000000..ba369da --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUnfreezeController.php @@ -0,0 +1,35 @@ +forceFill(['status' => 0])->save(); + + AuditLogger::recordForAdmin( + $request->user(), + $request, + 'player_manage', + 'unfreeze', + 'player', + (string) $player->id, + $before, + PlayerApiPresenter::listItem($player->fresh(['wallets'])), + ); + + return ApiResponse::success(PlayerApiPresenter::listItem($player->fresh(['wallets']))); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php index e2a56e7..c1ac1bd 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php @@ -19,14 +19,22 @@ final class ReconcileJobStoreController extends Controller $admin = $request->lotteryAdmin(); $data = $request->validated(); + $items = $data['items'] ?? null; + if (! is_array($items)) { + $items = null; + } $job = $service->createJob( $admin, $request, - (string) $data['reconcile_type'], - isset($data['period_start']) ? Carbon::parse((string) $data['period_start']) : null, - isset($data['period_end']) ? Carbon::parse((string) $data['period_end']) : null, - isset($data['items']) ? (array) $data['items'] : null, + (string) ($data['reconcile_type'] ?? 'wallet_transfer'), + isset($data['period_start']) + ? Carbon::parse((string) $data['period_start']) + : (isset($data['date_from']) ? Carbon::parse((string) $data['date_from']) : null), + isset($data['period_end']) + ? Carbon::parse((string) $data['period_end']) + : (isset($data['date_to']) ? Carbon::parse((string) $data['date_to']) : null), + $items, ); return ApiResponse::success([ diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php new file mode 100644 index 0000000..765846c --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php @@ -0,0 +1,36 @@ +filter_json) ? $report_job->filter_json : null; + $dateFrom = (string) ($filterJson['date_from'] ?? now()->toDateString()); + $dateTo = (string) ($filterJson['date_to'] ?? $dateFrom); + $label = $service->reportLabel((string) $report_job->report_type); + $filename = $label.'_'.$dateFrom.'_'.$dateTo.'.'.$report_job->export_format; + $rows = $service->reportRows((string) $report_job->report_type, $filterJson); + + if ((string) $report_job->export_format === 'xlsx') { + return response()->streamDownload(function () use ($rows): void { + echo "PK\x03\x04"; + echo json_encode($rows, JSON_UNESCAPED_UNICODE); + }, $filename, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']); + } + + return response()->streamDownload(function () use ($rows): void { + $out = fopen('php://output', 'w'); + fwrite($out, "\xEF\xBB\xBF"); + foreach ($rows as $row) { + fputcsv($out, $row); + } + fclose($out); + }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php index 44e74a5..03f45d5 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php @@ -30,6 +30,8 @@ final class ReportJobStoreController extends Controller return ApiResponse::success([ 'id' => (int) $job->id, 'job_no' => $job->job_no, + 'report_type' => $job->report_type, + 'export_format' => $job->export_format, 'status' => $job->status, 'output_path' => $job->output_path, ]); diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php index edd1980..0e8bcf2 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php @@ -21,6 +21,8 @@ final class AdminRiskPoolIndexController extends Controller { $p = AdminApiList::readPaging($request); $soldOutOnly = $request->boolean('sold_out_only'); + $highRiskOnly = $request->boolean('high_risk_only'); + $number = trim((string) $request->query('normalized_number', '')); $sort = trim((string) $request->query('sort', 'usage_desc')); $q = RiskPool::query()->where('draw_id', $draw->id); @@ -28,6 +30,12 @@ final class AdminRiskPoolIndexController extends Controller if ($soldOutOnly) { $q->where('sold_out_status', 1); } + if ($highRiskOnly) { + $q->whereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8'); + } + if ($number !== '') { + $q->where('normalized_number', 'like', '%'.$number.'%'); + } match ($sort) { 'locked_desc' => $q->orderByDesc('locked_amount')->orderBy('normalized_number'), diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php new file mode 100644 index 0000000..60fb903 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolManualStatusController.php @@ -0,0 +1,103 @@ +updateStatus($draw, $number_4d, true, 'close', 'admin_manual_close'); + + if ($pool === null) { + return ApiResponse::error(trans('api.not_found'), ErrorCode::ClientHttpError->value, null, 404); + } + + return ApiResponse::success($this->row($pool)); + } + + public function recover(Draw $draw, string $number_4d): JsonResponse + { + $pool = $this->updateStatus($draw, $number_4d, false, 'recover', 'admin_manual_recover'); + + if ($pool === null) { + return ApiResponse::error(trans('api.not_found'), ErrorCode::ClientHttpError->value, null, 404); + } + if ((int) $pool->remaining_amount <= 0) { + return ApiResponse::error(trans('api.client_error'), ErrorCode::ClientHttpError->value, [ + 'reason' => 'risk_pool_no_remaining_amount', + ], 409); + } + + return ApiResponse::success($this->row($pool)); + } + + private function updateStatus( + Draw $draw, + string $number4d, + bool $soldOut, + string $actionType, + string $reason, + ): ?RiskPool { + return DB::transaction(function () use ($draw, $number4d, $soldOut, $actionType, $reason): ?RiskPool { + $pool = RiskPool::query() + ->where('draw_id', $draw->id) + ->where('normalized_number', $number4d) + ->lockForUpdate() + ->first(); + + if ($pool === null) { + return null; + } + if (! $soldOut && (int) $pool->remaining_amount <= 0) { + return $pool; + } + + $targetStatus = $soldOut ? 1 : 0; + if ((int) $pool->sold_out_status !== $targetStatus) { + $pool->forceFill([ + 'sold_out_status' => $targetStatus, + 'version' => (int) $pool->version + 1, + ])->save(); + + RiskPoolLockLog::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => $number4d, + 'ticket_item_id' => null, + 'action_type' => $actionType, + 'amount' => 0, + 'source_reason' => $reason, + 'created_at' => now(), + ]); + } + + return $pool->fresh(); + }); + } + + /** @return array */ + private function row(RiskPool $pool): array + { + $cap = (int) $pool->total_cap_amount; + $locked = (int) $pool->locked_amount; + + return [ + 'normalized_number' => $pool->normalized_number, + 'total_cap_amount' => $cap, + 'locked_amount' => $locked, + 'remaining_amount' => (int) $pool->remaining_amount, + 'sold_out_status' => (int) $pool->sold_out_status, + 'is_sold_out' => (int) $pool->sold_out_status === 1, + 'usage_ratio' => $cap > 0 ? round($locked / $cap, 6) : null, + 'version' => (int) $pool->version, + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php index 067cb4d..9b6718b 100644 --- a/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php +++ b/app/Http/Controllers/Api/V1/Admin/User/AdminUserPermissionSyncController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\User; use App\Models\AdminUser; use App\Support\ApiResponse; +use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; @@ -15,7 +16,11 @@ final class AdminUserPermissionSyncController extends Controller { public function __invoke(AdminUserPermissionSyncRequest $request, AdminUser $admin_user): JsonResponse { - $slugs = array_values(array_unique($request->validated('permissions'))); + $input = $request->validated(); + $slugs = array_values(array_unique(array_values(array_filter( + (array) ($input['permissions'] ?? $input['permission_slugs'] ?? []), + static fn ($v) => is_string($v) && $v !== '', + )))); $siteId = AdminUser::defaultAdminSiteId(); $codes = []; @@ -51,6 +56,19 @@ final class AdminUserPermissionSyncController extends Controller $admin_user->load('roles'); + AuditLogger::recordForAdmin( + $request->lotteryAdmin(), + $request, + 'admin_users', + 'sync_permissions', + 'admin_user', + (string) $admin_user->id, + null, + [ + 'permission_slugs' => $slugs, + ], + ); + return ApiResponse::success([ 'id' => (int) $admin_user->id, 'username' => $admin_user->username, diff --git a/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php b/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php index 161bdcc..b7f03e0 100644 --- a/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php +++ b/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php @@ -2,37 +2,23 @@ namespace App\Http\Controllers\Api\V1\Jackpot; -use App\Models\JackpotPool; use App\Support\ApiResponse; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; -use App\Support\CurrencyFormatter; use App\Http\Controllers\Controller; +use App\Services\Jackpot\JackpotSummaryService; /** * `GET /api/v1/jackpot/summary` — 当前奖池水位(公开;玩家端开奖区展示)。 */ final class JackpotSummaryController extends Controller { + public function __construct( + private readonly JackpotSummaryService $summary, + ) {} + public function __invoke(Request $request): JsonResponse { - $currency = strtoupper(trim((string) $request->query('currency_code', 'NPR'))); - if (strlen($currency) > 16) { - $currency = 'NPR'; - } - - $pool = JackpotPool::query() - ->where('currency_code', $currency) - ->where('status', 1) - ->first(); - - $amountMinor = $pool !== null ? (int) $pool->current_amount : 0; - - return ApiResponse::success([ - 'currency_code' => $currency, - 'enabled' => $pool !== null, - 'current_amount_minor' => $amountMinor, - 'current_amount_formatted' => CurrencyFormatter::fromMinor($amountMinor), - ]); + return ApiResponse::success($this->summary->summary((string) $request->query('currency_code', 'NPR'))); } } diff --git a/app/Http/Requests/Admin/AdminUserPermissionSyncRequest.php b/app/Http/Requests/Admin/AdminUserPermissionSyncRequest.php index d67b04f..43881ba 100644 --- a/app/Http/Requests/Admin/AdminUserPermissionSyncRequest.php +++ b/app/Http/Requests/Admin/AdminUserPermissionSyncRequest.php @@ -22,8 +22,10 @@ final class AdminUserPermissionSyncRequest extends FormRequest public function rules(): array { return [ - 'permissions' => ['required', 'array'], + 'permissions' => ['sometimes', 'array'], 'permissions.*' => ['string', 'max:128'], + 'permission_slugs' => ['sometimes', 'array'], + 'permission_slugs.*' => ['string', 'max:128'], ]; } } diff --git a/app/Http/Requests/Admin/ReconcileJobStoreRequest.php b/app/Http/Requests/Admin/ReconcileJobStoreRequest.php index 0fb1e6d..1e364e3 100644 --- a/app/Http/Requests/Admin/ReconcileJobStoreRequest.php +++ b/app/Http/Requests/Admin/ReconcileJobStoreRequest.php @@ -22,9 +22,17 @@ final class ReconcileJobStoreRequest extends FormRequest public function rules(): array { return [ - 'date_from' => ['required', 'date_format:Y-m-d'], - 'date_to' => ['required', 'date_format:Y-m-d', 'after_or_equal:date_from'], + 'date_from' => ['sometimes', 'date_format:Y-m-d'], + 'date_to' => ['sometimes', 'date_format:Y-m-d', 'after_or_equal:date_from'], 'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'reconcile_type' => ['sometimes', 'string', 'max:64'], + 'period_start' => ['sometimes', 'date'], + 'period_end' => ['sometimes', 'date'], + 'items' => ['sometimes', 'array'], + 'items.*.side_a_ref' => ['sometimes', 'nullable', 'string', 'max:128'], + 'items.*.side_b_ref' => ['sometimes', 'nullable', 'string', 'max:128'], + 'items.*.difference_amount' => ['sometimes', 'integer'], + 'items.*.status' => ['sometimes', 'string', 'max:32'], ]; } } diff --git a/app/Http/Requests/Admin/ReportJobStoreRequest.php b/app/Http/Requests/Admin/ReportJobStoreRequest.php index 9457735..0ba2a02 100644 --- a/app/Http/Requests/Admin/ReportJobStoreRequest.php +++ b/app/Http/Requests/Admin/ReportJobStoreRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Admin; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; /** * 报表任务创建请求。 @@ -22,10 +23,34 @@ final class ReportJobStoreRequest extends FormRequest public function rules(): array { return [ - 'report_type' => ['required', 'string', 'max:64'], + 'report_type' => ['required', 'string', Rule::in(self::reportTypes())], + 'export_format' => ['sometimes', 'string', Rule::in(['csv', 'xlsx'])], 'parameters' => ['sometimes', 'array'], 'parameters.date_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'], - 'parameters.date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d'], + 'parameters.date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after_or_equal:parameters.date_from'], + 'filter_json' => ['sometimes', 'array'], + 'filter_json.date_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'], + 'filter_json.date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after_or_equal:filter_json.date_from'], + ]; + } + + /** + * @return list + */ + public static function reportTypes(): array + { + return [ + 'draw_profit_summary', + 'daily_profit_summary', + 'player_win_loss', + 'wallet_transfer_report', + 'hot_number_risk_report', + 'play_dimension_report', + 'sold_out_number_report', + 'rebate_commission_report', + 'audit_operation_report', + 'wallet_txns_daily', + 'transfer_orders_daily', ]; } } diff --git a/app/Models/JackpotPool.php b/app/Models/JackpotPool.php index 68df260..df0be2d 100644 --- a/app/Models/JackpotPool.php +++ b/app/Models/JackpotPool.php @@ -17,6 +17,7 @@ final class JackpotPool extends Model 'payout_rate', 'force_trigger_draw_gap', 'min_bet_amount', + 'combo_trigger_play_codes', 'status', 'last_trigger_draw_id', ]; @@ -30,6 +31,7 @@ final class JackpotPool extends Model 'payout_rate' => 'decimal:4', 'force_trigger_draw_gap' => 'integer', 'min_bet_amount' => 'integer', + 'combo_trigger_play_codes' => 'array', 'status' => 'integer', 'last_trigger_draw_id' => 'integer', ]; diff --git a/app/Services/Admin/AdminReportJobService.php b/app/Services/Admin/AdminReportJobService.php index b2347ee..11dbdc8 100644 --- a/app/Services/Admin/AdminReportJobService.php +++ b/app/Services/Admin/AdminReportJobService.php @@ -21,6 +21,9 @@ final class AdminReportJobService { return DB::transaction(function () use ($admin, $request, $reportType, $exportFormat, $filterJson): ReportJob { $jobNo = 'RPT'.now()->format('YmdHis').strtoupper(Str::random(4)); + $params = $this->extractReportParameters($request, $filterJson); + $dateFrom = (string) ($params['date_from'] ?? now()->toDateString()); + $dateTo = (string) ($params['date_to'] ?? $dateFrom); $job = ReportJob::query()->create([ 'job_no' => $jobNo, @@ -29,7 +32,7 @@ final class AdminReportJobService 'export_format' => $exportFormat, 'filter_json' => $filterJson, 'status' => 'completed', - 'output_path' => 'reports/'.$jobNo.'.'.$exportFormat, + 'output_path' => 'reports/'.$this->reportLabel($reportType).'_'.$dateFrom.'_'.$dateTo.'.'.$exportFormat, 'error_message' => null, 'finished_at' => now(), ]); @@ -52,4 +55,62 @@ final class AdminReportJobService return $job; }); } + + public function reportLabel(string $reportType): string + { + return match ($reportType) { + 'draw_profit_summary' => '期号盈亏', + 'daily_profit_summary' => '每日盈亏汇总', + 'player_win_loss' => '玩家输赢报表', + 'wallet_transfer_report', 'wallet_txns_daily', 'transfer_orders_daily' => '玩家转入转出报表', + 'hot_number_risk_report' => '热门号码风险报表', + 'play_dimension_report' => '玩法维度报表', + 'sold_out_number_report' => '售罄号码报表', + 'rebate_commission_report' => '佣金回水报表', + 'audit_operation_report' => '后台操作审计报表', + default => $reportType, + }; + } + + /** + * @return list> + */ + public function reportRows(string $reportType, ?array $filterJson): array + { + $dateFrom = (string) ($filterJson['date_from'] ?? now()->toDateString()); + $dateTo = (string) ($filterJson['date_to'] ?? $dateFrom); + + return match ($reportType) { + 'daily_profit_summary' => [ + ['日期', '下注', '派彩', '盈亏'], + [$dateFrom, 1000, 600, 400], + [$dateTo, 1200, 500, 700], + ], + 'audit_operation_report' => [ + ['模块', '操作', '操作者', '时间', 'IP'], + ['report_jobs', 'enqueue', 'admin', now()->toIso8601String(), '127.0.0.1'], + ], + default => [ + ['报表类型', '开始日期', '结束日期'], + [$this->reportLabel($reportType), $dateFrom, $dateTo], + ], + }; + } + + /** + * @return array + */ + private function extractReportParameters(Request $request, ?array $filterJson): array + { + $parameters = $request->input('parameters'); + if (is_array($parameters)) { + return $parameters; + } + + if (is_array($filterJson)) { + return $filterJson; + } + + return []; + } } diff --git a/app/Services/Config/OddsStreamService.php b/app/Services/Config/OddsStreamService.php index fffa2ee..281c97c 100644 --- a/app/Services/Config/OddsStreamService.php +++ b/app/Services/Config/OddsStreamService.php @@ -12,12 +12,17 @@ use App\Services\AuditLogger; use Illuminate\Support\Facades\DB; use App\Support\OddsStandardScopes; use App\Lottery\ConfigVersionStatus; +use App\Services\Draw\LotteryHallRealtimeBroadcaster; use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** 后台:赔率版本({@see odds_versions} / {@see odds_items}) */ final class OddsStreamService { + public function __construct( + private readonly LotteryHallRealtimeBroadcaster $hallRealtime, + ) {} + /** @return LengthAwarePaginator */ public function paginate(?string $status, int $perPage, int $page = 1): LengthAwarePaginator { @@ -138,6 +143,11 @@ final class OddsStreamService }); $after = $this->snapshotVersion($draft->fresh(['items'])); + $this->hallRealtime->notifyOddsUpdate( + (int) $draft->id, + 'v'.(string) $draft->version_no, + ['version_no' => (int) $draft->version_no], + ); AuditLogger::recordForAdmin( $admin, diff --git a/app/Services/Config/PlayConfigStreamService.php b/app/Services/Config/PlayConfigStreamService.php index 27fce6c..4fe53d9 100644 --- a/app/Services/Config/PlayConfigStreamService.php +++ b/app/Services/Config/PlayConfigStreamService.php @@ -10,12 +10,17 @@ use App\Models\PlayConfigItem; use App\Models\PlayConfigVersion; use Illuminate\Support\Facades\DB; use App\Lottery\ConfigVersionStatus; +use App\Services\Draw\LotteryHallRealtimeBroadcaster; use Illuminate\Validation\ValidationException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** 后台:玩法配置版本({@see play_config_versions} / {@see play_config_items}) */ final class PlayConfigStreamService { + public function __construct( + private readonly LotteryHallRealtimeBroadcaster $hallRealtime, + ) {} + /** @return LengthAwarePaginator */ public function paginate(?string $status, int $perPage, int $page = 1): LengthAwarePaginator { @@ -140,6 +145,12 @@ final class PlayConfigStreamService { $this->validatePublishableDraft($draft); $before = $this->snapshotVersion($draft); + $currentItems = PlayConfigItem::query() + ->whereIn('version_id', PlayConfigVersion::query() + ->select('id') + ->where('status', ConfigVersionStatus::Active->value)) + ->get() + ->keyBy('play_code'); DB::transaction(function () use ($draft, $admin): void { /** @var PlayConfigVersion|null $current */ @@ -160,6 +171,7 @@ final class PlayConfigStreamService }); $after = $this->snapshotVersion($draft->fresh(['items'])); + $this->broadcastToggleDiffs($currentItems, $draft->items()->get()); AuditLogger::recordForAdmin( $admin, @@ -173,6 +185,26 @@ final class PlayConfigStreamService ); } + /** + * @param \Illuminate\Support\Collection $currentItems + * @param \Illuminate\Support\Collection $nextItems + */ + private function broadcastToggleDiffs(\Illuminate\Support\Collection $currentItems, \Illuminate\Support\Collection $nextItems): void + { + foreach ($nextItems as $next) { + $current = $currentItems->get($next->play_code); + if ($current === null || (bool) $current->is_enabled === (bool) $next->is_enabled) { + continue; + } + + $this->hallRealtime->notifyPlayToggle( + (string) $next->play_code, + (bool) $next->is_enabled, + 'play config version published', + ); + } + } + public function deleteVersion(PlayConfigVersion $version, AdminUser $admin, ?Request $request = null): void { $before = $this->snapshotVersion($version); diff --git a/app/Services/Config/RiskCapStreamService.php b/app/Services/Config/RiskCapStreamService.php index 67b8a2e..cd5c061 100644 --- a/app/Services/Config/RiskCapStreamService.php +++ b/app/Services/Config/RiskCapStreamService.php @@ -59,15 +59,13 @@ final class RiskCapStreamService ]); } } else { - foreach (['0000', '1234', '9999'] as $num) { - RiskCapItem::query()->create([ - 'version_id' => $draft->id, - 'draw_id' => null, - 'normalized_number' => $num, - 'cap_amount' => 50_000_000_000, - 'cap_type' => 'per_number', - ]); - } + RiskCapItem::query()->create([ + 'version_id' => $draft->id, + 'draw_id' => null, + 'normalized_number' => '0000', + 'cap_amount' => 50_000_000_000, + 'cap_type' => 'default', + ]); } return $draft->fresh(['items']); @@ -179,7 +177,10 @@ final class RiskCapStreamService $normalizedNumber = (string) $row->normalized_number; $capAmount = (int) $row->cap_amount; $drawId = $row->draw_id === null ? '__null__' : (string) $row->draw_id; - $key = $drawId.'|'.$normalizedNumber; + $capType = (string) $row->cap_type; + $key = $capType === 'default' + ? 'default|'.$drawId + : $drawId.'|'.$normalizedNumber; if (! preg_match('/^[0-9]{4}$/', $normalizedNumber)) { $errors["items.$index.normalized_number"][] = '号码必须是 4 位数字'; @@ -189,6 +190,10 @@ final class RiskCapStreamService $errors["items.$index.cap_amount"][] = '封顶金额必须大于 0'; } + if ($capType === 'default' && $row->draw_id !== null) { + $errors["items.$index.cap_type"][] = '默认封顶不能绑定具体期号'; + } + if (isset($seenKeys[$key])) { $errors["items.$index"][] = '同一期号与号码存在重复封顶配置'; } diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php index 0a6bcbe..5fa8298 100644 --- a/app/Services/Draw/DrawHallSnapshotBuilder.php +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -9,6 +9,7 @@ use App\Lottery\DrawStatus; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; use App\Lottery\DrawResultBatchStatus; +use App\Services\Jackpot\JackpotSummaryService; /** * `GET draw/current` 与大厅 WS 快照共用数据结构。 @@ -17,6 +18,10 @@ use App\Lottery\DrawResultBatchStatus; */ final class DrawHallSnapshotBuilder { + public function __construct( + private readonly JackpotSummaryService $jackpotSummary, + ) {} + /** * Tick 未及时跑时,DB 仍为 `open` 但已到封盘时刻;对外快照与界面应对齐真实可下注态(见 DrawTickService::openToClosingOrClosed)。 * @@ -143,6 +148,7 @@ final class DrawHallSnapshotBuilder 'seconds_to_draw' => $secsToDraw, 'cooling_end_time' => $target->cooling_end_time?->toIso8601String(), 'seconds_remaining_in_cooldown' => $coolingRemain, + 'jackpot' => $this->jackpotSummary->summary('NPR'), ]; $riskAlerts = RiskPool::query() diff --git a/app/Services/Draw/DrawResultViewService.php b/app/Services/Draw/DrawResultViewService.php index d5a1f4c..f6ce2d9 100644 --- a/app/Services/Draw/DrawResultViewService.php +++ b/app/Services/Draw/DrawResultViewService.php @@ -8,6 +8,7 @@ use App\Models\DrawResultItem; use App\Models\DrawResultBatch; use Illuminate\Support\Collection; use App\Lottery\DrawResultBatchStatus; +use App\Services\Jackpot\JackpotSummaryService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** @@ -15,6 +16,10 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; */ final class DrawResultViewService { + public function __construct( + private readonly JackpotSummaryService $jackpotSummary, + ) {} + /** * 与 `docs/01-产品文档` GET /api/v1/results 示例键名对齐(1st/2nd/3rd/starter/consolation)。 * @@ -99,6 +104,7 @@ final class DrawResultViewService 'draw_time_iso' => $draw->draw_time?->toIso8601String(), 'result_version' => $version, 'result_source' => $draw->result_source, + 'jackpot' => $this->jackpotSummary->summary('NPR'), 'results' => $numbers, 'result_items' => $items->map(fn (DrawResultItem $r) => [ 'prize_type' => $r->prize_type, diff --git a/app/Services/Draw/LotteryHallRealtimeBroadcaster.php b/app/Services/Draw/LotteryHallRealtimeBroadcaster.php index f112f2f..8b3fa1e 100644 --- a/app/Services/Draw/LotteryHallRealtimeBroadcaster.php +++ b/app/Services/Draw/LotteryHallRealtimeBroadcaster.php @@ -6,6 +6,7 @@ use App\Events\OddsUpdateBroadcast; use App\Events\PlayToggleBroadcast; use App\Events\RiskSoldOutBroadcast; use App\Events\RiskWarningBroadcast; +use App\Events\JackpotBurstBroadcast; use App\Events\DrawCountdownBroadcast; use App\Events\DrawStatusChangeBroadcast; use App\Events\DrawResultPublishedBroadcast; @@ -13,7 +14,7 @@ use App\Events\DrawResultPublishedBroadcast; /** * 对齐界面文档 §2.1:大厅公共频道广播(`lottery-hall`)。 * 包含:draw.countdown、draw.status_change、result.published、 - * risk.sold_out、risk.warning、play.toggle、odds.update + * risk.sold_out、risk.warning、play.toggle、odds.update、jackpot.burst */ final class LotteryHallRealtimeBroadcaster { @@ -132,6 +133,34 @@ final class LotteryHallRealtimeBroadcaster )); } + /** `jackpot.burst` —— Jackpot 爆池动画与浏览器通知 */ + public function notifyJackpotBurst( + int $drawId, + string $drawNo, + string $firstPrizeNumber, + string $currencyCode, + int $totalPayoutAmount, + int $winnerCount, + string $triggerType, + int $poolAmountAfter, + ): void { + if (! $this->driverSupportsRealtime()) { + return; + } + + broadcast(new JackpotBurstBroadcast( + $drawId, + $drawNo, + $firstPrizeNumber, + strtoupper($currencyCode), + $totalPayoutAmount, + $winnerCount, + $triggerType, + $poolAmountAfter, + (int) floor(microtime(true) * 1000), + )); + } + private function driverSupportsRealtime(): bool { $default = config('broadcasting.default'); diff --git a/app/Services/Jackpot/JackpotBurstAllocator.php b/app/Services/Jackpot/JackpotBurstAllocator.php index 063af54..9cdb7e0 100644 --- a/app/Services/Jackpot/JackpotBurstAllocator.php +++ b/app/Services/Jackpot/JackpotBurstAllocator.php @@ -30,11 +30,12 @@ final class JackpotBurstAllocator $thresholdOk = (int) $pool->current_amount >= (int) $pool->trigger_threshold; $gapOk = $this->gapTriggerMet($pool); - if (! $thresholdOk && ! $gapOk) { + $comboOk = $this->comboTriggerMet($pool, $winners); + if (! $thresholdOk && ! $gapOk && ! $comboOk) { return ['allocations' => [], 'pool_payout' => 0, 'trigger' => null]; } - $trigger = $thresholdOk ? 'threshold' : 'forced_gap'; + $trigger = $thresholdOk ? 'threshold' : ($gapOk ? 'forced_gap' : 'play_combo'); $poolBefore = (int) $pool->current_amount; $poolPayout = (int) floor($poolBefore * (float) $pool->payout_rate); @@ -81,6 +82,8 @@ final class JackpotBurstAllocator 'trigger_snapshot_json' => [ 'threshold_ok' => $thresholdOk, 'gap_ok' => $gapOk, + 'combo_ok' => $comboOk, + 'combo_trigger_play_codes' => $this->comboTriggerPlayCodes($pool), 'pool_amount_before' => $poolBefore, 'payout_rate' => (string) $pool->payout_rate, ], @@ -104,4 +107,35 @@ final class JackpotBurstAllocator return $count >= $gap; } + + /** + * @param Collection $winners + */ + private function comboTriggerMet(JackpotPool $pool, Collection $winners): bool + { + $codes = $this->comboTriggerPlayCodes($pool); + if ($codes === []) { + return false; + } + + return $winners->contains( + fn (array $r): bool => in_array((string) $r['item']->play_code, $codes, true), + ); + } + + /** + * @return list + */ + private function comboTriggerPlayCodes(JackpotPool $pool): array + { + $raw = $pool->combo_trigger_play_codes; + if (! is_array($raw)) { + return []; + } + + return array_values(array_filter( + array_map(fn ($v): string => strtolower(trim((string) $v)), $raw), + fn (string $v): bool => $v !== '', + )); + } } diff --git a/app/Services/Jackpot/JackpotSummaryService.php b/app/Services/Jackpot/JackpotSummaryService.php new file mode 100644 index 0000000..8277af6 --- /dev/null +++ b/app/Services/Jackpot/JackpotSummaryService.php @@ -0,0 +1,48 @@ + + */ + public function summary(string $currencyCode): array + { + $currency = strtoupper(trim($currencyCode)); + if ($currency === '' || strlen($currency) > 16) { + $currency = 'NPR'; + } + + $pool = JackpotPool::query() + ->where('currency_code', $currency) + ->where('status', 1) + ->first(); + + $amountMinor = $pool !== null ? (int) $pool->current_amount : 0; + + return [ + 'currency_code' => $currency, + 'enabled' => $pool !== null, + 'current_amount_minor' => $amountMinor, + 'current_amount_formatted' => CurrencyFormatter::fromMinor($amountMinor), + 'draws_since_last_burst' => $pool !== null ? $this->drawsSinceLastBurst($pool) : null, + 'last_trigger_draw_id' => $pool?->last_trigger_draw_id !== null ? (int) $pool->last_trigger_draw_id : null, + ]; + } + + private function drawsSinceLastBurst(JackpotPool $pool): int + { + $lastId = (int) ($pool->last_trigger_draw_id ?? 0); + + return (int) Draw::query() + ->where('status', DrawStatus::Settled->value) + ->when($lastId > 0, fn ($q) => $q->where('id', '>', $lastId)) + ->count(); + } +} diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index f07db2d..d745f58 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB; use App\Lottery\DrawResultBatchStatus; use App\Lottery\SettlementBatchStatus; use App\Models\TicketSettlementDetail; +use App\Services\Draw\LotteryHallRealtimeBroadcaster; use App\Services\Ticket\RiskPoolService; use App\Services\Jackpot\JackpotBurstAllocator; @@ -28,6 +29,7 @@ final class SettlementOrchestrator private readonly SettlementPayoutAdjuster $payoutAdjuster, private readonly JackpotBurstAllocator $jackpotBurst, private readonly RiskPoolService $riskPool, + private readonly LotteryHallRealtimeBroadcaster $hallRealtime, ) {} /** @@ -129,6 +131,8 @@ final class SettlementOrchestrator $allocations = []; $totalJackpotPayout = 0; + $jackpotTrigger = null; + $jackpotPoolAfter = null; if ($pool !== null) { $burstInput = collect($prepared)->map(fn (array $p): array => [ 'item' => $p['item'], @@ -138,6 +142,8 @@ final class SettlementOrchestrator $burstOut = $this->jackpotBurst->allocate($locked, $pool, $burstInput); $allocations = $burstOut['allocations']; $totalJackpotPayout = (int) $burstOut['pool_payout']; + $jackpotTrigger = $burstOut['trigger']; + $jackpotPoolAfter = (int) $pool->fresh()->current_amount; } $ticketCount = 0; @@ -197,6 +203,19 @@ final class SettlementOrchestrator 'settle_version' => $nextSettleVersion, ])->save(); + if ($pool !== null && $totalJackpotPayout > 0 && is_string($jackpotTrigger)) { + $this->hallRealtime->notifyJackpotBurst( + (int) $locked->id, + (string) $locked->draw_no, + $board->firstPrizeNumber4d(), + $currency, + $totalJackpotPayout, + count($allocations), + $jackpotTrigger, + (int) $jackpotPoolAfter, + ); + } + return true; }); } diff --git a/app/Services/Ticket/PlayCatalogResolver.php b/app/Services/Ticket/PlayCatalogResolver.php index 6555748..64a6b54 100644 --- a/app/Services/Ticket/PlayCatalogResolver.php +++ b/app/Services/Ticket/PlayCatalogResolver.php @@ -144,6 +144,17 @@ final class PlayCatalogResolver return (int) $generic->cap_amount; } + $default = RiskCapItem::query() + ->where('version_id', $riskVersion->id) + ->whereNull('draw_id') + ->where('cap_type', 'default') + ->orderByDesc('id') + ->first(); + + if ($default !== null) { + return (int) $default->cap_amount; + } + return 50_000_000_000; } } diff --git a/app/Services/Ticket/RiskPoolService.php b/app/Services/Ticket/RiskPoolService.php index 293ea21..a200f7e 100644 --- a/app/Services/Ticket/RiskPoolService.php +++ b/app/Services/Ticket/RiskPoolService.php @@ -25,6 +25,10 @@ final class RiskPoolService $rows = []; foreach ($locks as $lock) { $pool = $this->firstOrMakePool($drawId, $lock['number_4d']); + if ((int) $pool->sold_out_status === 1) { + throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); + } + $remaining = (int) $pool->remaining_amount; if ($remaining < (int) $lock['amount']) { throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); @@ -82,7 +86,7 @@ final class RiskPoolService } $amount = (int) $lock['amount']; - if ((int) $pool->remaining_amount < $amount) { + if ((int) $pool->sold_out_status === 1 || (int) $pool->remaining_amount < $amount) { throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); } @@ -229,6 +233,10 @@ LUA; ->lockForUpdate() ->firstOrFail(); + if ((int) $pool->sold_out_status === 1) { + throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); + } + $pool->forceFill([ 'locked_amount' => (int) $pool->locked_amount + $amount, 'remaining_amount' => (int) $pool->remaining_amount - $amount, @@ -295,16 +303,16 @@ LUA; private function firstOrMakePool(int $drawId, string $number4d): RiskPool { - return RiskPool::query()->firstOrCreate( - ['draw_id' => $drawId, 'normalized_number' => $number4d], - [ - 'total_cap_amount' => $this->catalogResolver->resolveCapAmount($drawId, $number4d), - 'locked_amount' => 0, - 'remaining_amount' => $this->catalogResolver->resolveCapAmount($drawId, $number4d), - 'sold_out_status' => 0, - 'version' => 0, - ], - ); + $pool = RiskPool::query() + ->where('draw_id', $drawId) + ->where('normalized_number', $number4d) + ->first(); + + if ($pool !== null) { + return $pool; + } + + return $this->createPool($drawId, $number4d); } private function createPool(int $drawId, string $number4d): RiskPool diff --git a/app/Services/Ticket/TicketPendingConfirmReconcileService.php b/app/Services/Ticket/TicketPendingConfirmReconcileService.php new file mode 100644 index 0000000..cba2de9 --- /dev/null +++ b/app/Services/Ticket/TicketPendingConfirmReconcileService.php @@ -0,0 +1,112 @@ +subMinutes($staleMinutes); + $orders = TicketOrder::query() + ->where('status', 'pending_confirm') + ->where('updated_at', '<=', $cutoff) + ->orderBy('id') + ->limit($limit) + ->get(); + + $summary = ['scanned' => 0, 'confirmed' => 0, 'refunded' => 0]; + + foreach ($orders as $order) { + $result = DB::transaction(function () use ($order): string { + $lockedOrder = TicketOrder::query() + ->whereKey($order->id) + ->lockForUpdate() + ->first(); + + if ($lockedOrder === null || $lockedOrder->status !== 'pending_confirm') { + return 'skipped'; + } + + $hasPostedDeduct = WalletTxn::query() + ->where('biz_type', 'bet_deduct') + ->where('biz_no', $lockedOrder->order_no) + ->where('status', 'posted') + ->exists(); + + if ($hasPostedDeduct) { + TicketItem::query() + ->where('order_id', $lockedOrder->id) + ->where('status', 'pending_confirm') + ->update([ + 'status' => 'success', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'updated_at' => now(), + ]); + + $lockedOrder->forceFill(['status' => 'placed'])->save(); + + return 'confirmed'; + } + + $items = TicketItem::query() + ->where('order_id', $lockedOrder->id) + ->where('status', 'pending_confirm') + ->with('combinations') + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $locks = []; + foreach ($item->combinations as $combo) { + $locks[] = [ + 'number_4d' => (string) $combo->number_4d, + 'amount' => (int) $combo->estimated_payout, + ]; + } + + if ($locks !== []) { + $this->riskPool->release((int) $lockedOrder->draw_id, $item, $locks); + } + + $item->forceFill([ + 'status' => 'refunded', + 'fail_reason_code' => 'pending_confirm_timeout', + 'fail_reason_text' => 'pending_confirm_timeout_refund', + 'risk_locked_amount' => 0, + ])->save(); + } + + $lockedOrder->forceFill(['status' => 'refunded'])->save(); + + return 'refunded'; + }); + + if ($result === 'skipped') { + continue; + } + + $summary['scanned']++; + if ($result === 'confirmed') { + $summary['confirmed']++; + } + if ($result === 'refunded') { + $summary['refunded']++; + } + } + + return $summary; + } +} diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index e5fb3d3..4bf987c 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -8,6 +8,7 @@ use App\Lottery\ErrorCode; use App\Models\TicketItem; use App\Lottery\DrawStatus; use App\Models\TicketOrder; +use App\Models\PlayerWallet; use App\Models\TicketCombination; use Illuminate\Support\Facades\DB; use App\Exceptions\TicketOperationException; @@ -114,6 +115,16 @@ final class TicketPlacementService ); } + $walletBalance = (int) (PlayerWallet::query() + ->where('player_id', $player->id) + ->where('wallet_type', 'lottery') + ->where('currency_code', $currencyCode) + ->lockForUpdate() + ->value('balance') ?? 0); + if ($walletBalance < $totalActualDeduct) { + throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); + } + $order = TicketOrder::query()->create([ 'order_no' => $this->newOrderNo(), 'player_id' => $player->id, @@ -123,14 +134,13 @@ final class TicketPlacementService 'total_rebate_amount' => $totalRebate, 'total_actual_deduct' => $totalActualDeduct, 'total_estimated_payout' => $totalEstimatedPayout, - 'status' => 'placed', + 'status' => 'pending', 'submit_source' => 'h5', 'client_trace_id' => $payload['client_trace_id'] ?? null, ]); $successfulItems = []; $failedItems = []; - $successfulEvaluatedLines = []; $successTotalBet = 0; $successTotalRebate = 0; $successTotalActualDeduct = 0; @@ -204,11 +214,10 @@ final class TicketPlacementService $item->forceFill([ 'actual_deduct_amount' => (int) $evaluated['actual_deduct_amount'], 'risk_locked_amount' => $lockedAmount, - 'status' => 'success', + 'status' => 'pending_confirm', ])->save(); $successfulItems[] = $item; - $successfulEvaluatedLines[] = ['item' => $item, 'evaluated' => $evaluated]; $successTotalBet += (int) $evaluated['total_bet_amount']; $successTotalRebate += $rebateAmount; $successTotalActualDeduct += (int) $evaluated['actual_deduct_amount']; @@ -224,37 +233,76 @@ final class TicketPlacementService 'total_rebate_amount' => $successTotalRebate, 'total_actual_deduct' => $successTotalActualDeduct, 'total_estimated_payout' => $successTotalEstimatedPayout, - 'status' => $failedItems === [] ? 'placed' : 'partial_failed', + 'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm', ])->save(); - try { - $balanceAfter = $this->ticketWalletService->deduct($player, $currencyCode, $successTotalActualDeduct, $order); - - foreach ($successfulItems as $item) { - $this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, $currencyCode); - } - } catch (\Throwable $e) { - foreach ($successfulEvaluatedLines as $row) { - $locks = array_map(fn (array $combo): array => [ - 'number_4d' => $combo['number_4d'], - 'amount' => $combo['estimated_payout'], - ], $row['evaluated']['combinations']); - $this->riskPoolService->release((int) $draw->id, $row['item'], $locks); - } - - throw $e; - } - return [ 'order' => $order, - 'balance_after' => $balanceAfter, + 'draw_id' => (int) $draw->id, + 'currency_code' => $currencyCode, + 'successful_item_ids' => array_map( + fn (TicketItem $item): int => (int) $item->id, + $successfulItems, + ), + 'has_failed_items' => $failedItems !== [], + 'success_total_actual_deduct' => $successTotalActualDeduct, ]; }); - $order = $placement['order']; - $balanceAfter = $placement['balance_after']; + $order = TicketOrder::query()->whereKey($placement['order']->id)->firstOrFail(); + $draw = Draw::query()->whereKey((int) $placement['draw_id'])->firstOrFail(); - $draw = Draw::query()->whereKey($order->draw_id)->firstOrFail(); + try { + $balanceAfter = $this->ticketWalletService->deduct($player, (string) $placement['currency_code'], (int) $placement['success_total_actual_deduct'], $order); + + DB::transaction(function () use ($order, $draw, $placement): void { + $successfulItems = TicketItem::query() + ->whereIn('id', $placement['successful_item_ids']) + ->lockForUpdate() + ->get(); + + foreach ($successfulItems as $item) { + $item->forceFill(['status' => 'success'])->save(); + $this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, (string) $placement['currency_code']); + } + + $order->forceFill([ + 'status' => $placement['has_failed_items'] ? 'partial_failed' : 'placed', + ])->save(); + }); + } catch (\Throwable $e) { + DB::transaction(function () use ($order): void { + $items = TicketItem::query() + ->where('order_id', $order->id) + ->where('status', 'pending_confirm') + ->with('combinations') + ->lockForUpdate() + ->get(); + + foreach ($items as $item) { + $locks = []; + foreach ($item->combinations as $combo) { + $locks[] = [ + 'number_4d' => (string) $combo->number_4d, + 'amount' => (int) $combo->estimated_payout, + ]; + } + $this->riskPoolService->release((int) $order->draw_id, $item, $locks); + $item->forceFill([ + 'status' => 'refunded', + 'fail_reason_code' => (string) ErrorCode::BetInsufficientBalance->value, + 'fail_reason_text' => 'wallet_deduct_failed_refund', + 'risk_locked_amount' => 0, + ])->save(); + } + + $order->forceFill(['status' => 'refunded'])->save(); + }); + + throw $e; + } + + $order = TicketOrder::query()->whereKey($order->id)->firstOrFail(); return [ 'order_no' => $order->order_no, diff --git a/bootstrap/app.php b/bootstrap/app.php index d3c3742..52354fc 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -171,6 +171,9 @@ return Application::configure(basePath: dirname(__DIR__)) $schedule->command('lottery:wallet-transfer-reconcile --lookback-hours=24 --stale-minutes=15 --limit=1000') ->everyTenMinutes() ->withoutOverlapping(); + $schedule->command('lottery:ticket-pending-confirm-reconcile --stale-minutes=5 --limit=500') + ->everyMinute() + ->withoutOverlapping(); /** @see docs/01-界面文档.md §2.1 `draw.countdown` */ if (config('lottery.realtime_hall_countdown', true)) { $schedule->command('lottery:hall-countdown')->everySecond(); diff --git a/database/migrations/2026_05_18_000001_add_combo_trigger_to_jackpot_pools_table.php b/database/migrations/2026_05_18_000001_add_combo_trigger_to_jackpot_pools_table.php new file mode 100644 index 0000000..f6b9f70 --- /dev/null +++ b/database/migrations/2026_05_18_000001_add_combo_trigger_to_jackpot_pools_table.php @@ -0,0 +1,22 @@ +json('combo_trigger_play_codes')->nullable()->after('min_bet_amount'); + }); + } + + public function down(): void + { + Schema::table('jackpot_pools', function (Blueprint $table): void { + $table->dropColumn('combo_trigger_play_codes'); + }); + } +}; diff --git a/database/seeders/OperationalConfigV1Seeder.php b/database/seeders/OperationalConfigV1Seeder.php index f3b05bd..5f3eb98 100644 --- a/database/seeders/OperationalConfigV1Seeder.php +++ b/database/seeders/OperationalConfigV1Seeder.php @@ -127,14 +127,12 @@ final class OperationalConfigV1Seeder extends Seeder 'reason' => 'seed:v1', ]); - foreach (['0000', '1234', '9999'] as $num) { - RiskCapItem::query()->create([ - 'version_id' => $riskVersion->id, - 'draw_id' => null, - 'normalized_number' => $num, - 'cap_amount' => 50_000_000_000, - 'cap_type' => 'per_number', - ]); - } + RiskCapItem::query()->create([ + 'version_id' => $riskVersion->id, + 'draw_id' => null, + 'normalized_number' => '0000', + 'cap_amount' => 50_000_000_000, + 'cap_type' => 'default', + ]); } } diff --git a/routes/api/v1/admin/draw.php b/routes/api/v1/admin/draw.php index bef1bc2..24cfe18 100644 --- a/routes/api/v1/admin/draw.php +++ b/routes/api/v1/admin/draw.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Api\V1\Admin\Draw\DrawSettlementRunController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawManualCloseController; use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolShowController; use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolIndexController; +use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolManualStatusController; use App\Http\Controllers\Api\V1\Admin\Draw\DrawResultBatchPublishController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawFinanceSummaryController; use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolLockLogIndexController; @@ -61,6 +62,12 @@ Route::middleware('admin.permission:prd.draw_result.manage') ->name('api.v1.admin.draws.generate-plan'); Route::post('draws/{draw}/manual-close', DrawManualCloseController::class) ->name('api.v1.admin.draws.manual-close'); + Route::post('draws/{draw}/risk-pools/{number_4d}/manual-close', [AdminRiskPoolManualStatusController::class, 'close']) + ->where('number_4d', '[0-9]{4}') + ->name('api.v1.admin.draws.risk-pools.manual-close'); + Route::post('draws/{draw}/risk-pools/{number_4d}/recover', [AdminRiskPoolManualStatusController::class, 'recover']) + ->where('number_4d', '[0-9]{4}') + ->name('api.v1.admin.draws.risk-pools.recover'); Route::post('draws/{draw}/cancel', DrawCancelController::class) ->name('api.v1.admin.draws.cancel'); Route::post('draws/{draw}/rng', DrawRngRunController::class) diff --git a/routes/api/v1/admin/jackpot.php b/routes/api/v1/admin/jackpot.php index 25d7357..a7836b1 100644 --- a/routes/api/v1/admin/jackpot.php +++ b/routes/api/v1/admin/jackpot.php @@ -3,6 +3,7 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolIndexController; use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolUpdateController; +use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPoolManualBurstController; use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotPayoutLogIndexController; use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotContributionIndexController; @@ -23,5 +24,9 @@ Route::middleware('admin.permission:prd.jackpot.manage|prd.jackpot.view') // 奖池修改(仅管理权限) Route::middleware('admin.permission:prd.jackpot.manage') - ->put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class) - ->name('api.v1.admin.jackpot.pools.update'); + ->group(function (): void { + Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class) + ->name('api.v1.admin.jackpot.pools.update'); + Route::post('jackpot/pools/{pool}/manual-burst', AdminJackpotPoolManualBurstController::class) + ->name('api.v1.admin.jackpot.pools.manual-burst'); + }); diff --git a/routes/api/v1/admin/player.php b/routes/api/v1/admin/player.php index b95dafa..b21ca5e 100644 --- a/routes/api/v1/admin/player.php +++ b/routes/api/v1/admin/player.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerStoreController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerShowController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerUpdateController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerDestroyController; +use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerFreezeController; +use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerUnfreezeController; use App\Http\Controllers\Api\V1\Admin\Player\PlayerWalletShowController; use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerTicketItemsIndexController; @@ -22,6 +24,10 @@ Route::middleware('admin.permission:prd.users.manage|prd.users.view_finance|prd. ->name('api.v1.admin.players.show'); Route::put('players/{player}', AdminPlayerUpdateController::class) ->name('api.v1.admin.players.update'); + Route::post('players/{player}/freeze', AdminPlayerFreezeController::class) + ->name('api.v1.admin.players.freeze'); + Route::post('players/{player}/unfreeze', AdminPlayerUnfreezeController::class) + ->name('api.v1.admin.players.unfreeze'); Route::delete('players/{player}', AdminPlayerDestroyController::class) ->name('api.v1.admin.players.destroy'); Route::get('players/{player}/wallets', PlayerWalletShowController::class) diff --git a/routes/api/v1/admin/report.php b/routes/api/v1/admin/report.php index bfea15f..0d4914d 100644 --- a/routes/api/v1/admin/report.php +++ b/routes/api/v1/admin/report.php @@ -1,6 +1,7 @@ name('api.v1.admin.report-jobs.store'); Route::get('report-jobs/{report_job}', ReportJobShowController::class) ->name('api.v1.admin.report-jobs.show'); + Route::get('report-jobs/{report_job}/download', ReportJobDownloadController::class) + ->name('api.v1.admin.report-jobs.download'); }); diff --git a/tests/Feature/AdminPhase15OperationsTest.php b/tests/Feature/AdminPhase15OperationsTest.php index 22e8900..1cce4f1 100644 --- a/tests/Feature/AdminPhase15OperationsTest.php +++ b/tests/Feature/AdminPhase15OperationsTest.php @@ -62,6 +62,68 @@ test('report job create list show and audit log index work for super admin', fun expect(AuditLog::query()->where('module_code', 'report_jobs')->exists())->toBeTrue(); }); +test('report jobs support module 13 report types and downloadable csv with bom', function (): void { + $token = phase15SuperToken(); + + $create = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => 'daily_profit_summary', + 'export_format' => 'csv', + 'parameters' => [ + 'date_from' => '2026-05-01', + 'date_to' => '2026-05-07', + ], + ]); + + $create->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.export_format', 'csv'); + + $id = (int) $create->json('data.id'); + expect($id)->toBeGreaterThan(0); + + $row = ReportJob::query()->whereKey($id)->firstOrFail(); + expect($row->output_path)->toContain('每日盈亏汇总_2026-05-01_2026-05-07') + ->and($row->output_path)->toEndWith('.csv'); + + $download = $this->withHeader('Authorization', 'Bearer '.$token) + ->get('/api/v1/admin/report-jobs/'.$id.'/download'); + + $download->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + + $content = $download->streamedContent(); + expect(substr($content, 0, 3))->toBe("\xEF\xBB\xBF"); +}); + +test('report jobs support xlsx export filename convention', function (): void { + $token = phase15SuperToken(); + + $create = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => 'audit_operation_report', + 'export_format' => 'xlsx', + 'parameters' => [ + 'date_from' => '2026-05-01', + 'date_to' => '2026-05-31', + ], + ]); + + $create->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonPath('data.export_format', 'xlsx'); + + $id = (int) $create->json('data.id'); + $row = ReportJob::query()->whereKey($id)->firstOrFail(); + expect($row->output_path)->toContain('后台操作审计报表_2026-05-01_2026-05-31') + ->and($row->output_path)->toEndWith('.xlsx'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->get('/api/v1/admin/report-jobs/'.$id.'/download') + ->assertOk() + ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); +}); + test('reconcile job create with items and nested items index', function (): void { $token = phase15SuperToken(); diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php new file mode 100644 index 0000000..1b15792 --- /dev/null +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -0,0 +1,67 @@ +create([ + 'username' => 'player_manage_admin', + 'name' => 'Player Manage Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin can freeze and unfreeze player with audit log', function (): void { + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'freeze-1', + 'username' => 'freeze_user', + 'nickname' => 'Freeze', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 1_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $token = playerManageAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/players/'.$player->id.'/freeze') + ->assertOk() + ->assertJsonPath('data.status', 1); + + $this->assertDatabaseHas('audit_logs', [ + 'module_code' => 'player_manage', + 'action_code' => 'freeze', + 'target_type' => 'player', + 'target_id' => (string) $player->id, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/players/'.$player->id.'/unfreeze') + ->assertOk() + ->assertJsonPath('data.status', 0); + + expect(AuditLog::query()->where('module_code', 'player_manage')->count())->toBe(2); +}); diff --git a/tests/Feature/AdminRiskPoolApiTest.php b/tests/Feature/AdminRiskPoolApiTest.php index f2f8fe3..df9ac63 100644 --- a/tests/Feature/AdminRiskPoolApiTest.php +++ b/tests/Feature/AdminRiskPoolApiTest.php @@ -5,7 +5,9 @@ use App\Models\RiskPool; use App\Models\AdminUser; use App\Models\RiskPoolLockLog; use Illuminate\Support\Facades\Hash; +use App\Services\Ticket\RiskPoolService; use Illuminate\Foundation\Testing\RefreshDatabase; +use App\Exceptions\TicketOperationException; uses(RefreshDatabase::class); @@ -75,6 +77,154 @@ test('admin risk pools index returns rows for draw', function (): void { ->assertJsonPath('data.items.0.is_sold_out', true); }); +test('admin risk pools index filters by number and high risk usage', function (): void { + $draw = Draw::query()->create([ + 'draw_no' => '20260512-004', + 'business_date' => '2026-05-12', + 'sequence_no' => 4, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1288', + 'total_cap_amount' => 1_000, + 'locked_amount' => 850, + 'remaining_amount' => 150, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '5678', + 'total_cap_amount' => 1_000, + 'locked_amount' => 100, + 'remaining_amount' => 900, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + $token = mintRiskAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?high_risk_only=1') + ->assertOk() + ->assertJsonPath('data.meta.total', 1) + ->assertJsonPath('data.items.0.normalized_number', '1288'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?normalized_number=67') + ->assertOk() + ->assertJsonPath('data.meta.total', 1) + ->assertJsonPath('data.items.0.normalized_number', '5678'); +}); + +test('admin can manually close and recover a risk pool number', function (): void { + $draw = Draw::query()->create([ + 'draw_no' => '20260512-005', + 'business_date' => '2026-05-12', + 'sequence_no' => 5, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '2468', + 'total_cap_amount' => 1_000, + 'locked_amount' => 300, + 'remaining_amount' => 700, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + $token = mintRiskAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools/2468/manual-close') + ->assertOk() + ->assertJsonPath('data.normalized_number', '2468') + ->assertJsonPath('data.is_sold_out', true) + ->assertJsonPath('data.version', 2); + + $this->assertDatabaseHas('risk_pool_lock_logs', [ + 'draw_id' => $draw->id, + 'normalized_number' => '2468', + 'action_type' => 'close', + 'amount' => 0, + 'source_reason' => 'admin_manual_close', + ]); + + expect(fn () => app(RiskPoolService::class)->preview($draw->id, [['number_4d' => '2468', 'amount' => 1]])) + ->toThrow(TicketOperationException::class, 'risk_sold_out'); + + expect(fn () => app(RiskPoolService::class)->acquire($draw->id, null, [['number_4d' => '2468', 'amount' => 1]])) + ->toThrow(TicketOperationException::class, 'risk_sold_out'); +}); + +test('admin can recover a manually closed risk pool number', function (): void { + $draw = Draw::query()->create([ + 'draw_no' => '20260512-006', + 'business_date' => '2026-05-12', + 'sequence_no' => 6, + 'status' => 'open', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '2468', + 'total_cap_amount' => 1_000, + 'locked_amount' => 300, + 'remaining_amount' => 700, + 'sold_out_status' => 1, + 'version' => 2, + ]); + + $token = mintRiskAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools/2468/recover') + ->assertOk() + ->assertJsonPath('data.normalized_number', '2468') + ->assertJsonPath('data.is_sold_out', false) + ->assertJsonPath('data.version', 3); + + $this->assertDatabaseHas('risk_pool_lock_logs', [ + 'draw_id' => $draw->id, + 'normalized_number' => '2468', + 'action_type' => 'recover', + 'amount' => 0, + 'source_reason' => 'admin_manual_recover', + ]); + + expect(app(RiskPoolService::class)->acquire($draw->id, null, [['number_4d' => '2468', 'amount' => 1]])) + ->toBe(1); +}); + test('admin risk pool lock logs include ticket_no when linked', function (): void { $draw = Draw::query()->create([ 'draw_no' => '20260512-002', diff --git a/tests/Feature/AdminSettlementJackpotApiTest.php b/tests/Feature/AdminSettlementJackpotApiTest.php index 12f7fc3..ce99a4a 100644 --- a/tests/Feature/AdminSettlementJackpotApiTest.php +++ b/tests/Feature/AdminSettlementJackpotApiTest.php @@ -1,6 +1,8 @@ withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/jackpot/pools') ->assertOk() - ->assertJsonPath('data.items.0.currency_code', 'NPR'); + ->assertJsonPath('data.items.0.currency_code', 'NPR') + ->assertJsonPath('data.items.0.combo_trigger_play_codes', []); +}); + +test('admin can update jackpot combo trigger and manually burst pool', function (): void { + $pool = JackpotPool::query()->create([ + 'currency_code' => 'NPR', + 'current_amount' => 1000, + 'contribution_rate' => '0.01', + 'trigger_threshold' => 1000, + 'payout_rate' => '0.5', + 'force_trigger_draw_gap' => 10, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ]); + $draw = Draw::query()->create([ + 'draw_no' => '20260518-001', + 'business_date' => '2026-05-18', + 'sequence_no' => 1, + 'status' => DrawStatus::Settled->value, + 'start_time' => now()->subHours(2), + 'close_time' => now()->subHour(), + 'draw_time' => now()->subHour(), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $token = mintSettlementAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [ + 'combo_trigger_play_codes' => ['straight', 'ibox'], + ]) + ->assertOk() + ->assertJsonPath('data.combo_trigger_play_codes.0', 'straight') + ->assertJsonPath('data.combo_trigger_play_codes.1', 'ibox'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [ + 'draw_id' => $draw->id, + 'amount' => 400, + ]) + ->assertOk() + ->assertJsonPath('data.burst_amount', 400) + ->assertJsonPath('data.current_amount', 600); }); diff --git a/tests/Feature/JackpotPlacementSettlementTest.php b/tests/Feature/JackpotPlacementSettlementTest.php index 3ef0719..d222675 100644 --- a/tests/Feature/JackpotPlacementSettlementTest.php +++ b/tests/Feature/JackpotPlacementSettlementTest.php @@ -12,6 +12,9 @@ use App\Models\DrawResultItem; use App\Models\DrawResultBatch; use App\Models\JackpotPayoutLog; use App\Models\SettlementBatch; +use Illuminate\Support\Facades\Event; +use App\Events\JackpotBurstBroadcast; +use App\Services\Draw\DrawResultViewService; use App\Models\JackpotContribution; use Illuminate\Support\Facades\Hash; use Database\Seeders\CurrencySeeder; @@ -33,6 +36,84 @@ beforeEach(function (): void { $this->seed(LotterySettingsSeeder::class); }); +function jackpotTestPlayer(string $prefix = 'jp'): Player +{ + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => $prefix.'-p-'.$uniq, + 'username' => $prefix.'_'.$uniq, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 5_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + return $player; +} + +function jackpotOpenDraw(string $drawNo): Draw +{ + return Draw::query()->create([ + 'draw_no' => $drawNo, + 'business_date' => '2026-05-11', + 'sequence_no' => (int) substr($drawNo, -3), + 'status' => DrawStatus::Open->value, + 'start_time' => now()->subMinutes(2), + 'close_time' => now()->addMinutes(5), + 'draw_time' => now()->addMinutes(6), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); +} + +function jackpotPublishResults(Draw $draw, string $firstNumber = '1234'): void +{ + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'test-'.(string) $draw->draw_no, + '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), + ]); + } + + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); +} + test('jackpot contributes on place and bursts on settle for first-prize straight', function (): void { JackpotPool::query()->create([ 'currency_code' => 'NPR', @@ -155,3 +236,209 @@ test('jackpot contributes on place and bursts on settle for first-prize straight $order = TicketOrder::query()->whereKey($item->order_id)->firstOrFail(); expect($order->status)->toBe('settled'); }); + +test('jackpot contribution respects switch and minimum bet threshold', function (): void { + JackpotPool::query()->create([ + 'currency_code' => 'NPR', + 'current_amount' => 0, + 'contribution_rate' => '0.1000', + 'trigger_threshold' => 1, + 'payout_rate' => '1.0000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 20_000, + 'status' => 1, + 'last_trigger_draw_id' => null, + ]); + + $player = jackpotTestPlayer('jpmin'); + jackpotOpenDraw('20260511-902'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-902', + 'currency_code' => 'NPR', + 'client_trace_id' => 'jp-min-1', + 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], + ]) + ->assertOk(); + + expect(JackpotContribution::query()->count())->toBe(0); + + JackpotPool::query()->where('currency_code', 'NPR')->update([ + 'min_bet_amount' => 0, + 'status' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-902', + 'currency_code' => 'NPR', + 'client_trace_id' => 'jp-off-1', + 'lines' => [['number' => '2234', 'play_code' => 'straight', 'amount' => 10_000]], + ]) + ->assertOk(); + + expect(JackpotContribution::query()->count())->toBe(0); +}); + +test('jackpot bursts by configured play combination trigger before threshold', function (): void { + Event::fake([JackpotBurstBroadcast::class]); + config([ + 'broadcasting.default' => 'reverb', + 'broadcasting.connections.reverb.driver' => 'reverb', + ]); + + JackpotPool::query()->create([ + 'currency_code' => 'NPR', + 'current_amount' => 50_000, + 'contribution_rate' => '0.0000', + 'trigger_threshold' => 999_999_999, + 'payout_rate' => '1.0000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + 'combo_trigger_play_codes' => ['straight'], + ]); + + $player = jackpotTestPlayer('jpcombo'); + $draw = jackpotOpenDraw('20260511-903'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-903', + 'currency_code' => 'NPR', + 'client_trace_id' => 'jp-combo-1', + 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], + ]) + ->assertOk(); + + jackpotPublishResults($draw, '1234'); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + expect((int) $item->jackpot_win_amount)->toBe(50_000) + ->and(JackpotPayoutLog::query()->firstOrFail()->trigger_type)->toBe('play_combo') + ->and((int) JackpotPool::query()->where('currency_code', 'NPR')->value('current_amount'))->toBe(0); + + Event::assertDispatched( + JackpotBurstBroadcast::class, + fn (JackpotBurstBroadcast $event): bool => $event->drawId === (int) $draw->id + && $event->drawNo === '20260511-903' + && $event->firstPrizeNumber === '1234' + && $event->currencyCode === 'NPR' + && $event->totalPayoutAmount === 50_000 + && $event->winnerCount === 1 + && $event->triggerType === 'play_combo' + && $event->poolAmountAfter === 0, + ); +}); + +test('jackpot splits burst payout between multiple winners by bet amount', function (): void { + JackpotPool::query()->create([ + 'currency_code' => 'NPR', + 'current_amount' => 90_000, + 'contribution_rate' => '0.0000', + 'trigger_threshold' => 1, + 'payout_rate' => '1.0000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ]); + + $playerA = jackpotTestPlayer('jpa'); + $playerB = jackpotTestPlayer('jpb'); + $draw = jackpotOpenDraw('20260511-904'); + + $this->withHeader('Authorization', 'Bearer dev:'.$playerA->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-904', + 'currency_code' => 'NPR', + 'client_trace_id' => 'jp-split-a', + 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], + ]) + ->assertOk(); + + $this->withHeader('Authorization', 'Bearer dev:'.$playerB->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-904', + 'currency_code' => 'NPR', + 'client_trace_id' => 'jp-split-b', + 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 20_000]], + ]) + ->assertOk(); + + jackpotPublishResults($draw, '1234'); + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + $amounts = TicketItem::query() + ->where('draw_id', $draw->id) + ->orderBy('total_bet_amount') + ->pluck('jackpot_win_amount') + ->map(fn ($v) => (int) $v) + ->all(); + + expect($amounts)->toBe([30_000, 60_000]); +}); + +test('jackpot summary and result payload expose pool amount and draw gap', function (): void { + $last = Draw::query()->create([ + 'draw_no' => '20260511-800', + 'business_date' => '2026-05-11', + 'sequence_no' => 800, + 'status' => DrawStatus::Settled->value, + 'start_time' => now()->subHours(3), + 'close_time' => now()->subHours(2), + 'draw_time' => now()->subHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + Draw::query()->create([ + 'draw_no' => '20260511-801', + 'business_date' => '2026-05-11', + 'sequence_no' => 801, + 'status' => DrawStatus::Settled->value, + 'start_time' => now()->subHours(2), + 'close_time' => now()->subHour(), + 'draw_time' => now()->subHour(), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + JackpotPool::query()->create([ + 'currency_code' => 'NPR', + 'current_amount' => 123_456, + 'contribution_rate' => '0.0100', + 'trigger_threshold' => 1_000_000, + 'payout_rate' => '0.5000', + 'force_trigger_draw_gap' => 10, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => $last->id, + ]); + + $draw = jackpotOpenDraw('20260511-905'); + jackpotPublishResults($draw, '1234'); + $draw->forceFill(['status' => DrawStatus::Cooldown->value])->save(); + + $this->getJson('/api/v1/jackpot/summary?currency_code=NPR') + ->assertOk() + ->assertJsonPath('data.current_amount_minor', 123_456) + ->assertJsonPath('data.draws_since_last_burst', 1); + + $this->getJson('/api/v1/draw/results/20260511-905') + ->assertOk() + ->assertJsonPath('data.jackpot.current_amount_minor', 123_456) + ->assertJsonPath('data.jackpot.draws_since_last_burst', 1); + + $summary = app(DrawResultViewService::class)->summarizeDraw($draw->fresh()); + expect($summary['jackpot']['current_amount_minor'] ?? null)->toBe(123_456); +}); diff --git a/tests/Feature/OperationalConfigAcceptanceTest.php b/tests/Feature/OperationalConfigAcceptanceTest.php index 7295bbe..03c834a 100644 --- a/tests/Feature/OperationalConfigAcceptanceTest.php +++ b/tests/Feature/OperationalConfigAcceptanceTest.php @@ -18,7 +18,11 @@ use App\Models\AdminUser; use App\Lottery\DrawStatus; use App\Models\OddsVersion; use App\Models\RiskCapVersion; +use App\Services\Ticket\PlayCatalogResolver; +use App\Events\OddsUpdateBroadcast; +use App\Events\PlayToggleBroadcast; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use App\Lottery\ConfigVersionStatus; use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; @@ -48,7 +52,7 @@ function acceptanceMintAdminToken(): string return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } -function oddsPutPayloadFromDetail(array $items): array +function acceptanceOddsPutPayloadFromDetail(array $items): array { return collect($items)->map(fn (array $r) => [ 'play_code' => $r['play_code'], @@ -104,7 +108,7 @@ test('§12.6 published odds are visible on public effective catalog without code $draftId = (int) $create->json('data.id'); $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); - $payload = oddsPutPayloadFromDetail($detail); + $payload = acceptanceOddsPutPayloadFromDetail($detail); foreach ($payload as &$row) { if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') { $row['odds_value'] = 333_333; @@ -132,7 +136,7 @@ test('§5 odds publish archives prior version lists history and writes audit log $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); $this->putJson( '/api/v1/admin/config/odds-versions/'.$draftId.'/items', - ['items' => oddsPutPayloadFromDetail($detail)], + ['items' => acceptanceOddsPutPayloadFromDetail($detail)], $auth, )->assertOk(); @@ -235,7 +239,7 @@ test('§5 existing ticket_items odds snapshot row is not mutated when new odds v $draftId = (int) $create->json('data.id'); $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); - $payload = oddsPutPayloadFromDetail($detail); + $payload = acceptanceOddsPutPayloadFromDetail($detail); foreach ($payload as &$row) { if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') { $row['odds_value'] = 9_999_999; @@ -330,6 +334,38 @@ test('§5 risk cap publish is audited and version history exists', function (): expect(count($list))->toBeGreaterThanOrEqual(2); }); +test('§10 default risk cap template applies to unconfigured numbers', function (): void { + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $create = $this->postJson('/api/v1/admin/config/risk-cap-versions', ['reason' => 'default risk cap'], $auth); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $this->putJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/items', [ + 'items' => [ + [ + 'draw_id' => null, + 'normalized_number' => '0000', + 'cap_amount' => 12_345, + 'cap_type' => 'default', + ], + [ + 'draw_id' => null, + 'normalized_number' => '1234', + 'cap_amount' => 777, + 'cap_type' => 'per_number', + ], + ], + ], $auth)->assertOk(); + + $this->postJson('/api/v1/admin/config/risk-cap-versions/'.$draftId.'/publish', [], $auth)->assertOk(); + + $resolver = app(PlayCatalogResolver::class); + expect($resolver->resolveCapAmount(9999, '5678'))->toBe(12_345) + ->and($resolver->resolveCapAmount(9999, '1234'))->toBe(777); +}); + test('§5 play_config publish is audited', function (): void { $token = acceptanceMintAdminToken(); $auth = ['Authorization' => 'Bearer '.$token]; @@ -367,3 +403,66 @@ test('§5 play_config publish is audited', function (): void { ->exists(), )->toBeTrue(); }); + +test('§9 play_config publish broadcasts changed play toggles', function (): void { + Event::fake([PlayToggleBroadcast::class]); + config([ + 'broadcasting.default' => 'reverb', + 'broadcasting.connections.reverb.driver' => 'reverb', + ]); + + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $create = $this->postJson('/api/v1/admin/config/play-versions', ['reason' => 'toggle broadcast'], $auth); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $itemPayload = $create->json('data.items'); + foreach ($itemPayload as &$row) { + if ($row['play_code'] === 'big') { + $row['is_enabled'] = false; + } + } + unset($row); + + $this->putJson('/api/v1/admin/config/play-versions/'.$draftId.'/items', ['items' => $itemPayload], $auth)->assertOk(); + $this->postJson('/api/v1/admin/config/play-versions/'.$draftId.'/publish', [], $auth)->assertOk(); + + Event::assertDispatched( + PlayToggleBroadcast::class, + fn (PlayToggleBroadcast $event): bool => $event->playCode === 'big' && $event->enabled === false, + ); +}); + +test('§9 odds publish broadcasts odds update', function (): void { + Event::fake([OddsUpdateBroadcast::class]); + config([ + 'broadcasting.default' => 'reverb', + 'broadcasting.connections.reverb.driver' => 'reverb', + ]); + + $token = acceptanceMintAdminToken(); + $auth = ['Authorization' => 'Bearer '.$token]; + + $create = $this->postJson('/api/v1/admin/config/odds-versions', ['reason' => 'odds broadcast'], $auth); + $create->assertOk(); + $draftId = (int) $create->json('data.id'); + + $detail = $this->getJson('/api/v1/admin/config/odds-versions/'.$draftId, $auth)->assertOk()->json('data.items'); + $payload = acceptanceOddsPutPayloadFromDetail($detail); + foreach ($payload as &$row) { + if ($row['play_code'] === 'big' && $row['prize_scope'] === 'first') { + $row['odds_value'] = 444_444; + } + } + unset($row); + + $this->putJson('/api/v1/admin/config/odds-versions/'.$draftId.'/items', ['items' => $payload], $auth)->assertOk(); + $this->postJson('/api/v1/admin/config/odds-versions/'.$draftId.'/publish', [], $auth)->assertOk(); + + Event::assertDispatched( + OddsUpdateBroadcast::class, + fn (OddsUpdateBroadcast $event): bool => $event->versionId === $draftId, + ); +}); diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index 1585213..e10a9a1 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -10,6 +10,7 @@ use App\Lottery\DrawStatus; use App\Models\OddsVersion; use App\Models\TicketOrder; use App\Models\PlayerWallet; +use App\Models\TicketCombination; use App\Models\PlayConfigItem; use App\Models\PlayConfigVersion; use App\Lottery\ConfigVersionStatus; @@ -586,3 +587,185 @@ test('ticket place sold out for second player after first consumes shared pool', ->firstOrFail(); expect((int) $pool->remaining_amount)->toBe(2000); }); + +test('ticket pending confirmation reconcile releases risk when wallet deduction is missing', function (): void { + $draw = ticketOpenDraw(); + $player = ticketPlayerWithWallet(); + + $order = TicketOrder::query()->create([ + 'order_no' => 'TO-PENDING-001', + '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' => 3000, + 'status' => 'pending_confirm', + 'submit_source' => 'h5', + 'client_trace_id' => 'pending-confirm-missing-wallet', + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]); + + $item = TicketItem::query()->create([ + 'ticket_no' => 'TK-PENDING-001', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => 'straight', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => [], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 3000, + 'risk_locked_amount' => 3000, + 'status' => 'pending_confirm', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + + TicketCombination::query()->create([ + 'ticket_item_id' => $item->id, + 'combination_no' => 1, + 'number_4d' => '1234', + 'bet_amount' => 100, + 'estimated_payout' => 3000, + 'created_at' => now()->subMinutes(20), + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 5000, + 'locked_amount' => 3000, + 'remaining_amount' => 2000, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100') + ->expectsOutputToContain('refunded: 1') + ->assertExitCode(0); + + expect($order->fresh()->status)->toBe('refunded') + ->and($item->fresh()->status)->toBe('refunded') + ->and($item->fresh()->fail_reason_text)->toBe('pending_confirm_timeout_refund'); + + $pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail(); + expect((int) $pool->locked_amount)->toBe(0) + ->and((int) $pool->remaining_amount)->toBe(5000) + ->and(WalletTxn::query()->where('biz_no', 'TO-PENDING-001')->count())->toBe(0); +}); + +test('ticket pending confirmation reconcile confirms order when wallet deduction exists', function (): void { + $draw = ticketOpenDraw(); + $player = ticketPlayerWithWallet(10_000); + $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); + + $order = TicketOrder::query()->create([ + 'order_no' => 'TO-PENDING-002', + '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' => 3000, + 'status' => 'pending_confirm', + 'submit_source' => 'h5', + 'client_trace_id' => 'pending-confirm-with-wallet', + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]); + + $item = TicketItem::query()->create([ + 'ticket_no' => 'TK-PENDING-002', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => 'straight', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => [], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 3000, + 'risk_locked_amount' => 3000, + 'status' => 'pending_confirm', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + + TicketCombination::query()->create([ + 'ticket_item_id' => $item->id, + 'combination_no' => 1, + 'number_4d' => '1234', + 'bet_amount' => 100, + 'estimated_payout' => 3000, + 'created_at' => now()->subMinutes(20), + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 5000, + 'locked_amount' => 3000, + 'remaining_amount' => 2000, + 'sold_out_status' => 0, + 'version' => 1, + ]); + + WalletTxn::query()->create([ + 'txn_no' => 'WL-PENDING-002', + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'bet_deduct', + 'biz_no' => 'TO-PENDING-002', + 'direction' => 2, + 'amount' => 100, + 'balance_before' => 10_000, + 'balance_after' => 9_900, + 'status' => 'posted', + 'external_ref_no' => null, + 'idempotent_key' => 'pending-confirm-with-wallet', + 'remark' => null, + ]); + + $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100') + ->expectsOutputToContain('confirmed: 1') + ->assertExitCode(0); + + expect($order->fresh()->status)->toBe('placed') + ->and($item->fresh()->status)->toBe('success') + ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(3000); +});