feat: 拆分开奖与结算审核流程,新增手动结果录入、重开和派彩审批接口

This commit is contained in:
2026-05-16 18:01:06 +08:00
parent 83046b402d
commit 4f143c7cb1
38 changed files with 1992 additions and 170 deletions

View File

@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use Illuminate\Http\Request;
use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse;
@@ -56,6 +58,14 @@ final class AdminDrawIndexController extends Controller
'current_result_version' => (int) $draw->current_result_version,
'settle_version' => (int) $draw->settle_version,
'is_reopened' => (bool) $draw->is_reopened,
'total_bet_minor' => (int) TicketOrder::query()->where('draw_id', $draw->id)->sum('total_actual_deduct'),
'total_payout_minor' => (int) TicketItem::query()->where('draw_id', $draw->id)->sum('win_amount')
+ (int) TicketItem::query()->where('draw_id', $draw->id)->sum('jackpot_win_amount'),
'profit_loss_minor' => (int) TicketOrder::query()->where('draw_id', $draw->id)->sum('total_actual_deduct')
- (
(int) TicketItem::query()->where('draw_id', $draw->id)->sum('win_amount')
+ (int) TicketItem::query()->where('draw_id', $draw->id)->sum('jackpot_win_amount')
),
'updated_at' => $draw->updated_at?->toIso8601String(),
];
}

View File

@@ -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,
]);
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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(),
],
]);
}
}

View File

@@ -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());
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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(),
],
]);
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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']);
}
}

View File

@@ -46,6 +46,9 @@ final class AdminSettlementBatchIndexController extends Controller
'result_batch_id' => (int) $b->result_batch_id,
'settle_version' => (int) $b->settle_version,
'status' => $b->status,
'review_status' => $b->review_status,
'reviewed_at' => $b->reviewed_at?->toIso8601String(),
'paid_at' => $b->paid_at?->toIso8601String(),
'total_ticket_count' => (int) $b->total_ticket_count,
'total_win_count' => (int) $b->total_win_count,
'total_payout_amount' => (int) $b->total_payout_amount,

View File

@@ -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(),
]);
}
}

View File

@@ -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,
]);
}
}

View File

@@ -26,6 +26,11 @@ final class AdminSettlementBatchShowController extends Controller
'result_batch_status' => $batch->resultBatch?->status,
'settle_version' => (int) $batch->settle_version,
'status' => $batch->status,
'review_status' => $batch->review_status,
'reviewed_by' => $batch->reviewed_by,
'reviewed_at' => $batch->reviewed_at?->toIso8601String(),
'review_remark' => $batch->review_remark,
'paid_at' => $batch->paid_at?->toIso8601String(),
'total_ticket_count' => (int) $batch->total_ticket_count,
'total_win_count' => (int) $batch->total_win_count,
'total_payout_amount' => (int) $batch->total_payout_amount,

View File

