feat: 拆分开奖与结算审核流程,新增手动结果录入、重开和派彩审批接口
This commit is contained in:
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
|||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
|
use App\Models\TicketItem;
|
||||||
|
use App\Models\TicketOrder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Support\AdminApiList;
|
use App\Support\AdminApiList;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -56,6 +58,14 @@ final class AdminDrawIndexController extends Controller
|
|||||||
'current_result_version' => (int) $draw->current_result_version,
|
'current_result_version' => (int) $draw->current_result_version,
|
||||||
'settle_version' => (int) $draw->settle_version,
|
'settle_version' => (int) $draw->settle_version,
|
||||||
'is_reopened' => (bool) $draw->is_reopened,
|
'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(),
|
'updated_at' => $draw->updated_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Draw\DrawAdminActionService;
|
||||||
|
|
||||||
|
final class DrawCancelController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DrawAdminActionService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Draw $draw): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$cancelled = $this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Draw\DrawAdminActionService;
|
||||||
|
|
||||||
|
final class DrawManualCloseController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DrawAdminActionService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Draw $draw): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$closed = $this->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Draw\DrawManualResultService;
|
||||||
|
use App\Http\Requests\Admin\DrawManualResultBatchStoreRequest;
|
||||||
|
|
||||||
|
final class DrawManualResultBatchStoreController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DrawManualResultService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(DrawManualResultBatchStoreRequest $request, Draw $draw): JsonResponse
|
||||||
|
{
|
||||||
|
$admin = $request->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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||||
|
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Draw\DrawPlannerService;
|
||||||
|
|
||||||
|
final class DrawPlanGenerateController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DrawPlannerService $planner,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
return ApiResponse::success($this->planner->ensureBuffer());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Draw\DrawReopenService;
|
||||||
|
use App\Http\Requests\Admin\DrawReopenRequest;
|
||||||
|
|
||||||
|
final class DrawReopenController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DrawReopenService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(DrawReopenRequest $request, Draw $draw): JsonResponse
|
||||||
|
{
|
||||||
|
$admin = $request->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Draw;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use App\Lottery\DrawStatus;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Services\Draw\DrawRngRunner;
|
||||||
|
|
||||||
|
final class DrawRngRunController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DrawRngRunner $rng,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Draw $draw): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$batch = DB::transaction(function () use ($draw) {
|
||||||
|
/** @var Draw $locked */
|
||||||
|
$locked = Draw::query()->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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Settlement;
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use App\Models\SettlementBatch;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\SettlementBatchReviewRequest;
|
||||||
|
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||||
|
|
||||||
|
final class AdminSettlementBatchApproveController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SettlementBatchWorkflowService $service) {}
|
||||||
|
|
||||||
|
public function __invoke(SettlementBatchReviewRequest $request, SettlementBatch $batch): JsonResponse
|
||||||
|
{
|
||||||
|
$admin = $request->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Settlement;
|
||||||
|
|
||||||
|
use App\Models\SettlementBatch;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
final class AdminSettlementBatchExportController
|
||||||
|
{
|
||||||
|
public function __invoke(SettlementBatch $batch): StreamedResponse
|
||||||
|
{
|
||||||
|
$batch->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,9 @@ final class AdminSettlementBatchIndexController extends Controller
|
|||||||
'result_batch_id' => (int) $b->result_batch_id,
|
'result_batch_id' => (int) $b->result_batch_id,
|
||||||
'settle_version' => (int) $b->settle_version,
|
'settle_version' => (int) $b->settle_version,
|
||||||
'status' => $b->status,
|
'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_ticket_count' => (int) $b->total_ticket_count,
|
||||||
'total_win_count' => (int) $b->total_win_count,
|
'total_win_count' => (int) $b->total_win_count,
|
||||||
'total_payout_amount' => (int) $b->total_payout_amount,
|
'total_payout_amount' => (int) $b->total_payout_amount,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Settlement;
|
||||||
|
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use App\Models\SettlementBatch;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||||
|
|
||||||
|
final class AdminSettlementBatchPayoutController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SettlementBatchWorkflowService $service) {}
|
||||||
|
|
||||||
|
public function __invoke(SettlementBatch $batch): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$updated = $this->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Admin\Settlement;
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use App\Models\SettlementBatch;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\SettlementBatchReviewRequest;
|
||||||
|
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||||
|
|
||||||
|
final class AdminSettlementBatchRejectController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SettlementBatchWorkflowService $service) {}
|
||||||
|
|
||||||
|
public function __invoke(SettlementBatchReviewRequest $request, SettlementBatch $batch): JsonResponse
|
||||||
|
{
|
||||||
|
$admin = $request->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ final class AdminSettlementBatchShowController extends Controller
|
|||||||
'result_batch_status' => $batch->resultBatch?->status,
|
'result_batch_status' => $batch->resultBatch?->status,
|
||||||
'settle_version' => (int) $batch->settle_version,
|
'settle_version' => (int) $batch->settle_version,
|
||||||
'status' => $batch->status,
|
'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_ticket_count' => (int) $batch->total_ticket_count,
|
||||||
'total_win_count' => (int) $batch->total_win_count,
|
'total_win_count' => (int) $batch->total_win_count,
|
||||||
'total_payout_amount' => (int) $batch->total_payout_amount,
|
'total_payout_amount' => (int) $batch->total_payout_amount,
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ namespace App\Http\Controllers\Api\V1\Ticket;
|
|||||||
|
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Lottery\ErrorCode;
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Models\SettlementBatch;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
|
use App\Models\TicketOrder;
|
||||||
|
use App\Models\WalletTxn;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -51,6 +54,89 @@ final class TicketItemShowController extends Controller
|
|||||||
$drawPayload = $published && $draw !== null ? $this->drawResultView->summarizeDraw($draw) : null;
|
$drawPayload = $published && $draw !== null ? $this->drawResultView->summarizeDraw($draw) : null;
|
||||||
|
|
||||||
$detail = $item->latestSettlementDetail;
|
$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([
|
return ApiResponse::success([
|
||||||
'ticket_no' => $item->ticket_no,
|
'ticket_no' => $item->ticket_no,
|
||||||
@@ -83,6 +169,8 @@ final class TicketItemShowController extends Controller
|
|||||||
'win_amount_minor' => (int) $detail->win_amount,
|
'win_amount_minor' => (int) $detail->win_amount,
|
||||||
'jackpot_allocation_minor' => (int) $detail->jackpot_allocation_amount,
|
'jackpot_allocation_minor' => (int) $detail->jackpot_allocation_amount,
|
||||||
],
|
],
|
||||||
|
'match_result' => $matchResult,
|
||||||
|
'timeline' => $timeline,
|
||||||
'published_draw_results' => $drawPayload,
|
'published_draw_results' => $drawPayload,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Ticket;
|
|||||||
|
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
|
use App\Models\WalletTxn;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Support\PaginationTrait;
|
use App\Support\PaginationTrait;
|
||||||
@@ -26,6 +27,14 @@ final class TicketItemsIndexController extends Controller
|
|||||||
$perPage = $this->perPage($request, 'per_page', 20, 50);
|
$perPage = $this->perPage($request, 'per_page', 20, 50);
|
||||||
$page = $this->page($request);
|
$page = $this->page($request);
|
||||||
$drawNo = $request->query('draw_no');
|
$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()
|
$query = TicketItem::query()
|
||||||
->where('ticket_items.player_id', $player->id)
|
->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));
|
$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);
|
$paginator = $query->paginate(perPage: $perPage, page: $page);
|
||||||
|
|
||||||
$items = collect($paginator->items())->map(function (TicketItem $row): array {
|
$items = collect($paginator->items())->map(function (TicketItem $row): array {
|
||||||
@@ -77,4 +106,14 @@ final class TicketItemsIndexController extends Controller
|
|||||||
'last_page' => $paginator->lastPage(),
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use App\Services\Draw\DrawPrizeLayout;
|
||||||
|
|
||||||
|
final class DrawManualResultBatchStoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'items' => ['required', 'array', 'size:23'],
|
||||||
|
'items.*.prize_type' => ['required', 'string', Rule::in(['first', 'second', 'third', 'starter', 'consolation'])],
|
||||||
|
'items.*.prize_index' => ['required', 'integer', 'min:0', 'max:9'],
|
||||||
|
'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<int, array<string, mixed>> $items
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function slotKeys(array $items): array
|
||||||
|
{
|
||||||
|
$keys = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$keys[] = (string) ($item['prize_type'] ?? '').':'.(string) ($item['prize_index'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Http/Requests/Admin/DrawReopenRequest.php
Normal file
20
app/Http/Requests/Admin/DrawReopenRequest.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class DrawReopenRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Http/Requests/Admin/SettlementBatchReviewRequest.php
Normal file
20
app/Http/Requests/Admin/SettlementBatchReviewRequest.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class SettlementBatchReviewRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'remark' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,15 @@ enum SettlementBatchStatus: string
|
|||||||
{
|
{
|
||||||
case Running = 'running';
|
case Running = 'running';
|
||||||
|
|
||||||
|
case PendingReview = 'pending_review';
|
||||||
|
|
||||||
|
case Approved = 'approved';
|
||||||
|
|
||||||
|
case Rejected = 'rejected';
|
||||||
|
|
||||||
case Completed = 'completed';
|
case Completed = 'completed';
|
||||||
|
|
||||||
|
case Paid = 'paid';
|
||||||
|
|
||||||
case Failed = 'failed';
|
case Failed = 'failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ final class SettlementBatch extends Model
|
|||||||
'total_win_count',
|
'total_win_count',
|
||||||
'total_payout_amount',
|
'total_payout_amount',
|
||||||
'total_jackpot_payout_amount',
|
'total_jackpot_payout_amount',
|
||||||
|
'review_status',
|
||||||
|
'reviewed_by',
|
||||||
|
'reviewed_at',
|
||||||
|
'review_remark',
|
||||||
|
'paid_at',
|
||||||
'started_at',
|
'started_at',
|
||||||
'finished_at',
|
'finished_at',
|
||||||
];
|
];
|
||||||
@@ -33,6 +38,9 @@ final class SettlementBatch extends Model
|
|||||||
'total_win_count' => 'integer',
|
'total_win_count' => 'integer',
|
||||||
'total_payout_amount' => 'integer',
|
'total_payout_amount' => 'integer',
|
||||||
'total_jackpot_payout_amount' => 'integer',
|
'total_jackpot_payout_amount' => 'integer',
|
||||||
|
'reviewed_by' => 'integer',
|
||||||
|
'reviewed_at' => 'datetime',
|
||||||
|
'paid_at' => 'datetime',
|
||||||
'started_at' => 'datetime',
|
'started_at' => 'datetime',
|
||||||
'finished_at' => 'datetime',
|
'finished_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|||||||
51
app/Services/Draw/DrawAdminActionService.php
Normal file
51
app/Services/Draw/DrawAdminActionService.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Draw;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Lottery\DrawStatus;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class DrawAdminActionService
|
||||||
|
{
|
||||||
|
public function manualClose(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::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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Services/Draw/DrawManualResultService.php
Normal file
84
app/Services/Draw/DrawManualResultService.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Draw;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\DrawStatus;
|
||||||
|
use App\Models\DrawResultItem;
|
||||||
|
use App\Models\DrawResultBatch;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Lottery\DrawResultSourceType;
|
||||||
|
use App\Lottery\DrawResultBatchStatus;
|
||||||
|
|
||||||
|
final class DrawManualResultService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array{prize_type: string, prize_index: int, number_4d: string}> $items
|
||||||
|
*/
|
||||||
|
public function createPendingBatch(Draw $draw, AdminUser $admin, array $items): DrawResultBatch
|
||||||
|
{
|
||||||
|
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<array{prize_type: string, prize_index: int, number_4d: string}> $items
|
||||||
|
* @return list<array{prize_type: string, prize_index: int, number_4d: string}>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Services/Draw/DrawReopenService.php
Normal file
35
app/Services/Draw/DrawReopenService.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Draw;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\DrawStatus;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class DrawReopenService
|
||||||
|
{
|
||||||
|
public function reopenCooldownDraw(Draw $draw, AdminUser $admin, ?string $reason = null): Draw
|
||||||
|
{
|
||||||
|
unset($admin, $reason);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($draw): Draw {
|
||||||
|
/** @var Draw $locked */
|
||||||
|
$locked = Draw::query()->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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Services\Settlement\Matchers;
|
namespace App\Services\Settlement\Matchers;
|
||||||
|
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Models\TicketCombination;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use App\Services\Settlement\OddsSnapshotReader;
|
use App\Services\Settlement\OddsSnapshotReader;
|
||||||
use App\Services\Settlement\PublishedDrawResultBoard;
|
use App\Services\Settlement\PublishedDrawResultBoard;
|
||||||
@@ -36,42 +35,39 @@ final class Pos2AbcSettlementMatcher implements SettlementPlayMatcher
|
|||||||
$bestTier = null;
|
$bestTier = null;
|
||||||
$bestRank = 99;
|
$bestRank = 99;
|
||||||
|
|
||||||
foreach ($combinations as $c) {
|
$suf = substr((string) $item->normalized_number, -2);
|
||||||
/** @var TicketCombination $c */
|
$hitTier = null;
|
||||||
$n = (string) $c->number_4d;
|
$rank = 99;
|
||||||
if (strlen($n) < 2) {
|
foreach ($suffixByTier as $t => $sx) {
|
||||||
|
if ($suf !== $sx) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$suf = substr($n, -2);
|
$r = match ($t) {
|
||||||
$hitTier = null;
|
'first' => 0,
|
||||||
$rank = 99;
|
'second' => 1,
|
||||||
foreach ($suffixByTier as $t => $sx) {
|
'third' => 2,
|
||||||
if ($suf !== $sx) {
|
default => 99,
|
||||||
continue;
|
};
|
||||||
}
|
if ($r < $rank) {
|
||||||
$r = match ($t) {
|
$rank = $r;
|
||||||
'first' => 0,
|
$hitTier = $t;
|
||||||
'second' => 1,
|
|
||||||
'third' => 2,
|
|
||||||
default => 99,
|
|
||||||
};
|
|
||||||
if ($r < $rank) {
|
|
||||||
$rank = $r;
|
|
||||||
$hitTier = $t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($hitTier === null) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hitTier !== null) {
|
||||||
$oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier);
|
$oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier);
|
||||||
$bet = (int) $c->bet_amount;
|
$bet = (int) $item->unit_bet_amount;
|
||||||
$payout = (int) floor($bet * ($oddsVal / 10_000));
|
$total = (int) floor($bet * ($oddsVal / 10_000));
|
||||||
$total += $payout;
|
$lines[] = [
|
||||||
$lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout];
|
'number' => $item->original_number,
|
||||||
if ($rank < $bestRank) {
|
'suffix2' => $suf,
|
||||||
$bestRank = $rank;
|
'tier' => $hitTier,
|
||||||
$bestTier = $hitTier;
|
'bet_amount' => $bet,
|
||||||
}
|
'odds_value' => $oddsVal,
|
||||||
|
'payout' => $total,
|
||||||
|
];
|
||||||
|
$bestRank = $rank;
|
||||||
|
$bestTier = $hitTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Services\Settlement\Matchers;
|
namespace App\Services\Settlement\Matchers;
|
||||||
|
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Models\TicketCombination;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use App\Services\Settlement\OddsSnapshotReader;
|
use App\Services\Settlement\OddsSnapshotReader;
|
||||||
use App\Services\Settlement\PublishedDrawResultBoard;
|
use App\Services\Settlement\PublishedDrawResultBoard;
|
||||||
@@ -36,16 +35,16 @@ final class Pos2TierSettlementMatcher implements SettlementPlayMatcher
|
|||||||
$lines = [];
|
$lines = [];
|
||||||
$total = 0;
|
$total = 0;
|
||||||
|
|
||||||
foreach ($combinations as $c) {
|
if (substr((string) $item->normalized_number, -2) === $suffix) {
|
||||||
/** @var TicketCombination $c */
|
$bet = (int) $item->unit_bet_amount;
|
||||||
$n = (string) $c->number_4d;
|
$total = (int) floor($bet * ($oddsVal / 10_000));
|
||||||
if (strlen($n) < 2 || substr($n, -2) !== $suffix) {
|
$lines[] = [
|
||||||
continue;
|
'number' => $item->original_number,
|
||||||
}
|
'suffix2' => $suffix,
|
||||||
$bet = (int) $c->bet_amount;
|
'bet_amount' => $bet,
|
||||||
$payout = (int) floor($bet * ($oddsVal / 10_000));
|
'odds_value' => $oddsVal,
|
||||||
$total += $payout;
|
'payout' => $total,
|
||||||
$lines[] = ['number_4d' => $n, 'suffix2' => $suffix, 'payout' => $payout];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Services\Settlement\Matchers;
|
namespace App\Services\Settlement\Matchers;
|
||||||
|
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Models\TicketCombination;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use App\Services\Settlement\OddsSnapshotReader;
|
use App\Services\Settlement\OddsSnapshotReader;
|
||||||
use App\Services\Settlement\PublishedDrawResultBoard;
|
use App\Services\Settlement\PublishedDrawResultBoard;
|
||||||
@@ -36,42 +35,39 @@ final class Pos3AbcSettlementMatcher implements SettlementPlayMatcher
|
|||||||
$bestTier = null;
|
$bestTier = null;
|
||||||
$bestRank = 99;
|
$bestRank = 99;
|
||||||
|
|
||||||
foreach ($combinations as $c) {
|
$suf = substr((string) $item->normalized_number, -3);
|
||||||
/** @var TicketCombination $c */
|
$hitTier = null;
|
||||||
$n = (string) $c->number_4d;
|
$rank = 99;
|
||||||
if (strlen($n) < 3) {
|
foreach ($suffixByTier as $t => $sx) {
|
||||||
|
if ($suf !== $sx) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$suf = substr($n, -3);
|
$r = match ($t) {
|
||||||
$hitTier = null;
|
'first' => 0,
|
||||||
$rank = 99;
|
'second' => 1,
|
||||||
foreach ($suffixByTier as $t => $sx) {
|
'third' => 2,
|
||||||
if ($suf !== $sx) {
|
default => 99,
|
||||||
continue;
|
};
|
||||||
}
|
if ($r < $rank) {
|
||||||
$r = match ($t) {
|
$rank = $r;
|
||||||
'first' => 0,
|
$hitTier = $t;
|
||||||
'second' => 1,
|
|
||||||
'third' => 2,
|
|
||||||
default => 99,
|
|
||||||
};
|
|
||||||
if ($r < $rank) {
|
|
||||||
$rank = $r;
|
|
||||||
$hitTier = $t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($hitTier === null) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hitTier !== null) {
|
||||||
$oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier);
|
$oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier);
|
||||||
$bet = (int) $c->bet_amount;
|
$bet = (int) $item->unit_bet_amount;
|
||||||
$payout = (int) floor($bet * ($oddsVal / 10_000));
|
$total = (int) floor($bet * ($oddsVal / 10_000));
|
||||||
$total += $payout;
|
$lines[] = [
|
||||||
$lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout];
|
'number' => $item->original_number,
|
||||||
if ($rank < $bestRank) {
|
'suffix3' => $suf,
|
||||||
$bestRank = $rank;
|
'tier' => $hitTier,
|
||||||
$bestTier = $hitTier;
|
'bet_amount' => $bet,
|
||||||
}
|
'odds_value' => $oddsVal,
|
||||||
|
'payout' => $total,
|
||||||
|
];
|
||||||
|
$bestRank = $rank;
|
||||||
|
$bestTier = $hitTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Services\Settlement\Matchers;
|
namespace App\Services\Settlement\Matchers;
|
||||||
|
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Models\TicketCombination;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use App\Services\Settlement\OddsSnapshotReader;
|
use App\Services\Settlement\OddsSnapshotReader;
|
||||||
use App\Services\Settlement\PublishedDrawResultBoard;
|
use App\Services\Settlement\PublishedDrawResultBoard;
|
||||||
@@ -36,16 +35,16 @@ final class Pos3TierSettlementMatcher implements SettlementPlayMatcher
|
|||||||
$lines = [];
|
$lines = [];
|
||||||
$total = 0;
|
$total = 0;
|
||||||
|
|
||||||
foreach ($combinations as $c) {
|
if (substr((string) $item->normalized_number, -3) === $suffix) {
|
||||||
/** @var TicketCombination $c */
|
$bet = (int) $item->unit_bet_amount;
|
||||||
$n = (string) $c->number_4d;
|
$total = (int) floor($bet * ($oddsVal / 10_000));
|
||||||
if (strlen($n) < 3 || substr($n, -3) !== $suffix) {
|
$lines[] = [
|
||||||
continue;
|
'number' => $item->original_number,
|
||||||
}
|
'suffix3' => $suffix,
|
||||||
$bet = (int) $c->bet_amount;
|
'bet_amount' => $bet,
|
||||||
$payout = (int) floor($bet * ($oddsVal / 10_000));
|
'odds_value' => $oddsVal,
|
||||||
$total += $payout;
|
'payout' => $total,
|
||||||
$lines[] = ['number_4d' => $n, 'suffix3' => $suffix, 'payout' => $payout];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
134
app/Services/Settlement/SettlementBatchWorkflowService.php
Normal file
134
app/Services/Settlement/SettlementBatchWorkflowService.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Settlement;
|
||||||
|
|
||||||
|
use App\Models\Draw;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\TicketItem;
|
||||||
|
use App\Lottery\DrawStatus;
|
||||||
|
use App\Models\TicketOrder;
|
||||||
|
use App\Models\SettlementBatch;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Lottery\SettlementBatchStatus;
|
||||||
|
use App\Services\Ticket\TicketWalletService;
|
||||||
|
|
||||||
|
final class SettlementBatchWorkflowService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TicketWalletService $wallet,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function approve(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');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,9 @@
|
|||||||
namespace App\Services\Settlement;
|
namespace App\Services\Settlement;
|
||||||
|
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
use App\Models\Player;
|
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use App\Models\JackpotPool;
|
use App\Models\JackpotPool;
|
||||||
use App\Models\TicketOrder;
|
|
||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
use App\Models\DrawResultBatch;
|
use App\Models\DrawResultBatch;
|
||||||
use App\Models\SettlementBatch;
|
use App\Models\SettlementBatch;
|
||||||
@@ -16,13 +14,12 @@ use App\Lottery\DrawResultBatchStatus;
|
|||||||
use App\Lottery\SettlementBatchStatus;
|
use App\Lottery\SettlementBatchStatus;
|
||||||
use App\Models\TicketSettlementDetail;
|
use App\Models\TicketSettlementDetail;
|
||||||
use App\Services\Ticket\RiskPoolService;
|
use App\Services\Ticket\RiskPoolService;
|
||||||
use App\Services\Ticket\TicketWalletService;
|
|
||||||
use App\Services\Jackpot\JackpotBurstAllocator;
|
use App\Services\Jackpot\JackpotBurstAllocator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 入账)。
|
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 → 回水派彩调整 → Jackpot 爆池分配 → 明细 → 风险池释放 → 待审核)。
|
||||||
*
|
*
|
||||||
* 幂等:同一 `draw` + 已发布 `result_batch` 若已有 `completed` 批次,则仅推进期号状态为 `settled`。
|
* 派彩入账由审核通过后的独立 payout 动作执行,避免未确认结果直接入账。
|
||||||
*/
|
*/
|
||||||
final class SettlementOrchestrator
|
final class SettlementOrchestrator
|
||||||
{
|
{
|
||||||
@@ -30,7 +27,6 @@ final class SettlementOrchestrator
|
|||||||
private readonly SettlementMatcherRegistry $matchers,
|
private readonly SettlementMatcherRegistry $matchers,
|
||||||
private readonly SettlementPayoutAdjuster $payoutAdjuster,
|
private readonly SettlementPayoutAdjuster $payoutAdjuster,
|
||||||
private readonly JackpotBurstAllocator $jackpotBurst,
|
private readonly JackpotBurstAllocator $jackpotBurst,
|
||||||
private readonly TicketWalletService $wallet,
|
|
||||||
private readonly RiskPoolService $riskPool,
|
private readonly RiskPoolService $riskPool,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -65,12 +61,16 @@ final class SettlementOrchestrator
|
|||||||
$existingDone = SettlementBatch::query()
|
$existingDone = SettlementBatch::query()
|
||||||
->where('draw_id', $locked->id)
|
->where('draw_id', $locked->id)
|
||||||
->where('result_batch_id', $publishedBatch->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();
|
->first();
|
||||||
|
|
||||||
if ($existingDone !== null) {
|
if ($existingDone !== null) {
|
||||||
$locked->forceFill([
|
$locked->forceFill([
|
||||||
'status' => DrawStatus::Settled->value,
|
|
||||||
'settle_version' => (int) $existingDone->settle_version,
|
'settle_version' => (int) $existingDone->settle_version,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
@@ -91,6 +91,7 @@ final class SettlementOrchestrator
|
|||||||
'result_batch_id' => $publishedBatch->id,
|
'result_batch_id' => $publishedBatch->id,
|
||||||
'settle_version' => $nextSettleVersion,
|
'settle_version' => $nextSettleVersion,
|
||||||
'status' => SettlementBatchStatus::Running->value,
|
'status' => SettlementBatchStatus::Running->value,
|
||||||
|
'review_status' => 'pending',
|
||||||
'started_at' => now(),
|
'started_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -139,7 +140,6 @@ final class SettlementOrchestrator
|
|||||||
$totalJackpotPayout = (int) $burstOut['pool_payout'];
|
$totalJackpotPayout = (int) $burstOut['pool_payout'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$playerTotals = [];
|
|
||||||
$ticketCount = 0;
|
$ticketCount = 0;
|
||||||
$winCount = 0;
|
$winCount = 0;
|
||||||
$totalPayout = 0;
|
$totalPayout = 0;
|
||||||
@@ -164,8 +164,8 @@ final class SettlementOrchestrator
|
|||||||
$item->forceFill([
|
$item->forceFill([
|
||||||
'win_amount' => $net,
|
'win_amount' => $net,
|
||||||
'jackpot_win_amount' => $jackpotShare,
|
'jackpot_win_amount' => $jackpotShare,
|
||||||
'settled_at' => now(),
|
'settled_at' => null,
|
||||||
'status' => $finalCredit > 0 ? 'settled_win' : 'settled_lose',
|
'status' => $finalCredit > 0 ? 'pending_payout' : 'settled_lose',
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
if ($finalCredit > 0) {
|
if ($finalCredit > 0) {
|
||||||
@@ -173,9 +173,6 @@ final class SettlementOrchestrator
|
|||||||
}
|
}
|
||||||
$totalPayout += $finalCredit;
|
$totalPayout += $finalCredit;
|
||||||
|
|
||||||
$pid = (int) $item->player_id;
|
|
||||||
$playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $finalCredit;
|
|
||||||
|
|
||||||
$locks = [];
|
$locks = [];
|
||||||
foreach ($item->combinations as $c) {
|
foreach ($item->combinations as $c) {
|
||||||
$locks[] = [
|
$locks[] = [
|
||||||
@@ -186,16 +183,8 @@ final class SettlementOrchestrator
|
|||||||
$this->riskPool->release((int) $locked->id, $item, $locks);
|
$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([
|
$batchRow->forceFill([
|
||||||
'status' => SettlementBatchStatus::Completed->value,
|
'status' => SettlementBatchStatus::PendingReview->value,
|
||||||
'total_ticket_count' => $ticketCount,
|
'total_ticket_count' => $ticketCount,
|
||||||
'total_win_count' => $winCount,
|
'total_win_count' => $winCount,
|
||||||
'total_payout_amount' => $totalPayout,
|
'total_payout_amount' => $totalPayout,
|
||||||
@@ -204,20 +193,10 @@ final class SettlementOrchestrator
|
|||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$locked->forceFill([
|
$locked->forceFill([
|
||||||
'status' => DrawStatus::Settled->value,
|
'status' => DrawStatus::Settling->value,
|
||||||
'settle_version' => $nextSettleVersion,
|
'settle_version' => $nextSettleVersion,
|
||||||
])->save();
|
])->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;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ final class PlayRuleEngine
|
|||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'digit_slot' => $digitSlotInt,
|
'digit_slot' => $digitSlotInt,
|
||||||
'combination_count' => $combinationCount,
|
'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 {
|
'combinations' => collect($combos)->values()->map(function (string $combo, int $index) use ($unitBetAmount, $estimatedPayoutPerCombo): array {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ return new class extends Migration
|
|||||||
$table->unsignedInteger('total_win_count')->default(0);
|
$table->unsignedInteger('total_win_count')->default(0);
|
||||||
$table->bigInteger('total_payout_amount')->default(0);
|
$table->bigInteger('total_payout_amount')->default(0);
|
||||||
$table->bigInteger('total_jackpot_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('started_at')->nullable();
|
||||||
$table->timestamp('finished_at')->nullable();
|
$table->timestamp('finished_at')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|||||||
@@ -3,15 +3,25 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawShowController;
|
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\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\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\AdminRiskPoolShowController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Risk\AdminRiskPoolIndexController;
|
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\DrawResultBatchPublishController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawFinanceSummaryController;
|
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\Risk\AdminRiskPoolLockLogIndexController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Draw\AdminDrawResultBatchesIndexController;
|
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\AdminSettlementBatchShowController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchIndexController;
|
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;
|
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')
|
Route::middleware('admin.permission:prd.draw_result.manage')
|
||||||
->post('draws/{draw}/result-batches/{batch}/publish', DrawResultBatchPublishController::class)
|
->group(function (): void {
|
||||||
->name('api.v1.admin.draws.result-batches.publish');
|
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')
|
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');
|
->name('api.v1.admin.settlement-batches.show');
|
||||||
Route::get('settlement-batches/{batch}/details', AdminSettlementBatchDetailsController::class)
|
Route::get('settlement-batches/{batch}/details', AdminSettlementBatchDetailsController::class)
|
||||||
->name('api.v1.admin.settlement-batches.details');
|
->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');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
|
use App\Models\AdminRole;
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
@@ -46,6 +47,151 @@ test('draw planner fills buffer rows with ordered draw_no', function (): void {
|
|||||||
expect($drawNos)->toEqual($sorted);
|
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 {
|
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'));
|
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();
|
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 {
|
test('cooldown expiry tick moves draw to settling', function (): void {
|
||||||
config([
|
config([
|
||||||
'lottery.draw.require_manual_review' => false,
|
'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());
|
app(DrawTickService::class)->tick(now()->utc());
|
||||||
|
|
||||||
$draw->refresh();
|
$draw->refresh();
|
||||||
expect($draw->status)->toBe(DrawStatus::Settled->value);
|
expect($draw->status)->toBe(DrawStatus::Settling->value);
|
||||||
expect((int) $draw->settle_version)->toBe(1);
|
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();
|
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')
|
$this->getJson('/api/v1/draw/current')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.draw_no', '20260509-300')
|
->assertJsonPath('data.data.draw_no', '20260509-300')
|
||||||
->assertJsonPath('data.status', DrawStatus::Open->value)
|
->assertJsonPath('data.data.status', DrawStatus::Open->value)
|
||||||
->assertJsonPath('data.seconds_to_close', 60 * 60 - 30)
|
->assertJsonPath('data.data.seconds_to_close', 60 * 60 - 30)
|
||||||
->assertJsonPath('data.seconds_to_draw', 3600);
|
->assertJsonPath('data.data.seconds_to_draw', 3600);
|
||||||
|
|
||||||
Carbon::setTestNow();
|
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')
|
$this->getJson('/api/v1/draw/current')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.draw_no', '20260509-310')
|
->assertJsonPath('data.data.draw_no', '20260509-310')
|
||||||
->assertJsonPath('data.status', DrawStatus::Closing->value)
|
->assertJsonPath('data.data.status', DrawStatus::Closing->value)
|
||||||
->assertJsonPath('data.seconds_to_close', 0)
|
->assertJsonPath('data.data.seconds_to_close', 0)
|
||||||
->assertJsonPath('data.seconds_to_draw', 20);
|
->assertJsonPath('data.data.seconds_to_draw', 20);
|
||||||
|
|
||||||
Carbon::setTestNow();
|
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')
|
$this->getJson('/api/v1/draw/current')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.draw_no', '20260509-311')
|
->assertJsonPath('data.data.draw_no', '20260509-311')
|
||||||
->assertJsonPath('data.status', DrawStatus::Closed->value)
|
->assertJsonPath('data.data.status', DrawStatus::Closed->value)
|
||||||
->assertJsonPath('data.seconds_to_close', 0)
|
->assertJsonPath('data.data.seconds_to_close', 0)
|
||||||
->assertJsonPath('data.seconds_to_draw', 0);
|
->assertJsonPath('data.data.seconds_to_draw', 0);
|
||||||
|
|
||||||
Carbon::setTestNow();
|
Carbon::setTestNow();
|
||||||
});
|
});
|
||||||
@@ -343,8 +674,8 @@ test('GET draw current includes result_items when cooldown', function (): void {
|
|||||||
|
|
||||||
$this->getJson('/api/v1/draw/current')
|
$this->getJson('/api/v1/draw/current')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', DrawStatus::Cooldown->value)
|
->assertJsonPath('data.data.status', DrawStatus::Cooldown->value)
|
||||||
->assertJsonPath('data.result_items.0.number_4d', '1234');
|
->assertJsonPath('data.data.result_items.0.number_4d', '1234');
|
||||||
|
|
||||||
Carbon::setTestNow();
|
Carbon::setTestNow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
|
use App\Models\AdminUser;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use App\Models\JackpotPool;
|
use App\Models\JackpotPool;
|
||||||
@@ -10,7 +11,9 @@ use App\Models\PlayerWallet;
|
|||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
use App\Models\DrawResultBatch;
|
use App\Models\DrawResultBatch;
|
||||||
use App\Models\JackpotPayoutLog;
|
use App\Models\JackpotPayoutLog;
|
||||||
|
use App\Models\SettlementBatch;
|
||||||
use App\Models\JackpotContribution;
|
use App\Models\JackpotContribution;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
use App\Lottery\DrawResultBatchStatus;
|
use App\Lottery\DrawResultBatchStatus;
|
||||||
@@ -19,6 +22,7 @@ use Database\Seeders\LotterySettingsSeeder;
|
|||||||
use Database\Seeders\OperationalConfigV1Seeder;
|
use Database\Seeders\OperationalConfigV1Seeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use App\Services\Settlement\SettlementOrchestrator;
|
use App\Services\Settlement\SettlementOrchestrator;
|
||||||
|
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
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());
|
$ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh());
|
||||||
expect($ran)->toBeTrue();
|
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();
|
$item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||||
expect((int) $item->win_amount)->toBe(250_000);
|
expect((int) $item->win_amount)->toBe(250_000);
|
||||||
expect((int) $item->jackpot_win_amount)->toBe(1_000);
|
expect((int) $item->jackpot_win_amount)->toBe(1_000);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use App\Models\PlayerWallet;
|
|||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
use App\Models\DrawResultBatch;
|
use App\Models\DrawResultBatch;
|
||||||
use App\Models\SettlementBatch;
|
use App\Models\SettlementBatch;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
use App\Lottery\DrawResultBatchStatus;
|
use App\Lottery\DrawResultBatchStatus;
|
||||||
@@ -18,6 +20,7 @@ use Database\Seeders\LotterySettingsSeeder;
|
|||||||
use Database\Seeders\OperationalConfigV1Seeder;
|
use Database\Seeders\OperationalConfigV1Seeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use App\Services\Settlement\SettlementOrchestrator;
|
use App\Services\Settlement\SettlementOrchestrator;
|
||||||
|
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
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());
|
$ran = app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh());
|
||||||
expect($ran)->toBeTrue();
|
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();
|
$draw->refresh();
|
||||||
expect($draw->status)->toBe(DrawStatus::Settled->value);
|
expect($draw->status)->toBe(DrawStatus::Settled->value);
|
||||||
expect((int) $draw->settle_version)->toBe(1);
|
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);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
|
use App\Models\AdminUser;
|
||||||
use App\Models\RiskPool;
|
use App\Models\RiskPool;
|
||||||
use App\Models\WalletTxn;
|
use App\Models\WalletTxn;
|
||||||
use App\Lottery\ErrorCode;
|
use App\Lottery\ErrorCode;
|
||||||
@@ -17,6 +18,7 @@ use App\Models\PlayerWallet;
|
|||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
use App\Models\DrawResultBatch;
|
use App\Models\DrawResultBatch;
|
||||||
use App\Models\JackpotPayoutLog;
|
use App\Models\JackpotPayoutLog;
|
||||||
|
use App\Models\SettlementBatch;
|
||||||
use App\Models\TicketCombination;
|
use App\Models\TicketCombination;
|
||||||
use App\Models\JackpotContribution;
|
use App\Models\JackpotContribution;
|
||||||
use App\Support\OddsStandardScopes;
|
use App\Support\OddsStandardScopes;
|
||||||
@@ -24,7 +26,9 @@ use Database\Seeders\CurrencySeeder;
|
|||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
use App\Lottery\DrawResultBatchStatus;
|
use App\Lottery\DrawResultBatchStatus;
|
||||||
use App\Models\TicketSettlementDetail;
|
use App\Models\TicketSettlementDetail;
|
||||||
|
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||||
use App\Services\Draw\DrawPrizeLayout;
|
use App\Services\Draw\DrawPrizeLayout;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Database\Seeders\LotterySettingsSeeder;
|
use Database\Seeders\LotterySettingsSeeder;
|
||||||
use Database\Seeders\OperationalConfigV1Seeder;
|
use Database\Seeders\OperationalConfigV1Seeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
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 {
|
test('§14.5 big no-hit settles lose wallet unchanged except bet and no settle_payout txn', function (): void {
|
||||||
$player = p145_player();
|
$player = p145_player();
|
||||||
$drawNo = p145_next_draw_no();
|
$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();
|
])->save();
|
||||||
|
|
||||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||||
|
p145_approve_and_payout($draw);
|
||||||
|
|
||||||
$item->refresh();
|
$item->refresh();
|
||||||
expect($item->status)->toBe('settled_lose')
|
expect($item->status)->toBe('settled_lose')
|
||||||
@@ -224,6 +245,7 @@ test('§14.5 small hits second tier only', function (): void {
|
|||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||||
|
p145_approve_and_payout($draw);
|
||||||
|
|
||||||
$item->refresh();
|
$item->refresh();
|
||||||
expect($item->status)->toBe('settled_win')
|
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;
|
$deduct = (int) $item->actual_deduct_amount;
|
||||||
$odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']];
|
$odds = OddsStandardScopes::PRESET_ODDS_BY_SCOPE[$case['scope']];
|
||||||
$perComboWin = (int) floor(10_000 * $odds / 10_000);
|
$perComboWin = (int) floor(10_000 * $odds / 10_000);
|
||||||
$comboCount = (int) $item->combination_count;
|
$expectedWin = $perComboWin;
|
||||||
$expectedWin = match ($case['play']) {
|
|
||||||
'pos_3a', 'pos_2a' => $perComboWin * $comboCount,
|
|
||||||
default => $perComboWin,
|
|
||||||
};
|
|
||||||
|
|
||||||
p145_publish_board($draw, $case['board']);
|
p145_publish_board($draw, $case['board']);
|
||||||
$draw->forceFill([
|
$draw->forceFill([
|
||||||
@@ -313,6 +331,7 @@ test('§14.5 pos_4b pos_3a pos_2a pos_4e each settle with expected win', functio
|
|||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||||
|
p145_approve_and_payout($draw);
|
||||||
|
|
||||||
$item->refresh();
|
$item->refresh();
|
||||||
expect($item->status)->toBe('settled_win', $case['play'])
|
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 {
|
test('§14.5 jackpot contributes on place and stays in pool when no first-prize burst', function (): void {
|
||||||
JackpotPool::query()->create([
|
JackpotPool::query()->create([
|
||||||
'currency_code' => 'NPR',
|
'currency_code' => 'NPR',
|
||||||
@@ -371,6 +461,7 @@ test('§14.5 jackpot contributes on place and stays in pool when no first-prize
|
|||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
|
||||||
|
p145_approve_and_payout($draw);
|
||||||
|
|
||||||
expect(JackpotPayoutLog::query()->count())->toBe(0);
|
expect(JackpotPayoutLog::query()->count())->toBe(0);
|
||||||
$poolAfter = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail();
|
$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),
|
default => p145_board_without_8888($t, $i),
|
||||||
},
|
},
|
||||||
'scope' => 'second',
|
'scope' => 'second',
|
||||||
'comboMultiplier' => 10,
|
'comboMultiplier' => 1,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'play' => 'pos_3c',
|
'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),
|
default => p145_board_without_8888($t, $i),
|
||||||
},
|
},
|
||||||
'scope' => 'third',
|
'scope' => 'third',
|
||||||
'comboMultiplier' => 10,
|
'comboMultiplier' => 1,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'play' => 'pos_3abc',
|
'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),
|
default => p145_board_without_8888($t, $i),
|
||||||
},
|
},
|
||||||
'scope' => 'first',
|
'scope' => 'first',
|
||||||
'comboMultiplier' => 10,
|
'comboMultiplier' => 1,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'play' => 'pos_2b',
|
'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),
|
default => p145_board_without_8888($t, $i),
|
||||||
},
|
},
|
||||||
'scope' => 'second',
|
'scope' => 'second',
|
||||||
'comboMultiplier' => 100,
|
'comboMultiplier' => 1,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'play' => 'pos_2c',
|
'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),
|
default => p145_board_without_8888($t, $i),
|
||||||
},
|
},
|
||||||
'scope' => 'third',
|
'scope' => 'third',
|
||||||
'comboMultiplier' => 100,
|
'comboMultiplier' => 1,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'play' => 'pos_2abc',
|
'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),
|
default => p145_board_without_8888($t, $i),
|
||||||
},
|
},
|
||||||
'scope' => 'first',
|
'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();
|
])->save();
|
||||||
|
|
||||||
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
|
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()), $case['play'])->toBeTrue();
|
||||||
|
p145_approve_and_payout($draw);
|
||||||
|
|
||||||
$item->refresh();
|
$item->refresh();
|
||||||
expect($item->status)->toBe('settled_win', $case['play'])
|
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();
|
])->save();
|
||||||
|
|
||||||
app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh());
|
app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh());
|
||||||
|
p145_approve_and_payout($draw);
|
||||||
|
|
||||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||||
->getJson('/api/v1/ticket/items/'.$ticketNo)
|
->getJson('/api/v1/ticket/items/'.$ticketNo)
|
||||||
|
|||||||
@@ -110,6 +110,100 @@ test('ticket preview returns computed summary for open draw', function (): void
|
|||||||
->assertJsonCount(2, 'data.lines');
|
->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 {
|
test('ticket place deducts wallet and persists order items combinations and logs', function (): void {
|
||||||
$player = ticketPlayerWithWallet();
|
$player = ticketPlayerWithWallet();
|
||||||
ticketOpenDraw();
|
ticketOpenDraw();
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ use App\Models\JackpotPool;
|
|||||||
use App\Models\PlayerWallet;
|
use App\Models\PlayerWallet;
|
||||||
use App\Models\DrawResultItem;
|
use App\Models\DrawResultItem;
|
||||||
use App\Models\DrawResultBatch;
|
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\CurrencySeeder;
|
||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
use App\Lottery\DrawResultBatchStatus;
|
use App\Lottery\DrawResultBatchStatus;
|
||||||
use App\Services\Draw\DrawPrizeLayout;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Database\Seeders\LotterySettingsSeeder;
|
use Database\Seeders\LotterySettingsSeeder;
|
||||||
use Database\Seeders\OperationalConfigV1Seeder;
|
use Database\Seeders\OperationalConfigV1Seeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -24,6 +30,79 @@ beforeEach(function (): void {
|
|||||||
$this->seed(LotterySettingsSeeder::class);
|
$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 {
|
test('jackpot summary is public', function (): void {
|
||||||
JackpotPool::query()->create([
|
JackpotPool::query()->create([
|
||||||
'currency_code' => 'NPR',
|
'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 {
|
test('ticket items index returns placed ticket for player', function (): void {
|
||||||
$uniq = bin2hex(random_bytes(4));
|
$player = ticketItemsPlayer();
|
||||||
$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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$draw = Draw::query()->create([
|
$draw = Draw::query()->create([
|
||||||
'draw_no' => '20260511-777',
|
'draw_no' => '20260511-777',
|
||||||
@@ -117,6 +179,136 @@ test('ticket items index returns placed ticket for player', function (): void {
|
|||||||
->assertJsonPath('data.total', 0);
|
->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 {
|
test('my-match returns hit numbers when draw published', function (): void {
|
||||||
$uniq = bin2hex(random_bytes(4));
|
$uniq = bin2hex(random_bytes(4));
|
||||||
$player = Player::query()->create([
|
$player = Player::query()->create([
|
||||||
|
|||||||
Reference in New Issue
Block a user