From 4f143c7cb13e435da18593fa71d953bcef8f8ff3 Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 16 May 2026 18:01:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8B=86=E5=88=86=E5=BC=80=E5=A5=96?= =?UTF-8?q?=E4=B8=8E=E7=BB=93=E7=AE=97=E5=AE=A1=E6=A0=B8=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E6=89=8B=E5=8A=A8=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=BD=95=E5=85=A5=E3=80=81=E9=87=8D=E5=BC=80=E5=92=8C=E6=B4=BE?= =?UTF-8?q?=E5=BD=A9=E5=AE=A1=E6=89=B9=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/Draw/AdminDrawIndexController.php | 10 + .../V1/Admin/Draw/DrawCancelController.php | 31 ++ .../Admin/Draw/DrawManualCloseController.php | 32 ++ .../DrawManualResultBatchStoreController.php | 57 +++ .../Admin/Draw/DrawPlanGenerateController.php | 20 + .../V1/Admin/Draw/DrawReopenController.php | 59 +++ .../V1/Admin/Draw/DrawRngRunController.php | 50 +++ .../AdminSettlementBatchApproveController.php | 37 ++ .../AdminSettlementBatchExportController.php | 34 ++ .../AdminSettlementBatchIndexController.php | 3 + .../AdminSettlementBatchPayoutController.php | 30 ++ .../AdminSettlementBatchRejectController.php | 37 ++ .../AdminSettlementBatchShowController.php | 5 + .../V1/Ticket/TicketItemShowController.php | 88 +++++ .../V1/Ticket/TicketItemsIndexController.php | 39 ++ .../DrawManualResultBatchStoreRequest.php | 54 +++ app/Http/Requests/Admin/DrawReopenRequest.php | 20 + .../Admin/SettlementBatchReviewRequest.php | 20 + app/Lottery/SettlementBatchStatus.php | 8 + app/Models/SettlementBatch.php | 8 + app/Services/Draw/DrawAdminActionService.php | 51 +++ app/Services/Draw/DrawManualResultService.php | 84 ++++ app/Services/Draw/DrawReopenService.php | 35 ++ .../Matchers/Pos2AbcSettlementMatcher.php | 62 ++- .../Matchers/Pos2TierSettlementMatcher.php | 21 +- .../Matchers/Pos3AbcSettlementMatcher.php | 62 ++- .../Matchers/Pos3TierSettlementMatcher.php | 21 +- .../SettlementBatchWorkflowService.php | 134 +++++++ .../Settlement/SettlementOrchestrator.php | 47 +-- app/Services/Ticket/PlayRuleEngine.php | 3 + ...8_create_settlement_and_jackpot_tables.php | 5 + routes/api/v1/admin/draw.php | 42 +- tests/Feature/DrawPipelineTest.php | 363 +++++++++++++++++- .../JackpotPlacementSettlementTest.php | 16 + tests/Feature/SettlementOrchestratorTest.php | 135 +++++++ .../SettlementPhase145AcceptanceTest.php | 115 +++++- tests/Feature/TicketBettingApiTest.php | 94 +++++ tests/Feature/TicketItemsApiTest.php | 230 ++++++++++- 38 files changed, 1992 insertions(+), 170 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/DrawCancelController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/DrawManualCloseController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/DrawManualResultBatchStoreController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/DrawPlanGenerateController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/DrawReopenController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Draw/DrawRngRunController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchApproveController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchExportController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchPayoutController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchRejectController.php create mode 100644 app/Http/Requests/Admin/DrawManualResultBatchStoreRequest.php create mode 100644 app/Http/Requests/Admin/DrawReopenRequest.php create mode 100644 app/Http/Requests/Admin/SettlementBatchReviewRequest.php create mode 100644 app/Services/Draw/DrawAdminActionService.php create mode 100644 app/Services/Draw/DrawManualResultService.php create mode 100644 app/Services/Draw/DrawReopenService.php create mode 100644 app/Services/Settlement/SettlementBatchWorkflowService.php diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php index 735e4f6..7cfb852 100644 --- a/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Draw/AdminDrawIndexController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw; use Carbon\Carbon; use App\Models\Draw; +use App\Models\TicketItem; +use App\Models\TicketOrder; use Illuminate\Http\Request; use App\Support\AdminApiList; use Illuminate\Http\JsonResponse; @@ -56,6 +58,14 @@ final class AdminDrawIndexController extends Controller 'current_result_version' => (int) $draw->current_result_version, 'settle_version' => (int) $draw->settle_version, 'is_reopened' => (bool) $draw->is_reopened, + 'total_bet_minor' => (int) TicketOrder::query()->where('draw_id', $draw->id)->sum('total_actual_deduct'), + 'total_payout_minor' => (int) TicketItem::query()->where('draw_id', $draw->id)->sum('win_amount') + + (int) TicketItem::query()->where('draw_id', $draw->id)->sum('jackpot_win_amount'), + 'profit_loss_minor' => (int) TicketOrder::query()->where('draw_id', $draw->id)->sum('total_actual_deduct') + - ( + (int) TicketItem::query()->where('draw_id', $draw->id)->sum('win_amount') + + (int) TicketItem::query()->where('draw_id', $draw->id)->sum('jackpot_win_amount') + ), 'updated_at' => $draw->updated_at?->toIso8601String(), ]; } diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawCancelController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawCancelController.php new file mode 100644 index 0000000..721a93a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawCancelController.php @@ -0,0 +1,31 @@ +service->cancelBeforeResult($draw); + } catch (\RuntimeException) { + return ApiResponse::error(trans('api.client_error'), ErrorCode::ClientHttpError->value, null, 409); + } + + return ApiResponse::success([ + 'draw_no' => $cancelled->draw_no, + 'status' => $cancelled->status, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualCloseController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualCloseController.php new file mode 100644 index 0000000..3cfea64 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualCloseController.php @@ -0,0 +1,32 @@ +service->manualClose($draw); + } catch (\RuntimeException) { + return ApiResponse::error(trans('api.client_error'), ErrorCode::ClientHttpError->value, null, 409); + } + + return ApiResponse::success([ + 'draw_no' => $closed->draw_no, + 'status' => $closed->status, + 'close_time' => $closed->close_time?->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualResultBatchStoreController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualResultBatchStoreController.php new file mode 100644 index 0000000..84b9966 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawManualResultBatchStoreController.php @@ -0,0 +1,57 @@ +user(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + + try { + $batch = $this->service->createPendingBatch($draw, $admin, $request->validated('items')); + } catch (\RuntimeException) { + return ApiResponse::error( + trans('api.client_error', [], $request->lotteryLocale()), + ErrorCode::ClientHttpError->value, + null, + 409, + ); + } + + $draw->refresh(); + + return ApiResponse::success([ + 'draw_no' => $draw->draw_no, + 'status' => $draw->status, + 'batch' => [ + 'id' => (int) $batch->id, + 'result_version' => (int) $batch->result_version, + 'source_type' => $batch->source_type, + 'status' => $batch->status, + 'items_count' => $batch->items()->count(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawPlanGenerateController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawPlanGenerateController.php new file mode 100644 index 0000000..ab82ccf --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawPlanGenerateController.php @@ -0,0 +1,20 @@ +planner->ensureBuffer()); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawReopenController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawReopenController.php new file mode 100644 index 0000000..ce7cb8a --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawReopenController.php @@ -0,0 +1,59 @@ +user(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error( + trans('admin.unauthenticated', [], $request->lotteryLocale()), + ErrorCode::AdminUnauthenticated->value, + null, + 401, + ); + } + if (! $admin->isSuperAdmin()) { + return ApiResponse::error( + trans('admin.permission_denied', [], $request->lotteryLocale()), + ErrorCode::AdminForbidden->value, + ['required_any' => [AdminUser::ROLE_SUPER_ADMIN]], + 403, + ); + } + + try { + $reopened = $this->service->reopenCooldownDraw($draw, $admin, $request->validated('reason') ?? null); + } catch (\RuntimeException) { + return ApiResponse::error( + trans('api.client_error', [], $request->lotteryLocale()), + ErrorCode::ClientHttpError->value, + null, + 409, + ); + } + + return ApiResponse::success([ + 'draw_no' => $reopened->draw_no, + 'status' => $reopened->status, + 'is_reopened' => (bool) $reopened->is_reopened, + 'current_result_version' => (int) $reopened->current_result_version, + 'cooling_end_time' => $reopened->cooling_end_time?->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Draw/DrawRngRunController.php b/app/Http/Controllers/Api/V1/Admin/Draw/DrawRngRunController.php new file mode 100644 index 0000000..7dac739 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Draw/DrawRngRunController.php @@ -0,0 +1,50 @@ +whereKey($draw->id)->lockForUpdate()->firstOrFail(); + if ($locked->status !== DrawStatus::Closed->value || $locked->resultBatches()->exists()) { + throw new \RuntimeException('draw_not_runnable'); + } + + return $this->rng->executeLocked($locked); + }); + } catch (\RuntimeException) { + return ApiResponse::error(trans('api.client_error'), ErrorCode::ClientHttpError->value, null, 409); + } + + $draw->refresh(); + + return ApiResponse::success([ + 'draw_no' => $draw->draw_no, + 'status' => $draw->status, + 'batch' => [ + 'id' => (int) $batch->id, + 'result_version' => (int) $batch->result_version, + 'source_type' => $batch->source_type, + 'status' => $batch->status, + 'items_count' => $batch->items()->count(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchApproveController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchApproveController.php new file mode 100644 index 0000000..1910f10 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchApproveController.php @@ -0,0 +1,37 @@ +user(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error(trans('admin.unauthenticated'), ErrorCode::AdminUnauthenticated->value, null, 401); + } + + try { + $updated = $this->service->approve($batch, $admin, $request->validated('remark') ?? null); + } catch (\RuntimeException) { + return ApiResponse::error(trans('api.client_error'), ErrorCode::ClientHttpError->value, null, 409); + } + + return ApiResponse::success([ + 'id' => (int) $updated->id, + 'status' => $updated->status, + 'review_status' => $updated->review_status, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchExportController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchExportController.php new file mode 100644 index 0000000..00a8f76 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchExportController.php @@ -0,0 +1,34 @@ +load(['draw:id,draw_no']); + $filename = 'settlement-'.$batch->id.'-'.($batch->draw?->draw_no ?? 'draw').'.csv'; + + return response()->streamDownload(function () use ($batch): void { + $out = fopen('php://output', 'w'); + fputcsv($out, ['ticket_no', 'play_code', 'player_id', 'matched_prize_tier', 'win_amount', 'jackpot_amount', 'match_detail']); + $batch->details()->with('ticketItem')->orderBy('id')->chunk(200, function ($rows) use ($out): void { + foreach ($rows as $row) { + fputcsv($out, [ + $row->ticketItem?->ticket_no, + $row->ticketItem?->play_code, + $row->ticketItem?->player_id, + $row->matched_prize_tier, + (int) $row->win_amount, + (int) $row->jackpot_allocation_amount, + json_encode($row->match_detail_json, JSON_UNESCAPED_UNICODE), + ]); + } + }); + fclose($out); + }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php index 5406458..7f502e9 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php @@ -46,6 +46,9 @@ final class AdminSettlementBatchIndexController extends Controller 'result_batch_id' => (int) $b->result_batch_id, 'settle_version' => (int) $b->settle_version, 'status' => $b->status, + 'review_status' => $b->review_status, + 'reviewed_at' => $b->reviewed_at?->toIso8601String(), + 'paid_at' => $b->paid_at?->toIso8601String(), 'total_ticket_count' => (int) $b->total_ticket_count, 'total_win_count' => (int) $b->total_win_count, 'total_payout_amount' => (int) $b->total_payout_amount, diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchPayoutController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchPayoutController.php new file mode 100644 index 0000000..83e71d0 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchPayoutController.php @@ -0,0 +1,30 @@ +service->payout($batch); + } catch (\RuntimeException) { + return ApiResponse::error(trans('api.client_error'), ErrorCode::ClientHttpError->value, null, 409); + } + + return ApiResponse::success([ + 'id' => (int) $updated->id, + 'status' => $updated->status, + 'paid_at' => $updated->paid_at?->toIso8601String(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchRejectController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchRejectController.php new file mode 100644 index 0000000..da673e9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchRejectController.php @@ -0,0 +1,37 @@ +user(); + if (! $admin instanceof AdminUser) { + return ApiResponse::error(trans('admin.unauthenticated'), ErrorCode::AdminUnauthenticated->value, null, 401); + } + + try { + $updated = $this->service->reject($batch, $admin, $request->validated('remark') ?? null); + } catch (\RuntimeException) { + return ApiResponse::error(trans('api.client_error'), ErrorCode::ClientHttpError->value, null, 409); + } + + return ApiResponse::success([ + 'id' => (int) $updated->id, + 'status' => $updated->status, + 'review_status' => $updated->review_status, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php index f387fad..6844e16 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php @@ -26,6 +26,11 @@ final class AdminSettlementBatchShowController extends Controller 'result_batch_status' => $batch->resultBatch?->status, 'settle_version' => (int) $batch->settle_version, 'status' => $batch->status, + 'review_status' => $batch->review_status, + 'reviewed_by' => $batch->reviewed_by, + 'reviewed_at' => $batch->reviewed_at?->toIso8601String(), + 'review_remark' => $batch->review_remark, + 'paid_at' => $batch->paid_at?->toIso8601String(), 'total_ticket_count' => (int) $batch->total_ticket_count, 'total_win_count' => (int) $batch->total_win_count, 'total_payout_amount' => (int) $batch->total_payout_amount, diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php index cfe5abe..a567677 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemShowController.php @@ -4,7 +4,10 @@ namespace App\Http\Controllers\Api\V1\Ticket; use App\Models\Player; use App\Lottery\ErrorCode; +use App\Models\SettlementBatch; use App\Models\TicketItem; +use App\Models\TicketOrder; +use App\Models\WalletTxn; use App\Support\ApiResponse; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; @@ -51,6 +54,89 @@ final class TicketItemShowController extends Controller $drawPayload = $published && $draw !== null ? $this->drawResultView->summarizeDraw($draw) : null; $detail = $item->latestSettlementDetail; + $settlementBatch = $detail?->batch; + $order = $item->order; + $betTxn = WalletTxn::query() + ->where('player_id', $player->id) + ->where('biz_type', 'bet_deduct') + ->where('biz_no', $order?->order_no) + ->orderByDesc('id') + ->first(); + $payoutTxn = WalletTxn::query() + ->where('player_id', $player->id) + ->where('biz_type', 'settle_payout') + ->where('biz_no', 'like', 'SB%') + ->orderByDesc('id') + ->first(); + + $timeline = []; + if ($order?->created_at !== null) { + $timeline[] = [ + 'code' => 'placed', + 'label' => '已下注', + 'time' => $order->created_at->toIso8601String(), + ]; + } + if ($betTxn?->created_at !== null) { + $timeline[] = [ + 'code' => 'deducted', + 'label' => '已扣款', + 'time' => $betTxn->created_at->toIso8601String(), + ]; + } + if ($drawPayload !== null && $draw?->current_result_version !== null) { + $timeline[] = [ + 'code' => 'draw_published', + 'label' => '开奖结果已发布', + 'time' => $draw->draw_time?->toIso8601String() ?? $draw->updated_at?->toIso8601String(), + ]; + } + if ($settlementBatch?->started_at !== null) { + $timeline[] = [ + 'code' => 'settlement_started', + 'label' => '结算开始', + 'time' => $settlementBatch->started_at->toIso8601String(), + ]; + } + if ($item->settled_at !== null) { + $timeline[] = [ + 'code' => 'settled', + 'label' => $item->status === 'settled_win' ? '已派彩' : '已结算', + 'time' => $item->settled_at->toIso8601String(), + ]; + } elseif ($payoutTxn?->created_at !== null) { + $timeline[] = [ + 'code' => 'settled', + 'label' => '已派彩', + 'time' => $payoutTxn->created_at->toIso8601String(), + ]; + } + + $matchDetail = $detail?->match_detail_json; + $matchedLines = []; + if (is_array($matchDetail['lines'] ?? null)) { + foreach ($matchDetail['lines'] as $line) { + if (! is_array($line)) { + continue; + } + $matchedLines[] = [ + 'number_4d' => isset($line['number_4d']) ? (string) $line['number_4d'] : null, + 'matched_tier' => isset($line['matched_tier']) ? (string) $line['matched_tier'] : null, + 'bet_amount' => isset($line['bet_amount']) ? (int) $line['bet_amount'] : null, + 'odds_value' => isset($line['odds_value']) ? (int) $line['odds_value'] : null, + 'payout' => isset($line['payout']) ? (int) $line['payout'] : null, + ]; + } + } + + $matchResult = [ + 'matched' => $detail !== null && ((int) $detail->win_amount > 0 || (int) $detail->jackpot_allocation_amount > 0), + 'matched_prize_tier' => $detail?->matched_prize_tier, + 'win_amount_minor' => $detail !== null ? (int) $detail->win_amount : 0, + 'jackpot_allocation_minor' => $detail !== null ? (int) $detail->jackpot_allocation_amount : 0, + 'match_detail' => $matchDetail, + 'lines' => $matchedLines, + ]; return ApiResponse::success([ 'ticket_no' => $item->ticket_no, @@ -83,6 +169,8 @@ final class TicketItemShowController extends Controller 'win_amount_minor' => (int) $detail->win_amount, 'jackpot_allocation_minor' => (int) $detail->jackpot_allocation_amount, ], + 'match_result' => $matchResult, + 'timeline' => $timeline, 'published_draw_results' => $drawPayload, ]); } diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php index 821be7d..c760572 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Ticket; use App\Models\Player; use App\Models\TicketItem; +use App\Models\WalletTxn; use App\Support\ApiResponse; use Illuminate\Http\Request; use App\Support\PaginationTrait; @@ -26,6 +27,14 @@ final class TicketItemsIndexController extends Controller $perPage = $this->perPage($request, 'per_page', 20, 50); $page = $this->page($request); $drawNo = $request->query('draw_no'); + $statusInput = $request->query('status', []); + $statusValues = is_array($statusInput) ? array_values(array_filter(array_map( + fn ($status) => is_string($status) ? trim($status) : '', + $statusInput, + ))) : []; + $number = trim((string) $request->query('number', '')); + $startDate = $this->normalizeDate((string) $request->query('start_date', '')); + $endDate = $this->normalizeDate((string) $request->query('end_date', '')); $query = TicketItem::query() ->where('ticket_items.player_id', $player->id) @@ -40,6 +49,26 @@ final class TicketItemsIndexController extends Controller $query->whereHas('draw', fn ($q) => $q->where('draw_no', $drawNo)); } + if ($statusValues !== []) { + $query->whereIn('ticket_items.status', $statusValues); + } + + if ($number !== '') { + $query->where(function ($q) use ($number): void { + $q->where('ticket_items.original_number', 'like', '%'.$number.'%') + ->orWhere('ticket_items.normalized_number', 'like', '%'.$number.'%') + ->orWhere('ticket_items.ticket_no', 'like', '%'.$number.'%'); + }); + } + + if ($startDate !== null) { + $query->whereHas('order', fn ($q) => $q->whereDate('created_at', '>=', $startDate)); + } + + if ($endDate !== null) { + $query->whereHas('order', fn ($q) => $q->whereDate('created_at', '<=', $endDate)); + } + $paginator = $query->paginate(perPage: $perPage, page: $page); $items = collect($paginator->items())->map(function (TicketItem $row): array { @@ -77,4 +106,14 @@ final class TicketItemsIndexController extends Controller 'last_page' => $paginator->lastPage(), ]); } + + private function normalizeDate(string $value): ?string + { + $value = trim($value); + if ($value === '' || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return null; + } + + return $value; + } } diff --git a/app/Http/Requests/Admin/DrawManualResultBatchStoreRequest.php b/app/Http/Requests/Admin/DrawManualResultBatchStoreRequest.php new file mode 100644 index 0000000..55e8189 --- /dev/null +++ b/app/Http/Requests/Admin/DrawManualResultBatchStoreRequest.php @@ -0,0 +1,54 @@ + ['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'], + 'items.*.number_4d' => ['required', 'regex:/^[0-9]{4}$/'], + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator): void { + $expected = $this->slotKeys(DrawPrizeLayout::slots()); + $actual = $this->slotKeys((array) $this->input('items', [])); + + sort($expected); + sort($actual); + + if ($actual !== $expected) { + $validator->errors()->add('items', 'items must contain the complete 23 draw prize slots.'); + } + }); + } + + /** + * @param array> $items + * @return list + */ + private function slotKeys(array $items): array + { + $keys = []; + foreach ($items as $item) { + $keys[] = (string) ($item['prize_type'] ?? '').':'.(string) ($item['prize_index'] ?? ''); + } + + return $keys; + } +} diff --git a/app/Http/Requests/Admin/DrawReopenRequest.php b/app/Http/Requests/Admin/DrawReopenRequest.php new file mode 100644 index 0000000..6cc0145 --- /dev/null +++ b/app/Http/Requests/Admin/DrawReopenRequest.php @@ -0,0 +1,20 @@ + ['sometimes', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Admin/SettlementBatchReviewRequest.php b/app/Http/Requests/Admin/SettlementBatchReviewRequest.php new file mode 100644 index 0000000..af9f9a8 --- /dev/null +++ b/app/Http/Requests/Admin/SettlementBatchReviewRequest.php @@ -0,0 +1,20 @@ + ['sometimes', 'nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Lottery/SettlementBatchStatus.php b/app/Lottery/SettlementBatchStatus.php index 6f50620..1da039b 100644 --- a/app/Lottery/SettlementBatchStatus.php +++ b/app/Lottery/SettlementBatchStatus.php @@ -7,7 +7,15 @@ enum SettlementBatchStatus: string { case Running = 'running'; + case PendingReview = 'pending_review'; + + case Approved = 'approved'; + + case Rejected = 'rejected'; + case Completed = 'completed'; + case Paid = 'paid'; + case Failed = 'failed'; } diff --git a/app/Models/SettlementBatch.php b/app/Models/SettlementBatch.php index d381945..ffb2397 100644 --- a/app/Models/SettlementBatch.php +++ b/app/Models/SettlementBatch.php @@ -19,6 +19,11 @@ final class SettlementBatch extends Model 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', + 'review_status', + 'reviewed_by', + 'reviewed_at', + 'review_remark', + 'paid_at', 'started_at', 'finished_at', ]; @@ -33,6 +38,9 @@ final class SettlementBatch extends Model 'total_win_count' => 'integer', 'total_payout_amount' => 'integer', 'total_jackpot_payout_amount' => 'integer', + 'reviewed_by' => 'integer', + 'reviewed_at' => 'datetime', + 'paid_at' => 'datetime', 'started_at' => 'datetime', 'finished_at' => 'datetime', ]; diff --git a/app/Services/Draw/DrawAdminActionService.php b/app/Services/Draw/DrawAdminActionService.php new file mode 100644 index 0000000..2dfba37 --- /dev/null +++ b/app/Services/Draw/DrawAdminActionService.php @@ -0,0 +1,51 @@ +whereKey($draw->id)->lockForUpdate()->firstOrFail(); + if (! in_array($locked->status, [DrawStatus::Open->value, DrawStatus::Pending->value], true)) { + throw new \RuntimeException('draw_not_closeable'); + } + + $locked->forceFill([ + 'status' => DrawStatus::Closing->value, + 'close_time' => now(), + ])->save(); + + return $locked->refresh(); + }); + } + + public function cancelBeforeResult(Draw $draw): Draw + { + return DB::transaction(function () use ($draw): Draw { + /** @var Draw $locked */ + $locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail(); + if (! in_array($locked->status, [ + DrawStatus::Pending->value, + DrawStatus::Open->value, + DrawStatus::Closing->value, + DrawStatus::Closed->value, + ], true)) { + throw new \RuntimeException('draw_not_cancelable'); + } + if ($locked->resultBatches()->exists()) { + throw new \RuntimeException('draw_result_exists'); + } + + $locked->forceFill(['status' => DrawStatus::Cancelled->value])->save(); + + return $locked->refresh(); + }); + } +} diff --git a/app/Services/Draw/DrawManualResultService.php b/app/Services/Draw/DrawManualResultService.php new file mode 100644 index 0000000..d6ab8dc --- /dev/null +++ b/app/Services/Draw/DrawManualResultService.php @@ -0,0 +1,84 @@ + $items + */ + public function createPendingBatch(Draw $draw, AdminUser $admin, array $items): DrawResultBatch + { + return DB::transaction(function () use ($draw, $admin, $items): DrawResultBatch { + /** @var Draw $locked */ + $locked = Draw::query()->whereKey($draw->id)->lockForUpdate()->firstOrFail(); + if (! in_array($locked->status, [DrawStatus::Closed->value, DrawStatus::Review->value], true)) { + throw new \RuntimeException('draw_not_editable'); + } + if ($locked->settle_version > 0 || $locked->status === DrawStatus::Settled->value) { + throw new \RuntimeException('draw_already_settled'); + } + + $nextVersion = max(1, (int) $locked->current_result_version + 1); + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $locked->id, + 'result_version' => $nextVersion, + 'source_type' => DrawResultSourceType::Manual->value, + 'rng_seed_hash' => null, + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::PendingReview->value, + 'created_by' => $admin->id, + 'confirmed_by' => null, + 'confirmed_at' => null, + ]); + + foreach ($this->sortByLayout($items) as $item) { + $number = (string) $item['number_4d']; + DrawResultItem::query()->create([ + 'draw_id' => $locked->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $item['prize_type'], + 'prize_index' => (int) $item['prize_index'], + 'number_4d' => $number, + 'suffix_3d' => substr($number, -3), + 'suffix_2d' => substr($number, -2), + 'head_digit' => (int) substr($number, 0, 1), + 'tail_digit' => (int) substr($number, 3, 1), + ]); + } + + $locked->forceFill([ + 'status' => DrawStatus::Review->value, + 'result_source' => DrawResultSourceType::Manual->value, + ])->save(); + + return $batch->fresh(['items']); + }); + } + + /** + * @param list $items + * @return list + */ + private function sortByLayout(array $items): array + { + $order = []; + foreach (DrawPrizeLayout::slots() as $i => $slot) { + $order[$slot['prize_type'].':'.$slot['prize_index']] = $i; + } + + usort($items, fn (array $a, array $b): int => ($order[$a['prize_type'].':'.$a['prize_index']] ?? 99) + <=> ($order[$b['prize_type'].':'.$b['prize_index']] ?? 99)); + + return $items; + } +} diff --git a/app/Services/Draw/DrawReopenService.php b/app/Services/Draw/DrawReopenService.php new file mode 100644 index 0000000..b8cd362 --- /dev/null +++ b/app/Services/Draw/DrawReopenService.php @@ -0,0 +1,35 @@ +whereKey($draw->id)->lockForUpdate()->firstOrFail(); + if ($locked->status !== DrawStatus::Cooldown->value) { + throw new \RuntimeException('draw_not_in_cooldown'); + } + if ((int) $locked->settle_version > 0) { + throw new \RuntimeException('draw_already_settled'); + } + + $locked->forceFill([ + 'status' => DrawStatus::Closed->value, + 'cooling_end_time' => null, + 'is_reopened' => true, + ])->save(); + + return $locked->refresh(); + }); + } +} diff --git a/app/Services/Settlement/Matchers/Pos2AbcSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos2AbcSettlementMatcher.php index f153c50..f3f4dab 100644 --- a/app/Services/Settlement/Matchers/Pos2AbcSettlementMatcher.php +++ b/app/Services/Settlement/Matchers/Pos2AbcSettlementMatcher.php @@ -3,7 +3,6 @@ namespace App\Services\Settlement\Matchers; use App\Models\TicketItem; -use App\Models\TicketCombination; use Illuminate\Support\Collection; use App\Services\Settlement\OddsSnapshotReader; use App\Services\Settlement\PublishedDrawResultBoard; @@ -36,42 +35,39 @@ final class Pos2AbcSettlementMatcher implements SettlementPlayMatcher $bestTier = null; $bestRank = 99; - foreach ($combinations as $c) { - /** @var TicketCombination $c */ - $n = (string) $c->number_4d; - if (strlen($n) < 2) { + $suf = substr((string) $item->normalized_number, -2); + $hitTier = null; + $rank = 99; + foreach ($suffixByTier as $t => $sx) { + if ($suf !== $sx) { continue; } - $suf = substr($n, -2); - $hitTier = null; - $rank = 99; - foreach ($suffixByTier as $t => $sx) { - if ($suf !== $sx) { - continue; - } - $r = match ($t) { - 'first' => 0, - 'second' => 1, - 'third' => 2, - default => 99, - }; - if ($r < $rank) { - $rank = $r; - $hitTier = $t; - } - } - if ($hitTier === null) { - continue; + $r = match ($t) { + 'first' => 0, + 'second' => 1, + 'third' => 2, + default => 99, + }; + if ($r < $rank) { + $rank = $r; + $hitTier = $t; } + } + + if ($hitTier !== null) { $oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier); - $bet = (int) $c->bet_amount; - $payout = (int) floor($bet * ($oddsVal / 10_000)); - $total += $payout; - $lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout]; - if ($rank < $bestRank) { - $bestRank = $rank; - $bestTier = $hitTier; - } + $bet = (int) $item->unit_bet_amount; + $total = (int) floor($bet * ($oddsVal / 10_000)); + $lines[] = [ + 'number' => $item->original_number, + 'suffix2' => $suf, + 'tier' => $hitTier, + 'bet_amount' => $bet, + 'odds_value' => $oddsVal, + 'payout' => $total, + ]; + $bestRank = $rank; + $bestTier = $hitTier; } return [ diff --git a/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php index 581dd29..9bb59aa 100644 --- a/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php +++ b/app/Services/Settlement/Matchers/Pos2TierSettlementMatcher.php @@ -3,7 +3,6 @@ namespace App\Services\Settlement\Matchers; use App\Models\TicketItem; -use App\Models\TicketCombination; use Illuminate\Support\Collection; use App\Services\Settlement\OddsSnapshotReader; use App\Services\Settlement\PublishedDrawResultBoard; @@ -36,16 +35,16 @@ final class Pos2TierSettlementMatcher implements SettlementPlayMatcher $lines = []; $total = 0; - foreach ($combinations as $c) { - /** @var TicketCombination $c */ - $n = (string) $c->number_4d; - if (strlen($n) < 2 || substr($n, -2) !== $suffix) { - continue; - } - $bet = (int) $c->bet_amount; - $payout = (int) floor($bet * ($oddsVal / 10_000)); - $total += $payout; - $lines[] = ['number_4d' => $n, 'suffix2' => $suffix, 'payout' => $payout]; + if (substr((string) $item->normalized_number, -2) === $suffix) { + $bet = (int) $item->unit_bet_amount; + $total = (int) floor($bet * ($oddsVal / 10_000)); + $lines[] = [ + 'number' => $item->original_number, + 'suffix2' => $suffix, + 'bet_amount' => $bet, + 'odds_value' => $oddsVal, + 'payout' => $total, + ]; } return [ diff --git a/app/Services/Settlement/Matchers/Pos3AbcSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos3AbcSettlementMatcher.php index 8cd1a95..1e53833 100644 --- a/app/Services/Settlement/Matchers/Pos3AbcSettlementMatcher.php +++ b/app/Services/Settlement/Matchers/Pos3AbcSettlementMatcher.php @@ -3,7 +3,6 @@ namespace App\Services\Settlement\Matchers; use App\Models\TicketItem; -use App\Models\TicketCombination; use Illuminate\Support\Collection; use App\Services\Settlement\OddsSnapshotReader; use App\Services\Settlement\PublishedDrawResultBoard; @@ -36,42 +35,39 @@ final class Pos3AbcSettlementMatcher implements SettlementPlayMatcher $bestTier = null; $bestRank = 99; - foreach ($combinations as $c) { - /** @var TicketCombination $c */ - $n = (string) $c->number_4d; - if (strlen($n) < 3) { + $suf = substr((string) $item->normalized_number, -3); + $hitTier = null; + $rank = 99; + foreach ($suffixByTier as $t => $sx) { + if ($suf !== $sx) { continue; } - $suf = substr($n, -3); - $hitTier = null; - $rank = 99; - foreach ($suffixByTier as $t => $sx) { - if ($suf !== $sx) { - continue; - } - $r = match ($t) { - 'first' => 0, - 'second' => 1, - 'third' => 2, - default => 99, - }; - if ($r < $rank) { - $rank = $r; - $hitTier = $t; - } - } - if ($hitTier === null) { - continue; + $r = match ($t) { + 'first' => 0, + 'second' => 1, + 'third' => 2, + default => 99, + }; + if ($r < $rank) { + $rank = $r; + $hitTier = $t; } + } + + if ($hitTier !== null) { $oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier); - $bet = (int) $c->bet_amount; - $payout = (int) floor($bet * ($oddsVal / 10_000)); - $total += $payout; - $lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout]; - if ($rank < $bestRank) { - $bestRank = $rank; - $bestTier = $hitTier; - } + $bet = (int) $item->unit_bet_amount; + $total = (int) floor($bet * ($oddsVal / 10_000)); + $lines[] = [ + 'number' => $item->original_number, + 'suffix3' => $suf, + 'tier' => $hitTier, + 'bet_amount' => $bet, + 'odds_value' => $oddsVal, + 'payout' => $total, + ]; + $bestRank = $rank; + $bestTier = $hitTier; } return [ diff --git a/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php b/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php index dd7ede7..9034f37 100644 --- a/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php +++ b/app/Services/Settlement/Matchers/Pos3TierSettlementMatcher.php @@ -3,7 +3,6 @@ namespace App\Services\Settlement\Matchers; use App\Models\TicketItem; -use App\Models\TicketCombination; use Illuminate\Support\Collection; use App\Services\Settlement\OddsSnapshotReader; use App\Services\Settlement\PublishedDrawResultBoard; @@ -36,16 +35,16 @@ final class Pos3TierSettlementMatcher implements SettlementPlayMatcher $lines = []; $total = 0; - foreach ($combinations as $c) { - /** @var TicketCombination $c */ - $n = (string) $c->number_4d; - if (strlen($n) < 3 || substr($n, -3) !== $suffix) { - continue; - } - $bet = (int) $c->bet_amount; - $payout = (int) floor($bet * ($oddsVal / 10_000)); - $total += $payout; - $lines[] = ['number_4d' => $n, 'suffix3' => $suffix, 'payout' => $payout]; + if (substr((string) $item->normalized_number, -3) === $suffix) { + $bet = (int) $item->unit_bet_amount; + $total = (int) floor($bet * ($oddsVal / 10_000)); + $lines[] = [ + 'number' => $item->original_number, + 'suffix3' => $suffix, + 'bet_amount' => $bet, + 'odds_value' => $oddsVal, + 'payout' => $total, + ]; } return [ diff --git a/app/Services/Settlement/SettlementBatchWorkflowService.php b/app/Services/Settlement/SettlementBatchWorkflowService.php new file mode 100644 index 0000000..0a02967 --- /dev/null +++ b/app/Services/Settlement/SettlementBatchWorkflowService.php @@ -0,0 +1,134 @@ +whereKey($batch->id)->lockForUpdate()->firstOrFail(); + if ($locked->status !== SettlementBatchStatus::PendingReview->value) { + throw new \RuntimeException('settlement_not_pending_review'); + } + + $locked->forceFill([ + 'status' => SettlementBatchStatus::Approved->value, + 'review_status' => 'approved', + 'reviewed_by' => $admin->id, + 'reviewed_at' => now(), + 'review_remark' => $remark, + ])->save(); + + return $locked->refresh(); + }); + } + + public function reject(SettlementBatch $batch, AdminUser $admin, ?string $remark = null): SettlementBatch + { + return DB::transaction(function () use ($batch, $admin, $remark): SettlementBatch { + /** @var SettlementBatch $locked */ + $locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail(); + if ($locked->status !== SettlementBatchStatus::PendingReview->value) { + throw new \RuntimeException('settlement_not_pending_review'); + } + + TicketItem::query() + ->whereIn('id', $locked->details()->pluck('ticket_item_id')) + ->where('status', 'pending_payout') + ->update(['status' => 'success', 'win_amount' => 0, 'jackpot_win_amount' => 0]); + + $locked->forceFill([ + 'status' => SettlementBatchStatus::Rejected->value, + 'review_status' => 'rejected', + 'reviewed_by' => $admin->id, + 'reviewed_at' => now(), + 'review_remark' => $remark, + ])->save(); + + return $locked->refresh(); + }); + } + + public function payout(SettlementBatch $batch): SettlementBatch + { + return DB::transaction(function () use ($batch): SettlementBatch { + /** @var SettlementBatch $locked */ + $locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->firstOrFail(); + if ($locked->status !== SettlementBatchStatus::Approved->value || $locked->review_status !== 'approved') { + throw new \RuntimeException('settlement_not_approved'); + } + + $details = $locked->details()->with(['ticketItem.order'])->get(); + $playerTotals = []; + $currencyByPlayer = []; + + foreach ($details as $detail) { + $item = $detail->ticketItem; + if ($item === null) { + continue; + } + $finalCredit = (int) $detail->win_amount + (int) $detail->jackpot_allocation_amount; + if ($finalCredit > 0) { + $pid = (int) $item->player_id; + $playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $finalCredit; + $currencyByPlayer[$pid] = strtoupper((string) ($item->order?->currency_code ?? 'NPR')); + $item->forceFill(['status' => 'settled_win', 'settled_at' => now()])->save(); + } elseif ($item->status !== 'settled_lose') { + $item->forceFill(['status' => 'settled_lose', 'settled_at' => now()])->save(); + } + } + + foreach ($playerTotals as $playerId => $amount) { + if ($amount <= 0) { + continue; + } + $player = Player::query()->whereKey($playerId)->firstOrFail(); + $this->wallet->creditSettlementPayout($player, $currencyByPlayer[$playerId] ?? 'NPR', $amount, (int) $locked->id); + } + + $orderIds = TicketItem::query() + ->whereIn('id', $locked->details()->pluck('ticket_item_id')) + ->pluck('order_id') + ->unique() + ->all(); + foreach ($orderIds as $orderId) { + $pending = TicketItem::query() + ->where('order_id', $orderId) + ->whereNotIn('status', ['settled_win', 'settled_lose']) + ->exists(); + if (! $pending) { + TicketOrder::query()->whereKey($orderId)->update(['status' => 'settled']); + } + } + + $locked->forceFill([ + 'status' => SettlementBatchStatus::Paid->value, + 'paid_at' => now(), + ])->save(); + + Draw::query()->whereKey($locked->draw_id)->update([ + 'status' => DrawStatus::Settled->value, + 'settle_version' => (int) $locked->settle_version, + ]); + + return $locked->refresh(); + }); + } +} diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index 82537f0..f07db2d 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -3,11 +3,9 @@ namespace App\Services\Settlement; use App\Models\Draw; -use App\Models\Player; use App\Models\TicketItem; use App\Lottery\DrawStatus; use App\Models\JackpotPool; -use App\Models\TicketOrder; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; use App\Models\SettlementBatch; @@ -16,13 +14,12 @@ use App\Lottery\DrawResultBatchStatus; use App\Lottery\SettlementBatchStatus; use App\Models\TicketSettlementDetail; use App\Services\Ticket\RiskPoolService; -use App\Services\Ticket\TicketWalletService; use App\Services\Jackpot\JackpotBurstAllocator; /** - * 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 入账)。 + * 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 待审核)。 * - * 幂等:同一 `draw` + 已发布 `result_batch` 若已有 `completed` 批次,则仅推进期号状态为 `settled`。 + * 派彩入账由审核通过后的独立 payout 动作执行,避免未确认结果直接入账。 */ final class SettlementOrchestrator { @@ -30,7 +27,6 @@ final class SettlementOrchestrator private readonly SettlementMatcherRegistry $matchers, private readonly SettlementPayoutAdjuster $payoutAdjuster, private readonly JackpotBurstAllocator $jackpotBurst, - private readonly TicketWalletService $wallet, private readonly RiskPoolService $riskPool, ) {} @@ -65,12 +61,16 @@ final class SettlementOrchestrator $existingDone = SettlementBatch::query() ->where('draw_id', $locked->id) ->where('result_batch_id', $publishedBatch->id) - ->where('status', SettlementBatchStatus::Completed->value) + ->whereIn('status', [ + SettlementBatchStatus::PendingReview->value, + SettlementBatchStatus::Approved->value, + SettlementBatchStatus::Paid->value, + SettlementBatchStatus::Completed->value, + ]) ->first(); if ($existingDone !== null) { $locked->forceFill([ - 'status' => DrawStatus::Settled->value, 'settle_version' => (int) $existingDone->settle_version, ])->save(); @@ -91,6 +91,7 @@ final class SettlementOrchestrator 'result_batch_id' => $publishedBatch->id, 'settle_version' => $nextSettleVersion, 'status' => SettlementBatchStatus::Running->value, + 'review_status' => 'pending', 'started_at' => now(), ]); @@ -139,7 +140,6 @@ final class SettlementOrchestrator $totalJackpotPayout = (int) $burstOut['pool_payout']; } - $playerTotals = []; $ticketCount = 0; $winCount = 0; $totalPayout = 0; @@ -164,8 +164,8 @@ final class SettlementOrchestrator $item->forceFill([ 'win_amount' => $net, 'jackpot_win_amount' => $jackpotShare, - 'settled_at' => now(), - 'status' => $finalCredit > 0 ? 'settled_win' : 'settled_lose', + 'settled_at' => null, + 'status' => $finalCredit > 0 ? 'pending_payout' : 'settled_lose', ])->save(); if ($finalCredit > 0) { @@ -173,9 +173,6 @@ final class SettlementOrchestrator } $totalPayout += $finalCredit; - $pid = (int) $item->player_id; - $playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $finalCredit; - $locks = []; foreach ($item->combinations as $c) { $locks[] = [ @@ -186,16 +183,8 @@ final class SettlementOrchestrator $this->riskPool->release((int) $locked->id, $item, $locks); } - foreach ($playerTotals as $playerId => $amount) { - if ($amount <= 0) { - continue; - } - $player = Player::query()->whereKey($playerId)->firstOrFail(); - $this->wallet->creditSettlementPayout($player, $currency, $amount, (int) $batchRow->id); - } - $batchRow->forceFill([ - 'status' => SettlementBatchStatus::Completed->value, + 'status' => SettlementBatchStatus::PendingReview->value, 'total_ticket_count' => $ticketCount, 'total_win_count' => $winCount, 'total_payout_amount' => $totalPayout, @@ -204,20 +193,10 @@ final class SettlementOrchestrator ])->save(); $locked->forceFill([ - 'status' => DrawStatus::Settled->value, + 'status' => DrawStatus::Settling->value, 'settle_version' => $nextSettleVersion, ])->save(); - foreach ($ticketItems->pluck('order_id')->unique()->all() as $orderId) { - $pending = TicketItem::query() - ->where('order_id', $orderId) - ->whereNotIn('status', ['settled_win', 'settled_lose']) - ->exists(); - if (! $pending) { - TicketOrder::query()->whereKey($orderId)->update(['status' => 'settled']); - } - } - return true; }); } diff --git a/app/Services/Ticket/PlayRuleEngine.php b/app/Services/Ticket/PlayRuleEngine.php index ef2abe2..e416917 100644 --- a/app/Services/Ticket/PlayRuleEngine.php +++ b/app/Services/Ticket/PlayRuleEngine.php @@ -81,6 +81,9 @@ final class PlayRuleEngine 'dimension' => $dimension, 'digit_slot' => $digitSlotInt, 'combination_count' => $combinationCount, + 'rounding_refund_amount' => $playCode === 'mbox' + ? max(0, $amount - $totalBetAmount) + : 0, ], 'combinations' => collect($combos)->values()->map(function (string $combo, int $index) use ($unitBetAmount, $estimatedPayoutPerCombo): array { return [ diff --git a/database/migrations/2026_05_08_130008_create_settlement_and_jackpot_tables.php b/database/migrations/2026_05_08_130008_create_settlement_and_jackpot_tables.php index 89e0ed6..240c745 100644 --- a/database/migrations/2026_05_08_130008_create_settlement_and_jackpot_tables.php +++ b/database/migrations/2026_05_08_130008_create_settlement_and_jackpot_tables.php @@ -18,6 +18,11 @@ return new class extends Migration $table->unsignedInteger('total_win_count')->default(0); $table->bigInteger('total_payout_amount')->default(0); $table->bigInteger('total_jackpot_payout_amount')->default(0); + $table->string('review_status', 32)->default('pending'); + $table->foreignId('reviewed_by')->nullable()->constrained('admin_users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable(); + $table->string('review_remark', 255)->nullable(); + $table->timestamp('paid_at')->nullable(); $table->timestamp('started_at')->nullable(); $table->timestamp('finished_at')->nullable(); $table->timestamps(); diff --git a/routes/api/v1/admin/draw.php b/routes/api/v1/admin/draw.php index fc495f5..bef1bc2 100644 --- a/routes/api/v1/admin/draw.php +++ b/routes/api/v1/admin/draw.php @@ -3,15 +3,25 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawShowController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawIndexController; +use App\Http\Controllers\Api\V1\Admin\Draw\DrawReopenController; +use App\Http\Controllers\Api\V1\Admin\Draw\DrawCancelController; +use App\Http\Controllers\Api\V1\Admin\Draw\DrawRngRunController; +use App\Http\Controllers\Api\V1\Admin\Draw\DrawPlanGenerateController; 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\Draw\DrawResultBatchPublishController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawFinanceSummaryController; use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolLockLogIndexController; use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawResultBatchesIndexController; +use App\Http\Controllers\Api\V1\Admin\Draw\DrawManualResultBatchStoreController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchShowController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchIndexController; +use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchExportController; +use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchPayoutController; +use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchRejectController; +use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchApproveController; use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchDetailsController; /** @@ -40,8 +50,22 @@ Route::middleware('admin.permission:prd.draw_result.manage|prd.draw_result.view' // 开奖结果录入(发布批次) Route::middleware('admin.permission:prd.draw_result.manage') - ->post('draws/{draw}/result-batches/{batch}/publish', DrawResultBatchPublishController::class) - ->name('api.v1.admin.draws.result-batches.publish'); + ->group(function (): void { + Route::post('draws/{draw}/result-batches', DrawManualResultBatchStoreController::class) + ->name('api.v1.admin.draws.result-batches.store'); + Route::post('draws/{draw}/result-batches/{batch}/publish', DrawResultBatchPublishController::class) + ->name('api.v1.admin.draws.result-batches.publish'); + Route::post('draws/{draw}/reopen', DrawReopenController::class) + ->name('api.v1.admin.draws.reopen'); + Route::post('draws/generate-plan', DrawPlanGenerateController::class) + ->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}/cancel', DrawCancelController::class) + ->name('api.v1.admin.draws.cancel'); + Route::post('draws/{draw}/rng', DrawRngRunController::class) + ->name('api.v1.admin.draws.rng'); + }); // 派彩确认 Route::middleware('admin.permission:prd.payout.manage|prd.payout.review') @@ -57,4 +81,18 @@ Route::middleware('admin.permission:prd.payout.manage|prd.payout.review|prd.payo ->name('api.v1.admin.settlement-batches.show'); Route::get('settlement-batches/{batch}/details', AdminSettlementBatchDetailsController::class) ->name('api.v1.admin.settlement-batches.details'); + Route::get('settlement-batches/{batch}/export', AdminSettlementBatchExportController::class) + ->name('api.v1.admin.settlement-batches.export'); }); + +Route::middleware('admin.permission:prd.payout.review') + ->group(function (): void { + Route::post('settlement-batches/{batch}/approve', AdminSettlementBatchApproveController::class) + ->name('api.v1.admin.settlement-batches.approve'); + Route::post('settlement-batches/{batch}/reject', AdminSettlementBatchRejectController::class) + ->name('api.v1.admin.settlement-batches.reject'); + }); + +Route::middleware('admin.permission:prd.payout.manage') + ->post('settlement-batches/{batch}/payout', AdminSettlementBatchPayoutController::class) + ->name('api.v1.admin.settlement-batches.payout'); diff --git a/tests/Feature/DrawPipelineTest.php b/tests/Feature/DrawPipelineTest.php index 5eb4389..7baaed1 100644 --- a/tests/Feature/DrawPipelineTest.php +++ b/tests/Feature/DrawPipelineTest.php @@ -2,6 +2,7 @@ use Carbon\Carbon; use App\Models\Draw; +use App\Models\AdminRole; use App\Models\AdminUser; use App\Lottery\DrawStatus; use App\Models\DrawResultItem; @@ -46,6 +47,151 @@ test('draw planner fills buffer rows with ordered draw_no', function (): void { expect($drawNos)->toEqual($sorted); }); +test('admin can batch generate draw schedule buffer', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-09 12:00:00', 'UTC')); + + $admin = AdminUser::query()->create([ + 'username' => 'draw_plan_admin', + 'name' => 'Draw Plan 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/generate-plan') + ->assertOk() + ->assertJsonPath('data.buffer_target', 3); + + expect(Draw::query()->count())->toBeGreaterThanOrEqual(3); + + Carbon::setTestNow(); +}); + +test('admin can manually close open draw', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-09 12:10:00', 'UTC')); + + $draw = Draw::query()->create([ + 'draw_no' => '20260509-120', + 'business_date' => '2026-05-09', + 'sequence_no' => 120, + 'status' => DrawStatus::Open->value, + 'start_time' => now()->copy()->subMinute(), + 'close_time' => now()->copy()->addMinutes(10), + 'draw_time' => now()->copy()->addMinutes(15), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'draw_close_admin', + 'name' => 'Draw Close 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}/manual-close") + ->assertOk() + ->assertJsonPath('data.status', DrawStatus::Closing->value); + + $draw->refresh(); + expect($draw->status)->toBe(DrawStatus::Closing->value); + expect($draw->close_time?->timestamp)->toBe(now()->timestamp); + + Carbon::setTestNow(); +}); + +test('admin can cancel draw before results exist', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-09 12:15:00', 'UTC')); + + $draw = Draw::query()->create([ + 'draw_no' => '20260509-121', + 'business_date' => '2026-05-09', + 'sequence_no' => 121, + 'status' => DrawStatus::Pending->value, + 'start_time' => now()->copy()->addMinute(), + 'close_time' => now()->copy()->addMinutes(10), + 'draw_time' => now()->copy()->addMinutes(15), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'draw_cancel_admin', + 'name' => 'Draw Cancel 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}/cancel") + ->assertOk() + ->assertJsonPath('data.status', DrawStatus::Cancelled->value); + + $draw->refresh(); + expect($draw->status)->toBe(DrawStatus::Cancelled->value); + + Carbon::setTestNow(); +}); + +test('admin can manually trigger rng for closed draw', function (): void { + config(['lottery.draw.require_manual_review' => true]); + Carbon::setTestNow(Carbon::parse('2026-05-09 12:20:00', 'UTC')); + + $draw = Draw::query()->create([ + 'draw_no' => '20260509-122', + 'business_date' => '2026-05-09', + 'sequence_no' => 122, + '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_admin', + 'name' => 'Draw Rng 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() + ->assertJsonPath('data.status', DrawStatus::Review->value) + ->assertJsonPath('data.batch.source_type', 'rng') + ->assertJsonPath('data.batch.items_count', 23); + + $draw->refresh(); + expect($draw->status)->toBe(DrawStatus::Review->value); + expect(DrawResultBatch::query()->where('draw_id', $draw->id)->count())->toBe(1); + + 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')); @@ -167,6 +313,191 @@ test('draw tick rng awaits manual publish when review enabled', function (): voi Carbon::setTestNow(); }); +test('admin can create manual result batch with 23 numbers for review', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-09 14:20:00', 'UTC')); + + $draw = Draw::query()->create([ + 'draw_no' => '20260509-220', + 'business_date' => '2026-05-09', + 'sequence_no' => 220, + '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' => 'manual_draw_admin', + 'name' => '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 + 1), 4, '0', STR_PAD_LEFT), + ]; + } + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/result-batches", ['items' => $items]) + ->assertOk() + ->assertJsonPath('data.draw_no', '20260509-220') + ->assertJsonPath('data.status', DrawStatus::Review->value) + ->assertJsonPath('data.batch.status', DrawResultBatchStatus::PendingReview->value) + ->assertJsonPath('data.batch.source_type', 'manual') + ->assertJsonPath('data.batch.items_count', 23); + + $draw->refresh(); + expect($draw->status)->toBe(DrawStatus::Review->value); + expect($draw->result_source)->toBe('manual'); + expect(DrawResultBatch::query()->where('draw_id', $draw->id)->where('source_type', 'manual')->count())->toBe(1); + expect(DrawResultItem::query()->where('draw_id', $draw->id)->count())->toBe(23); + + Carbon::setTestNow(); +}); + +test('admin can reopen cooldown draw for a replacement result batch', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-09 14:30:00', 'UTC')); + + $draw = Draw::query()->create([ + 'draw_no' => '20260509-230', + 'business_date' => '2026-05-09', + 'sequence_no' => 230, + 'status' => DrawStatus::Cooldown->value, + 'start_time' => now()->copy()->subMinutes(20), + 'close_time' => now()->copy()->subMinutes(3), + 'draw_time' => now()->copy()->subMinutes(2), + 'cooling_end_time' => now()->copy()->addMinutes(10), + 'result_source' => 'rng', + 'current_result_version' => 1, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => hash('sha256', 'seed'), + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + foreach (App\Services\Draw\DrawPrizeLayout::slots() as $i => $slot) { + $number = str_pad((string) ($i + 100), 4, '0', STR_PAD_LEFT); + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $batch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $number, + 'suffix_3d' => substr($number, -3), + 'suffix_2d' => substr($number, -2), + 'head_digit' => (int) substr($number, 0, 1), + 'tail_digit' => (int) substr($number, 3, 1), + ]); + } + + $admin = AdminUser::query()->create([ + 'username' => 'reopen_draw_admin', + 'name' => 'Reopen Draw 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}/reopen", ['reason' => 'wrong result']) + ->assertOk() + ->assertJsonPath('data.draw_no', '20260509-230') + ->assertJsonPath('data.status', DrawStatus::Closed->value) + ->assertJsonPath('data.is_reopened', true) + ->assertJsonPath('data.current_result_version', 1); + + $draw->refresh(); + expect($draw->status)->toBe(DrawStatus::Closed->value); + expect($draw->is_reopened)->toBeTrue(); + expect($draw->cooling_end_time)->toBeNull(); + + Carbon::setTestNow(); +}); + +test('non super admin cannot reopen cooldown draw', function (): void { + Carbon::setTestNow(Carbon::parse('2026-05-09 14:35:00', 'UTC')); + + $draw = Draw::query()->create([ + 'draw_no' => '20260509-231', + 'business_date' => '2026-05-09', + 'sequence_no' => 231, + 'status' => DrawStatus::Cooldown->value, + 'start_time' => now()->copy()->subMinutes(20), + 'close_time' => now()->copy()->subMinutes(3), + 'draw_time' => now()->copy()->subMinutes(2), + 'cooling_end_time' => now()->copy()->addMinutes(10), + 'result_source' => 'rng', + 'current_result_version' => 1, + 'settle_version' => 0, + 'is_reopened' => false, + ]); + + $role = AdminRole::query()->create([ + 'slug' => 'draw_manager_test', + 'name' => 'Draw Manager Test', + ]); + $ids = DB::table('admin_menu_actions') + ->whereIn('permission_code', App\Support\AdminPermissionBridge::menuActionCodesForLegacy('prd.draw_result.manage')) + ->where('status', 1) + ->pluck('id'); + foreach ($ids as $mid) { + DB::table('admin_role_menu_actions')->insert([ + 'role_id' => $role->id, + 'menu_action_id' => (int) $mid, + ]); + } + + $admin = AdminUser::query()->create([ + 'username' => 'draw_manager_only', + 'name' => 'Draw Manager Only', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + $admin->roles()->sync([ + (int) $role->id => [ + 'site_id' => AdminUser::defaultAdminSiteId(), + 'granted_at' => now(), + ], + ]); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/draws/{$draw->id}/reopen") + ->assertStatus(403); + + $draw->refresh(); + expect($draw->status)->toBe(DrawStatus::Cooldown->value); + expect($draw->is_reopened)->toBeFalse(); + + Carbon::setTestNow(); +}); + test('cooldown expiry tick moves draw to settling', function (): void { config([ 'lottery.draw.require_manual_review' => false, @@ -201,9 +532,9 @@ test('cooldown expiry tick moves draw to settling', function (): void { app(DrawTickService::class)->tick(now()->utc()); $draw->refresh(); - expect($draw->status)->toBe(DrawStatus::Settled->value); + expect($draw->status)->toBe(DrawStatus::Settling->value); expect((int) $draw->settle_version)->toBe(1); - expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', 'completed')->count())->toBe(1); + expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', 'pending_review')->count())->toBe(1); Carbon::setTestNow(); }); @@ -231,10 +562,10 @@ test('GET draw current returns open draw with seconds to close', function (): vo $this->getJson('/api/v1/draw/current') ->assertOk() - ->assertJsonPath('data.draw_no', '20260509-300') - ->assertJsonPath('data.status', DrawStatus::Open->value) - ->assertJsonPath('data.seconds_to_close', 60 * 60 - 30) - ->assertJsonPath('data.seconds_to_draw', 3600); + ->assertJsonPath('data.data.draw_no', '20260509-300') + ->assertJsonPath('data.data.status', DrawStatus::Open->value) + ->assertJsonPath('data.data.seconds_to_close', 60 * 60 - 30) + ->assertJsonPath('data.data.seconds_to_draw', 3600); Carbon::setTestNow(); }); @@ -261,10 +592,10 @@ test('GET draw current exposes closing when row is open in DB but close_time has $this->getJson('/api/v1/draw/current') ->assertOk() - ->assertJsonPath('data.draw_no', '20260509-310') - ->assertJsonPath('data.status', DrawStatus::Closing->value) - ->assertJsonPath('data.seconds_to_close', 0) - ->assertJsonPath('data.seconds_to_draw', 20); + ->assertJsonPath('data.data.draw_no', '20260509-310') + ->assertJsonPath('data.data.status', DrawStatus::Closing->value) + ->assertJsonPath('data.data.seconds_to_close', 0) + ->assertJsonPath('data.data.seconds_to_draw', 20); Carbon::setTestNow(); }); @@ -291,10 +622,10 @@ test('GET draw current exposes closed when row is open in DB but draw_time has p $this->getJson('/api/v1/draw/current') ->assertOk() - ->assertJsonPath('data.draw_no', '20260509-311') - ->assertJsonPath('data.status', DrawStatus::Closed->value) - ->assertJsonPath('data.seconds_to_close', 0) - ->assertJsonPath('data.seconds_to_draw', 0); + ->assertJsonPath('data.data.draw_no', '20260509-311') + ->assertJsonPath('data.data.status', DrawStatus::Closed->value) + ->assertJsonPath('data.data.seconds_to_close', 0) + ->assertJsonPath('data.data.seconds_to_draw', 0); Carbon::setTestNow(); }); @@ -343,8 +674,8 @@ test('GET draw current includes result_items when cooldown', function (): void { $this->getJson('/api/v1/draw/current') ->assertOk() - ->assertJsonPath('data.status', DrawStatus::Cooldown->value) - ->assertJsonPath('data.result_items.0.number_4d', '1234'); + ->assertJsonPath('data.data.status', DrawStatus::Cooldown->value) + ->assertJsonPath('data.data.result_items.0.number_4d', '1234'); Carbon::setTestNow(); }); diff --git a/tests/Feature/JackpotPlacementSettlementTest.php b/tests/Feature/JackpotPlacementSettlementTest.php index c404c89..3ef0719 100644 --- a/tests/Feature/JackpotPlacementSettlementTest.php +++ b/tests/Feature/JackpotPlacementSettlementTest.php @@ -2,6 +2,7 @@ use App\Models\Draw; use App\Models\Player; +use App\Models\AdminUser; use App\Models\TicketItem; use App\Lottery\DrawStatus; use App\Models\JackpotPool; @@ -10,7 +11,9 @@ use App\Models\PlayerWallet; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; use App\Models\JackpotPayoutLog; +use App\Models\SettlementBatch; use App\Models\JackpotContribution; +use Illuminate\Support\Facades\Hash; use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; use App\Lottery\DrawResultBatchStatus; @@ -19,6 +22,7 @@ use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\OperationalConfigV1Seeder; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Services\Settlement\SettlementOrchestrator; +use App\Services\Settlement\SettlementBatchWorkflowService; uses(RefreshDatabase::class); @@ -128,6 +132,18 @@ test('jackpot contributes on place and bursts on settle for first-prize straight $ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()); expect($ran)->toBeTrue(); + $admin = AdminUser::query()->create([ + 'username' => 'jp_settle_admin', + 'name' => 'JP Settle Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin); + app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh()); + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); expect((int) $item->win_amount)->toBe(250_000); expect((int) $item->jackpot_win_amount)->toBe(1_000); diff --git a/tests/Feature/SettlementOrchestratorTest.php b/tests/Feature/SettlementOrchestratorTest.php index 0d7cdb4..c98f442 100644 --- a/tests/Feature/SettlementOrchestratorTest.php +++ b/tests/Feature/SettlementOrchestratorTest.php @@ -10,6 +10,8 @@ use App\Models\PlayerWallet; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; use App\Models\SettlementBatch; +use App\Models\AdminUser; +use Illuminate\Support\Facades\Hash; use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; use App\Lottery\DrawResultBatchStatus; @@ -18,6 +20,7 @@ use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\OperationalConfigV1Seeder; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Services\Settlement\SettlementOrchestrator; +use App\Services\Settlement\SettlementBatchWorkflowService; uses(RefreshDatabase::class); @@ -111,6 +114,18 @@ test('settlement pays big winner and marks ticket settled', function (): void { $ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()); expect($ran)->toBeTrue(); + $admin = AdminUser::query()->create([ + 'username' => 'settlement_legacy_reviewer', + 'name' => 'Settlement Legacy Reviewer', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin); + app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh()); + $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Settled->value); expect((int) $draw->settle_version)->toBe(1); @@ -129,3 +144,123 @@ test('settlement pays big winner and marks ticket settled', function (): void { expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1); }); + +test('admin settlement requires review before payout and can export report', function (): void { + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'settle-review-p-'.$uniq, + 'username' => 'srp_'.$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, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260511-901', + 'business_date' => '2026-05-11', + 'sequence_no' => 901, + '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, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => '20260511-901', + 'currency_code' => 'NPR', + 'client_trace_id' => 'settle-review-trace-1', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'review-test', + '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' ? '1234' : '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(); + + $admin = AdminUser::query()->create([ + 'username' => 'settlement_reviewer', + 'name' => 'Settlement Reviewer', + '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}/settlement/run") + ->assertOk() + ->assertJsonPath('data.status', DrawStatus::Settling->value); + + $settlement = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + expect($settlement->status)->toBe('pending_review'); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + expect($item->status)->toBe('pending_payout'); + expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(0); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/settlement-batches/{$settlement->id}/approve") + ->assertOk() + ->assertJsonPath('data.review_status', 'approved'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson("/api/v1/admin/settlement-batches/{$settlement->id}/payout") + ->assertOk() + ->assertJsonPath('data.status', 'paid'); + + $item->refresh(); + expect($item->status)->toBe('settled_win'); + expect(WalletTxn::query()->where('biz_type', 'settle_payout')->count())->toBe(1); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->get("/api/v1/admin/settlement-batches/{$settlement->id}/export") + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); +}); diff --git a/tests/Feature/SettlementPhase145AcceptanceTest.php b/tests/Feature/SettlementPhase145AcceptanceTest.php index 615a43d..e6af553 100644 --- a/tests/Feature/SettlementPhase145AcceptanceTest.php +++ b/tests/Feature/SettlementPhase145AcceptanceTest.php @@ -6,6 +6,7 @@ use App\Models\Draw; use App\Models\Player; +use App\Models\AdminUser; use App\Models\RiskPool; use App\Models\WalletTxn; use App\Lottery\ErrorCode; @@ -17,6 +18,7 @@ use App\Models\PlayerWallet; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; use App\Models\JackpotPayoutLog; +use App\Models\SettlementBatch; use App\Models\TicketCombination; use App\Models\JackpotContribution; use App\Support\OddsStandardScopes; @@ -24,7 +26,9 @@ use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; use App\Lottery\DrawResultBatchStatus; use App\Models\TicketSettlementDetail; +use App\Services\Settlement\SettlementBatchWorkflowService; use App\Services\Draw\DrawPrizeLayout; +use Illuminate\Support\Facades\Hash; use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\OperationalConfigV1Seeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -135,6 +139,22 @@ function p145_board_without_8888(string $prizeType, int $prizeIndex): string }; } +function p145_approve_and_payout(Draw $draw): void +{ + $batch = SettlementBatch::query()->where('draw_id', $draw->id)->latest('id')->firstOrFail(); + $admin = AdminUser::query()->create([ + 'username' => 'p145_settle_'.bin2hex(random_bytes(3)), + 'name' => 'P145 Settlement', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $workflow = app(SettlementBatchWorkflowService::class); + $workflow->approve($batch, $admin); + $workflow->payout($batch->fresh()); +} + test('§14.5 big no-hit settles lose wallet unchanged except bet and no settle_payout txn', function (): void { $player = p145_player(); $drawNo = p145_next_draw_no(); @@ -161,6 +181,7 @@ test('§14.5 big no-hit settles lose wallet unchanged except bet and no settle_p ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_lose') @@ -224,6 +245,7 @@ test('§14.5 small hits second tier only', function (): void { ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_win') @@ -300,11 +322,7 @@ test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', functio $deduct = (int) $item->actual_deduct_amount; $odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']]; $perComboWin = (int) floor(10_000 * $odds / 10_000); - $comboCount = (int) $item->combination_count; - $expectedWin = match ($case['play']) { - 'pos_3a', 'pos_2a' => $perComboWin * $comboCount, - default => $perComboWin, - }; + $expectedWin = $perComboWin; p145_publish_board($draw, $case['board']); $draw->forceFill([ @@ -313,6 +331,7 @@ test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', functio ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_win', $case['play']) @@ -323,6 +342,77 @@ test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', functio } }); +test('module 6 suffix plays settle once per ticket item instead of once per expanded prefix', function (): void { + $cases = [ + [ + 'play' => 'pos_3a', + 'number' => '234', + 'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i), + 'scope' => 'first', + ], + [ + 'play' => 'pos_2a', + 'number' => '34', + 'board' => fn (string $t, int $i): string => $t === 'first' ? '1234' : p145_board_without_8888($t, $i), + 'scope' => 'first', + ], + [ + 'play' => 'pos_3abc', + 'number' => '567', + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '4567', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'first', + ], + [ + 'play' => 'pos_2abc', + 'number' => '99', + 'board' => fn (string $t, int $i): string => match ($t) { + 'first' => '8899', + 'second' => '2299', + 'third' => '1199', + default => p145_board_without_8888($t, $i), + }, + 'scope' => 'first', + ], + ]; + + foreach ($cases as $case) { + $player = p145_player(80_000_000); + $drawNo = p145_next_draw_no(); + $draw = p145_draw($drawNo, random_int(1, 99_999)); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $drawNo, + 'currency_code' => 'NPR', + 'client_trace_id' => 'module6-suffix-'.$case['play'].'-'.uniqid('', true), + 'lines' => [ + ['number' => $case['number'], 'play_code' => $case['play'], 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); + expect((int) $item->combination_count)->toBeIn([10, 100]); + $expectedWin = (int) floor(10_000 * OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']] / 10_000); + + p145_publish_board($draw, $case['board']); + $draw->forceFill([ + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + ])->save(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue(); + p145_approve_and_payout($draw); + + $item->refresh(); + expect($item->status)->toBe('settled_win', $case['play']) + ->and((int) $item->win_amount)->toBe($expectedWin, $case['play']); + } +}); + test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void { JackpotPool::query()->create([ 'currency_code' => 'NPR', @@ -371,6 +461,7 @@ test('§14.5 jackpot contributes on place and stays in pool when no first-prize ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + p145_approve_and_payout($draw); expect(JackpotPayoutLog::query()->count())->toBe(0); $poolAfter = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); @@ -532,7 +623,7 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s default => p145_board_without_8888($t, $i), }, 'scope' => 'second', - 'comboMultiplier' => 10, + 'comboMultiplier' => 1, ], [ 'play' => 'pos_3c', @@ -542,7 +633,7 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s default => p145_board_without_8888($t, $i), }, 'scope' => 'third', - 'comboMultiplier' => 10, + 'comboMultiplier' => 1, ], [ 'play' => 'pos_3abc', @@ -554,7 +645,7 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s default => p145_board_without_8888($t, $i), }, 'scope' => 'first', - 'comboMultiplier' => 10, + 'comboMultiplier' => 1, ], [ 'play' => 'pos_2b', @@ -565,7 +656,7 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s default => p145_board_without_8888($t, $i), }, 'scope' => 'second', - 'comboMultiplier' => 100, + 'comboMultiplier' => 1, ], [ 'play' => 'pos_2c', @@ -577,7 +668,7 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s default => p145_board_without_8888($t, $i), }, 'scope' => 'third', - 'comboMultiplier' => 100, + 'comboMultiplier' => 1, ], [ 'play' => 'pos_2abc', @@ -589,7 +680,7 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s default => p145_board_without_8888($t, $i), }, 'scope' => 'first', - 'comboMultiplier' => 100, + 'comboMultiplier' => 1, ], ]; @@ -631,6 +722,7 @@ test('§14.5 straight roll box ibox mbox head tail odd even digit pos variants s ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue(); + p145_approve_and_payout($draw); $item->refresh(); expect($item->status)->toBe('settled_win', $case['play']) @@ -672,6 +764,7 @@ test('§14.6 ticket detail shows settlement tier after win', function (): void { ])->save(); app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()); + p145_approve_and_payout($draw); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->getJson('/api/v1/ticket/items/'.$ticketNo) diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index 3d1da19..2b1d587 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -110,6 +110,100 @@ test('ticket preview returns computed summary for open draw', function (): void ->assertJsonCount(2, 'data.lines'); }); +test('module 6 box family expands combinations and computes amount semantics', function (): void { + $player = ticketPlayerWithWallet(500_000); + ticketOpenDraw(); + + $payload = [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-module6-box', + 'lines' => [ + ['number' => '1234', 'play_code' => 'box', 'amount' => 10_000], + ['number' => '1123', 'play_code' => 'box', 'amount' => 10_000], + ['number' => '1122', 'play_code' => 'box', 'amount' => 10_000], + ['number' => '1112', 'play_code' => 'box', 'amount' => 10_000], + ['number' => '1111', 'play_code' => 'box', 'amount' => 10_000], + ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100], + ['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001], + ], + ]; + + $resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', $payload) + ->assertOk(); + + $lines = collect($resp->json('data.lines'))->keyBy('client_line_no'); + expect($lines[1]['combination_count'])->toBe(24) + ->and($lines[2]['combination_count'])->toBe(12) + ->and($lines[3]['combination_count'])->toBe(6) + ->and($lines[4]['combination_count'])->toBe(4) + ->and($lines[5]['combination_count'])->toBe(1) + ->and($lines[6]['combination_count'])->toBe(6) + ->and($lines[6]['total_bet_amount'])->toBe(600) + ->and($lines[7]['combination_count'])->toBe(24) + ->and($lines[7]['total_bet_amount'])->toBe(9_984) + ->and($lines[7]['actual_deduct_amount'])->toBe(9_984) + ->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17); +}); + +test('module 6 roll expands each R position and charges per expanded combination', function (): void { + $player = ticketPlayerWithWallet(500_000); + ticketOpenDraw(); + + $resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-module6-roll', + 'lines' => [ + ['number' => 'R234', 'play_code' => 'roll', 'amount' => 100], + ['number' => 'RR34', 'play_code' => 'roll', 'amount' => 100], + ], + ]) + ->assertOk(); + + $lines = collect($resp->json('data.lines'))->keyBy('client_line_no'); + expect($lines[1]['combination_count'])->toBe(10) + ->and($lines[1]['total_bet_amount'])->toBe(1_000) + ->and($lines[2]['combination_count'])->toBe(100) + ->and($lines[2]['total_bet_amount'])->toBe(10_000); +}); + +test('module 6 reserved and phase two plays are not available for betting or public entry', function (): void { + $player = ticketPlayerWithWallet(); + ticketOpenDraw(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-module6-half-box', + 'lines' => [ + ['number' => '1234', 'play_code' => 'half_box', 'amount' => 10_000], + ], + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::PlayModeClosed->value); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', [ + 'draw_id' => '20260511-001', + 'currency_code' => 'NPR', + 'client_trace_id' => 'trace-module6-5d', + 'lines' => [ + ['number' => '12345', 'play_code' => '5d', 'amount' => 10_000], + ], + ]) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::PlayModeClosed->value); + + $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); + expect($plays->firstWhere('play_code', 'half_box')['config']['is_enabled'])->toBeFalse() + ->and($plays->contains('play_code', '5d'))->toBeFalse() + ->and($plays->contains('play_code', '6d'))->toBeFalse(); +}); + test('ticket place deducts wallet and persists order items combinations and logs', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); diff --git a/tests/Feature/TicketItemsApiTest.php b/tests/Feature/TicketItemsApiTest.php index 3be49a1..f072164 100644 --- a/tests/Feature/TicketItemsApiTest.php +++ b/tests/Feature/TicketItemsApiTest.php @@ -7,10 +7,16 @@ use App\Models\JackpotPool; use App\Models\PlayerWallet; use App\Models\DrawResultItem; use App\Models\DrawResultBatch; +use App\Models\TicketOrder; +use App\Models\AdminUser; +use App\Models\SettlementBatch; +use App\Services\Draw\DrawPrizeLayout; +use App\Services\Settlement\SettlementOrchestrator; +use App\Services\Settlement\SettlementBatchWorkflowService; use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; use App\Lottery\DrawResultBatchStatus; -use App\Services\Draw\DrawPrizeLayout; +use Illuminate\Support\Facades\Hash; use Database\Seeders\LotterySettingsSeeder; use Database\Seeders\OperationalConfigV1Seeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -24,6 +30,79 @@ beforeEach(function (): void { $this->seed(LotterySettingsSeeder::class); }); +function ticketItemsPlayer(): Player +{ + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'items-p-'.$uniq, + 'username' => 'ti_'.$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 ticketItemsPublishAndSettle(Draw $draw, string $firstNumber): void +{ + $batch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'items-'.(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(); + + expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); + + $admin = AdminUser::query()->create([ + 'username' => 'ticket_items_settle_'.bin2hex(random_bytes(3)), + 'name' => 'Ticket Items Settle', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); + app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin); + app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh()); +} + test('jackpot summary is public', function (): void { JackpotPool::query()->create([ 'currency_code' => 'NPR', @@ -44,24 +123,7 @@ test('jackpot summary is public', function (): void { }); test('ticket items index returns placed ticket for player', function (): void { - $uniq = bin2hex(random_bytes(4)); - $player = Player::query()->create([ - 'site_code' => 'test', - 'site_player_id' => 'items-p-'.$uniq, - 'username' => 'ti_'.$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, - ]); + $player = ticketItemsPlayer(); $draw = Draw::query()->create([ 'draw_no' => '20260511-777', @@ -117,6 +179,136 @@ test('ticket items index returns placed ticket for player', function (): void { ->assertJsonPath('data.total', 0); }); +test('ticket items index filters by status number and date range', function (): void { + $player = ticketItemsPlayer(); + + $draw1 = Draw::query()->create([ + 'draw_no' => '20260511-779', + 'business_date' => '2026-05-11', + 'sequence_no' => 779, + '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, + ]); + + $draw2 = Draw::query()->create([ + 'draw_no' => '20260512-780', + 'business_date' => '2026-05-12', + 'sequence_no' => 780, + '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, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $draw1->draw_no, + 'currency_code' => 'NPR', + 'client_trace_id' => 'items-filter-1', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $draw2->draw_no, + 'currency_code' => 'NPR', + 'client_trace_id' => 'items-filter-2', + 'lines' => [ + ['number' => '4321', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + TicketOrder::query()->where('draw_id', $draw1->id)->update([ + 'created_at' => '2026-05-01 10:00:00', + 'updated_at' => '2026-05-01 10:00:00', + ]); + TicketOrder::query()->where('draw_id', $draw2->id)->update([ + 'created_at' => '2026-05-10 10:00:00', + 'updated_at' => '2026-05-10 10:00:00', + ]); + + ticketItemsPublishAndSettle($draw2, '4321'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items?status[]=settled_win') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.draw_no', '20260512-780'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items?number=1234') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.original_number', '1234'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items?start_date=2026-05-09&end_date=2026-05-11') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.draw_no', '20260512-780'); +}); + +test('ticket item show returns match result and timeline', function (): void { + $player = ticketItemsPlayer(); + + $draw = Draw::query()->create([ + 'draw_no' => '20260513-781', + 'business_date' => '2026-05-13', + 'sequence_no' => 781, + '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, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', [ + 'draw_id' => $draw->draw_no, + 'currency_code' => 'NPR', + 'client_trace_id' => 'items-detail-1', + 'lines' => [ + ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], + ], + ]) + ->assertOk(); + + ticketItemsPublishAndSettle($draw, '1234'); + + $ticketNo = \App\Models\TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items/'.$ticketNo) + ->assertOk() + ->assertJsonPath('data.match_result.matched', true) + ->assertJsonPath('data.match_result.matched_prize_tier', 'first') + ->assertJsonPath('data.timeline.0.code', 'placed') + ->assertJsonPath('data.timeline.1.code', 'deducted') + ->assertJsonPath('data.timeline.2.code', 'draw_published') + ->assertJsonPath('data.timeline.3.code', 'settlement_started') + ->assertJsonPath('data.timeline.4.code', 'settled'); +}); + test('my-match returns hit numbers when draw published', function (): void { $uniq = bin2hex(random_bytes(4)); $player = Player::query()->create([