@@ -4,7 +4,10 @@ namespace App\Http\Controllers\Api\V1\Ticket;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Models\SettlementBatch;
use App\Models\TicketItem;
use App\Models\TicketOrder;
use App\Models\WalletTxn;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
@@ -51,6 +54,89 @@ final class TicketItemShowController extends Controller
$drawPayload = $published && $draw !== null ? $this->drawResultView->summarizeDraw($draw) : null;
$detail = $item->latestSettlementDetail;
$settlementBatch = $detail?->batch;
$order = $item->order;
$betTxn = WalletTxn::query()
->where('player_id', $player->id)
->where('biz_type', 'bet_deduct')
->where('biz_no', $order?->order_no)
->orderByDesc('id')
->first();
$payoutTxn = WalletTxn::query()
->where('player_id', $player->id)
->where('biz_type', 'settle_payout')
->where('biz_no', 'like', 'SB%')
->orderByDesc('id')
->first();
$timeline = [];
if ($order?->created_at !== null) {
$timeline[] = [
'code' => 'placed',
'label' => '已下注',
'time' => $order->created_at->toIso8601String(),
];
}
if ($betTxn?->created_at !== null) {
$timeline[] = [
'code' => 'deducted',
'label' => '已扣款',
'time' => $betTxn->created_at->toIso8601String(),
];
}
if ($drawPayload !== null && $draw?->current_result_version !== null) {
$timeline[] = [
'code' => 'draw_published',
'label' => '开奖结果已发布',
'time' => $draw->draw_time?->toIso8601String() ?? $draw->updated_at?->toIso8601String(),
];
}
if ($settlementBatch?->started_at !== null) {
$timeline[] = [
'code' => 'settlement_started',
'label' => '结算开始',
'time' => $settlementBatch->started_at->toIso8601String(),
];
}
if ($item->settled_at !== null) {
$timeline[] = [
'code' => 'settled',
'label' => $item->status === 'settled_win' ? '已派彩' : '已结算',
'time' => $item->settled_at->toIso8601String(),
];
} elseif ($payoutTxn?->created_at !== null) {
$timeline[] = [
'code' => 'settled',
'label' => '已派彩',
'time' => $payoutTxn->created_at->toIso8601String(),
];
}
$matchDetail = $detail?->match_detail_json;
$matchedLines = [];
if (is_array($matchDetail['lines'] ?? null)) {
foreach ($matchDetail['lines'] as $line) {
if (! is_array($line)) {
continue;
}
$matchedLines[] = [
'number_4d' => isset($line['number_4d']) ? (string) $line['number_4d'] : null,
'matched_tier' => isset($line['matched_tier']) ? (string) $line['matched_tier'] : null,
'bet_amount' => isset($line['bet_amount']) ? (int) $line['bet_amount'] : null,
'odds_value' => isset($line['odds_value']) ? (int) $line['odds_value'] : null,
'payout' => isset($line['payout']) ? (int) $line['payout'] : null,
];
}
}
$matchResult = [
'matched' => $detail !== null && ((int) $detail->win_amount > 0 || (int) $detail->jackpot_allocation_amount > 0),
'matched_prize_tier' => $detail?->matched_prize_tier,
'win_amount_minor' => $detail !== null ? (int) $detail->win_amount : 0,
'jackpot_allocation_minor' => $detail !== null ? (int) $detail->jackpot_allocation_amount : 0,
'match_detail' => $matchDetail,
'lines' => $matchedLines,
];
return ApiResponse::success([
'ticket_no' => $item->ticket_no,
@@ -83,6 +169,8 @@ final class TicketItemShowController extends Controller
'win_amount_minor' => (int) $detail->win_amount,
'jackpot_allocation_minor' => (int) $detail->jackpot_allocation_amount,
],
'match_result' => $matchResult,
'timeline' => $timeline,
'published_draw_results' => $drawPayload,
]);
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Ticket;
use App\Models\Player;
use App\Models\TicketItem;
use App\Models\WalletTxn;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use App\Support\PaginationTrait;
@@ -26,6 +27,14 @@ final class TicketItemsIndexController extends Controller
$perPage = $this->perPage($request, 'per_page', 20, 50);
$page = $this->page($request);
$drawNo = $request->query('draw_no');
$statusInput = $request->query('status', []);
$statusValues = is_array($statusInput) ? array_values(array_filter(array_map(
fn ($status) => is_string($status) ? trim($status) : '',
$statusInput,
))) : [];
$number = trim((string) $request->query('number', ''));
$startDate = $this->normalizeDate((string) $request->query('start_date', ''));
$endDate = $this->normalizeDate((string) $request->query('end_date', ''));
$query = TicketItem::query()
->where('ticket_items.player_id', $player->id)
@@ -40,6 +49,26 @@ final class TicketItemsIndexController extends Controller
$query->whereHas('draw', fn ($q) => $q->where('draw_no', $drawNo));
}
if ($statusValues !== []) {
$query->whereIn('ticket_items.status', $statusValues);
}
if ($number !== '') {
$query->where(function ($q) use ($number): void {
$q->where('ticket_items.original_number', 'like', '%'.$number.'%')
->orWhere('ticket_items.normalized_number', 'like', '%'.$number.'%')
->orWhere('ticket_items.ticket_no', 'like', '%'.$number.'%');
});
}
if ($startDate !== null) {
$query->whereHas('order', fn ($q) => $q->whereDate('created_at', '>=', $startDate));
}
if ($endDate !== null) {
$query->whereHas('order', fn ($q) => $q->whereDate('created_at', '<=', $endDate));
}
$paginator = $query->paginate(perPage: $perPage, page: $page);
$items = collect($paginator->items())->map(function (TicketItem $row): array {
@@ -77,4 +106,14 @@ final class TicketItemsIndexController extends Controller
'last_page' => $paginator->lastPage(),
]);
}
private function normalizeDate(string $value): ?string
{
$value = trim($value);
if ($value === '' || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return null;
}
return $value;
}
}

View File

@@ -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;
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View File

@@ -7,7 +7,15 @@ enum SettlementBatchStatus: string
{
case Running = 'running';
case PendingReview = 'pending_review';
case Approved = 'approved';
case Rejected = 'rejected';
case Completed = 'completed';
case Paid = 'paid';
case Failed = 'failed';
}

View File

@@ -19,6 +19,11 @@ final class SettlementBatch extends Model
'total_win_count',
'total_payout_amount',
'total_jackpot_payout_amount',
'review_status',
'reviewed_by',
'reviewed_at',
'review_remark',
'paid_at',
'started_at',
'finished_at',
];
@@ -33,6 +38,9 @@ final class SettlementBatch extends Model
'total_win_count' => 'integer',
'total_payout_amount' => 'integer',
'total_jackpot_payout_amount' => 'integer',
'reviewed_by' => 'integer',
'reviewed_at' => 'datetime',
'paid_at' => 'datetime',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];

View 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();
});
}
}

View 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;
}
}

View 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();
});
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Services\Settlement\Matchers;
use App\Models\TicketItem;
use App\Models\TicketCombination;
use Illuminate\Support\Collection;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
@@ -36,42 +35,39 @@ final class Pos2AbcSettlementMatcher implements SettlementPlayMatcher
$bestTier = null;
$bestRank = 99;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
$n = (string) $c->number_4d;
if (strlen($n) < 2) {
$suf = substr((string) $item->normalized_number, -2);
$hitTier = null;
$rank = 99;
foreach ($suffixByTier as $t => $sx) {
if ($suf !== $sx) {
continue;
}
$suf = substr($n, -2);
$hitTier = null;
$rank = 99;
foreach ($suffixByTier as $t => $sx) {
if ($suf !== $sx) {
continue;
}
$r = match ($t) {
'first' => 0,
'second' => 1,
'third' => 2,
default => 99,
};
if ($r < $rank) {
$rank = $r;
$hitTier = $t;
}
}
if ($hitTier === null) {
continue;
$r = match ($t) {
'first' => 0,
'second' => 1,
'third' => 2,
default => 99,
};
if ($r < $rank) {
$rank = $r;
$hitTier = $t;
}
}
if ($hitTier !== null) {
$oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier);
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout];
if ($rank < $bestRank) {
$bestRank = $rank;
$bestTier = $hitTier;
}
$bet = (int) $item->unit_bet_amount;
$total = (int) floor($bet * ($oddsVal / 10_000));
$lines[] = [
'number' => $item->original_number,
'suffix2' => $suf,
'tier' => $hitTier,
'bet_amount' => $bet,
'odds_value' => $oddsVal,
'payout' => $total,
];
$bestRank = $rank;
$bestTier = $hitTier;
}
return [

View File

@@ -3,7 +3,6 @@
namespace App\Services\Settlement\Matchers;
use App\Models\TicketItem;
use App\Models\TicketCombination;
use Illuminate\Support\Collection;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
@@ -36,16 +35,16 @@ final class Pos2TierSettlementMatcher implements SettlementPlayMatcher
$lines = [];
$total = 0;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
$n = (string) $c->number_4d;
if (strlen($n) < 2 || substr($n, -2) !== $suffix) {
continue;
}
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = ['number_4d' => $n, 'suffix2' => $suffix, 'payout' => $payout];
if (substr((string) $item->normalized_number, -2) === $suffix) {
$bet = (int) $item->unit_bet_amount;
$total = (int) floor($bet * ($oddsVal / 10_000));
$lines[] = [
'number' => $item->original_number,
'suffix2' => $suffix,
'bet_amount' => $bet,
'odds_value' => $oddsVal,
'payout' => $total,
];
}
return [

View File

@@ -3,7 +3,6 @@
namespace App\Services\Settlement\Matchers;
use App\Models\TicketItem;
use App\Models\TicketCombination;
use Illuminate\Support\Collection;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
@@ -36,42 +35,39 @@ final class Pos3AbcSettlementMatcher implements SettlementPlayMatcher
$bestTier = null;
$bestRank = 99;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
$n = (string) $c->number_4d;
if (strlen($n) < 3) {
$suf = substr((string) $item->normalized_number, -3);
$hitTier = null;
$rank = 99;
foreach ($suffixByTier as $t => $sx) {
if ($suf !== $sx) {
continue;
}
$suf = substr($n, -3);
$hitTier = null;
$rank = 99;
foreach ($suffixByTier as $t => $sx) {
if ($suf !== $sx) {
continue;
}
$r = match ($t) {
'first' => 0,
'second' => 1,
'third' => 2,
default => 99,
};
if ($r < $rank) {
$rank = $r;
$hitTier = $t;
}
}
if ($hitTier === null) {
continue;
$r = match ($t) {
'first' => 0,
'second' => 1,
'third' => 2,
default => 99,
};
if ($r < $rank) {
$rank = $r;
$hitTier = $t;
}
}
if ($hitTier !== null) {
$oddsVal = $this->odds->oddsValueForScope($snapshot, $hitTier);
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = ['number_4d' => $n, 'tier' => $hitTier, 'payout' => $payout];
if ($rank < $bestRank) {
$bestRank = $rank;
$bestTier = $hitTier;
}
$bet = (int) $item->unit_bet_amount;
$total = (int) floor($bet * ($oddsVal / 10_000));
$lines[] = [
'number' => $item->original_number,
'suffix3' => $suf,
'tier' => $hitTier,
'bet_amount' => $bet,
'odds_value' => $oddsVal,
'payout' => $total,
];
$bestRank = $rank;
$bestTier = $hitTier;
}
return [

View File

@@ -3,7 +3,6 @@
namespace App\Services\Settlement\Matchers;
use App\Models\TicketItem;
use App\Models\TicketCombination;
use Illuminate\Support\Collection;
use App\Services\Settlement\OddsSnapshotReader;
use App\Services\Settlement\PublishedDrawResultBoard;
@@ -36,16 +35,16 @@ final class Pos3TierSettlementMatcher implements SettlementPlayMatcher
$lines = [];
$total = 0;
foreach ($combinations as $c) {
/** @var TicketCombination $c */
$n = (string) $c->number_4d;
if (strlen($n) < 3 || substr($n, -3) !== $suffix) {
continue;
}
$bet = (int) $c->bet_amount;
$payout = (int) floor($bet * ($oddsVal / 10_000));
$total += $payout;
$lines[] = ['number_4d' => $n, 'suffix3' => $suffix, 'payout' => $payout];
if (substr((string) $item->normalized_number, -3) === $suffix) {
$bet = (int) $item->unit_bet_amount;
$total = (int) floor($bet * ($oddsVal / 10_000));
$lines[] = [
'number' => $item->original_number,
'suffix3' => $suffix,
'bet_amount' => $bet,
'odds_value' => $oddsVal,
'payout' => $total,
];
}
return [

View 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();
});
}
}

View File

@@ -3,11 +3,9 @@
namespace App\Services\Settlement;
use App\Models\Draw;
use App\Models\Player;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\JackpotPool;
use App\Models\TicketOrder;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
@@ -16,13 +14,12 @@ use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Models\TicketSettlementDetail;
use App\Services\Ticket\RiskPoolService;
use App\Services\Ticket\TicketWalletService;
use App\Services\Jackpot\JackpotBurstAllocator;
/**
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 回水派彩调整 Jackpot 爆池分配 明细 风险池释放 入账)。
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 回水派彩调整 Jackpot 爆池分配 明细 风险池释放 待审核)。
*
* 幂等:同一 `draw` + 已发布 `result_batch` 若已有 `completed` 批次,则仅推进期号状态为 `settled`
* 派彩入账由审核通过后的独立 payout 动作执行,避免未确认结果直接入账
*/
final class SettlementOrchestrator
{
@@ -30,7 +27,6 @@ final class SettlementOrchestrator
private readonly SettlementMatcherRegistry $matchers,
private readonly SettlementPayoutAdjuster $payoutAdjuster,
private readonly JackpotBurstAllocator $jackpotBurst,
private readonly TicketWalletService $wallet,
private readonly RiskPoolService $riskPool,
) {}
@@ -65,12 +61,16 @@ final class SettlementOrchestrator
$existingDone = SettlementBatch::query()
->where('draw_id', $locked->id)
->where('result_batch_id', $publishedBatch->id)
->where('status', SettlementBatchStatus::Completed->value)
->whereIn('status', [
SettlementBatchStatus::PendingReview->value,
SettlementBatchStatus::Approved->value,
SettlementBatchStatus::Paid->value,
SettlementBatchStatus::Completed->value,
])
->first();
if ($existingDone !== null) {
$locked->forceFill([
'status' => DrawStatus::Settled->value,
'settle_version' => (int) $existingDone->settle_version,
])->save();
@@ -91,6 +91,7 @@ final class SettlementOrchestrator
'result_batch_id' => $publishedBatch->id,
'settle_version' => $nextSettleVersion,
'status' => SettlementBatchStatus::Running->value,
'review_status' => 'pending',
'started_at' => now(),
]);
@@ -139,7 +140,6 @@ final class SettlementOrchestrator
$totalJackpotPayout = (int) $burstOut['pool_payout'];
}
$playerTotals = [];
$ticketCount = 0;
$winCount = 0;
$totalPayout = 0;
@@ -164,8 +164,8 @@ final class SettlementOrchestrator
$item->forceFill([
'win_amount' => $net,
'jackpot_win_amount' => $jackpotShare,
'settled_at' => now(),
'status' => $finalCredit > 0 ? 'settled_win' : 'settled_lose',
'settled_at' => null,
'status' => $finalCredit > 0 ? 'pending_payout' : 'settled_lose',
])->save();
if ($finalCredit > 0) {
@@ -173,9 +173,6 @@ final class SettlementOrchestrator
}
$totalPayout += $finalCredit;
$pid = (int) $item->player_id;
$playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $finalCredit;
$locks = [];
foreach ($item->combinations as $c) {
$locks[] = [
@@ -186,16 +183,8 @@ final class SettlementOrchestrator
$this->riskPool->release((int) $locked->id, $item, $locks);
}
foreach ($playerTotals as $playerId => $amount) {
if ($amount <= 0) {
continue;
}
$player = Player::query()->whereKey($playerId)->firstOrFail();
$this->wallet->creditSettlementPayout($player, $currency, $amount, (int) $batchRow->id);
}
$batchRow->forceFill([
'status' => SettlementBatchStatus::Completed->value,
'status' => SettlementBatchStatus::PendingReview->value,
'total_ticket_count' => $ticketCount,
'total_win_count' => $winCount,
'total_payout_amount' => $totalPayout,
@@ -204,20 +193,10 @@ final class SettlementOrchestrator
])->save();
$locked->forceFill([
'status' => DrawStatus::Settled->value,
'status' => DrawStatus::Settling->value,
'settle_version' => $nextSettleVersion,
])->save();
foreach ($ticketItems->pluck('order_id')->unique()->all() as $orderId) {
$pending = TicketItem::query()
->where('order_id', $orderId)
->whereNotIn('status', ['settled_win', 'settled_lose'])
->exists();
if (! $pending) {
TicketOrder::query()->whereKey($orderId)->update(['status' => 'settled']);
}
}
return true;
});
}

View File

@@ -81,6 +81,9 @@ final class PlayRuleEngine
'dimension' => $dimension,
'digit_slot' => $digitSlotInt,
'combination_count' => $combinationCount,
'rounding_refund_amount' => $playCode === 'mbox'
? max(0, $amount - $totalBetAmount)
: 0,
],
'combinations' => collect($combos)->values()->map(function (string $combo, int $index) use ($unitBetAmount, $estimatedPayoutPerCombo): array {
return [